use rustlavel_auth::{base64, random};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChallengeMethod {
#[default]
S256,
Plain,
}
impl ChallengeMethod {
pub fn as_str(self) -> &'static str {
match self {
ChallengeMethod::S256 => "S256",
ChallengeMethod::Plain => "plain",
}
}
pub fn parse(raw: &str) -> Option<ChallengeMethod> {
match raw {
"S256" => Some(ChallengeMethod::S256),
"plain" => Some(ChallengeMethod::Plain),
_ => None,
}
}
}
impl std::fmt::Display for ChallengeMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone)]
pub struct Pkce {
verifier: String,
method: ChallengeMethod,
}
impl Pkce {
pub fn generate() -> Pkce {
Pkce {
verifier: base64::encode_url(&random::bytes(32)),
method: ChallengeMethod::S256,
}
}
pub fn from_verifier(verifier: impl Into<String>) -> Result<Pkce, String> {
let verifier = verifier.into();
validate_verifier(&verifier)?;
Ok(Pkce { verifier, method: ChallengeMethod::S256 })
}
pub fn plain(mut self) -> Pkce {
self.method = ChallengeMethod::Plain;
self
}
pub fn verifier(&self) -> &str {
&self.verifier
}
pub fn method(&self) -> ChallengeMethod {
self.method
}
pub fn challenge(&self) -> String {
match self.method {
ChallengeMethod::S256 => s256(&self.verifier),
ChallengeMethod::Plain => self.verifier.clone(),
}
}
}
impl std::fmt::Debug for Pkce {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Pkce")
.field("verifier", &"<redacted>")
.field("method", &self.method)
.finish()
}
}
pub fn s256(verifier: &str) -> String {
base64::encode_url(&Sha256::digest(verifier.as_bytes()))
}
pub fn verify(verifier: &str, challenge: &str, method: ChallengeMethod) -> bool {
if validate_verifier(verifier).is_err() {
return false;
}
let derived = match method {
ChallengeMethod::S256 => s256(verifier),
ChallengeMethod::Plain => verifier.to_string(),
};
rustlavel_auth::constant_time_eq(derived.as_bytes(), challenge.as_bytes())
}
pub fn validate_verifier(verifier: &str) -> Result<(), String> {
if !(43..=128).contains(&verifier.len()) {
return Err(format!(
"a PKCE verifier must be 43 to 128 characters (RFC 7636 §4.1); this one is {}",
verifier.len()
));
}
match verifier.bytes().find(|byte| {
!matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
}) {
Some(byte) => Err(format!(
"a PKCE verifier may only contain [A-Za-z0-9-._~]; this one contains {:?}",
byte as char
)),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_the_worked_example_from_rfc_7636() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
assert_eq!(s256(verifier), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
#[test]
fn a_generated_verifier_satisfies_the_grammar() {
let pkce = Pkce::generate();
assert_eq!(pkce.verifier().len(), 43, "32 bytes of entropy, unpadded");
validate_verifier(pkce.verifier()).expect("generated verifiers must be valid");
}
#[test]
fn two_verifiers_are_never_the_same() {
assert_ne!(Pkce::generate().verifier(), Pkce::generate().verifier());
}
#[test]
fn the_challenge_is_not_the_verifier_under_s256() {
let pkce = Pkce::generate();
assert_ne!(pkce.challenge(), pkce.verifier());
assert_eq!(pkce.challenge(), s256(pkce.verifier()));
}
#[test]
fn plain_leaks_the_verifier_into_the_url() {
let pkce = Pkce::generate().plain();
assert_eq!(pkce.challenge(), pkce.verifier());
}
#[test]
fn the_right_verifier_passes_and_a_wrong_one_does_not() {
let pkce = Pkce::generate();
let challenge = pkce.challenge();
assert!(verify(pkce.verifier(), &challenge, ChallengeMethod::S256));
assert!(!verify(Pkce::generate().verifier(), &challenge, ChallengeMethod::S256));
}
#[test]
fn a_verifier_cannot_be_replayed_under_the_wrong_method() {
let pkce = Pkce::generate();
let challenge = pkce.challenge();
assert!(!verify(&challenge, &challenge, ChallengeMethod::S256));
}
#[test]
fn verification_refuses_a_malformed_verifier_outright() {
assert!(!verify("", &s256(""), ChallengeMethod::S256));
assert!(!verify("short", &s256("short"), ChallengeMethod::S256));
}
#[test]
fn the_grammar_error_says_what_is_wrong() {
let too_short = validate_verifier("abc").unwrap_err();
assert!(too_short.contains("43 to 128"), "got {too_short}");
let long_enough_but_illegal = format!("{}/{}", "a".repeat(21), "b".repeat(21));
let bad_character = validate_verifier(&long_enough_but_illegal).unwrap_err();
assert!(bad_character.contains('/'), "got {bad_character}");
}
#[test]
fn a_verifier_at_each_boundary_is_accepted() {
assert!(validate_verifier(&"a".repeat(43)).is_ok());
assert!(validate_verifier(&"a".repeat(128)).is_ok());
assert!(validate_verifier(&"a".repeat(42)).is_err());
assert!(validate_verifier(&"a".repeat(129)).is_err());
}
#[test]
fn from_verifier_rejects_what_the_provider_would_reject() {
assert!(Pkce::from_verifier("too-short").is_err());
assert!(Pkce::from_verifier("a".repeat(43)).is_ok());
}
#[test]
fn methods_round_trip_through_their_wire_spelling() {
assert_eq!(ChallengeMethod::parse("S256"), Some(ChallengeMethod::S256));
assert_eq!(ChallengeMethod::parse("plain"), Some(ChallengeMethod::Plain));
assert_eq!(ChallengeMethod::parse("s256"), None, "the spelling is case-sensitive");
assert_eq!(ChallengeMethod::parse("none"), None);
}
#[test]
fn debug_does_not_print_the_verifier() {
let pkce = Pkce::generate();
let printed = format!("{pkce:?}");
assert!(!printed.contains(pkce.verifier()), "the verifier reached a log");
assert!(printed.contains("redacted"));
}
}