Skip to main content

oauth_toolkit/
pkce.rs

1//! RFC 7636 PKCE (Proof Key for Code Exchange), as narrowed by OAuth 2.1.
2//!
3//! # Why `plain` is refused by default
4//!
5//! RFC 7636 §4.2 defines two challenge methods, `plain` and `S256`. OAuth 2.1
6//! (`draft-ietf-oauth-v2-1-16` §7.5.2) then **forbids `plain` outright**, on the
7//! grounds that its only historical justification was clients incapable of
8//! SHA-256 — and that OAuth 2.1 requires TLS 1.2+, which mandates SHA-256, so
9//! *"any device capable of implementing OAuth 2.1 necessarily supports SHA-256."*
10//!
11//! [`PkcePolicy::default`] therefore refuses `plain`. A deployment that really
12//! does face a client without SHA-256 can say so with
13//! [`PkcePolicy::allow_plain`], which exists as an explicit, greppable decision
14//! rather than as an accident of a `&str` comparison.
15//!
16//! # Why the method is a type, not a string
17//!
18//! This module used to take `method: &str` and return `bool`. A client that sent
19//! `"s256"`, `"S-256"` or `"S256 "` got a quiet `false` — indistinguishable from
20//! a wrong verifier. Those are different failures: a typo is a client bug to be
21//! reported, a mismatch is a possible attack to be refused silently. [`PkceMethod`]
22//! parses once and fails loudly; [`verify`] returns a [`PkceOutcome`] that says
23//! which it was.
24
25use base64::Engine;
26use base64::engine::general_purpose::URL_SAFE_NO_PAD;
27use rand::Rng;
28use sha2::Digest;
29
30/// Minimum length of a `code_verifier`, from RFC 7636 §4.1.
31pub const MIN_VERIFIER_LEN: usize = 43;
32/// Maximum length of a `code_verifier`, from RFC 7636 §4.1.
33pub const MAX_VERIFIER_LEN: usize = 128;
34
35/// PKCE code challenge method.
36///
37/// Deliberately not `#[non_exhaustive]`: a new method from a future spec is a
38/// semver event we want to make deliberately.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PkceMethod {
41    /// `S256` — `base64url(SHA256(code_verifier))`. The only method OAuth 2.1
42    /// permits.
43    S256,
44    /// `plain` — the verifier is sent as the challenge. Forbidden by OAuth 2.1
45    /// §7.5.2; see the module docs.
46    Plain,
47}
48
49impl PkceMethod {
50    /// The method's `code_challenge_method` wire value.
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::S256 => "S256",
54            Self::Plain => "plain",
55        }
56    }
57
58    /// Parse a `code_challenge_method` value.
59    ///
60    /// The comparison is exact: RFC 7636 registers `plain` and `S256`, and
61    /// these values are compared byte-for-byte, so a near-miss is an error
62    /// rather than a mismatch.
63    pub fn parse(value: &str) -> Result<Self, PkceError> {
64        match value {
65            "S256" => Ok(Self::S256),
66            "plain" => Ok(Self::Plain),
67            other => Err(PkceError::UnknownMethod(other.to_string())),
68        }
69    }
70}
71
72/// Why a PKCE verification failed.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum PkceError {
75    /// The `code_challenge_method` is not one this server knows. A client bug:
76    /// report it back as `invalid_request`.
77    UnknownMethod(String),
78    /// The method is known but refused by policy. A server decision, not a
79    /// client error.
80    MethodNotPermitted {
81        /// The method the client asked for.
82        method: PkceMethod,
83    },
84    /// The verifier does not satisfy RFC 7636 §4.1's syntax.
85    MalformedVerifier(String),
86    /// The verifier and challenge do not correspond.
87    Mismatch,
88}
89
90impl std::fmt::Display for PkceError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::UnknownMethod(m) => {
94                write!(
95                    f,
96                    "unknown code_challenge_method {m:?}; RFC 7636 defines plain and S256"
97                )
98            }
99            Self::MethodNotPermitted { method } => write!(
100                f,
101                "code_challenge_method {:?} is refused; OAuth 2.1 section 7.5.2 forbids it",
102                method.as_str()
103            ),
104            Self::MalformedVerifier(why) => {
105                write!(f, "code_verifier rejected: {why}")
106            }
107            Self::Mismatch => write!(f, "code_verifier does not match the stored challenge"),
108        }
109    }
110}
111
112impl std::error::Error for PkceError {}
113
114/// Which challenge methods this server accepts.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct PkcePolicy {
117    /// Whether RFC 7636's `plain` is accepted.
118    pub plain: bool,
119}
120
121impl Default for PkcePolicy {
122    /// `S256` only, per OAuth 2.1 `draft-ietf-oauth-v2-1-16` §7.5.2.
123    fn default() -> Self {
124        Self { plain: false }
125    }
126}
127
128impl PkcePolicy {
129    /// A policy that also accepts `plain`.
130    ///
131    /// Only for a deployment with a client demonstrably unable to do SHA-256.
132    /// The name is the point: the decision is visible in a code review.
133    pub fn allow_plain() -> Self {
134        Self { plain: true }
135    }
136
137    /// Whether this policy permits `method`.
138    pub fn permits(&self, method: PkceMethod) -> bool {
139        match method {
140            PkceMethod::S256 => true,
141            PkceMethod::Plain => self.plain,
142        }
143    }
144}
145
146/// Generate a PKCE code verifier and its S256 challenge.
147///
148/// Returns `(verifier, challenge)` where:
149/// - `verifier` is 43 characters of URL-safe base64, from 32 bytes of CSPRNG
150///   output (RFC 7636 §4.1 allows 43-128)
151/// - `challenge` is `base64url(SHA256(verifier))`
152pub fn generate_pkce_pair() -> (String, String) {
153    let mut verifier_bytes = [0u8; 32];
154    rand::rng().fill(&mut verifier_bytes[..]);
155    let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
156
157    let challenge = sha256_base64url(&verifier);
158    (verifier, challenge)
159}
160
161/// Validate a `code_verifier` against RFC 7636 §4.1's syntax.
162///
163/// `43 <= len <= 128`, and every character in the unreserved set
164/// `[A-Za-z0-9-._~]`. Enforced on the way in: a verifier that cannot have been
165/// produced by a conforming client is not worth hashing.
166pub fn validate_verifier(code_verifier: &str) -> Result<(), PkceError> {
167    let len = code_verifier.chars().count();
168    if !(MIN_VERIFIER_LEN..=MAX_VERIFIER_LEN).contains(&len) {
169        return Err(PkceError::MalformedVerifier(format!(
170            "{len} characters, RFC 7636 section 4.1 requires {MIN_VERIFIER_LEN}-{MAX_VERIFIER_LEN}"
171        )));
172    }
173    if let Some(bad) = code_verifier
174        .chars()
175        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~')))
176    {
177        return Err(PkceError::MalformedVerifier(format!(
178            "{bad:?} is not in the unreserved set [A-Za-z0-9-._~]"
179        )));
180    }
181    Ok(())
182}
183
184/// Verify a PKCE code challenge against the verifier, under `policy`.
185///
186/// The error distinguishes a client bug (unknown method, malformed verifier)
187/// from a possible attack (mismatch), because the two deserve different
188/// responses: the first is a `400 invalid_request` the client should see, the
189/// second is a `400 invalid_grant` that reveals nothing.
190pub fn verify(
191    code_verifier: &str,
192    stored_challenge: &str,
193    method: PkceMethod,
194    policy: &PkcePolicy,
195) -> Result<(), PkceError> {
196    if !policy.permits(method) {
197        return Err(PkceError::MethodNotPermitted { method });
198    }
199    validate_verifier(code_verifier)?;
200    match method {
201        PkceMethod::S256 => {
202            let computed = sha256_base64url(code_verifier);
203            if constant_time_eq(computed.as_bytes(), stored_challenge.as_bytes()) {
204                Ok(())
205            } else {
206                Err(PkceError::Mismatch)
207            }
208        }
209        PkceMethod::Plain => {
210            if constant_time_eq(code_verifier.as_bytes(), stored_challenge.as_bytes()) {
211                Ok(())
212            } else {
213                Err(PkceError::Mismatch)
214            }
215        }
216    }
217}
218
219/// Verify using the default policy (`S256` only).
220pub fn verify_with_default_policy(
221    code_verifier: &str,
222    stored_challenge: &str,
223    method: PkceMethod,
224) -> Result<(), PkceError> {
225    verify(
226        code_verifier,
227        stored_challenge,
228        method,
229        &PkcePolicy::default(),
230    )
231}
232
233fn sha256_base64url(input: &str) -> String {
234    let hash = sha2::Sha256::digest(input.as_bytes());
235    URL_SAFE_NO_PAD.encode(hash)
236}
237
238fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
239    if a.len() != b.len() {
240        return false;
241    }
242    a.iter()
243        .zip(b.iter())
244        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
245        == 0
246}
247
248#[cfg(test)]
249// Test code: unwrap/expect are the idiomatic way to assert setup success.
250#[allow(clippy::unwrap_used, clippy::expect_used)]
251mod tests {
252    use super::*;
253
254    /// RFC 7636 Appendix B, verbatim. Published test data, not a value this
255    /// crate produced — the one kind of vector that cannot drift.
256    const RFC7636_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
257    const RFC7636_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
258
259    #[test]
260    fn rfc7636_appendix_b_vector() {
261        assert!(
262            verify_with_default_policy(RFC7636_VERIFIER, RFC7636_CHALLENGE, PkceMethod::S256)
263                .is_ok()
264        );
265    }
266
267    #[test]
268    fn generate_pkce_pair_deterministic_challenge() {
269        let (v, c) = generate_pkce_pair();
270        let expected = sha256_base64url(&v);
271        assert_eq!(c, expected);
272        // A generated verifier must satisfy the syntax we now enforce.
273        validate_verifier(&v).expect("generated verifier is well formed");
274        assert_eq!(v.chars().count(), MIN_VERIFIER_LEN);
275    }
276
277    #[test]
278    fn s256_correct() {
279        let (verifier, challenge) = generate_pkce_pair();
280        assert!(verify_with_default_policy(&verifier, &challenge, PkceMethod::S256).is_ok());
281    }
282
283    #[test]
284    fn s256_wrong_verifier_is_a_mismatch_not_a_client_bug() {
285        let (verifier, challenge) = generate_pkce_pair();
286        let other = format!("{verifier}X");
287        assert_eq!(
288            verify_with_default_policy(&other, &challenge, PkceMethod::S256),
289            Err(PkceError::Mismatch)
290        );
291    }
292
293    /// OAuth 2.1 `-16` §7.5.2. The default policy refuses `plain` even when
294    /// the verifier and challenge are identical — the old `verify_pkce("test",
295    /// "test", "plain")` shape, which returned `true`.
296    #[test]
297    fn plain_is_refused_by_default_even_when_it_would_match() {
298        assert_eq!(
299            verify_with_default_policy(RFC7636_VERIFIER, RFC7636_VERIFIER, PkceMethod::Plain),
300            Err(PkceError::MethodNotPermitted {
301                method: PkceMethod::Plain
302            })
303        );
304    }
305
306    #[test]
307    fn plain_is_available_only_through_an_explicit_policy() {
308        let policy = PkcePolicy::allow_plain();
309        assert!(
310            verify(
311                RFC7636_VERIFIER,
312                RFC7636_VERIFIER,
313                PkceMethod::Plain,
314                &policy
315            )
316            .is_ok(),
317            "a deployment that knowingly accepts plain can still do so"
318        );
319    }
320
321    /// The finding that motivated this module: a near-miss method used to be a
322    /// silent `false`, indistinguishable from a wrong verifier.
323    #[test]
324    fn a_misspelled_method_is_an_error_not_a_mismatch() {
325        for typo in ["s256", "S-256", "S256 ", "sha256", ""] {
326            assert_eq!(
327                PkceMethod::parse(typo),
328                Err(PkceError::UnknownMethod(typo.to_string())),
329                "{typo:?} must not parse as a method"
330            );
331        }
332        assert_eq!(PkceMethod::parse("S256"), Ok(PkceMethod::S256));
333        assert_eq!(PkceMethod::parse("plain"), Ok(PkceMethod::Plain));
334    }
335
336    #[test]
337    fn verifier_syntax_is_enforced_per_rfc7636_4_1() {
338        assert!(validate_verifier(RFC7636_VERIFIER).is_ok());
339        assert!(matches!(
340            validate_verifier("too-short"),
341            Err(PkceError::MalformedVerifier(_))
342        ));
343        assert!(matches!(
344            validate_verifier(&"a".repeat(MAX_VERIFIER_LEN + 1)),
345            Err(PkceError::MalformedVerifier(_))
346        ));
347        assert!(matches!(
348            validate_verifier(&"a".repeat(MAX_VERIFIER_LEN)),
349            Ok(())
350        ));
351        // `+` and `/` are base64 but not unreserved.
352        assert!(matches!(
353            validate_verifier(&"a".repeat(42).replace('a', "+")),
354            Err(PkceError::MalformedVerifier(_))
355        ));
356        // A malformed verifier is reported before any hashing happens.
357        assert!(matches!(
358            verify_with_default_policy("short", RFC7636_CHALLENGE, PkceMethod::S256),
359            Err(PkceError::MalformedVerifier(_))
360        ));
361    }
362}