Skip to main content

postrust_proxy/saas/
auth.rs

1//! Authentication middleware for SaaS domain management.
2//!
3//! Supports both JWT and API key authentication.
4
5use 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/// Authentication context extracted from the request.
19#[derive(Clone, Debug)]
20pub struct AuthContext {
21    /// The tenant ID
22    pub tenant_id: Uuid,
23    /// The authentication type used
24    pub auth_type: AuthType,
25    /// Scopes available to this authentication
26    pub scopes: Vec<String>,
27}
28
29/// Type of authentication used.
30#[derive(Clone, Debug)]
31pub enum AuthType {
32    /// JWT authentication
33    Jwt {
34        /// User ID from JWT claims
35        user_id: String,
36        /// Role from JWT claims
37        role: Option<String>,
38    },
39    /// API key authentication
40    ApiKey {
41        /// API key ID
42        key_id: Uuid,
43    },
44}
45
46impl AuthContext {
47    /// Check if the context has a specific scope.
48    pub fn has_scope(&self, scope: &str) -> bool {
49        self.scopes.iter().any(|s| s == scope || s == "*")
50    }
51
52    /// Check if the context can read a resource.
53    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    /// Check if the context can write to a resource.
60    pub fn can_write(&self, resource: &str) -> bool {
61        self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
62    }
63}
64
65/// SaaS authentication layer state.
66#[derive(Clone)]
67pub struct SaasAuthLayer {
68    pool: PgPool,
69    jwt_secret: Option<String>,
70}
71
72impl SaasAuthLayer {
73    /// Create a new SaaS auth layer.
74    pub fn new(pool: PgPool, jwt_secret: Option<String>) -> Self {
75        Self { pool, jwt_secret }
76    }
77}
78
79/// Authentication middleware function.
80pub 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            // JWT authentication
93            let token = header.strip_prefix("Bearer ").unwrap();
94            validate_jwt(token, &auth_layer).await?
95        }
96        Some(header) if header.starts_with("ApiKey ") => {
97            // API key authentication
98            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            // Could support basic auth in the future
103            return Err(AuthError::UnsupportedAuthMethod);
104        }
105        Some(_) => {
106            return Err(AuthError::InvalidFormat);
107        }
108        None => {
109            return Err(AuthError::MissingHeader);
110        }
111    };
112
113    // Check if tenant is active
114    if !db::is_tenant_active(&auth_layer.pool, auth_context.tenant_id).await? {
115        return Err(AuthError::TenantSuspended);
116    }
117
118    // Insert auth context into request extensions
119    request.extensions_mut().insert(auth_context);
120
121    Ok(next.run(request).await)
122}
123
124/// Validate a JWT token.
125async 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    // Get tenant ID from claims
134    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
148/// Validate an API key.
149async 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    // Update last used timestamp asynchronously
161    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/// JWT claims structure.
177#[derive(Debug, Deserialize)]
178struct JwtClaims {
179    /// Subject (user ID)
180    sub: String,
181    /// Tenant ID
182    tenant_id: Option<Uuid>,
183    /// User role
184    role: Option<String>,
185    /// Scopes
186    scopes: Option<Vec<String>>,
187    /// Expiration time
188    exp: Option<i64>,
189}
190
191/// Decode and validate a JWT token (HMAC-SHA256 signature, then expiry).
192fn 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    // `exp` is optional for these tokens: don't require it, and check it
197    // manually below when present so absence isn't an error.
198    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    // Check expiration
214    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/// Authentication errors.
225#[derive(Debug)]
226pub enum AuthError {
227    /// No `Authorization` header at all.
228    MissingHeader,
229    /// The header is present but not `Bearer <jwt>` or `ApiKey <key>`.
230    InvalidFormat,
231    /// A JWT that does not verify, with the reason.
232    InvalidToken(String),
233    /// A JWT whose `exp` has passed.
234    TokenExpired,
235    /// A JWT missing a claim this proxy needs, named.
236    MissingClaim(String),
237    /// An API key that matches no stored hash.
238    InvalidApiKey,
239    /// A key that exists but has been revoked.
240    ApiKeyDisabled,
241    /// A key past its `expires_at`.
242    ApiKeyExpired,
243    /// The credential is good but its tenant may not act.
244    TenantSuspended,
245    /// A JWT was presented and no signing secret is configured, so it cannot
246    /// be verified. Refused rather than trusted.
247    JwtNotConfigured,
248    /// An `Authorization` scheme this proxy does not implement.
249    UnsupportedAuthMethod,
250    /// The lookup itself failed. Ours, not the caller's -- reported as a 500
251    /// rather than a 401, so an outage does not read as a bad credential.
252    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/// Extractor for AuthContext from request extensions.
324#[derive(Clone, Debug)]
325pub struct Auth(
326    /// The context the middleware put in the request's extensions.
327    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        // Re-encode the payload with an escalated tenant_id while keeping the
411        // original (now mismatched) signature.
412        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        // A classic "alg": "none" forgery must not pass HS256 validation,
429        // with or without a trailing signature segment.
430        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}