Skip to main content

cratefield_oauth_client/
pkce.rs

1//! PKCE (RFC 7636) and the `state` parameter: the two single-use values an
2//! authorization request carries, and the two things that must be fresh
3//! every time.
4
5use base64::Engine as _;
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8use zeroize::Zeroizing;
9
10/// The OS refused randomness. A verifier or a state that is not fresh is
11/// worth exactly nothing, so this is an error and never a fallback.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
13#[error("no randomness available")]
14pub struct RandomError;
15
16/// An S256 proof-key pair (RFC 7636 §4): a verifier kept for the token
17/// exchange and the challenge derived from it for the authorize URL.
18///
19/// The verifier is base64url of 32 random bytes — 43 characters, the
20/// length RFC 7636 §4.1 sets as the floor — and is zeroized when dropped.
21pub struct Pkce {
22    verifier: Zeroizing<String>,
23    challenge: String,
24}
25
26impl Pkce {
27    /// A fresh pair. Fails only when randomness does.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`RandomError`] when the platform cannot provide randomness,
32    /// which on wasm means the JS backend was wired wrong: refuse rather
33    /// than authorize with a predictable verifier.
34    pub fn new() -> Result<Self, RandomError> {
35        let mut bytes = [0u8; 32];
36        getrandom::fill(&mut bytes).map_err(|_| RandomError)?;
37        let verifier =
38            Zeroizing::new(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes));
39        Ok(Self {
40            challenge: challenge_of(&verifier),
41            verifier,
42        })
43    }
44
45    /// The secret half, sent to the token endpoint with the code.
46    #[must_use]
47    pub fn verifier(&self) -> &str {
48        &self.verifier
49    }
50
51    /// The public half, sent on the authorize URL.
52    #[must_use]
53    pub fn challenge(&self) -> &str {
54        &self.challenge
55    }
56}
57
58/// The S256 challenge for a verifier (RFC 7636 §4.2): base64url of the
59/// SHA-256 of the verifier, no padding.
60fn challenge_of(verifier: &str) -> String {
61    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
62}
63
64/// A fresh opaque `state` (RFC 6749 §10.12): base64url of 16 random bytes.
65/// Whatever else a caller binds to the state — a signed token, a database
66/// row — freshness starts here.
67///
68/// # Errors
69///
70/// Returns [`RandomError`] when randomness is unavailable.
71pub fn random_state() -> Result<String, RandomError> {
72    let mut bytes = [0u8; 16];
73    getrandom::fill(&mut bytes).map_err(|_| RandomError)?;
74    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    /// RFC 7636 Appendix B, the one worked vector the RFC ships: 32 octets,
82    /// their base64url verifier, and the challenge the RFC names for it.
83    #[test]
84    fn the_s256_challenge_matches_rfc_7636_appendix_b() {
85        // [116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216,
86        //  173, 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5,
87        //  88, 83, 132, 141, 121]
88        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
89        assert_eq!(
90            challenge_of(verifier),
91            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
92        );
93    }
94
95    #[test]
96    fn a_fresh_pair_has_the_minimum_length_and_matches_itself() {
97        let pkce = Pkce::new().expect("randomness");
98        assert_eq!(pkce.verifier().len(), 43, "32 bytes of base64url");
99        assert_eq!(challenge_of(pkce.verifier()), pkce.challenge());
100    }
101
102    #[test]
103    fn pairs_are_never_reused() {
104        let first = Pkce::new().expect("randomness");
105        let second = Pkce::new().expect("randomness");
106        assert_ne!(first.verifier(), second.verifier());
107    }
108
109    #[test]
110    fn states_are_fresh_too() {
111        assert_ne!(
112            random_state().expect("randomness"),
113            random_state().expect("randomness")
114        );
115        assert_eq!(
116            random_state().expect("randomness").len(),
117            22,
118            "16 bytes of base64url"
119        );
120    }
121}