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,
228 InvalidFormat,
229 InvalidToken(String),
230 TokenExpired,
231 MissingClaim(String),
232 InvalidApiKey,
233 ApiKeyDisabled,
234 ApiKeyExpired,
235 TenantSuspended,
236 JwtNotConfigured,
237 UnsupportedAuthMethod,
238 Database(sqlx::Error),
239}
240
241impl From<sqlx::Error> for AuthError {
242 fn from(err: sqlx::Error) -> Self {
243 AuthError::Database(err)
244 }
245}
246
247impl From<crate::error::ProxyError> for AuthError {
248 fn from(err: crate::error::ProxyError) -> Self {
249 match err {
250 crate::error::ProxyError::Database(e) => AuthError::Database(e),
251 _ => AuthError::InvalidToken(err.to_string()),
252 }
253 }
254}
255
256impl IntoResponse for AuthError {
257 fn into_response(self) -> Response {
258 let (status, message) = match self {
259 AuthError::MissingHeader => (
260 StatusCode::UNAUTHORIZED,
261 "Missing Authorization header".to_string(),
262 ),
263 AuthError::InvalidFormat => (
264 StatusCode::UNAUTHORIZED,
265 "Invalid Authorization header format".to_string(),
266 ),
267 AuthError::InvalidToken(msg) => {
268 (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", msg))
269 }
270 AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired".to_string()),
271 AuthError::MissingClaim(claim) => (
272 StatusCode::UNAUTHORIZED,
273 format!("Missing required claim: {}", claim),
274 ),
275 AuthError::InvalidApiKey => (StatusCode::UNAUTHORIZED, "Invalid API key".to_string()),
276 AuthError::ApiKeyDisabled => {
277 (StatusCode::UNAUTHORIZED, "API key is disabled".to_string())
278 }
279 AuthError::ApiKeyExpired => {
280 (StatusCode::UNAUTHORIZED, "API key has expired".to_string())
281 }
282 AuthError::TenantSuspended => (
283 StatusCode::FORBIDDEN,
284 "Tenant account is suspended".to_string(),
285 ),
286 AuthError::JwtNotConfigured => (
287 StatusCode::INTERNAL_SERVER_ERROR,
288 "JWT authentication not configured".to_string(),
289 ),
290 AuthError::UnsupportedAuthMethod => (
291 StatusCode::UNAUTHORIZED,
292 "Unsupported authentication method".to_string(),
293 ),
294 AuthError::Database(_) => (
295 StatusCode::INTERNAL_SERVER_ERROR,
296 "Internal server error".to_string(),
297 ),
298 };
299
300 let body = serde_json::json!({
301 "success": false,
302 "error": message
303 });
304
305 (status, axum::Json(body)).into_response()
306 }
307}
308
309#[derive(Clone, Debug)]
311pub struct Auth(pub AuthContext);
312
313impl<S> axum::extract::FromRequestParts<S> for Auth
314where
315 S: Send + Sync,
316{
317 type Rejection = AuthError;
318
319 async fn from_request_parts(
320 parts: &mut axum::http::request::Parts,
321 _state: &S,
322 ) -> Result<Self, Self::Rejection> {
323 parts
324 .extensions
325 .get::<AuthContext>()
326 .cloned()
327 .map(Auth)
328 .ok_or(AuthError::MissingHeader)
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use base64::Engine;
336 use jsonwebtoken::{encode, EncodingKey, Header};
337
338 const SECRET: &str = "test-secret";
339 const TENANT_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
340
341 fn sign(claims: &serde_json::Value, secret: &str) -> String {
342 encode(
343 &Header::default(),
344 claims,
345 &EncodingKey::from_secret(secret.as_bytes()),
346 )
347 .unwrap()
348 }
349
350 fn valid_claims() -> serde_json::Value {
351 serde_json::json!({
352 "sub": "user-1",
353 "tenant_id": TENANT_ID,
354 "role": "admin",
355 "scopes": ["domains:read"],
356 "exp": chrono::Utc::now().timestamp() + 3600,
357 })
358 }
359
360 fn b64url(data: &[u8]) -> String {
361 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
362 }
363
364 #[test]
365 fn valid_token_decodes() {
366 let token = sign(&valid_claims(), SECRET);
367 let claims = decode_jwt(&token, SECRET).unwrap();
368 assert_eq!(claims.sub, "user-1");
369 assert_eq!(claims.tenant_id, Some(TENANT_ID.parse().unwrap()));
370 assert_eq!(claims.role.as_deref(), Some("admin"));
371 assert_eq!(claims.scopes, Some(vec!["domains:read".to_string()]));
372 }
373
374 #[test]
375 fn token_without_exp_is_accepted() {
376 let mut claims = valid_claims();
377 claims.as_object_mut().unwrap().remove("exp");
378 let token = sign(&claims, SECRET);
379 assert!(decode_jwt(&token, SECRET).is_ok());
380 }
381
382 #[test]
383 fn token_signed_with_wrong_secret_is_rejected() {
384 let token = sign(&valid_claims(), "some-other-secret");
385 assert!(matches!(
386 decode_jwt(&token, SECRET),
387 Err(AuthError::InvalidToken(_))
388 ));
389 }
390
391 #[test]
392 fn tampered_payload_is_rejected() {
393 let token = sign(&valid_claims(), SECRET);
396 let parts: Vec<&str> = token.split('.').collect();
397
398 let mut claims = valid_claims();
399 claims["tenant_id"] = serde_json::json!("00000000-0000-0000-0000-000000000001");
400 let forged_payload = b64url(serde_json::to_vec(&claims).unwrap().as_slice());
401
402 let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]);
403 assert!(matches!(
404 decode_jwt(&forged, SECRET),
405 Err(AuthError::InvalidToken(_))
406 ));
407 }
408
409 #[test]
410 fn unsigned_alg_none_token_is_rejected() {
411 let header = b64url(br#"{"alg":"none","typ":"JWT"}"#);
414 let payload = b64url(serde_json::to_vec(&valid_claims()).unwrap().as_slice());
415
416 for token in [
417 format!("{header}.{payload}."),
418 format!("{header}.{payload}"),
419 ] {
420 assert!(
421 matches!(decode_jwt(&token, SECRET), Err(AuthError::InvalidToken(_))),
422 "token {token:?} should be rejected"
423 );
424 }
425 }
426
427 #[test]
428 fn expired_token_is_rejected() {
429 let mut claims = valid_claims();
430 claims["exp"] = serde_json::json!(chrono::Utc::now().timestamp() - 60);
431 let token = sign(&claims, SECRET);
432 assert!(matches!(
433 decode_jwt(&token, SECRET),
434 Err(AuthError::TokenExpired)
435 ));
436 }
437
438 #[test]
439 fn malformed_token_is_rejected() {
440 assert!(matches!(
441 decode_jwt("not-a-jwt", SECRET),
442 Err(AuthError::InvalidToken(_))
443 ));
444 }
445}