//! 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::general_purpose::URL_SAFE_NO_PAD, Engine}; use rand::RngCore; use sha2::{Digest, Sha256}; /// 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, } /// 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. pub fn generate_oauth_state() -> String { let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } #[cfg(test)] mod tests { use super::*; #[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()); } }