//! Client-side OAuth2 PKCE helpers (RFC 7636, S256). //! //! The MNW server's `/oauth/authorize` + `/oauth/token` flow requires PKCE with //! the `S256` method. A caller generates a [`Pkce`] pair up front, sends the //! `challenge` to `/oauth/authorize` (via //! [`build_authorize_url`](crate::SyncKitClient::build_authorize_url)), and sends //! the `verifier` to `/oauth/token` (via //! [`authenticate_with_code`](crate::SyncKitClient::authenticate_with_code)). //! //! Pair this with a localhost redirect listener and a browser to drive the full //! flow from a CLI. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::Rng; use sha2::{Digest, Sha256}; use zeroize::Zeroize; /// A PKCE verifier/challenge pair using the S256 method. pub struct Pkce { /// High-entropy secret kept by the client and sent at token-exchange time. pub verifier: String, /// `base64url(SHA256(verifier))`, sent to the authorize endpoint up front. pub challenge: String, } impl Drop for Pkce { fn drop(&mut self) { // The verifier is the PKCE secret that proves possession at token // exchange; scrub it from the heap on drop. The challenge is a public // SHA-256 of it (sent in the clear to /authorize), so it needs none. self.verifier.zeroize(); } } /// Generate a PKCE pair (S256). /// /// The verifier is 32 random bytes base64url-encoded (43 chars, well within /// RFC 7636's 43–128 range and using only the unreserved charset). pub fn generate_pkce() -> Pkce { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); let verifier = URL_SAFE_NO_PAD.encode(bytes); let digest = Sha256::digest(verifier.as_bytes()); let challenge = URL_SAFE_NO_PAD.encode(digest); Pkce { verifier, challenge, } } /// Generate a random opaque `state` value for CSRF protection on the OAuth flow. /// /// The caller must hold onto the returned value and, when the authorization /// redirect arrives at the localhost listener, confirm the echoed `state` /// matches via [`states_match`] **before** calling `authenticate_with_code`. /// Skipping that check reopens the CSRF hole this value exists to close (PKCE /// still binds the code to this client, so this is defense-in-depth). pub fn generate_oauth_state() -> String { let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } /// Constant-time comparison of an OAuth `state` value against the one returned /// on the redirect. Constant-time (over equal-length inputs) so a timing side /// channel can't be used to forge a matching `state` byte by byte. pub fn states_match(expected: &str, actual: &str) -> bool { let (a, b) = (expected.as_bytes(), actual.as_bytes()); if a.len() != b.len() { return false; } let mut diff = 0u8; for (x, y) in a.iter().zip(b) { diff |= x ^ y; } diff == 0 } #[cfg(test)] mod tests { use super::*; #[test] fn states_match_only_on_equal_values() { let s = generate_oauth_state(); assert!(states_match(&s, &s.clone())); assert!(!states_match(&s, "different")); assert!(!states_match("a", "ab")); // length mismatch assert!(!states_match("abc", "abd")); assert!(states_match("", "")); } #[test] fn verifier_is_43_chars_unreserved() { let p = generate_pkce(); assert_eq!(p.verifier.len(), 43); // 32 bytes base64url-nopad assert!( p.verifier .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), "verifier must use only the unreserved PKCE charset: {}", p.verifier ); } #[test] fn challenge_is_s256_of_verifier() { let p = generate_pkce(); let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(p.verifier.as_bytes())); assert_eq!(p.challenge, expected); // SHA256 digest is 32 bytes -> 43 base64url-nopad chars. assert_eq!(p.challenge.len(), 43); } #[test] fn pairs_and_states_are_unique() { assert_ne!(generate_pkce().verifier, generate_pkce().verifier); assert_ne!(generate_oauth_state(), generate_oauth_state()); } }