use super::*;
#[test]
fn user_codes_use_the_alphabet_and_are_unbiased_in_shape() {
let code = random_user_code(8).expect("the OS provides randomness in a test process");
assert_eq!(code.len(), 8);
assert!(code.bytes().all(|b| USER_CODE_ALPHABET.contains(&b)));
}
#[test]
fn display_form_hyphenates_even_lengths() {
assert_eq!(display_user_code("WDJBMJHT"), "WDJB-MJHT");
assert_eq!(display_user_code("ABCDEF"), "ABC-DEF");
assert_eq!(display_user_code("ABCDE"), "ABCDE");
}
#[test]
fn the_user_code_symbol_draw_is_exactly_uniform_over_the_alphabet() {
let mut counts = std::collections::BTreeMap::new();
let mut accepted = 0usize;
for byte in 0u8..=255 {
match user_code_symbol(byte) {
Some(symbol) => {
accepted += 1;
assert!(
USER_CODE_ALPHABET.contains(&symbol),
"byte {byte} produced {symbol}, which is outside the RFC 8628 s6.1 alphabet"
);
*counts.entry(symbol).or_insert(0usize) += 1;
}
None => assert!(
byte >= USER_CODE_REJECT_AT,
"byte {byte} is below the rejection bound and must have been accepted"
),
}
}
assert_eq!(
accepted, 240,
"exactly the 240 values below the rejection bound may be folded into the alphabet"
);
assert_eq!(
counts.len(),
USER_CODE_ALPHABET.len(),
"every symbol in the alphabet must be reachable"
);
for (symbol, count) in &counts {
assert_eq!(
*count, 12,
"symbol {} has {count} preimages, not the uniform 12: the draw is biased",
*symbol as char
);
}
}
#[test]
fn random_user_code_redraws_rejections_rather_than_shortening_the_code() {
for len in [MIN_USER_CODE_LENGTH, 9, 16] {
let code = random_user_code(len).expect("the OS provides randomness in a test process");
assert_eq!(code.len(), len, "a rejected byte must cost a redraw");
assert!(code.bytes().all(|b| USER_CODE_ALPHABET.contains(&b)));
}
}
#[test]
fn random_hex_has_the_stated_entropy_width() {
let h = try_random_hex(32).expect("the OS provides randomness in a test process");
assert_eq!(h.len(), 64);
assert!(h.bytes().all(|b| b.is_ascii_hexdigit()));
assert_ne!(h, try_random_hex(32).unwrap());
}
#[test]
fn c13_token_request_debug_redacts_every_credential() {
let cases = vec![
TokenRequest::AuthorizationCode {
client_id: ClientId::new("app"),
client_secret: Some("secret-value".into()),
code: "code-value".into(),
redirect_uri: Some("https://app.example/cb".into()),
code_verifier: Some("verifier-value".into()),
},
TokenRequest::ClientCredentials {
client_id: ClientId::new("app"),
client_secret: Some("secret-value".into()),
scope: None,
},
TokenRequest::DeviceCode {
client_id: ClientId::new("app"),
client_secret: Some("secret-value".into()),
device_code: "device-value".into(),
},
TokenRequest::RefreshToken {
client_id: ClientId::new("app"),
client_secret: Some("secret-value".into()),
refresh_token: "refresh-value".into(),
scope: None,
},
];
for request in &cases {
let printed = format!("{request:?}");
for leaked in [
"secret-value",
"code-value",
"verifier-value",
"device-value",
"refresh-value",
] {
assert!(
!printed.contains(leaked),
"debug format leaked {leaked}: {printed}"
);
}
assert!(
printed.contains("[redacted]"),
"debug format should say what was redacted: {printed}"
);
assert!(
printed.contains("app"),
"client_id must stay visible: {printed}"
);
}
}
#[test]
fn c13_token_request_debug_keeps_the_some_none_distinction() {
let with_secret = TokenRequest::AuthorizationCode {
client_id: ClientId::new("app"),
client_secret: Some("secret-value".into()),
code: "code-value".into(),
redirect_uri: None,
code_verifier: Some("verifier-value".into()),
};
let without_secret = TokenRequest::AuthorizationCode {
client_id: ClientId::new("app"),
client_secret: None,
code: "code-value".into(),
redirect_uri: None,
code_verifier: None,
};
let with = format!("{with_secret:?}");
let without = format!("{without_secret:?}");
assert_ne!(
with, without,
"a presented secret and an absent one must not debug-print identically"
);
assert!(with.contains("Some(\"[redacted]\")"), "{with}");
assert!(without.contains("client_secret: None"), "{without}");
assert!(without.contains("code_verifier: None"), "{without}");
}
#[test]
fn c13_token_request_debug_still_names_the_grant() {
let request = TokenRequest::RefreshToken {
client_id: ClientId::new("app"),
client_secret: None,
refresh_token: "refresh-value".into(),
scope: Some(ScopeSet::parse("read").unwrap()),
};
let printed = format!("{request:?}");
assert!(printed.starts_with("RefreshToken"), "{printed}");
assert!(printed.contains("read"), "{printed}");
}
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
#[test]
fn a_replay_key_separates_its_three_parts() {
assert_eq!(replay_key("ca", "client-1", "jti-1"), "ca:8:client-1jti-1");
assert_eq!(replay_key("dpop", "thumb", "jti-1"), "dpop:5:thumbjti-1");
assert_ne!(replay_key("ca", "x", "j"), replay_key("dpop", "x", "j"));
assert_ne!(replay_key("ca", "ab", "c"), replay_key("ca", "a", "bc"));
assert_ne!(
replay_key("ca", "urn", "client:foo:42"),
replay_key("ca", "urn:client:foo", "42")
);
assert_ne!(
replay_key("dpop", "thumb", ":x"),
replay_key("dpop", "thumb:", "x")
);
}
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
#[test]
fn the_decimal_width_is_the_number_of_digits() {
for (n, width) in [
(0usize, 1usize),
(1, 1),
(9, 1),
(10, 2),
(99, 2),
(100, 3),
(999, 3),
(1000, 4),
] {
assert_eq!(decimal_width(n), width, "{n}");
}
}
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
#[test]
fn a_replay_key_is_built_in_exactly_one_correctly_sized_allocation() {
for (kind, owner, jti) in [
("ca", "client-1", "jti-1"),
("dpop", "0OXy9SbXe0Y7YQ8Xw3sYQ2h1lKQ", "01234567-89ab-cdef"),
("ca", "", ""),
] {
let key = replay_key(kind, owner, jti);
let exact = kind.len() + owner.len() + jti.len() + 2 + decimal_width(owner.len());
assert_eq!(
key.len(),
exact,
"the two separators and the length prefix are the whole of the difference between \
the parts and the key"
);
assert_eq!(
key.capacity(),
exact,
"the hint must be exactly the final length: smaller reallocates, larger over-asks"
);
}
}
#[test]
fn the_request_reachable_randomness_draws_report_failure_rather_than_panicking() {
let hex: Option<String> = try_random_hex(32);
assert_eq!(
hex.expect("the OS provides randomness in a test process")
.len(),
64
);
let code: Option<String> = random_user_code(MIN_USER_CODE_LENGTH);
assert_eq!(
code.expect("the OS provides randomness in a test process")
.len(),
MIN_USER_CODE_LENGTH
);
assert_eq!(randomness_error().error, ErrorCode::ServerError);
assert!(randomness_error().error_description.is_none());
}
#[cfg(test)]
struct CountingLimiter {
allow: usize,
checks: std::sync::Mutex<usize>,
records: std::sync::Mutex<Vec<AttemptOutcome>>,
}
impl RateLimiter for std::sync::Arc<CountingLimiter> {
fn check(&self, _attempt: Attempt<'_>) -> RateLimitDecision {
let mut checks = self.checks.lock().expect("no panic while held");
*checks += 1;
if *checks <= self.allow {
RateLimitDecision::Allow
} else {
RateLimitDecision::Deny
}
}
fn record(&self, _attempt: Attempt<'_>, outcome: AttemptOutcome) {
self.records
.lock()
.expect("no panic while held")
.push(outcome);
}
}
fn limited_server(
limiter: std::sync::Arc<CountingLimiter>,
) -> AuthorizationServer<crate::store::MemoryStorage> {
AuthorizationServer::new(
ServerConfig::new("https://as.example", "https://as.example/device"),
crate::store::MemoryStorage::new(),
)
.with_rate_limiter(Box::new(limiter))
}
fn limited_client() -> Client {
Client {
client_id: ClientId::new("app"),
auth: crate::client::ClientAuth::Public,
grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
redirect_uris: vec!["https://app.example/cb".to_string()],
allowed_scopes: ScopeSet::parse("read").unwrap(),
default_scopes: ScopeSet::parse("read").unwrap(),
name: None,
registration: None,
}
}
#[cfg(test)]
const RFC7636_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
fn limited_request(challenge: &str) -> AuthorizationRequest<'static> {
AuthorizationRequest::from_pairs([
("response_type", "code".to_string()),
("client_id", "app".to_string()),
("redirect_uri", "https://app.example/cb".to_string()),
("scope", "read".to_string()),
("code_challenge", challenge.to_string()),
("code_challenge_method", "S256".to_string()),
])
}
#[tokio::test]
async fn a_denied_authorization_request_is_never_reported_back_as_a_failure() {
let limiter = std::sync::Arc::new(CountingLimiter {
allow: 1,
checks: std::sync::Mutex::new(0),
records: std::sync::Mutex::new(Vec::new()),
});
let srv = limited_server(limiter.clone());
srv.register_client(limited_client()).await.unwrap();
let challenge = crate::pkce::code_challenge_s256(RFC7636_VERIFIER);
let validated = srv
.validate_authorization_request(&limited_request(&challenge))
.await
.expect("the first charge is allowed");
let refused = srv
.issue_authorization_code(crate::server::UserApproval::granted(&validated, "user-1"))
.await
.expect_err("the second charge is denied");
assert!(
matches!(refused, AuthorizationError::Redirect(_)),
"the refusal shape is unchanged: RFC 6749 s4.1.2.1 sends it to the validated redirect URI"
);
let records = limiter.records.lock().expect("no panic while held").clone();
assert_eq!(
records,
vec![AttemptOutcome::Succeeded],
"the only outcome is the ALLOWED validation's; a deny must be reported to nobody"
);
}
#[tokio::test]
async fn a_rotation_survives_an_absurd_reuse_window_rather_than_panicking() {
let mut config = ServerConfig::new("https://as.example", "https://as.example/device");
config.refresh_reuse_window = Duration::MAX;
assert!(config.refresh_token_ttl.is_none());
let srv = AuthorizationServer::new(config, crate::store::MemoryStorage::new());
srv.register_client(limited_client()).await.unwrap();
let verifier = RFC7636_VERIFIER;
let challenge = crate::pkce::code_challenge_s256(verifier);
let validated = srv
.validate_authorization_request(&limited_request(&challenge))
.await
.unwrap();
let code = srv
.issue_authorization_code(crate::server::UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let issued = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("app"),
client_secret: None,
code: code.code,
redirect_uri: Some("https://app.example/cb".to_string()),
code_verifier: Some(verifier.to_string()),
})
.await
.unwrap();
let rotated = srv
.token(TokenRequest::RefreshToken {
client_id: ClientId::new("app"),
client_secret: None,
refresh_token: issued.refresh_token.expect("the grant mints one"),
scope: None,
})
.await
.expect("a rotation must not panic on a host-configured duration");
assert!(rotated.refresh_token.is_some(), "the chain continues");
}
#[cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
#[test]
fn the_dummy_assertion_material_costs_a_complete_es256_verification() {
use crate::jwt::Es256Verifier as _;
assert!(
crate::jwt::P256Verifier.verify(
&dummy_assertion_key(),
DUMMY_ASSERTION_SIGNING_INPUT.as_bytes(),
&DUMMY_ASSERTION_SIGNATURE,
),
"the dummy signature must verify under the dummy key, or the verification short-circuits"
);
}
#[cfg(test)]
fn system_time_ceiling() -> SystemTime {
use std::time::{Duration, UNIX_EPOCH};
let mut lo: u64 = 0;
let mut hi: u64 = u64::MAX;
while lo < hi {
let mid = lo + (hi - lo) / 2 + 1;
if UNIX_EPOCH.checked_add(Duration::from_secs(mid)).is_some() {
lo = mid;
} else {
hi = mid - 1;
}
}
let secs = lo;
let mut nlo: u32 = 0;
let mut nhi: u32 = 999_999_999;
while nlo < nhi {
let mid = nlo + (nhi - nlo) / 2 + 1;
if UNIX_EPOCH.checked_add(Duration::new(secs, mid)).is_some() {
nlo = mid;
} else {
nhi = mid - 1;
}
}
UNIX_EPOCH
.checked_add(Duration::new(secs, nlo))
.expect("the searched value is representable by construction")
}
#[test]
fn saturating_deadline_halves_toward_the_ceiling_when_the_sum_overflows() {
use std::time::Duration;
let ceiling = system_time_ceiling();
let base = ceiling
.checked_sub(Duration::from_millis(7_750))
.expect("7.75s below the ceiling is representable");
assert!(
base.checked_add(Duration::from_secs(8)).is_none(),
"the fixture is only meaningful if the 8s span genuinely overflows and forces the halving \
path"
);
let deadline = saturating_deadline(base, Duration::from_secs(8));
assert_eq!(
deadline,
base + Duration::from_secs(7),
"the halving loop must accumulate 4s + 2s + 1s and stop the instant the remaining span \
reaches one second: a loop that never runs leaves the deadline in the past, and one that \
runs a step too far pushes it half a second beyond where the real code lands"
);
}