use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::error::{OlError, ERR_AUTH_FLOW_FAILED};
pub const CHALLENGE_METHOD: &str = "S256";
#[derive(Clone)]
pub struct Pkce {
pub verifier: String,
pub challenge: String,
pub state: String,
}
impl Pkce {
pub fn new() -> Result<Self, OlError> {
let verifier = random_token()?;
let state = random_token()?;
Ok(Self {
challenge: challenge_for(&verifier),
verifier,
state,
})
}
pub fn state_matches(&self, candidate: &str) -> bool {
bool::from(self.state.as_bytes().ct_eq(candidate.as_bytes()))
}
}
pub fn challenge_for(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn random_token() -> Result<String, OlError> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("The operating system refused secure randomness: {e}"),
)
})?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_challenge_matches_rfc_7636_appendix_b() {
assert_eq!(
challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn pkce_verifier_and_state_carry_256_bits() {
let p = Pkce::new().expect("CSPRNG available");
assert_eq!(p.verifier.len(), 43);
assert_eq!(p.state.len(), 43);
assert!(p
.verifier
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
assert_eq!(p.challenge, challenge_for(&p.verifier));
assert_ne!(p.verifier, p.state, "two independent draws");
}
#[test]
fn pkce_each_sign_in_draws_fresh_values() {
let a = Pkce::new().unwrap();
let b = Pkce::new().unwrap();
assert_ne!(a.verifier, b.verifier);
assert_ne!(a.state, b.state);
}
#[test]
fn pkce_state_compare_is_exact() {
let p = Pkce::new().unwrap();
assert!(p.state_matches(&p.state.clone()));
assert!(!p.state_matches(""));
assert!(!p.state_matches(&p.state[..42]));
assert!(!p.state_matches(&format!("{}x", p.state)));
assert!(!p.state_matches(&p.verifier));
}
}