Skip to main content

vtcode_auth/
pkce.rs

1//! PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0.
2//!
3//! Implements RFC 7636 for secure OAuth flows without client secrets.
4//! Uses SHA-256 (S256) code challenge method as recommended by the spec.
5
6use anyhow::{Context, Result};
7use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
8use ring::rand::{SecureRandom, SystemRandom};
9use sha2::{Digest, Sha256};
10use std::fmt;
11
12/// PKCE code verifier length (43-128 characters per RFC 7636)
13const CODE_VERIFIER_LENGTH: usize = 64;
14
15/// Characters allowed in code verifier (unreserved URI characters)
16const CODE_VERIFIER_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
17
18/// PKCE challenge pair containing verifier and challenge strings.
19///
20/// Custom `Debug` redacts the `code_verifier` — it is a secret that must
21/// never leave the client. The `code_challenge` and method are safe to
22/// display (they are sent to the authorization server in the URL).
23#[derive(Clone)]
24pub struct PkceChallenge {
25    /// The code verifier (random string, kept secret by client)
26    pub(crate) code_verifier: String,
27    /// The code challenge (SHA-256 hash of verifier, sent to authorization server)
28    pub(crate) code_challenge: String,
29    /// The challenge method (always "S256" for SHA-256)
30    pub(crate) code_challenge_method: String,
31}
32
33impl fmt::Debug for PkceChallenge {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.debug_struct("PkceChallenge")
36            .field("code_verifier", &"<redacted>")
37            .field("code_challenge", &self.code_challenge)
38            .field("code_challenge_method", &self.code_challenge_method)
39            .finish()
40    }
41}
42
43impl PkceChallenge {
44    /// Return the code challenge (safe to share with the authorization server).
45    pub fn code_challenge(&self) -> &str {
46        &self.code_challenge
47    }
48
49    /// Return the challenge method (always `"S256"`).
50    pub fn code_challenge_method(&self) -> &str {
51        &self.code_challenge_method
52    }
53
54    /// Create a new PKCE challenge from a code verifier.
55    fn from_verifier(code_verifier: String) -> Result<Self> {
56        let code_challenge = compute_s256_challenge(&code_verifier)?;
57        Ok(Self {
58            code_verifier,
59            code_challenge,
60            code_challenge_method: "S256".to_string(),
61        })
62    }
63}
64
65/// Generate a cryptographically secure PKCE challenge pair.
66///
67/// This function generates a random code verifier and computes
68/// the corresponding S256 code challenge.
69///
70/// # Example
71/// ```
72/// use vtcode_auth::generate_pkce_challenge;
73///
74/// let challenge = generate_pkce_challenge().unwrap();
75/// // code_verifier is a secret — never print or log it.
76/// assert!(!challenge.code_challenge().is_empty());
77/// ```
78pub fn generate_pkce_challenge() -> Result<PkceChallenge> {
79    let code_verifier = generate_code_verifier()?;
80    PkceChallenge::from_verifier(code_verifier)
81}
82
83/// Generate a cryptographically random code verifier per RFC 7636 §4.1.
84///
85/// Uses `ring::rand::SystemRandom` (backed by the OS CSPRNG) instead of a
86/// user-space PRNG to ensure ≥128 bits of entropy as required by the spec.
87fn generate_code_verifier() -> Result<String> {
88    let rng = SystemRandom::new();
89    let charset_len = u8::try_from(CODE_VERIFIER_CHARSET.len()).context("PKCE verifier alphabet is too large")?;
90    let max_valid =
91        u8::try_from(256u16 - 256u16 % u16::from(charset_len)).context("PKCE verifier rejection range is invalid")?;
92    let mut verifier = String::with_capacity(CODE_VERIFIER_LENGTH);
93    let mut buf = [0u8; 1];
94
95    while verifier.len() < CODE_VERIFIER_LENGTH {
96        rng.fill(&mut buf)
97            .map_err(|_| anyhow::anyhow!("failed to read from OS random source"))?;
98        // Rejection sampling to avoid modulo bias.
99        if buf[0] < max_valid {
100            let idx = usize::from(buf[0] % charset_len);
101            if let Some(&character) = CODE_VERIFIER_CHARSET.get(idx) {
102                verifier.push(char::from(character));
103            }
104        }
105    }
106
107    Ok(verifier)
108}
109
110/// Compute S256 code challenge from a code verifier.
111///
112/// S256 = BASE64URL(SHA256(code_verifier))
113fn compute_s256_challenge(code_verifier: &str) -> Result<String> {
114    let mut hasher = Sha256::new();
115    hasher.update(code_verifier.as_bytes());
116    let hash = hasher.finalize();
117
118    Ok(URL_SAFE_NO_PAD.encode(hash))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_generate_pkce_challenge() {
127        let challenge = generate_pkce_challenge().unwrap();
128
129        // Verify code verifier length
130        assert_eq!(challenge.code_verifier.len(), CODE_VERIFIER_LENGTH);
131
132        // Verify all characters are in allowed charset
133        for c in challenge.code_verifier.chars() {
134            assert!(CODE_VERIFIER_CHARSET.contains(&(c as u8)), "Invalid character in verifier: {c}");
135        }
136
137        // Verify challenge method
138        assert_eq!(challenge.code_challenge_method, "S256");
139
140        // Verify challenge is valid base64url (43 chars for SHA-256)
141        assert_eq!(challenge.code_challenge.len(), 43);
142    }
143
144    #[test]
145    fn test_deterministic_challenge() {
146        // Same verifier should produce same challenge
147        let verifier = "test_verifier_string_for_deterministic_test";
148        let challenge1 = PkceChallenge::from_verifier(verifier.to_string()).unwrap();
149        let challenge2 = PkceChallenge::from_verifier(verifier.to_string()).unwrap();
150
151        assert_eq!(challenge1.code_challenge, challenge2.code_challenge);
152    }
153
154    #[test]
155    fn test_unique_verifiers() {
156        // Multiple calls should produce different verifiers
157        let c1 = generate_pkce_challenge().unwrap();
158        let c2 = generate_pkce_challenge().unwrap();
159
160        assert_ne!(c1.code_verifier, c2.code_verifier);
161    }
162}