Skip to main content

protocol_droid/domain/oauth/
pkce.rs

1//! Proof Key for Code Exchange, as RFC 7636 specifies it.
2//!
3//! # What this is for
4//!
5//! The authorization code arrives in a URL — a redirect, a browser history, a
6//! `Referer` header, an intermediary log. PKCE binds that code to the client
7//! that asked for it: the client holds a `code_verifier` it never sends, and
8//! the authorization server only issues the token to a redeemer that can
9//! produce the matching `code_challenge`. A stolen code on its own is useless.
10//!
11//! # The grammar, from the RFC
12//!
13//! ```text
14//! code-verifier = 43*128unreserved
15//! unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
16//! ```
17//!
18//! [`CodeVerifier`] parses exactly that and refuses anything else, because a
19//! verifier that does not satisfy the grammar is one some authorization servers
20//! reject and others accept — the kind of difference that is a login failure
21//! rather than an error message.
22//!
23//! ```text
24//! plain  code_challenge = code_verifier
25//! S256   code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
26//! ```
27//!
28//! `S256` is Mandatory To Implement on the server, so RFC 7636 §4.2 says a
29//! capable client **MUST** use it and may use `plain` only where it cannot
30//! support `S256` *and* has out-of-band knowledge that the server accepts it.
31//! [`ChallengeMethod::Plain`] exists because the grammar has it, and
32//! [`CodeVerifier::challenge`] does not stop a caller choosing it — the
33//! judgement is a deployment's, not a library's.
34//!
35//! # The verifier is a secret
36//!
37//! It is exactly as sensitive as the token it will be exchanged for, and more
38//! exposed: it crosses a public network on every authorization, while the token
39//! may only cross a back channel. So [`CodeVerifier`] renders as `CodeVerifier(…)`
40//! under [`Debug`](core::fmt::Debug) — the path a panic and an `assert_eq!`
41//! expand to — and reserves [`Display`](core::fmt::Display) for putting one in
42//! a token request body, which is the one place it legitimately has to appear.
43//!
44//! # Examples
45//!
46//! ```
47//! use protocol_droid::domain::oauth::{ChallengeMethod, CodeVerifier};
48//!
49//! // The vector in RFC 7636 Appendix A.
50//! let verifier = CodeVerifier::new("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")?;
51//! let challenge = verifier.challenge(ChallengeMethod::S256);
52//! assert_eq!(
53//!     challenge.as_str(),
54//!     "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
55//! );
56//! # Ok::<(), protocol_droid::domain::oauth::PkceError>(())
57//! ```
58
59use core::fmt;
60
61use base64::Engine as _;
62use base64::engine::general_purpose::URL_SAFE_NO_PAD;
63use sha2::{Digest, Sha256};
64
65/// RFC 7636 §4.1: `code-verifier = 43*128unreserved`.
66///
67/// The bounds are counted in characters, which is unambiguous because §4.1's
68/// `unreserved` is ASCII-only — there is no code point outside ASCII that the
69/// grammar admits.
70const VERIFIER_MIN: usize = 43;
71
72/// RFC 7636 §4.1: the upper bound of `43*128unreserved`.
73const VERIFIER_MAX: usize = 128;
74
75/// A `code_verifier`: the value a client keeps and never sends until it redeems
76/// the authorization code.
77///
78/// # Redaction
79///
80/// Renders as `CodeVerifier(…)` under [`Debug`](fmt::Debug). It is a secret
81/// with the same lifetime and reach as the token it becomes, so it must not
82/// escape through a panic message or an assertion failure. [`Display`](fmt::Display)
83/// is the deliberate exception, because a client has to put this in a
84/// `code_verifier` form field to redeem the code at all.
85#[derive(Clone, PartialEq, Eq)]
86pub struct CodeVerifier(String);
87
88impl fmt::Debug for CodeVerifier {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str("CodeVerifier(…)")
91    }
92}
93
94impl CodeVerifier {
95    /// Parse a verifier against RFC 7636 §4.1's grammar.
96    ///
97    /// # Errors
98    ///
99    /// [`PkceError::VerifierLength`] if it is shorter than 43 or longer than
100    /// 128 characters; [`PkceError::VerifierCharset`] if it contains anything
101    /// outside `unreserved`.
102    pub fn new(value: impl Into<String>) -> Result<Self, PkceError> {
103        let value = value.into();
104        let length = value.chars().count();
105        if !(VERIFIER_MIN..=VERIFIER_MAX).contains(&length) {
106            return Err(PkceError::VerifierLength { length });
107        }
108        // Every byte is checked rather than every character: the grammar is
109        // defined over ASCII, so a multi-byte character is a charset failure
110        // and should be reported as one rather than counted as several.
111        let offending = value
112            .as_bytes()
113            .iter()
114            .copied()
115            .find(|byte| !is_unreserved(*byte));
116        if let Some(byte) = offending {
117            return Err(PkceError::VerifierCharset { byte });
118        }
119        Ok(Self(value))
120    }
121
122    /// The verifier, as it goes in a `code_verifier` form field.
123    #[must_use]
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127
128    /// The challenge this verifier produces under `method`.
129    ///
130    /// RFC 7636 §4.3. `Plain` returns the verifier unchanged; `S256` returns
131    /// `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`, unpadded, which is
132    /// 43 characters and always inside the `code_challenge` grammar.
133    #[must_use]
134    pub fn challenge(&self, method: ChallengeMethod) -> CodeChallenge {
135        match method {
136            ChallengeMethod::Plain => CodeChallenge(self.0.clone()),
137            ChallengeMethod::S256 => {
138                let digest = Sha256::digest(self.0.as_bytes());
139                CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
140            }
141        }
142    }
143}
144
145/// Implements `fmt::Display` for a `CodeVerifier`.
146///
147/// This is the one path that prints the secret in full, and it exists so a
148/// client can build a `code_verifier` field. Prefer
149/// [`as_str`](CodeVerifier::as_str) at any call site that is not literally
150/// serialising one into a token request.
151impl fmt::Display for CodeVerifier {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter.write_str(&self.0)
154    }
155}
156
157/// RFC 7636 §4.3's `code_challenge_method`.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
159pub enum ChallengeMethod {
160    /// `code_challenge = code_verifier`, sent as `plain`.
161    ///
162    /// Permitted by the grammar and by §4.3, but §4.2 says a client capable of
163    /// `S256` **MUST** use it — `plain` is only for a client that cannot do
164    /// better *and* knows the server accepts it. It provides no protection
165    /// against an intercepted code, which is the whole point of the mechanism.
166    Plain,
167    /// `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`, sent as `S256`.
168    ///
169    /// Mandatory To Implement on the server per §4.2, so this is the default.
170    #[default]
171    S256,
172}
173
174impl ChallengeMethod {
175    /// The wire spelling: `plain` or `S256`.
176    #[must_use]
177    pub const fn as_str(self) -> &'static str {
178        match self {
179            Self::Plain => "plain",
180            Self::S256 => "S256",
181        }
182    }
183}
184
185impl fmt::Display for ChallengeMethod {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter.write_str(self.as_str())
188    }
189}
190
191/// A `code_challenge`: the public half of the exchange.
192///
193/// Not a secret — it travels in the authorization request, and an attacker
194/// holding one cannot derive the verifier from it. [`Debug`] is therefore
195/// derived, unlike [`CodeVerifier`]'s.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct CodeChallenge(String);
198
199impl CodeChallenge {
200    /// The challenge, as it goes in a `code_challenge` query parameter.
201    #[must_use]
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205}
206
207impl fmt::Display for CodeChallenge {
208    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
209        formatter.write_str(&self.0)
210    }
211}
212
213/// A `code_verifier` that does not satisfy RFC 7636 §4.1.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
215pub enum PkceError {
216    /// Outside `43*128unreserved`.
217    #[error("a code verifier must be 43 to 128 characters, got {length}")]
218    VerifierLength {
219        /// The length actually supplied, counted in characters.
220        length: usize,
221    },
222
223    /// Contains a byte `unreserved` does not admit.
224    #[error(
225        "a code verifier may only contain ALPHA, DIGIT, '-', '.', '_' and '~'; found 0x{byte:02X}"
226    )]
227    VerifierCharset {
228        /// The first offending byte, as it appeared.
229        byte: u8,
230    },
231}
232
233/// RFC 7636 §4.1: `unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"`.
234const fn is_unreserved(byte: u8) -> bool {
235    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{ChallengeMethod, CodeVerifier, PkceError, VERIFIER_MAX, VERIFIER_MIN};
241
242    /// The vector RFC 7636 Appendix A publishes, used verbatim so the
243    /// derivation is checked against the document rather than against this
244    /// crate's own idea of SHA-256 and base64url.
245    const RFC_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
246    const RFC_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
247
248    #[test]
249    fn the_rfc_vector_derives_the_documented_challenge() -> Result<(), PkceError> {
250        let verifier = CodeVerifier::new(RFC_VERIFIER)?;
251        let challenge = verifier.challenge(ChallengeMethod::S256);
252        assert_eq!(
253            challenge.as_str(),
254            RFC_CHALLENGE,
255            "RFC 7636 Appendix A pins this derivation"
256        );
257        Ok(())
258    }
259
260    #[test]
261    fn the_rfc_vector_is_at_the_grammar_minimum() {
262        // 43 characters: if this ever changes, the vector is being truncated
263        // rather than the grammar being read.
264        assert_eq!(RFC_VERIFIER.chars().count(), VERIFIER_MIN);
265        assert_eq!(RFC_CHALLENGE.chars().count(), VERIFIER_MIN);
266    }
267
268    #[test]
269    fn plain_is_the_verifier_unchanged() -> Result<(), PkceError> {
270        let verifier = CodeVerifier::new(RFC_VERIFIER)?;
271        assert_eq!(
272            verifier.challenge(ChallengeMethod::Plain).as_str(),
273            RFC_VERIFIER
274        );
275        Ok(())
276    }
277
278    /// Both bounds of `43*128unreserved`, checked by construction rather than
279    /// by asserting a length the parser happens to accept.
280    #[test]
281    fn both_length_bounds_are_enforced() {
282        let shortest = "a".repeat(VERIFIER_MIN);
283        let longest = "a".repeat(VERIFIER_MAX);
284        assert!(
285            CodeVerifier::new(shortest).is_ok(),
286            "{VERIFIER_MIN} characters is the grammar minimum"
287        );
288        assert!(
289            CodeVerifier::new(longest).is_ok(),
290            "{VERIFIER_MAX} characters is the grammar maximum"
291        );
292
293        let too_short = "a".repeat(VERIFIER_MIN - 1);
294        let too_long = "a".repeat(VERIFIER_MAX + 1);
295        assert_eq!(
296            CodeVerifier::new(too_short),
297            Err(PkceError::VerifierLength {
298                length: VERIFIER_MIN - 1
299            })
300        );
301        assert_eq!(
302            CodeVerifier::new(too_long),
303            Err(PkceError::VerifierLength {
304                length: VERIFIER_MAX + 1
305            })
306        );
307    }
308
309    /// Every character `unreserved` admits, and a representative sample of ones
310    /// it does not. The boundary is easy to get subtly wrong, so it is walked
311    /// rather than spot-checked.
312    #[test]
313    fn the_charset_is_exactly_unreserved() {
314        /// A legal-length verifier whose first character is `first`.
315        fn with_first(first: char) -> String {
316            std::iter::once(first)
317                .chain(std::iter::repeat_n('a', VERIFIER_MIN - 1))
318                .collect()
319        }
320
321        for byte in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" {
322            assert!(
323                CodeVerifier::new(with_first(char::from(*byte))).is_ok(),
324                "0x{byte:02X} is unreserved and must be accepted"
325            );
326        }
327        // Characters the grammar excludes. `+`, `/` and `=` are the ones that
328        // matter in practice: they are what a padded base64 alphabet contains,
329        // and a verifier that came from one fails at some servers and not
330        // others. Space and `%` are a value that reached the wire unencoded.
331        for byte in *b"+/= %*!" {
332            assert_eq!(
333                CodeVerifier::new(with_first(char::from(byte))),
334                Err(PkceError::VerifierCharset { byte }),
335                "0x{byte:02X} is not unreserved"
336            );
337        }
338    }
339
340    /// A non-ASCII character is a charset failure, not a length failure, even
341    /// though it is more than one byte. Reported as the former because the
342    /// remedy is different: a caller told it is too short will pad.
343    #[test]
344    fn a_multi_byte_character_is_a_charset_failure() {
345        // "€" is three bytes, so a byte count would call this 45 characters.
346        let candidate: String = std::iter::once('€')
347            .chain(std::iter::repeat_n('a', VERIFIER_MIN - 1))
348            .collect();
349        assert_eq!(
350            CodeVerifier::new(candidate),
351            Err(PkceError::VerifierCharset { byte: 0xE2 })
352        );
353    }
354
355    /// The verifier is a secret, and `Debug` is what a panic or an
356    /// `assert_eq!` expands to.
357    #[test]
358    fn debug_never_renders_the_verifier() -> Result<(), PkceError> {
359        let verifier = CodeVerifier::new(RFC_VERIFIER)?;
360        let rendered = format!("{verifier:?}");
361        assert_eq!(rendered, "CodeVerifier(…)");
362        assert!(
363            !rendered.contains(RFC_VERIFIER),
364            "the verifier leaked: {rendered}"
365        );
366        Ok(())
367    }
368
369    /// `Display` is the deliberate exception: it is how a client builds the
370    /// `code_verifier` field. Pinned here so making it redacting cannot happen
371    /// without breaking the exchange.
372    #[test]
373    fn display_is_the_full_verifier() -> Result<(), PkceError> {
374        let verifier = CodeVerifier::new(RFC_VERIFIER)?;
375        assert_eq!(verifier.to_string(), RFC_VERIFIER);
376        assert_eq!(verifier.as_str(), RFC_VERIFIER);
377        Ok(())
378    }
379
380    #[test]
381    fn the_method_spells_itself_the_way_the_rfc_writes_it() {
382        assert_eq!(ChallengeMethod::Plain.as_str(), "plain");
383        assert_eq!(ChallengeMethod::S256.as_str(), "S256");
384        // S256 is the default because §4.2 makes it mandatory for a capable
385        // client; defaulting the other way would make the unsafe choice the
386        // one a caller gets for writing nothing.
387        assert_eq!(ChallengeMethod::default(), ChallengeMethod::S256);
388    }
389
390    /// An S256 challenge is 43 characters regardless of the verifier, because
391    /// SHA-256 is 32 bytes and unpadded base64url of 32 bytes is 43. Both
392    /// bounds of the challenge grammar therefore hold for every input.
393    #[test]
394    fn an_s256_challenge_is_always_the_right_size() -> Result<(), PkceError> {
395        for length in [VERIFIER_MIN, VERIFIER_MIN + 1, VERIFIER_MAX] {
396            let verifier = CodeVerifier::new("a".repeat(length))?;
397            let challenge = verifier.challenge(ChallengeMethod::S256);
398            assert_eq!(
399                challenge.as_str().chars().count(),
400                VERIFIER_MIN,
401                "a challenge derived from a {length}-character verifier"
402            );
403        }
404        Ok(())
405    }
406}