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}