1use crate::saas::api_keys::hash_api_key;
6use crate::saas::db;
7use axum::{
8 extract::{Request, State},
9 http::{header, StatusCode},
10 middleware::Next,
11 response::{IntoResponse, Response},
12};
13use serde::Deserialize;
14use sqlx::PgPool;
15use std::sync::Arc;
16use uuid::Uuid;
17
18#[derive(Clone, Debug)]
20pub struct AuthContext {
21 pub tenant_id: Uuid,
23 pub auth_type: AuthType,
25 pub scopes: Vec<String>,
27}
28
29#[derive(Clone, Debug)]
31pub enum AuthType {
32 Jwt {
34 user_id: String,
36 role: Option<String>,
38 },
39 ApiKey {
41 key_id: Uuid,
43 },
44}
45
46impl AuthContext {
47 pub fn has_scope(&self, scope: &str) -> bool {
49 self.scopes.iter().any(|s| s == scope || s == "*")
50 }
51
52 pub fn can_read(&self, resource: &str) -> bool {
54 self.has_scope(&format!("{}:read", resource))
55 || self.has_scope(&format!("{}:write", resource))
56 || self.has_scope("*")
57 }
58
59 pub fn can_write(&self, resource: &str) -> bool {
61 self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
62 }
63}
64
65#[derive(Clone)]
67pub struct SaasAuthLayer {
68 pool: PgPool,
69 jwt_secret: Option<String>,
70}
71
72impl SaasAuthLayer {
73 pub fn new(pool: PgPool, jwt_secret: Option<String>) -> Self {
75 Self { pool, jwt_secret }
76 }
77}
78
79pub async fn auth_middleware(
81 State(auth_layer): State<Arc<SaasAuthLayer>>,
82 mut request: Request,
83 next: Next,
84) -> Result<Response, AuthError> {
85 let auth_header = request
86 .headers()
87 .get(header::AUTHORIZATION)
88 .and_then(|h| h.to_str().ok());
89
90 let auth_context = match auth_header {
91 Some(header) if header.starts_with("Bearer ") => {
92 let token = header.strip_prefix("Bearer ").unwrap();
94 validate_jwt(token, &auth_layer).await?
95 }
96 Some(header) if header.starts_with("ApiKey ") => {
97 let key = header.strip_prefix("ApiKey ").unwrap();
99 validate_api_key(key, &auth_layer.pool).await?
100 }
101 Some(header) if header.starts_with("Basic ") => {
102 return Err(AuthError::UnsupportedAuthMethod);
104 }
105 Some(_) => {
106 return Err(AuthError::InvalidFormat);
107 }
108 None => {
109 return Err(AuthError::MissingHeader);
110 }
111 };
112
113 if !db::is_tenant_active(&auth_layer.pool, auth_context.tenant_id).await? {
115 return Err(AuthError::TenantSuspended);
116 }
117
118 request.extensions_mut().insert(auth_context);
120
121 Ok(next.run(request).await)
122}
123
124async fn validate_jwt(token: &str, auth_layer: &SaasAuthLayer) -> Result<AuthContext, AuthError> {
126 let secret = auth_layer
127 .jwt_secret
128 .as_ref()
129 .ok_or(AuthError::JwtNotConfigured)?;
130
131 let claims = decode_jwt(token, secret)?;
132
133 let tenant_id = claims
135 .tenant_id
136 .ok_or_else(|| AuthError::MissingClaim("tenant_id".into()))?;
137
138 Ok(AuthContext {
139 tenant_id,
140 auth_type: AuthType::Jwt {
141 user_id: claims.sub,
142 role: claims.role,
143 },
144 scopes: claims.scopes.unwrap_or_else(|| vec!["*".to_string()]),
145 })
146}
147
148async fn validate_api_key(key: &str, pool: &PgPool) -> Result<AuthContext, AuthError> {
150 let key_hash = hash_api_key(key);
151
152 let validation = db::validate_api_key_by_hash(pool, &key_hash)
153 .await?
154 .ok_or(AuthError::InvalidApiKey)?;
155
156 if !validation.enabled {
157 return Err(AuthError::ApiKeyDisabled);
158 }
159
160 let pool = pool.clone();
162 let key_id = validation.id;
163 tokio::spawn(async move {
164 let _ = db::update_last_used(&pool, key_id).await;
165 });
166
167 Ok(AuthContext {
168 tenant_id: validation.tenant_id,
169 auth_type: AuthType::ApiKey {
170 key_id: validation.id,
171 },
172 scopes: validation.scopes,
173 })
174}
175
176#[derive(Debug, Deserialize)]
178struct JwtClaims {
179 sub: String,
181 tenant_id: Option<Uuid>,
183 role: Option<String>,
185 scopes: Option<Vec<String>>,
187 exp: Option<i64>,
189}
190
191fn decode_jwt(token: &str, secret: &str) -> Result<JwtClaims, AuthError> {
193 use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
194
195 let mut validation = Validation::new(Algorithm::HS256);
196 validation.required_spec_claims.clear();
199 validation.validate_exp = false;
200
201 let token_data = decode::<JwtClaims>(
202 token,
203 &DecodingKey::from_secret(secret.as_bytes()),
204 &validation,
205 )
206 .map_err(|e| match e.kind() {
207 jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
208 _ => AuthError::InvalidToken(e.to_string()),
209 })?;
210
211 let claims = token_data.claims;
212
213 if let Some(exp) = claims.exp {
215 let now = chrono::Utc::now().timestamp();
216 if exp < now {
217 return Err(AuthError::TokenExpired);
218 }
219 }
220
221 Ok(claims)
222}
223
224#[derive(Debug)]
226pub enum AuthError {
227 MissingHeader,
229 InvalidFormat,
231 InvalidToken(String),
233 TokenExpired,
235 MissingClaim(String),
237 InvalidApiKey,
239 ApiKeyDisabled,
241 ApiKeyExpired,
243 TenantSuspended,
245 JwtNotConfigured,
248 UnsupportedAuthMethod,
250 Database(sqlx::Error),
253}
254
255impl From<sqlx::Error> for AuthError {
256 fn from(err: sqlx::Error) -> Self {
257 AuthError::Database(err)
258 }
259}
260
261impl From<crate::error::ProxyError> for AuthError {
262 fn from(err: crate::error::ProxyError) -> Self {
263 match err {
264 crate::error::ProxyError::Database(e) => AuthError::Database(e),
265 _ => AuthError::InvalidToken(err.to_string()),
266 }
267 }
268}
269
270impl IntoResponse for AuthError {
271 fn into_response(self) -> Response {
272 let (status, message) = match self {
273 AuthError::MissingHeader => (
274 StatusCode::UNAUTHORIZED,
275 "Missing Authorization header".to_string(),
276 ),
277 AuthError::InvalidFormat => (
278 StatusCode::UNAUTHORIZED,
279 "Invalid Authorization header format".to_string(),
280 ),
281 AuthError::InvalidToken(msg) => {
282 (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", msg))
283 }
284 AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired".to_string()),
285 AuthError::MissingClaim(claim) => (
286 StatusCode::UNAUTHORIZED,
287 format!("Missing required claim: {}", claim),
288 ),
289 AuthError::InvalidApiKey => (StatusCode::UNAUTHORIZED, "Invalid API key".to_string()),
290 AuthError::ApiKeyDisabled => {
291 (StatusCode::UNAUTHORIZED, "API key is disabled".to_string())
292 }
293 AuthError::ApiKeyExpired => {
294 (StatusCode::UNAUTHORIZED, "API key has expired".to_string())
295 }
296 AuthError::TenantSuspended => (
297 StatusCode::FORBIDDEN,
298 "Tenant account is suspended".to_string(),
299 ),
300 AuthError::JwtNotConfigured => (
301 StatusCode::INTERNAL_SERVER_ERROR,
302 "JWT authentication not configured".to_string(),
303 ),
304 AuthError::UnsupportedAuthMethod => (
305 StatusCode::UNAUTHORIZED,
306 "Unsupported authentication method".to_string(),
307 ),
308 AuthError::Database(_) => (
309 StatusCode::INTERNAL_SERVER_ERROR,
310 "Internal server error".to_string(),
311 ),
312 };
313
314 let body = serde_json::json!({
315 "success": false,
316 "error": message
317 });
318
319 (status, axum::Json(body)).into_response()
320 }
321}
322
323#[derive(Clone, Debug)]
325pub struct Auth(
326 pub AuthContext,
328);
329
330impl<S> axum::extract::FromRequestParts<S> for Auth
331where
332 S: Send + Sync,
333{
334 type Rejection = AuthError;
335
336 async fn from_request_parts(
337 parts: &mut axum::http::request::Parts,
338 _state: &S,
339 ) -> Result<Self, Self::Rejection> {
340 parts
341 .extensions
342 .get::<AuthContext>()
343 .cloned()
344 .map(Auth)
345 .ok_or(AuthError::MissingHeader)
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use base64::Engine;
353 use jsonwebtoken::{encode, EncodingKey, Header};
354
355 const SECRET: &str = "test-secret";
356 const TENANT_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
357
358 fn sign(claims: &serde_json::Value, secret: &str) -> String {
359 encode(
360 &Header::default(),
361 claims,
362 &EncodingKey::from_secret(secret.as_bytes()),
363 )
364 .unwrap()
365 }
366
367 fn valid_claims() -> serde_json::Value {
368 serde_json::json!({
369 "sub": "user-1",
370 "tenant_id": TENANT_ID,
371 "role": "admin",
372 "scopes": ["domains:read"],
373 "exp": chrono::Utc::now().timestamp() + 3600,
374 })
375 }
376
377 fn b64url(data: &[u8]) -> String {
378 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
379 }
380
381 #[test]
382 fn valid_token_decodes() {
383 let token = sign(&valid_claims(), SECRET);
384 let claims = decode_jwt(&token, SECRET).unwrap();
385 assert_eq!(claims.sub, "user-1");
386 assert_eq!(claims.tenant_id, Some(TENANT_ID.parse().unwrap()));
387 assert_eq!(claims.role.as_deref(), Some("admin"));
388 assert_eq!(claims.scopes, Some(vec!["domains:read".to_string()]));
389 }
390
391 #[test]
392 fn token_without_exp_is_accepted() {
393 let mut claims = valid_claims();
394 claims.as_object_mut().unwrap().remove("exp");
395 let token = sign(&claims, SECRET);
396 assert!(decode_jwt(&token, SECRET).is_ok());
397 }
398
399 #[test]
400 fn token_signed_with_wrong_secret_is_rejected() {
401 let token = sign(&valid_claims(), "some-other-secret");
402 assert!(matches!(
403 decode_jwt(&token, SECRET),
404 Err(AuthError::InvalidToken(_))
405 ));
406 }
407
408 #[test]
409 fn tampered_payload_is_rejected() {
410 let token = sign(&valid_claims(), SECRET);
413 let parts: Vec<&str> = token.split('.').collect();
414
415 let mut claims = valid_claims();
416 claims["tenant_id"] = serde_json::json!("00000000-0000-0000-0000-000000000001");
417 let forged_payload = b64url(serde_json::to_vec(&claims).unwrap().as_slice());
418
419 let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]);
420 assert!(matches!(
421 decode_jwt(&forged, SECRET),
422 Err(AuthError::InvalidToken(_))
423 ));
424 }
425
426 #[test]
427 fn unsigned_alg_none_token_is_rejected() {
428 let header = b64url(br#"{"alg":"none","typ":"JWT"}"#);
431 let payload = b64url(serde_json::to_vec(&valid_claims()).unwrap().as_slice());
432
433 for token in [
434 format!("{header}.{payload}."),
435 format!("{header}.{payload}"),
436 ] {
437 assert!(
438 matches!(decode_jwt(&token, SECRET), Err(AuthError::InvalidToken(_))),
439 "token {token:?} should be rejected"
440 );
441 }
442 }
443
444 #[test]
445 fn expired_token_is_rejected() {
446 let mut claims = valid_claims();
447 claims["exp"] = serde_json::json!(chrono::Utc::now().timestamp() - 60);
448 let token = sign(&claims, SECRET);
449 assert!(matches!(
450 decode_jwt(&token, SECRET),
451 Err(AuthError::TokenExpired)
452 ));
453 }
454
455 #[test]
456 fn malformed_token_is_rejected() {
457 assert!(matches!(
458 decode_jwt("not-a-jwt", SECRET),
459 Err(AuthError::InvalidToken(_))
460 ));
461 }
462}