Skip to main content

rustlavel_oauth/
pkce.rs

1//! PKCE — Proof Key for Code Exchange, RFC 7636.
2//!
3//! The problem PKCE solves: an authorisation code travels back through a
4//! browser redirect, where another application on the device can intercept it.
5//! With only a code, the attacker can trade it for a token. PKCE makes the
6//! client commit to a secret up front — it sends the *hash* of a random
7//! verifier with the authorisation request, and the verifier itself with the
8//! token request. An intercepted code is useless without the verifier.
9//!
10//! OAuth 2.1 requires this for every authorisation code flow, public client or
11//! not, which is why nothing here makes it optional.
12
13use rustlavel_auth::{base64, random};
14use sha2::{Digest, Sha256};
15
16/// How the challenge was derived from the verifier.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ChallengeMethod {
19    /// SHA-256. The only method that offers any protection.
20    #[default]
21    S256,
22    /// The challenge *is* the verifier. RFC 7636 §4.2 permits it only where
23    /// SHA-256 is genuinely unavailable; it gives an interceptor the verifier
24    /// along with the code, so it protects against nothing.
25    Plain,
26}
27
28impl ChallengeMethod {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            ChallengeMethod::S256 => "S256",
32            ChallengeMethod::Plain => "plain",
33        }
34    }
35
36    pub fn parse(raw: &str) -> Option<ChallengeMethod> {
37        match raw {
38            "S256" => Some(ChallengeMethod::S256),
39            "plain" => Some(ChallengeMethod::Plain),
40            _ => None,
41        }
42    }
43}
44
45impl std::fmt::Display for ChallengeMethod {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(self.as_str())
48    }
49}
50
51/// A verifier and the challenge derived from it.
52///
53/// The verifier is a secret: it is held by the client between the two legs of
54/// the flow and never appears in a URL. `Debug` redacts it so it cannot reach a
55/// log by accident.
56#[derive(Clone)]
57pub struct Pkce {
58    verifier: String,
59    method: ChallengeMethod,
60}
61
62impl Pkce {
63    /// A fresh verifier from the OS CSPRNG.
64    ///
65    /// RFC 7636 §4.1 allows 43–128 characters; 32 random bytes base64url-encode
66    /// to 43, which is the minimum length and already 256 bits of entropy.
67    pub fn generate() -> Pkce {
68        Pkce {
69            verifier: base64::encode_url(&random::bytes(32)),
70            method: ChallengeMethod::S256,
71        }
72    }
73
74    /// Rebuild from a verifier the client stored between the two legs.
75    ///
76    /// Rejects anything outside RFC 7636's grammar rather than passing it on:
77    /// a verifier the provider will reject is better caught here, where the
78    /// error can say why.
79    pub fn from_verifier(verifier: impl Into<String>) -> Result<Pkce, String> {
80        let verifier = verifier.into();
81        validate_verifier(&verifier)?;
82        Ok(Pkce { verifier, method: ChallengeMethod::S256 })
83    }
84
85    /// Use `plain` instead of S256. Almost always the wrong choice.
86    pub fn plain(mut self) -> Pkce {
87        self.method = ChallengeMethod::Plain;
88        self
89    }
90
91    pub fn verifier(&self) -> &str {
92        &self.verifier
93    }
94
95    pub fn method(&self) -> ChallengeMethod {
96        self.method
97    }
98
99    /// The challenge sent with the authorisation request.
100    pub fn challenge(&self) -> String {
101        match self.method {
102            ChallengeMethod::S256 => s256(&self.verifier),
103            ChallengeMethod::Plain => self.verifier.clone(),
104        }
105    }
106}
107
108impl std::fmt::Debug for Pkce {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Pkce")
111            .field("verifier", &"<redacted>")
112            .field("method", &self.method)
113            .finish()
114    }
115}
116
117/// `BASE64URL(SHA256(ASCII(verifier)))`, without padding — RFC 7636 §4.2.
118pub fn s256(verifier: &str) -> String {
119    base64::encode_url(&Sha256::digest(verifier.as_bytes()))
120}
121
122/// Check a verifier against the challenge that was committed to earlier.
123///
124/// This is the server's half of PKCE. The comparison is constant-time: the
125/// challenge is a public value, but a timing oracle on the derived digest would
126/// still leak information about the verifier an attacker is guessing.
127pub fn verify(verifier: &str, challenge: &str, method: ChallengeMethod) -> bool {
128    if validate_verifier(verifier).is_err() {
129        return false;
130    }
131
132    let derived = match method {
133        ChallengeMethod::S256 => s256(verifier),
134        ChallengeMethod::Plain => verifier.to_string(),
135    };
136
137    rustlavel_auth::constant_time_eq(derived.as_bytes(), challenge.as_bytes())
138}
139
140/// RFC 7636 §4.1: 43–128 characters from `[A-Za-z0-9-._~]`.
141///
142/// The length floor is the point of the rule — a short verifier is guessable,
143/// and guessing it defeats the whole exchange.
144pub fn validate_verifier(verifier: &str) -> Result<(), String> {
145    if !(43..=128).contains(&verifier.len()) {
146        return Err(format!(
147            "a PKCE verifier must be 43 to 128 characters (RFC 7636 §4.1); this one is {}",
148            verifier.len()
149        ));
150    }
151
152    match verifier.bytes().find(|byte| {
153        !matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
154    }) {
155        Some(byte) => Err(format!(
156            "a PKCE verifier may only contain [A-Za-z0-9-._~]; this one contains {:?}",
157            byte as char
158        )),
159        None => Ok(()),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn matches_the_worked_example_from_rfc_7636() {
169        // Appendix B, the vector every implementation is checked against.
170        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
171        assert_eq!(s256(verifier), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
172    }
173
174    #[test]
175    fn a_generated_verifier_satisfies_the_grammar() {
176        let pkce = Pkce::generate();
177
178        assert_eq!(pkce.verifier().len(), 43, "32 bytes of entropy, unpadded");
179        validate_verifier(pkce.verifier()).expect("generated verifiers must be valid");
180    }
181
182    #[test]
183    fn two_verifiers_are_never_the_same() {
184        assert_ne!(Pkce::generate().verifier(), Pkce::generate().verifier());
185    }
186
187    #[test]
188    fn the_challenge_is_not_the_verifier_under_s256() {
189        let pkce = Pkce::generate();
190        assert_ne!(pkce.challenge(), pkce.verifier());
191        assert_eq!(pkce.challenge(), s256(pkce.verifier()));
192    }
193
194    #[test]
195    fn plain_leaks_the_verifier_into_the_url() {
196        // Asserting the property rather than approving of it: this is exactly
197        // why `plain` protects against nothing.
198        let pkce = Pkce::generate().plain();
199        assert_eq!(pkce.challenge(), pkce.verifier());
200    }
201
202    #[test]
203    fn the_right_verifier_passes_and_a_wrong_one_does_not() {
204        let pkce = Pkce::generate();
205        let challenge = pkce.challenge();
206
207        assert!(verify(pkce.verifier(), &challenge, ChallengeMethod::S256));
208        assert!(!verify(Pkce::generate().verifier(), &challenge, ChallengeMethod::S256));
209    }
210
211    #[test]
212    fn a_verifier_cannot_be_replayed_under_the_wrong_method() {
213        // An attacker who holds the challenge must not be able to pass it off
214        // as the verifier by claiming `plain`.
215        let pkce = Pkce::generate();
216        let challenge = pkce.challenge();
217
218        assert!(!verify(&challenge, &challenge, ChallengeMethod::S256));
219    }
220
221    #[test]
222    fn verification_refuses_a_malformed_verifier_outright() {
223        // Without the length check, "" hashed and compared would be a perfectly
224        // ordinary verifier — the grammar is what makes it guess-resistant.
225        assert!(!verify("", &s256(""), ChallengeMethod::S256));
226        assert!(!verify("short", &s256("short"), ChallengeMethod::S256));
227    }
228
229    #[test]
230    fn the_grammar_error_says_what_is_wrong() {
231        let too_short = validate_verifier("abc").unwrap_err();
232        assert!(too_short.contains("43 to 128"), "got {too_short}");
233
234        let long_enough_but_illegal = format!("{}/{}", "a".repeat(21), "b".repeat(21));
235        let bad_character = validate_verifier(&long_enough_but_illegal).unwrap_err();
236        assert!(bad_character.contains('/'), "got {bad_character}");
237    }
238
239    #[test]
240    fn a_verifier_at_each_boundary_is_accepted() {
241        assert!(validate_verifier(&"a".repeat(43)).is_ok());
242        assert!(validate_verifier(&"a".repeat(128)).is_ok());
243        assert!(validate_verifier(&"a".repeat(42)).is_err());
244        assert!(validate_verifier(&"a".repeat(129)).is_err());
245    }
246
247    #[test]
248    fn from_verifier_rejects_what_the_provider_would_reject() {
249        assert!(Pkce::from_verifier("too-short").is_err());
250        assert!(Pkce::from_verifier("a".repeat(43)).is_ok());
251    }
252
253    #[test]
254    fn methods_round_trip_through_their_wire_spelling() {
255        assert_eq!(ChallengeMethod::parse("S256"), Some(ChallengeMethod::S256));
256        assert_eq!(ChallengeMethod::parse("plain"), Some(ChallengeMethod::Plain));
257        assert_eq!(ChallengeMethod::parse("s256"), None, "the spelling is case-sensitive");
258        assert_eq!(ChallengeMethod::parse("none"), None);
259    }
260
261    #[test]
262    fn debug_does_not_print_the_verifier() {
263        let pkce = Pkce::generate();
264        let printed = format!("{pkce:?}");
265
266        assert!(!printed.contains(pkce.verifier()), "the verifier reached a log");
267        assert!(printed.contains("redacted"));
268    }
269}