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}