use subtle::ConstantTimeEq as _;
use crate::{
error::{AuthError, Result},
oauth::types::IdTokenClaims,
};
pub const CLOCK_SKEW_SECS: i64 = 60;
pub fn validate_nonce_claim(claims: &IdTokenClaims, expected_nonce: &str) -> Result<()> {
let token_nonce = claims.nonce.as_deref().ok_or(AuthError::MissingNonce)?;
if token_nonce.as_bytes().ct_eq(expected_nonce.as_bytes()).into() {
Ok(())
} else {
Err(AuthError::NonceMismatch)
}
}
pub fn validate_auth_time_claim(
claims: &IdTokenClaims,
max_age_secs: u64,
now_secs: i64,
) -> Result<()> {
let auth_time = claims.auth_time.ok_or(AuthError::MissingAuthTime)?;
let age = now_secs.saturating_sub(auth_time);
let max_age_i64 = i64::try_from(max_age_secs).unwrap_or(i64::MAX);
let allowed = max_age_i64.saturating_add(CLOCK_SKEW_SECS);
if age > allowed {
Err(AuthError::SessionTooOld { age, max_age_secs })
} else {
Ok(())
}
}
#[allow(clippy::unwrap_used)] #[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn make_claims(nonce: Option<&str>, auth_time: Option<i64>) -> IdTokenClaims {
let mut c = IdTokenClaims::new(
"https://idp.example.com".into(),
"user1".into(),
"client_id".into(),
9_999_999_999,
0,
);
c.nonce = nonce.map(str::to_owned);
c.auth_time = auth_time;
c
}
#[test]
fn test_callback_rejects_missing_nonce_claim() {
let claims = make_claims(None, None);
let result = validate_nonce_claim(&claims, "expected-nonce");
assert!(matches!(result, Err(AuthError::MissingNonce)));
}
#[test]
fn test_callback_rejects_wrong_nonce() {
let claims = make_claims(Some("actual-nonce"), None);
let result = validate_nonce_claim(&claims, "different-nonce");
assert!(matches!(result, Err(AuthError::NonceMismatch)));
}
#[test]
fn test_callback_accepts_correct_nonce() {
let claims = make_claims(Some("correct-nonce"), None);
validate_nonce_claim(&claims, "correct-nonce")
.unwrap_or_else(|e| panic!("expected Ok for correct nonce: {e}"));
}
#[test]
fn test_callback_nonce_is_one_shot() {
let claims = make_claims(Some("once-nonce"), None);
validate_nonce_claim(&claims, "once-nonce")
.unwrap_or_else(|e| panic!("expected Ok on first nonce use: {e}"));
let cleared_claims = make_claims(None, None);
let result = validate_nonce_claim(&cleared_claims, "once-nonce");
assert!(
matches!(result, Err(AuthError::MissingNonce)),
"second use must fail: stored nonce already consumed"
);
}
const NOW: i64 = 1_700_000_000;
#[test]
fn test_auth_time_within_max_age_accepted() {
let claims = make_claims(None, Some(NOW - 30));
validate_auth_time_claim(&claims, 60, NOW)
.unwrap_or_else(|e| panic!("expected Ok for auth_time within max_age: {e}"));
}
#[test]
fn test_auth_time_exceeds_max_age_rejected() {
let claims = make_claims(None, Some(NOW - 200));
let result = validate_auth_time_claim(&claims, 60, NOW);
assert!(
matches!(
result,
Err(AuthError::SessionTooOld {
age: 200,
max_age_secs: 60,
})
),
"expected SessionTooOld, got: {result:?}"
);
}
#[test]
fn test_missing_auth_time_when_max_age_present_rejected() {
let claims = make_claims(None, None); let result = validate_auth_time_claim(&claims, 3600, NOW);
assert!(matches!(result, Err(AuthError::MissingAuthTime)));
}
#[test]
fn test_max_age_absent_skips_auth_time_check() {
let claims = make_claims(None, Some(NOW - 59));
validate_auth_time_claim(&claims, 0, NOW)
.unwrap_or_else(|e| panic!("expected Ok for age(59) within skew window: {e}"));
}
}