use std::fmt;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{RandomError, Sha256, fill_random};
use crate::encoding::base64url_encode;
use crate::util::log::debug;
#[doc(alias = "pkce")]
pub struct PkceChallenge {
verifier: Zeroizing<String>,
challenge: String,
}
impl PkceChallenge {
pub fn generate() -> Result<Self, RandomError> {
let mut buf = [0u8; 32];
fill_random(&mut buf)?;
let verifier = base64url_encode(&buf);
crate::crypto::zeroize::zeroize(&mut buf);
let hash = Sha256::digest(verifier.as_bytes());
let challenge = base64url_encode(&hash);
debug!("oauth: PKCE challenge generated (method=S256)");
Ok(Self {
verifier: Zeroizing::new(verifier),
challenge,
})
}
#[must_use]
#[inline]
pub fn verifier(&self) -> &str {
&self.verifier
}
#[must_use]
#[inline]
pub fn challenge(&self) -> &str {
&self.challenge
}
#[must_use]
#[inline]
pub fn method(&self) -> &'static str {
"S256"
}
}
impl fmt::Debug for PkceChallenge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PkceChallenge")
.field("verifier", &"[REDACTED]")
.field("challenge", &self.challenge)
.field("method", &"S256")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoding::base64url_decode;
#[test]
fn generate_produces_valid_verifier_length() {
let pkce = PkceChallenge::generate().unwrap();
let verifier = pkce.verifier();
assert_eq!(verifier.len(), 43, "verifier should be 43 characters");
}
#[test]
fn generate_verifier_is_base64url() {
let pkce = PkceChallenge::generate().unwrap();
let verifier = pkce.verifier();
assert!(
verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"verifier should contain only base64url characters: {verifier}",
);
}
#[test]
fn generate_verifier_within_rfc7636_bounds() {
let pkce = PkceChallenge::generate().unwrap();
let len = pkce.verifier().len();
assert!(
(43..=128).contains(&len),
"verifier length {len} should be between 43 and 128",
);
}
#[test]
fn challenge_is_s256_of_verifier() {
let pkce = PkceChallenge::generate().unwrap();
let hash = Sha256::digest(pkce.verifier().as_bytes());
let expected_challenge = base64url_encode(&hash);
assert_eq!(
pkce.challenge(),
expected_challenge,
"challenge should be base64url(SHA-256(verifier))",
);
}
#[test]
fn challenge_is_base64url() {
let pkce = PkceChallenge::generate().unwrap();
let challenge = pkce.challenge();
assert!(
challenge
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"challenge should contain only base64url characters: {challenge}",
);
}
#[test]
fn challenge_decodes_to_32_bytes() {
let pkce = PkceChallenge::generate().unwrap();
let decoded = base64url_decode(pkce.challenge()).unwrap();
assert_eq!(decoded.len(), 32, "SHA-256 digest should be 32 bytes");
}
#[test]
fn method_is_s256() {
let pkce = PkceChallenge::generate().unwrap();
assert_eq!(pkce.method(), "S256");
}
#[test]
fn two_challenges_differ() {
let a = PkceChallenge::generate().unwrap();
let b = PkceChallenge::generate().unwrap();
assert_ne!(a.verifier(), b.verifier(), "verifiers should differ");
assert_ne!(a.challenge(), b.challenge(), "challenges should differ");
}
#[test]
fn debug_redacts_verifier() {
let pkce = PkceChallenge::generate().unwrap();
let debug_output = format!("{pkce:?}");
assert!(
debug_output.contains("[REDACTED]"),
"debug should contain [REDACTED]: {debug_output}",
);
assert!(
!debug_output.contains(pkce.verifier()),
"debug must not contain the actual verifier",
);
}
}