atproto_oauth/
pkce.rs

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