Skip to main content

max / makenotwork

2.6 KB · 77 lines History Blame Raw
1 //! Client-side OAuth2 PKCE helpers (RFC 7636, S256).
2 //!
3 //! The MNW server's `/oauth/authorize` + `/oauth/token` flow requires PKCE with
4 //! the `S256` method. A caller generates a [`Pkce`] pair up front, sends the
5 //! `challenge` to `/oauth/authorize` (via
6 //! [`build_authorize_url`](crate::SyncKitClient::build_authorize_url)), and sends
7 //! the `verifier` to `/oauth/token` (via
8 //! [`authenticate_with_code`](crate::SyncKitClient::authenticate_with_code)).
9 //!
10 //! Pair this with a localhost redirect listener and a browser to drive the full
11 //! flow from a CLI.
12
13 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
14 use rand::RngCore;
15 use sha2::{Digest, Sha256};
16
17 /// A PKCE verifier/challenge pair using the S256 method.
18 pub struct Pkce {
19 /// High-entropy secret kept by the client and sent at token-exchange time.
20 pub verifier: String,
21 /// `base64url(SHA256(verifier))`, sent to the authorize endpoint up front.
22 pub challenge: String,
23 }
24
25 /// Generate a PKCE pair (S256).
26 ///
27 /// The verifier is 32 random bytes base64url-encoded (43 chars, well within
28 /// RFC 7636's 43–128 range and using only the unreserved charset).
29 pub fn generate_pkce() -> Pkce {
30 let mut bytes = [0u8; 32];
31 rand::rng().fill_bytes(&mut bytes);
32 let verifier = URL_SAFE_NO_PAD.encode(bytes);
33 let digest = Sha256::digest(verifier.as_bytes());
34 let challenge = URL_SAFE_NO_PAD.encode(digest);
35 Pkce { verifier, challenge }
36 }
37
38 /// Generate a random opaque `state` value for CSRF protection on the OAuth flow.
39 pub fn generate_oauth_state() -> String {
40 let mut bytes = [0u8; 16];
41 rand::rng().fill_bytes(&mut bytes);
42 URL_SAFE_NO_PAD.encode(bytes)
43 }
44
45 #[cfg(test)]
46 mod tests {
47 use super::*;
48
49 #[test]
50 fn verifier_is_43_chars_unreserved() {
51 let p = generate_pkce();
52 assert_eq!(p.verifier.len(), 43); // 32 bytes base64url-nopad
53 assert!(
54 p.verifier
55 .chars()
56 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
57 "verifier must use only the unreserved PKCE charset: {}",
58 p.verifier
59 );
60 }
61
62 #[test]
63 fn challenge_is_s256_of_verifier() {
64 let p = generate_pkce();
65 let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(p.verifier.as_bytes()));
66 assert_eq!(p.challenge, expected);
67 // SHA256 digest is 32 bytes -> 43 base64url-nopad chars.
68 assert_eq!(p.challenge.len(), 43);
69 }
70
71 #[test]
72 fn pairs_and_states_are_unique() {
73 assert_ne!(generate_pkce().verifier, generate_pkce().verifier);
74 assert_ne!(generate_oauth_state(), generate_oauth_state());
75 }
76 }
77