spotify_cli/oauth/
pkce.rs

1//! PKCE (Proof Key for Code Exchange) implementation.
2//!
3//! PKCE is an extension to OAuth 2.0 that protects authorization codes from interception.
4//! It works by creating a cryptographic challenge that proves the token request comes from
5//! the same client that initiated the authorization.
6//!
7//! See: [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
8
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10use rand::Rng;
11use sha2::{Digest, Sha256};
12
13use crate::constants::PKCE_VERIFIER_LENGTH;
14
15/// PKCE challenge and verifier pair.
16///
17/// - `verifier`: A high-entropy random string sent with the token request
18/// - `challenge`: SHA-256 hash of verifier, base64url encoded, sent with auth request
19pub struct PkceChallenge {
20    pub verifier: String,
21    pub challenge: String,
22}
23
24impl PkceChallenge {
25    /// Generate a new PKCE challenge/verifier pair.
26    pub fn generate() -> Self {
27        let verifier = generate_verifier();
28        let challenge = generate_challenge(&verifier);
29
30        Self {
31            verifier,
32            challenge,
33        }
34    }
35}
36
37fn generate_verifier() -> String {
38    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
39
40    let mut rng = rand::thread_rng();
41
42    (0..PKCE_VERIFIER_LENGTH)
43        .map(|_| {
44            let idx = rng.gen_range(0..CHARSET.len());
45            CHARSET[idx] as char
46        })
47        .collect()
48}
49
50fn generate_challenge(verifier: &str) -> String {
51    let mut hasher = Sha256::new();
52    hasher.update(verifier.as_bytes());
53    let hash = hasher.finalize();
54
55    URL_SAFE_NO_PAD.encode(hash)
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn verifier_has_correct_length() {
64        let pkce = PkceChallenge::generate();
65        assert_eq!(pkce.verifier.len(), PKCE_VERIFIER_LENGTH);
66    }
67
68    #[test]
69    fn verifier_uses_valid_characters() {
70        let pkce = PkceChallenge::generate();
71        let valid_chars: &str =
72            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
73
74        for c in pkce.verifier.chars() {
75            assert!(valid_chars.contains(c));
76        }
77    }
78
79    #[test]
80    fn challenge_is_base64url_encoded() {
81        let pkce = PkceChallenge::generate();
82        assert!(URL_SAFE_NO_PAD.decode(&pkce.challenge).is_ok());
83    }
84
85    #[test]
86    fn challenge_is_sha256_of_verifier() {
87        let pkce = PkceChallenge::generate();
88        let expected = generate_challenge(&pkce.verifier);
89        assert_eq!(pkce.challenge, expected);
90    }
91}