fraiseql_core/security/auth_middleware/middleware.rs
1//! Authentication middleware implementation.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use jsonwebtoken::{Validation, decode};
7
8use super::{
9 config::AuthConfig,
10 signing_key::SigningKey,
11 types::{
12 AuthRequest, AuthenticatedUser, JwtClaims, TokenClaims, extract_claim_string,
13 extract_name_string,
14 },
15};
16use crate::security::errors::{Result, SecurityError};
17
18/// Authentication Middleware
19///
20/// Validates incoming requests for authentication requirements.
21/// Acts as the second layer in the security middleware pipeline.
22#[derive(Debug, Clone)]
23pub struct AuthMiddleware {
24 config: AuthConfig,
25}
26
27impl AuthMiddleware {
28 /// Create a new authentication middleware from configuration.
29 ///
30 /// Emits a warning when `required = true` but no signing key is configured,
31 /// because JWT signature verification will be disabled in that case.
32 #[must_use]
33 pub fn from_config(config: AuthConfig) -> Self {
34 if config.required && config.signing_key.is_none() {
35 tracing::warn!(
36 "AuthMiddleware: required=true but no signing_key configured — \
37 JWT signatures will NOT be verified"
38 );
39 }
40 Self { config }
41 }
42
43 /// Create middleware with permissive settings (authentication optional)
44 #[must_use]
45 pub fn permissive() -> Self {
46 Self::from_config(AuthConfig::permissive())
47 }
48
49 /// Create middleware with standard settings (authentication required)
50 #[must_use]
51 pub fn standard() -> Self {
52 Self::from_config(AuthConfig::standard())
53 }
54
55 /// Create middleware with strict settings (authentication required, short expiry)
56 #[must_use]
57 pub fn strict() -> Self {
58 Self::from_config(AuthConfig::strict())
59 }
60
61 /// Validate authentication in a request.
62 ///
63 /// Performs validation checks in order:
64 /// 1. Extract token from Authorization header
65 /// 2. Validate token signature (if signing key configured)
66 /// 3. Check token expiry (exp claim)
67 /// 4. Validate issuer/audience claims (if configured)
68 /// 5. Extract required claims (sub)
69 /// 6. Extract optional claims (scope, aud, iss)
70 ///
71 /// # Errors
72 ///
73 /// Returns [`SecurityError::AuthRequired`] if no Authorization header or
74 /// token is missing. Returns [`SecurityError::InvalidToken`] if the token
75 /// signature, expiry, issuer, or audience is invalid. Returns
76 /// [`SecurityError::TokenMissingClaim`] if a required claim is absent.
77 pub fn validate_request(&self, req: &AuthRequest) -> Result<AuthenticatedUser> {
78 // Check 1: Extract token from Authorization header
79 let token = self.extract_token(req)?;
80
81 // Check 2: Validate token (with or without signature verification)
82 if let Some(ref signing_key) = self.config.signing_key {
83 // Use jsonwebtoken crate for proper signature verification
84 self.validate_token_with_signature(&token, signing_key)
85 } else {
86 // Fallback: structure validation only (for testing/backwards compatibility)
87 // WARNING: This is insecure for production use!
88 self.validate_token_structure_only(&token)
89 }
90 }
91
92 /// Validate token with cryptographic signature verification.
93 ///
94 /// This is the secure path used when a signing key is configured.
95 fn validate_token_with_signature(
96 &self,
97 token: &str,
98 signing_key: &SigningKey,
99 ) -> Result<AuthenticatedUser> {
100 // Get the decoding key
101 let decoding_key = signing_key.to_decoding_key()?;
102
103 // Build validation configuration
104 let mut validation = Validation::new(signing_key.algorithm());
105
106 // Configure issuer validation (only validate if configured)
107 if let Some(ref issuer) = self.config.issuer {
108 validation.set_issuer(&[issuer]);
109 }
110 // Note: If issuer is not set, validation.iss is None and won't be validated
111
112 // Configure audience validation.
113 // SECURITY: `validate_aud = true` is the default in jsonwebtoken; we must
114 // NOT override it to `false` when no audience is configured, as that would
115 // silently accept tokens issued for any service (cross-service token replay).
116 // When no audience is pinned, any non-empty `aud` claim is accepted — callers
117 // should set `audience` in config to restrict this further.
118 if let Some(ref audience) = self.config.audience {
119 validation.set_audience(&[audience]);
120 }
121 // `validation.validate_aud` remains `true` (the library default) when no
122 // specific audience is configured.
123
124 // Set clock skew tolerance
125 validation.leeway = self.config.clock_skew_secs;
126
127 // Decode and validate the token
128 let token_data = decode::<JwtClaims>(token, &decoding_key, &validation).map_err(|e| {
129 match e.kind() {
130 jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
131 // Try to extract the actual expiry time from the token
132 SecurityError::TokenExpired {
133 expired_at: Utc::now(), // Approximate - actual time is not accessible
134 }
135 },
136 jsonwebtoken::errors::ErrorKind::InvalidSignature => {
137 SecurityError::JwtSignatureInvalid
138 },
139 jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
140 SecurityError::JwtIssuerMismatch {
141 expected: self
142 .config
143 .issuer
144 .clone()
145 .unwrap_or_else(|| "(not configured)".to_string()),
146 }
147 },
148 jsonwebtoken::errors::ErrorKind::InvalidAudience => {
149 SecurityError::JwtAudienceMismatch {
150 expected: self
151 .config
152 .audience
153 .clone()
154 .unwrap_or_else(|| "(not configured)".to_string()),
155 }
156 },
157 jsonwebtoken::errors::ErrorKind::InvalidAlgorithm => {
158 SecurityError::InvalidTokenAlgorithm {
159 algorithm: format!("{:?}", signing_key.algorithm()),
160 }
161 },
162 jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => {
163 SecurityError::TokenMissingClaim {
164 claim: claim.clone(),
165 }
166 },
167 _ => SecurityError::InvalidToken,
168 }
169 })?;
170
171 let claims = token_data.claims;
172
173 // Extract scopes (supports multiple formats)
174 let scopes = self.extract_scopes_from_jwt_claims(&claims);
175
176 // Extract user ID (required)
177 let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
178 claim: "sub".to_string(),
179 })?;
180 let user_id = crate::types::UserId::new(user_id_str);
181
182 // Extract expiration (required)
183 let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
184 claim: "exp".to_string(),
185 })?;
186
187 let expires_at =
188 DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
189
190 let email = claims.extra.get("email").and_then(extract_claim_string);
191 let display_name = claims.extra.get("name").and_then(extract_name_string);
192
193 Ok(AuthenticatedUser {
194 user_id,
195 scopes,
196 expires_at,
197 email,
198 display_name,
199 extra_claims: claims.extra,
200 })
201 }
202
203 /// Extract scopes from JWT claims.
204 ///
205 /// Supports multiple formats:
206 /// - `scope`: space-separated string (`OAuth2` standard)
207 /// - `scp`: array of strings (Microsoft)
208 /// - `permissions`: array of strings (Auth0 RBAC)
209 fn extract_scopes_from_jwt_claims(&self, claims: &JwtClaims) -> Vec<String> {
210 // Try space-separated scope string first (most common)
211 if let Some(ref scope) = claims.scope {
212 return scope.split_whitespace().map(String::from).collect();
213 }
214
215 // Try array of scopes (scp claim)
216 if let Some(ref scp) = claims.scp {
217 return scp.clone();
218 }
219
220 // Try permissions array (Auth0 RBAC)
221 if let Some(ref permissions) = claims.permissions {
222 return permissions.clone();
223 }
224
225 Vec::new()
226 }
227
228 /// Validate token structure only (no signature verification).
229 ///
230 /// WARNING: This is insecure and should only be used for testing
231 /// or when signature verification is handled elsewhere.
232 fn validate_token_structure_only(&self, token: &str) -> Result<AuthenticatedUser> {
233 // Validate basic structure
234 self.validate_token_structure(token)?;
235
236 // Parse claims
237 let claims = self.parse_claims(token)?;
238
239 // Extract and validate 'exp' claim (required)
240 let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
241 claim: "exp".to_string(),
242 })?;
243
244 // Check expiry
245 let expires_at =
246 DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
247
248 if expires_at <= Utc::now() {
249 return Err(SecurityError::TokenExpired {
250 expired_at: expires_at,
251 });
252 }
253
254 // Extract and validate 'sub' claim (required)
255 let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
256 claim: "sub".to_string(),
257 })?;
258 let user_id = crate::types::UserId::new(user_id_str);
259
260 // Extract optional claims
261 let scopes = claims
262 .scope
263 .as_ref()
264 .map(|s| s.split_whitespace().map(String::from).collect())
265 .unwrap_or_default();
266
267 Ok(AuthenticatedUser {
268 user_id,
269 scopes,
270 expires_at,
271 email: None,
272 display_name: None,
273 extra_claims: HashMap::new(),
274 })
275 }
276
277 /// Extract token from the authorization header
278 fn extract_token(&self, req: &AuthRequest) -> Result<String> {
279 // If auth is not required and no header present, that's OK
280 if !self.config.required && req.authorization_header.is_none() {
281 return Err(SecurityError::AuthRequired); // Will be handled differently
282 }
283
284 req.extract_bearer_token()
285 }
286
287 /// Validate token structure (basic checks)
288 ///
289 /// A real implementation would validate the signature here.
290 /// For now, we just check basic structure.
291 pub(crate) fn validate_token_structure(&self, token: &str) -> Result<()> {
292 // JWT has 3 parts separated by dots: header.payload.signature
293 let parts: Vec<&str> = token.split('.').collect();
294 if parts.len() != 3 {
295 return Err(SecurityError::InvalidToken);
296 }
297
298 // Check that each part is non-empty
299 if parts.iter().any(|p| p.is_empty()) {
300 return Err(SecurityError::InvalidToken);
301 }
302
303 Ok(())
304 }
305
306 /// Parse JWT claims (simplified, for demo purposes)
307 ///
308 /// In a real implementation, this would decode and validate the JWT signature.
309 /// For testing, we accept a special test token format: "`test:{json_payload`}"
310 fn parse_claims(&self, token: &str) -> Result<TokenClaims> {
311 // Split the token
312 let parts: Vec<&str> = token.split('.').collect();
313 if parts.len() != 3 {
314 return Err(SecurityError::InvalidToken);
315 }
316
317 // For testing, we use a simple format: part1.{json}.part3
318 // where {json} is a base64-like encoded JSON
319 // Since we don't have base64 in core dependencies, we'll try to parse directly
320 let payload_part = parts[1];
321
322 // Try to decode as hex (simpler than base64 and no dependencies)
323 // For test tokens, we'll encode the JSON as hex
324 if let Ok(decoded) = hex::decode(payload_part) {
325 if let Ok(json_str) = std::str::from_utf8(&decoded) {
326 if let Ok(json) = serde_json::from_str::<serde_json::Value>(json_str) {
327 return Ok(self.extract_claims_from_json(&json));
328 }
329 }
330 }
331
332 // If hex decoding fails, try to parse as UTF-8 directly (for test tokens created inline)
333 if let Ok(json) = serde_json::from_str::<serde_json::Value>(payload_part) {
334 return Ok(self.extract_claims_from_json(&json));
335 }
336
337 Err(SecurityError::InvalidToken)
338 }
339
340 /// Extract claims from parsed JSON
341 fn extract_claims_from_json(&self, json: &serde_json::Value) -> TokenClaims {
342 let sub = json["sub"].as_str().map(String::from);
343 let exp = json["exp"].as_i64();
344 let scope = json["scope"].as_str().map(String::from);
345 let aud = json["aud"]
346 .as_array()
347 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
348 let iss = json["iss"].as_str().map(String::from);
349
350 TokenClaims {
351 sub,
352 exp,
353 scope,
354 aud,
355 iss,
356 }
357 }
358
359 /// Get the underlying configuration
360 #[must_use]
361 pub const fn config(&self) -> &AuthConfig {
362 &self.config
363 }
364}