Skip to main content

max / synckit

4.2 KB · 121 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, engine::general_purpose::URL_SAFE_NO_PAD};
14 use rand::Rng;
15 use sha2::{Digest, Sha256};
16 use zeroize::Zeroize;
17
18 /// A PKCE verifier/challenge pair using the S256 method.
19 pub struct Pkce {
20 /// High-entropy secret kept by the client and sent at token-exchange time.
21 pub verifier: String,
22 /// `base64url(SHA256(verifier))`, sent to the authorize endpoint up front.
23 pub challenge: String,
24 }
25
26 impl Drop for Pkce {
27 fn drop(&mut self) {
28 // The verifier is the PKCE secret that proves possession at token
29 // exchange; scrub it from the heap on drop. The challenge is a public
30 // SHA-256 of it (sent in the clear to /authorize), so it needs none.
31 self.verifier.zeroize();
32 }
33 }
34
35 /// Generate a PKCE pair (S256).
36 ///
37 /// The verifier is 32 random bytes base64url-encoded (43 chars, well within
38 /// RFC 7636's 43–128 range and using only the unreserved charset).
39 pub fn generate_pkce() -> Pkce {
40 let mut bytes = [0u8; 32];
41 rand::rng().fill_bytes(&mut bytes);
42 let verifier = URL_SAFE_NO_PAD.encode(bytes);
43 let digest = Sha256::digest(verifier.as_bytes());
44 let challenge = URL_SAFE_NO_PAD.encode(digest);
45 Pkce {
46 verifier,
47 challenge,
48 }
49 }
50
51 /// Generate a random opaque `state` value for CSRF protection on the OAuth flow.
52 ///
53 /// The caller must hold onto the returned value and, when the authorization
54 /// redirect arrives at the localhost listener, confirm the echoed `state`
55 /// matches via [`states_match`] **before** calling `authenticate_with_code`.
56 /// Skipping that check reopens the CSRF hole this value exists to close (PKCE
57 /// still binds the code to this client, so this is defense-in-depth).
58 pub fn generate_oauth_state() -> String {
59 let mut bytes = [0u8; 16];
60 rand::rng().fill_bytes(&mut bytes);
61 URL_SAFE_NO_PAD.encode(bytes)
62 }
63
64 /// Constant-time comparison of an OAuth `state` value against the one returned
65 /// on the redirect. Constant-time (over equal-length inputs) so a timing side
66 /// channel can't be used to forge a matching `state` byte by byte.
67 pub fn states_match(expected: &str, actual: &str) -> bool {
68 let (a, b) = (expected.as_bytes(), actual.as_bytes());
69 if a.len() != b.len() {
70 return false;
71 }
72 let mut diff = 0u8;
73 for (x, y) in a.iter().zip(b) {
74 diff |= x ^ y;
75 }
76 diff == 0
77 }
78
79 #[cfg(test)]
80 mod tests {
81 use super::*;
82
83 #[test]
84 fn states_match_only_on_equal_values() {
85 let s = generate_oauth_state();
86 assert!(states_match(&s, &s.clone()));
87 assert!(!states_match(&s, "different"));
88 assert!(!states_match("a", "ab")); // length mismatch
89 assert!(!states_match("abc", "abd"));
90 assert!(states_match("", ""));
91 }
92
93 #[test]
94 fn verifier_is_43_chars_unreserved() {
95 let p = generate_pkce();
96 assert_eq!(p.verifier.len(), 43); // 32 bytes base64url-nopad
97 assert!(
98 p.verifier
99 .chars()
100 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
101 "verifier must use only the unreserved PKCE charset: {}",
102 p.verifier
103 );
104 }
105
106 #[test]
107 fn challenge_is_s256_of_verifier() {
108 let p = generate_pkce();
109 let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(p.verifier.as_bytes()));
110 assert_eq!(p.challenge, expected);
111 // SHA256 digest is 32 bytes -> 43 base64url-nopad chars.
112 assert_eq!(p.challenge.len(), 43);
113 }
114
115 #[test]
116 fn pairs_and_states_are_unique() {
117 assert_ne!(generate_pkce().verifier, generate_pkce().verifier);
118 assert_ne!(generate_oauth_state(), generate_oauth_state());
119 }
120 }
121