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
46/// Every scope an API key may be given.
47///
48/// `resource:read` and `resource:write`, where write implies read, plus `*` for
49/// everything. A key's scopes are checked against this list when it is
50/// created: a misspelt scope would otherwise be stored, grant nothing, and look
51/// to whoever reads the key's metadata as though it granted something.
52pub const KNOWN_SCOPES: &[&str] = &[
53    "*",
54    "domains:read",
55    "domains:write",
56    "routes:read",
57    "routes:write",
58    "upstreams:read",
59    "upstreams:write",
60    "api_keys:read",
61    "api_keys:write",
62    "tenant:read",
63];
64
65/// Refuse any scope not in [`KNOWN_SCOPES`].
66pub fn validate_scopes(scopes: &[String]) -> crate::error::ProxyResult<()> {
67    match scopes.iter().find(|s| !KNOWN_SCOPES.contains(&s.as_str())) {
68        Some(unknown) => Err(crate::error::ProxyError::Validation(format!(
69            "unknown scope {unknown:?}; known scopes are {}",
70            KNOWN_SCOPES.join(", ")
71        ))),
72        None => Ok(()),
73    }
74}
75
76impl AuthContext {
77    /// Check if the context has a specific scope.
78    pub fn has_scope(&self, scope: &str) -> bool {
79        self.scopes.iter().any(|s| s == scope || s == "*")
80    }
81
82    /// Whether this context holds at least what `scope` would grant.
83    ///
84    /// What decides whether a caller may hand a scope to a new key, or revoke
85    /// a key holding it: nobody may give or take away more than they have.
86    /// `*` is held only by `*`; `x:read` by anything that can read `x`;
87    /// `x:write` by anything that can write it. An unrecognised scope is held
88    /// by nobody but `*`.
89    pub fn grants(&self, scope: &str) -> bool {
90        if self.has_scope("*") {
91            return true;
92        }
93        match scope.rsplit_once(':') {
94            Some((resource, "read")) => self.can_read(resource),
95            Some((resource, "write")) => self.can_write(resource),
96            _ => false,
97        }
98    }
99
100    /// Check if the context can read a resource.
101    pub fn can_read(&self, resource: &str) -> bool {
102        self.has_scope(&format!("{}:read", resource))
103            || self.has_scope(&format!("{}:write", resource))
104            || self.has_scope("*")
105    }
106
107    /// Check if the context can write to a resource.
108    pub fn can_write(&self, resource: &str) -> bool {
109        self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
110    }
111}
112
113/// SaaS authentication layer state.
114#[derive(Clone)]
115pub struct SaasAuthLayer {
116    pool: PgPool,
117    jwt_secret: Option<String>,
118    jwt_audience: String,
119    jwt_issuer: Option<String>,
120}
121
122impl SaasAuthLayer {
123    /// Create a new SaaS auth layer.
124    ///
125    /// JWTs must carry `aud` = [`DEFAULT_JWT_AUDIENCE`] unless
126    /// [`with_jwt_audience`](Self::with_jwt_audience) says otherwise.
127    ///
128    /// [`DEFAULT_JWT_AUDIENCE`]: crate::saas::options::DEFAULT_JWT_AUDIENCE
129    pub fn new(pool: PgPool, jwt_secret: Option<String>) -> Self {
130        Self {
131            pool,
132            jwt_secret,
133            jwt_audience: crate::saas::options::DEFAULT_JWT_AUDIENCE.to_string(),
134            jwt_issuer: None,
135        }
136    }
137
138    /// Require JWTs to name this audience instead of the default.
139    pub fn with_jwt_audience(mut self, audience: impl Into<String>) -> Self {
140        self.jwt_audience = audience.into();
141        self
142    }
143
144    /// Require JWTs to name this issuer.
145    pub fn with_jwt_issuer(mut self, issuer: Option<String>) -> Self {
146        self.jwt_issuer = issuer;
147        self
148    }
149}
150
151/// Authentication middleware function.
152pub async fn auth_middleware(
153    State(auth_layer): State<Arc<SaasAuthLayer>>,
154    mut request: Request,
155    next: Next,
156) -> Result<Response, AuthError> {
157    let auth_header = request
158        .headers()
159        .get(header::AUTHORIZATION)
160        .and_then(|h| h.to_str().ok());
161
162    let auth_context = match auth_header {
163        Some(header) if header.starts_with("Bearer ") => {
164            // JWT authentication
165            let token = header.strip_prefix("Bearer ").unwrap();
166            validate_jwt(token, &auth_layer).await?
167        }
168        Some(header) if header.starts_with("ApiKey ") => {
169            // API key authentication
170            let key = header.strip_prefix("ApiKey ").unwrap();
171            validate_api_key(key, &auth_layer.pool).await?
172        }
173        Some(header) if header.starts_with("Basic ") => {
174            // Could support basic auth in the future
175            return Err(AuthError::UnsupportedAuthMethod);
176        }
177        Some(_) => {
178            return Err(AuthError::InvalidFormat);
179        }
180        None => {
181            return Err(AuthError::MissingHeader);
182        }
183    };
184
185    // Check if tenant is active
186    if !db::is_tenant_active(&auth_layer.pool, auth_context.tenant_id).await? {
187        return Err(AuthError::TenantSuspended);
188    }
189
190    // Insert auth context into request extensions
191    request.extensions_mut().insert(auth_context);
192
193    Ok(next.run(request).await)
194}
195
196/// Validate a JWT token.
197async fn validate_jwt(token: &str, auth_layer: &SaasAuthLayer) -> Result<AuthContext, AuthError> {
198    let secret = auth_layer
199        .jwt_secret
200        .as_ref()
201        .ok_or(AuthError::JwtNotConfigured)?;
202
203    let claims = decode_jwt(
204        token,
205        secret,
206        &auth_layer.jwt_audience,
207        auth_layer.jwt_issuer.as_deref(),
208    )?;
209
210    // Get tenant ID from claims
211    let tenant_id = claims
212        .tenant_id
213        .ok_or_else(|| AuthError::MissingClaim("tenant_id".into()))?;
214
215    Ok(AuthContext {
216        tenant_id,
217        auth_type: AuthType::Jwt {
218            user_id: claims.sub,
219            role: claims.role,
220        },
221        // No `scopes` claim means no scopes. Defaulting to `*` made every token
222        // minted without thinking about scopes an all-powerful one.
223        scopes: claims.scopes.unwrap_or_default(),
224    })
225}
226
227/// Validate an API key.
228async fn validate_api_key(key: &str, pool: &PgPool) -> Result<AuthContext, AuthError> {
229    let key_hash = hash_api_key(key);
230
231    let validation = db::validate_api_key_by_hash(pool, &key_hash)
232        .await?
233        .ok_or(AuthError::InvalidApiKey)?;
234
235    if !validation.enabled {
236        return Err(AuthError::ApiKeyDisabled);
237    }
238
239    // Update last used timestamp asynchronously
240    let pool = pool.clone();
241    let key_id = validation.id;
242    tokio::spawn(async move {
243        let _ = db::update_last_used(&pool, key_id).await;
244    });
245
246    Ok(AuthContext {
247        tenant_id: validation.tenant_id,
248        auth_type: AuthType::ApiKey {
249            key_id: validation.id,
250        },
251        scopes: validation.scopes,
252    })
253}
254
255/// JWT claims structure.
256#[derive(Debug, Deserialize)]
257struct JwtClaims {
258    /// Subject (user ID)
259    sub: String,
260    /// Tenant ID
261    tenant_id: Option<Uuid>,
262    /// User role
263    role: Option<String>,
264    /// Scopes. Absent means none.
265    scopes: Option<Vec<String>>,
266}
267
268/// Decode and validate a JWT: HMAC-SHA256 signature, then `exp`, `aud` and,
269/// when configured, `iss`.
270///
271/// `exp` and `aud` are required, and `iss` when an issuer is configured. A
272/// token without `exp` never expires, so a leaked one is good for ever. A token without the right `aud` may have been
273/// minted for something else that shares the secret -- the main Postrust
274/// server, typically -- and must not open this API.
275fn decode_jwt(
276    token: &str,
277    secret: &str,
278    audience: &str,
279    issuer: Option<&str>,
280) -> Result<JwtClaims, AuthError> {
281    use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
282
283    let mut validation = Validation::new(Algorithm::HS256);
284    validation.set_required_spec_claims(&["exp", "aud"]);
285    validation.validate_exp = true;
286    validation.set_audience(&[audience]);
287    if let Some(issuer) = issuer {
288        validation.set_issuer(&[issuer]);
289        validation.required_spec_claims.insert("iss".to_string());
290    }
291
292    let token_data = decode::<JwtClaims>(
293        token,
294        &DecodingKey::from_secret(secret.as_bytes()),
295        &validation,
296    )
297    .map_err(|e| match e.kind() {
298        jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
299        _ => AuthError::InvalidToken(e.to_string()),
300    })?;
301
302    Ok(token_data.claims)
303}
304
305/// Authentication errors.
306#[derive(Debug)]
307pub enum AuthError {
308    /// No `Authorization` header at all.
309    MissingHeader,
310    /// The header is present but not `Bearer <jwt>` or `ApiKey <key>`.
311    InvalidFormat,
312    /// A JWT that does not verify, with the reason.
313    InvalidToken(String),
314    /// A JWT whose `exp` has passed.
315    TokenExpired,
316    /// A JWT missing a claim this proxy needs, named.
317    MissingClaim(String),
318    /// An API key that matches no stored hash.
319    InvalidApiKey,
320    /// A key that exists but has been revoked.
321    ApiKeyDisabled,
322    /// A key past its `expires_at`.
323    ApiKeyExpired,
324    /// The credential is good but its tenant may not act.
325    TenantSuspended,
326    /// A JWT was presented and no signing secret is configured, so it cannot
327    /// be verified. Refused rather than trusted.
328    JwtNotConfigured,
329    /// An `Authorization` scheme this proxy does not implement.
330    UnsupportedAuthMethod,
331    /// The lookup itself failed. Ours, not the caller's -- reported as a 500
332    /// rather than a 401, so an outage does not read as a bad credential.
333    Database(sqlx::Error),
334    /// Some other failure of ours. Logged; the caller sees a generic 500.
335    Internal(String),
336}
337
338impl From<sqlx::Error> for AuthError {
339    fn from(err: sqlx::Error) -> Self {
340        AuthError::Database(err)
341    }
342}
343
344impl From<crate::error::ProxyError> for AuthError {
345    fn from(err: crate::error::ProxyError) -> Self {
346        match err {
347            crate::error::ProxyError::Database(e) => AuthError::Database(e),
348            // Anything else reaching here is a failure of ours, from a lookup.
349            // It used to be reported as an invalid token, with its text.
350            other => AuthError::Internal(other.to_string()),
351        }
352    }
353}
354
355impl IntoResponse for AuthError {
356    fn into_response(self) -> Response {
357        match &self {
358            AuthError::Database(error) => {
359                tracing::error!(%error, "authentication lookup failed");
360            }
361            AuthError::Internal(error) => {
362                tracing::error!(%error, "authentication failed internally");
363            }
364            AuthError::InvalidToken(reason) => {
365                tracing::debug!(%reason, "refused a JWT");
366            }
367            _ => {}
368        }
369
370        let (status, message) = match self {
371            AuthError::MissingHeader => (
372                StatusCode::UNAUTHORIZED,
373                "Missing Authorization header".to_string(),
374            ),
375            AuthError::InvalidFormat => (
376                StatusCode::UNAUTHORIZED,
377                "Invalid Authorization header format".to_string(),
378            ),
379            // The reason is logged, not returned: it is the library's text,
380            // and says which check a forger has yet to get past.
381            AuthError::InvalidToken(_) => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()),
382            AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired".to_string()),
383            AuthError::MissingClaim(claim) => (
384                StatusCode::UNAUTHORIZED,
385                format!("Missing required claim: {}", claim),
386            ),
387            AuthError::InvalidApiKey => (StatusCode::UNAUTHORIZED, "Invalid API key".to_string()),
388            AuthError::ApiKeyDisabled => {
389                (StatusCode::UNAUTHORIZED, "API key is disabled".to_string())
390            }
391            AuthError::ApiKeyExpired => {
392                (StatusCode::UNAUTHORIZED, "API key has expired".to_string())
393            }
394            AuthError::TenantSuspended => (
395                StatusCode::FORBIDDEN,
396                "Tenant account is suspended".to_string(),
397            ),
398            AuthError::JwtNotConfigured => (
399                StatusCode::INTERNAL_SERVER_ERROR,
400                "JWT authentication not configured".to_string(),
401            ),
402            AuthError::UnsupportedAuthMethod => (
403                StatusCode::UNAUTHORIZED,
404                "Unsupported authentication method".to_string(),
405            ),
406            AuthError::Database(_) | AuthError::Internal(_) => (
407                StatusCode::INTERNAL_SERVER_ERROR,
408                "Internal server error".to_string(),
409            ),
410        };
411
412        let body = serde_json::json!({
413            "success": false,
414            "error": message
415        });
416
417        (status, axum::Json(body)).into_response()
418    }
419}
420
421/// Extractor for AuthContext from request extensions.
422#[derive(Clone, Debug)]
423pub struct Auth(
424    /// The context the middleware put in the request's extensions.
425    pub AuthContext,
426);
427
428impl<S> axum::extract::FromRequestParts<S> for Auth
429where
430    S: Send + Sync,
431{
432    type Rejection = AuthError;
433
434    async fn from_request_parts(
435        parts: &mut axum::http::request::Parts,
436        _state: &S,
437    ) -> Result<Self, Self::Rejection> {
438        parts
439            .extensions
440            .get::<AuthContext>()
441            .cloned()
442            .map(Auth)
443            .ok_or(AuthError::MissingHeader)
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use base64::Engine;
451    use jsonwebtoken::{encode, EncodingKey, Header};
452
453    const SECRET: &str = "test-secret";
454    const TENANT_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
455
456    fn sign(claims: &serde_json::Value, secret: &str) -> String {
457        encode(
458            &Header::default(),
459            claims,
460            &EncodingKey::from_secret(secret.as_bytes()),
461        )
462        .unwrap()
463    }
464
465    fn valid_claims() -> serde_json::Value {
466        serde_json::json!({
467            "sub": "user-1",
468            "tenant_id": TENANT_ID,
469            "role": "admin",
470            "scopes": ["domains:read"],
471            "aud": AUDIENCE,
472            "exp": chrono::Utc::now().timestamp() + 3600,
473        })
474    }
475
476    const AUDIENCE: &str = "postrust-proxy";
477
478    fn decode(token: &str, secret: &str) -> Result<JwtClaims, AuthError> {
479        decode_jwt(token, secret, AUDIENCE, None)
480    }
481
482    fn b64url(data: &[u8]) -> String {
483        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
484    }
485
486    #[test]
487    fn valid_token_decodes() {
488        let token = sign(&valid_claims(), SECRET);
489        let claims = decode(&token, SECRET).unwrap();
490        assert_eq!(claims.sub, "user-1");
491        assert_eq!(claims.tenant_id, Some(TENANT_ID.parse().unwrap()));
492        assert_eq!(claims.role.as_deref(), Some("admin"));
493        assert_eq!(claims.scopes, Some(vec!["domains:read".to_string()]));
494    }
495
496    #[test]
497    fn token_without_exp_is_rejected() {
498        // It would never expire, so a leaked one would be good for ever.
499        let mut claims = valid_claims();
500        claims.as_object_mut().unwrap().remove("exp");
501        let token = sign(&claims, SECRET);
502        assert!(matches!(
503            decode(&token, SECRET),
504            Err(AuthError::InvalidToken(_))
505        ));
506    }
507
508    #[test]
509    fn token_without_an_audience_is_rejected() {
510        let mut claims = valid_claims();
511        claims.as_object_mut().unwrap().remove("aud");
512        let token = sign(&claims, SECRET);
513        assert!(matches!(
514            decode(&token, SECRET),
515            Err(AuthError::InvalidToken(_))
516        ));
517    }
518
519    #[test]
520    fn token_for_another_audience_is_rejected() {
521        // What the main Postrust server would mint with the same secret.
522        let mut claims = valid_claims();
523        claims["aud"] = serde_json::json!("postrust");
524        let token = sign(&claims, SECRET);
525        assert!(matches!(
526            decode(&token, SECRET),
527            Err(AuthError::InvalidToken(_))
528        ));
529
530        // A configured audience replaces the default rather than adding to it.
531        let token = sign(&valid_claims(), SECRET);
532        assert!(decode_jwt(&token, SECRET, "elsewhere", None).is_err());
533    }
534
535    #[test]
536    fn a_configured_issuer_is_required() {
537        let token = sign(&valid_claims(), SECRET);
538        assert!(
539            decode_jwt(&token, SECRET, AUDIENCE, Some("https://id.example")).is_err(),
540            "a token with no iss passed an issuer check"
541        );
542
543        let mut claims = valid_claims();
544        claims["iss"] = serde_json::json!("https://id.example");
545        let token = sign(&claims, SECRET);
546        assert!(decode_jwt(&token, SECRET, AUDIENCE, Some("https://id.example")).is_ok());
547        assert!(decode_jwt(&token, SECRET, AUDIENCE, Some("https://other.example")).is_err());
548    }
549
550    #[tokio::test]
551    async fn a_token_without_scopes_has_none() {
552        let mut claims = valid_claims();
553        claims.as_object_mut().unwrap().remove("scopes");
554        let token = sign(&claims, SECRET);
555
556        // Never connects: validate_jwt does not touch the database.
557        let pool = PgPool::connect_lazy("postgres://localhost/unused").unwrap();
558        let layer = SaasAuthLayer::new(pool, Some(SECRET.to_string()));
559        let context = validate_jwt(&token, &layer).await.expect("valid token");
560        assert!(context.scopes.is_empty(), "got {:?}", context.scopes);
561        assert!(!context.can_read("domains"));
562    }
563
564    fn context(scopes: &[&str]) -> AuthContext {
565        AuthContext {
566            tenant_id: Uuid::nil(),
567            auth_type: AuthType::ApiKey {
568                key_id: Uuid::nil(),
569            },
570            scopes: scopes.iter().map(|s| s.to_string()).collect(),
571        }
572    }
573
574    #[test]
575    fn only_a_wildcard_grants_a_wildcard() {
576        assert!(context(&["*"]).grants("*"));
577        assert!(!context(&["domains:write", "api_keys:write"]).grants("*"));
578        assert!(!context(&[]).grants("domains:read"));
579    }
580
581    #[test]
582    fn write_grants_read_but_not_the_reverse() {
583        let writer = context(&["domains:write"]);
584        assert!(writer.grants("domains:read"));
585        assert!(writer.grants("domains:write"));
586        assert!(!writer.grants("routes:read"));
587
588        let reader = context(&["domains:read"]);
589        assert!(reader.grants("domains:read"));
590        assert!(!reader.grants("domains:write"));
591        assert!(!reader.grants("bogus"));
592    }
593
594    #[test]
595    fn unknown_scopes_are_refused() {
596        assert!(validate_scopes(&["domains:read".into(), "tenant:read".into()]).is_ok());
597        assert!(validate_scopes(&["domain:read".into()]).is_err());
598        assert!(validate_scopes(&["domains:admin".into()]).is_err());
599        assert!(validate_scopes(&["".into()]).is_err());
600    }
601
602    #[test]
603    fn token_signed_with_wrong_secret_is_rejected() {
604        let token = sign(&valid_claims(), "some-other-secret");
605        assert!(matches!(
606            decode(&token, SECRET),
607            Err(AuthError::InvalidToken(_))
608        ));
609    }
610
611    #[test]
612    fn tampered_payload_is_rejected() {
613        // Re-encode the payload with an escalated tenant_id while keeping the
614        // original (now mismatched) signature.
615        let token = sign(&valid_claims(), SECRET);
616        let parts: Vec<&str> = token.split('.').collect();
617
618        let mut claims = valid_claims();
619        claims["tenant_id"] = serde_json::json!("00000000-0000-0000-0000-000000000001");
620        let forged_payload = b64url(serde_json::to_vec(&claims).unwrap().as_slice());
621
622        let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]);
623        assert!(matches!(
624            decode(&forged, SECRET),
625            Err(AuthError::InvalidToken(_))
626        ));
627    }
628
629    #[test]
630    fn unsigned_alg_none_token_is_rejected() {
631        // A classic "alg": "none" forgery must not pass HS256 validation,
632        // with or without a trailing signature segment.
633        let header = b64url(br#"{"alg":"none","typ":"JWT"}"#);
634        let payload = b64url(serde_json::to_vec(&valid_claims()).unwrap().as_slice());
635
636        for token in [
637            format!("{header}.{payload}."),
638            format!("{header}.{payload}"),
639        ] {
640            assert!(
641                matches!(decode(&token, SECRET), Err(AuthError::InvalidToken(_))),
642                "token {token:?} should be rejected"
643            );
644        }
645    }
646
647    #[test]
648    fn expired_token_is_rejected() {
649        let mut claims = valid_claims();
650        // Past the library's default sixty-second leeway for clock skew.
651        claims["exp"] = serde_json::json!(chrono::Utc::now().timestamp() - 3600);
652        let token = sign(&claims, SECRET);
653        assert!(matches!(
654            decode(&token, SECRET),
655            Err(AuthError::TokenExpired)
656        ));
657    }
658
659    #[test]
660    fn malformed_token_is_rejected() {
661        assert!(matches!(
662            decode("not-a-jwt", SECRET),
663            Err(AuthError::InvalidToken(_))
664        ));
665    }
666}