micromegas_auth/
oauth_state.rs

1//! OAuth state parameter signing and verification
2//!
3//! Provides HMAC-SHA256 signing for OAuth state parameters to prevent CSRF attacks
4//! by ensuring the state parameter cannot be tampered with during the OAuth flow.
5//!
6//! # Security
7//!
8//! The state parameter is signed with HMAC-SHA256 to prevent attackers from:
9//! - Modifying the return_url to redirect users to malicious sites
10//! - Tampering with the PKCE verifier
11//! - Forging nonce values
12//!
13//! # Format
14//!
15//! Signed state: `base64url(state_json).base64url(hmac_signature)`
16//!
17//! # Example
18//!
19//! ```rust
20//! use micromegas_auth::oauth_state::{OAuthState, sign_state, verify_state};
21//!
22//! let state = OAuthState {
23//!     nonce: "random-nonce".to_string(),
24//!     return_url: "/dashboard".to_string(),
25//!     pkce_verifier: "pkce-verifier".to_string(),
26//! };
27//!
28//! let secret = b"your-32-byte-secret-key-here!!!";
29//! let signed = sign_state(&state, secret).expect("signing failed");
30//!
31//! let verified = verify_state(&signed, secret).expect("verification failed");
32//! assert_eq!(verified.return_url, "/dashboard");
33//! ```
34
35use anyhow::{Result, anyhow};
36use base64::Engine;
37use hmac::{Hmac, Mac};
38use rand::Rng;
39use serde::{Deserialize, Serialize};
40use sha2::Sha256;
41
42/// Type alias for HMAC-SHA256
43type HmacSha256 = Hmac<Sha256>;
44
45/// OAuth state stored in the state parameter
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct OAuthState {
48    /// CSRF nonce for validation
49    pub nonce: String,
50    /// URL to redirect to after successful authentication
51    pub return_url: String,
52    /// PKCE code verifier for OAuth PKCE flow
53    pub pkce_verifier: String,
54}
55
56/// Generate a cryptographically secure random nonce
57///
58/// Returns a 32-byte random value encoded as base64url (URL-safe, no padding).
59/// Suitable for use in OAuth state parameters, CSRF tokens, and PKCE challenges.
60///
61/// # Example
62///
63/// ```rust
64/// use micromegas_auth::oauth_state::generate_nonce;
65///
66/// let nonce = generate_nonce();
67/// assert_eq!(nonce.len(), 43); // 32 bytes base64url = 43 chars
68/// ```
69pub fn generate_nonce() -> String {
70    let mut rng = rand::thread_rng();
71    let bytes: [u8; 32] = rng.r#gen();
72    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
73}
74
75/// Sign OAuth state parameter with HMAC-SHA256 to prevent tampering
76///
77/// Returns: base64url(state_json).base64url(hmac_signature)
78///
79/// # Arguments
80///
81/// * `state` - The OAuth state to sign
82/// * `secret` - Secret key for HMAC (recommended: 32 bytes)
83///
84/// # Example
85///
86/// ```rust
87/// use micromegas_auth::oauth_state::{OAuthState, sign_state};
88///
89/// let state = OAuthState {
90///     nonce: "random-nonce".to_string(),
91///     return_url: "/dashboard".to_string(),
92///     pkce_verifier: "pkce-verifier".to_string(),
93/// };
94///
95/// let secret = b"your-32-byte-secret-key-here!!!";
96/// let signed = sign_state(&state, secret).expect("signing failed");
97/// ```
98pub fn sign_state(state: &OAuthState, secret: &[u8]) -> Result<String> {
99    let state_json = serde_json::to_string(state)?;
100
101    let mut mac =
102        HmacSha256::new_from_slice(secret).map_err(|e| anyhow!("Failed to create HMAC: {e}"))?;
103    mac.update(state_json.as_bytes());
104    let signature = mac.finalize().into_bytes();
105
106    let signed = format!(
107        "{}.{}",
108        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&state_json),
109        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
110    );
111    Ok(signed)
112}
113
114/// Verify and decode signed OAuth state parameter
115///
116/// Validates HMAC signature and returns the decoded state
117///
118/// # Arguments
119///
120/// * `signed_state` - The signed state string (base64url(json).base64url(signature))
121/// * `secret` - Secret key used for HMAC (must match signing secret)
122///
123/// # Example
124///
125/// ```rust
126/// use micromegas_auth::oauth_state::{OAuthState, sign_state, verify_state};
127///
128/// let state = OAuthState {
129///     nonce: "random-nonce".to_string(),
130///     return_url: "/dashboard".to_string(),
131///     pkce_verifier: "pkce-verifier".to_string(),
132/// };
133///
134/// let secret = b"your-32-byte-secret-key-here!!!";
135/// let signed = sign_state(&state, secret).expect("signing failed");
136/// let verified = verify_state(&signed, secret).expect("verification failed");
137///
138/// assert_eq!(verified.nonce, "random-nonce");
139/// assert_eq!(verified.return_url, "/dashboard");
140/// ```
141pub fn verify_state(signed_state: &str, secret: &[u8]) -> Result<OAuthState> {
142    let parts: Vec<&str> = signed_state.split('.').collect();
143    if parts.len() != 2 {
144        return Err(anyhow!(
145            "Invalid state format: expected 2 parts, got {}",
146            parts.len()
147        ));
148    }
149
150    // Decode state JSON
151    let state_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[0])?;
152
153    // Decode signature
154    let signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1])?;
155
156    // Verify HMAC signature
157    let mut mac =
158        HmacSha256::new_from_slice(secret).map_err(|e| anyhow!("Failed to create HMAC: {e}"))?;
159    mac.update(&state_bytes);
160    mac.verify_slice(&signature_bytes)
161        .map_err(|_| anyhow!("HMAC signature verification failed"))?;
162
163    // Deserialize state
164    Ok(serde_json::from_slice(&state_bytes)?)
165}