use core::fmt;
use crate::crypto::Sha256;
use crate::crypto::constant_time::constant_time_eq;
use crate::encoding::base64url_encode;
use crate::util::timestamp::Timestamp;
#[doc(alias = "client_auth")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClientAuthResult {
Authenticated,
Failed,
}
#[derive(Debug, Clone, Copy)]
pub struct StoredAuthCode<'a> {
pub client_id: &'a str,
pub redirect_uri: &'a str,
pub code_challenge: Option<&'a str>,
pub require_pkce: bool,
pub expires_at: Timestamp,
pub consumed: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct TokenRequestPresented<'a> {
pub client_id: &'a str,
pub client_auth: ClientAuthResult,
pub redirect_uri: &'a str,
pub code_verifier: Option<&'a str>,
}
#[doc(alias = "redemption_error")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CodeRedemptionDenied {
ClientAuthenticationFailed,
ClientMismatch,
AlreadyConsumed,
Expired,
RedirectUriMismatch,
MissingCodeVerifier,
PkceVerificationFailed,
PkceDowngrade,
}
impl CodeRedemptionDenied {
#[must_use]
#[inline]
pub fn as_str(self) -> &'static str {
match self {
Self::ClientAuthenticationFailed => "client authentication failed",
Self::ClientMismatch => "code was issued to a different client",
Self::AlreadyConsumed => "code already consumed (replay)",
Self::Expired => "code expired",
Self::RedirectUriMismatch => "redirect_uri mismatch",
Self::MissingCodeVerifier => "missing PKCE code_verifier",
Self::PkceVerificationFailed => "PKCE verification failed",
Self::PkceDowngrade => "PKCE downgrade (no challenge bound to code)",
}
}
#[must_use]
#[inline]
pub fn is_replay(self) -> bool {
matches!(self, Self::AlreadyConsumed)
}
}
impl fmt::Display for CodeRedemptionDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "code redemption denied: {}", self.as_str())
}
}
impl std::error::Error for CodeRedemptionDenied {}
#[doc(alias = "code_verdict")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CodeRedemptionVerdict {
Accepted,
Denied(CodeRedemptionDenied),
}
#[must_use]
pub fn evaluate_code_redemption(
stored: &StoredAuthCode<'_>,
presented: &TokenRequestPresented<'_>,
now: Timestamp,
) -> CodeRedemptionVerdict {
use CodeRedemptionDenied as D;
use CodeRedemptionVerdict::{Accepted, Denied};
if presented.client_auth != ClientAuthResult::Authenticated {
return Denied(D::ClientAuthenticationFailed);
}
if !constant_time_eq(stored.client_id.as_bytes(), presented.client_id.as_bytes()) {
return Denied(D::ClientMismatch);
}
if stored.consumed {
return Denied(D::AlreadyConsumed);
}
if stored.expires_at.is_expired(&now) {
return Denied(D::Expired);
}
if !constant_time_eq(
stored.redirect_uri.as_bytes(),
presented.redirect_uri.as_bytes(),
) {
return Denied(D::RedirectUriMismatch);
}
match stored.code_challenge {
Some(challenge) => {
let Some(verifier) = presented.code_verifier else {
return Denied(D::MissingCodeVerifier);
};
if !verify_s256(verifier, challenge) {
return Denied(D::PkceVerificationFailed);
}
}
None => {
if stored.require_pkce || presented.code_verifier.is_some() {
return Denied(D::PkceDowngrade);
}
}
}
Accepted
}
fn verify_s256(verifier: &str, challenge: &str) -> bool {
let computed = base64url_encode(&Sha256::digest(verifier.as_bytes()));
constant_time_eq(computed.as_bytes(), challenge.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::PkceChallenge;
fn now() -> Timestamp {
Timestamp::from_unix_secs(1_000_000)
}
fn future() -> Timestamp {
Timestamp::from_unix_secs(1_000_060)
}
fn past() -> Timestamp {
Timestamp::from_unix_secs(999_999)
}
fn accepted(v: CodeRedemptionVerdict) -> bool {
matches!(v, CodeRedemptionVerdict::Accepted)
}
fn denied_reason(v: CodeRedemptionVerdict) -> CodeRedemptionDenied {
match v {
CodeRedemptionVerdict::Denied(d) => d,
CodeRedemptionVerdict::Accepted => panic!("expected denial, got accepted"),
}
}
#[test]
fn accepts_valid_redemption_with_pkce() {
let pkce = PkceChallenge::generate().unwrap();
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: Some(pkce.challenge()),
require_pkce: true,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: Some(pkce.verifier()),
};
assert!(accepted(evaluate_code_redemption(
&stored,
&presented,
now()
)));
}
#[test]
fn accepts_valid_redemption_without_pkce() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert!(accepted(evaluate_code_redemption(
&stored,
&presented,
now()
)));
}
#[test]
fn redirect_uri_mismatch_reported_before_pkce_failure() {
let pkce = PkceChallenge::generate().unwrap();
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: Some(pkce.challenge()),
require_pkce: true,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://attacker.example.com/cb", code_verifier: Some("the-wrong-verifier"), };
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::RedirectUriMismatch,
);
}
#[test]
fn rejects_failed_client_auth() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Failed,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::ClientAuthenticationFailed,
);
}
#[test]
fn rejects_client_mismatch() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c2",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::ClientMismatch,
);
}
#[test]
fn rejects_consumed_code_as_replay() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: true,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
let reason = denied_reason(evaluate_code_redemption(&stored, &presented, now()));
assert_eq!(reason, CodeRedemptionDenied::AlreadyConsumed);
assert!(reason.is_replay());
}
#[test]
fn rejects_expired_code() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: past(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::Expired,
);
}
#[test]
fn rejects_redirect_uri_mismatch() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/other",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::RedirectUriMismatch,
);
}
#[test]
fn rejects_missing_verifier_when_challenge_present() {
let pkce = PkceChallenge::generate().unwrap();
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: Some(pkce.challenge()),
require_pkce: true,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::MissingCodeVerifier,
);
}
#[test]
fn rejects_wrong_verifier() {
let pkce = PkceChallenge::generate().unwrap();
let other = PkceChallenge::generate().unwrap();
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: Some(pkce.challenge()),
require_pkce: true,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: Some(other.verifier()),
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::PkceVerificationFailed,
);
}
#[test]
fn pkce_s256_matches_known_rfc7636_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert!(verify_s256(verifier, challenge));
assert!(!verify_s256(verifier, "wrong-challenge"));
}
#[test]
fn rejects_pkce_downgrade_when_client_requires_pkce() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: true,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::PkceDowngrade,
);
}
#[test]
fn rejects_stray_verifier_on_non_pkce_code() {
let pkce = PkceChallenge::generate().unwrap();
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: future(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: Some(pkce.verifier()),
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::PkceDowngrade,
);
}
#[test]
fn exactly_expired_is_rejected() {
let stored = StoredAuthCode {
client_id: "c1",
redirect_uri: "https://app.example.com/cb",
code_challenge: None,
require_pkce: false,
expires_at: now(),
consumed: false,
};
let presented = TokenRequestPresented {
client_id: "c1",
client_auth: ClientAuthResult::Authenticated,
redirect_uri: "https://app.example.com/cb",
code_verifier: None,
};
assert_eq!(
denied_reason(evaluate_code_redemption(&stored, &presented, now())),
CodeRedemptionDenied::Expired,
);
}
}