atproto_oauth/
pkce.rs

1//! PKCE (Proof Key for Code Exchange) implementation for OAuth 2.0.
2//!
3//! This module implements the PKCE security extension defined in RFC 7636, which protects
4//! OAuth 2.0 authorization code flows from interception attacks. PKCE is particularly
5//! important for public clients such as mobile applications and single-page applications
6//! that cannot securely store client secrets.
7//!
8//! ## PKCE Flow
9//!
10//! 1. **Generate**: Create a cryptographically random code verifier and its SHA256 challenge
11//! 2. **Authorize**: Send the code challenge with the authorization request
12//! 3. **Exchange**: Send the original code verifier when exchanging the authorization code for tokens
13//!
14//! ## Example
15//!
16//! ```rust
17//! use atproto_oauth::pkce;
18//!
19//! // Generate PKCE parameters
20//! let (code_verifier, code_challenge) = pkce::generate();
21//!
22//! // Use code_challenge in authorization URL
23//! println!("Authorization URL: https://auth.example.com/oauth/authorize?code_challenge={}", code_challenge);
24//!
25//! // Later, use code_verifier when exchanging authorization code for tokens
26//! println!("Token exchange: code_verifier={}", code_verifier);
27//! ```
28//!
29//! ## Security
30//!
31//! - Code verifiers are generated using cryptographically secure random number generation
32//! - Challenges use SHA256 hashing with base64url encoding (without padding)
33//! - Implements the S256 code challenge method as specified in RFC 7636
34
35use base64::{Engine as _, engine::general_purpose};
36use rand::{Rng, distributions::Alphanumeric};
37use sha2::{Digest, Sha256};
38
39/// Generates a PKCE code verifier and code challenge pair.
40///
41/// Creates a cryptographically random code verifier (100 characters) and computes
42/// its corresponding SHA256 code challenge. This follows the PKCE specification
43/// in RFC 7636 using the S256 code challenge method.
44///
45/// # Returns
46///
47/// A tuple containing:
48/// - `String`: The code verifier (random alphanumeric string)
49/// - `String`: The code challenge (base64url-encoded SHA256 hash of verifier)
50///
51/// # Example
52///
53/// ```rust
54/// use atproto_oauth::pkce;
55///
56/// let (verifier, challenge) = pkce::generate();
57/// assert_eq!(verifier.len(), 100);
58/// assert!(!challenge.is_empty());
59/// ```
60pub fn generate() -> (String, String) {
61    let token: String = rand::thread_rng()
62        .sample_iter(&Alphanumeric)
63        .take(100)
64        .map(char::from)
65        .collect();
66    (token.clone(), challenge(&token))
67}
68
69/// Creates a PKCE code challenge from a code verifier.
70///
71/// Computes the SHA256 hash of the provided code verifier and encodes it using
72/// base64url encoding without padding. This implements the S256 code challenge
73/// method as specified in RFC 7636 section 4.2.
74///
75/// # Arguments
76///
77/// * `token` - The code verifier string to hash
78///
79/// # Returns
80///
81/// The base64url-encoded SHA256 hash of the code verifier
82///
83/// # Example
84///
85/// ```rust
86/// use atproto_oauth::pkce;
87///
88/// let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
89/// let challenge = pkce::challenge(verifier);
90/// assert!(!challenge.is_empty());
91/// assert!(!challenge.contains('='));  // No padding in base64url
92/// ```
93pub fn challenge(token: &str) -> String {
94    let mut hasher = Sha256::new();
95    hasher.update(token.as_bytes());
96    let result = hasher.finalize();
97
98    general_purpose::URL_SAFE_NO_PAD.encode(result)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::collections::HashSet;
105
106    #[test]
107    fn test_generate_returns_correct_verifier_length() {
108        let (verifier, _) = generate();
109        assert_eq!(
110            verifier.len(),
111            100,
112            "Code verifier should be exactly 100 characters"
113        );
114    }
115
116    #[test]
117    fn test_generate_verifier_is_alphanumeric() {
118        let (verifier, _) = generate();
119        assert!(
120            verifier.chars().all(|c| c.is_alphanumeric()),
121            "Code verifier should contain only alphanumeric characters"
122        );
123    }
124
125    #[test]
126    fn test_generate_challenge_is_not_empty() {
127        let (_, challenge) = generate();
128        assert!(!challenge.is_empty(), "Code challenge should not be empty");
129    }
130
131    #[test]
132    fn test_generate_challenge_is_base64url_without_padding() {
133        let (_, challenge) = generate();
134
135        // Should not contain padding characters
136        assert!(
137            !challenge.contains('='),
138            "Code challenge should not contain padding (=) characters"
139        );
140
141        // Should only contain valid base64url characters
142        assert!(
143            challenge
144                .chars()
145                .all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
146            "Code challenge should only contain base64url characters (A-Z, a-z, 0-9, -, _)"
147        );
148    }
149
150    #[test]
151    fn test_generate_produces_unique_values() {
152        let mut verifiers = HashSet::new();
153        let mut challenges = HashSet::new();
154
155        // Generate multiple PKCE pairs and ensure they're all unique
156        for _ in 0..10 {
157            let (verifier, challenge) = generate();
158            assert!(
159                verifiers.insert(verifier.clone()),
160                "Code verifiers should be unique"
161            );
162            assert!(
163                challenges.insert(challenge.clone()),
164                "Code challenges should be unique"
165            );
166        }
167    }
168
169    #[test]
170    fn test_generate_verifier_and_challenge_are_related() {
171        let (verifier, challenge_result) = generate();
172        let computed_challenge = challenge(&verifier);
173        assert_eq!(
174            challenge_result, computed_challenge,
175            "Generated challenge should match computed challenge from verifier"
176        );
177    }
178
179    #[test]
180    fn test_challenge_with_known_input() {
181        // Test vector from RFC 7636 example
182        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
183        let expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
184
185        let actual_challenge = challenge(verifier);
186        assert_eq!(
187            actual_challenge, expected_challenge,
188            "Challenge should match RFC 7636 test vector"
189        );
190    }
191
192    #[test]
193    fn test_challenge_deterministic() {
194        let verifier = "test_verifier_123";
195        let challenge1 = challenge(verifier);
196        let challenge2 = challenge(verifier);
197
198        assert_eq!(
199            challenge1, challenge2,
200            "Challenge function should be deterministic"
201        );
202    }
203
204    #[test]
205    fn test_challenge_different_inputs_produce_different_outputs() {
206        let challenge1 = challenge("verifier1");
207        let challenge2 = challenge("verifier2");
208
209        assert_ne!(
210            challenge1, challenge2,
211            "Different verifiers should produce different challenges"
212        );
213    }
214
215    #[test]
216    fn test_challenge_empty_string() {
217        let result = challenge("");
218        assert!(
219            !result.is_empty(),
220            "Challenge of empty string should not be empty"
221        );
222        assert!(
223            !result.contains('='),
224            "Challenge should not contain padding"
225        );
226    }
227
228    #[test]
229    fn test_challenge_unicode_input() {
230        let verifier = "test_émojis_🔐_unicode";
231        let result = challenge(verifier);
232
233        assert!(
234            !result.is_empty(),
235            "Challenge should work with unicode input"
236        );
237        assert!(
238            !result.contains('='),
239            "Challenge should not contain padding"
240        );
241    }
242
243    #[test]
244    fn test_challenge_very_long_input() {
245        let verifier = "a".repeat(1000);
246        let result = challenge(&verifier);
247
248        assert!(
249            !result.is_empty(),
250            "Challenge should work with very long input"
251        );
252        assert!(
253            !result.contains('='),
254            "Challenge should not contain padding"
255        );
256    }
257
258    #[test]
259    fn test_challenge_output_length_consistency() {
260        // SHA256 produces 32 bytes, base64url encoding should produce consistent length
261        let expected_length = 43; // 32 bytes -> 43 characters in base64url without padding
262
263        let long_string = "a".repeat(100);
264        let test_inputs = vec![
265            "",
266            "short",
267            &long_string,
268            "unicode_🔐_test",
269            "special!@#$%^&*()characters",
270        ];
271
272        for input in test_inputs {
273            let result = challenge(input);
274            assert_eq!(
275                result.len(),
276                expected_length,
277                "Challenge output length should be consistent for input: '{}'",
278                input
279            );
280        }
281    }
282
283    #[test]
284    fn test_rfc7636_compliance() {
285        // Test that our implementation follows RFC 7636 requirements
286        let (verifier, challenge_result) = generate();
287
288        // RFC 7636 Section 4.1: code_verifier should be 43-128 characters
289        // Our implementation uses 100 characters
290        assert!(
291            verifier.len() >= 43 && verifier.len() <= 128,
292            "Code verifier length should comply with RFC 7636 (43-128 characters)"
293        );
294
295        // RFC 7636 Section 4.1: code_verifier should use [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
296        // Our implementation uses only alphanumeric (subset of allowed characters)
297        assert!(
298            verifier.chars().all(|c| {
299                "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".contains(c)
300            }),
301            "Code verifier should use RFC 7636 compliant character set"
302        );
303
304        // RFC 7636 Section 4.2: code_challenge should be base64url-encoded SHA256
305        // Should be 43 characters (32 bytes SHA256 -> 43 chars base64url without padding)
306        assert_eq!(
307            challenge_result.len(),
308            43,
309            "Code challenge should be 43 characters (SHA256 base64url encoded)"
310        );
311    }
312
313    #[test]
314    fn test_pkce_flow_simulation() {
315        // Simulate a complete PKCE flow
316
317        // Step 1: Client generates PKCE parameters
318        let (code_verifier, code_challenge) = generate();
319
320        // Step 2: Client sends code_challenge to authorization server
321        // (In real implementation, this would be in authorization URL)
322        assert!(!code_challenge.is_empty());
323
324        // Step 3: Authorization server validates challenge format
325        assert!(
326            !code_challenge.contains('='),
327            "Challenge should not have padding"
328        );
329        assert_eq!(
330            code_challenge.len(),
331            43,
332            "Challenge should be correct length"
333        );
334
335        // Step 4: Client later sends code_verifier to token endpoint
336        // Server verifies that SHA256(code_verifier) == code_challenge
337        let server_computed_challenge = challenge(&code_verifier);
338        assert_eq!(
339            code_challenge, server_computed_challenge,
340            "Server should be able to verify PKCE parameters"
341        );
342    }
343}