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
46pub 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
65pub 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 pub fn has_scope(&self, scope: &str) -> bool {
79 self.scopes.iter().any(|s| s == scope || s == "*")
80 }
81
82 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 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 pub fn can_write(&self, resource: &str) -> bool {
109 self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
110 }
111}
112
113#[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 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 pub fn with_jwt_audience(mut self, audience: impl Into<String>) -> Self {
140 self.jwt_audience = audience.into();
141 self
142 }
143
144 pub fn with_jwt_issuer(mut self, issuer: Option<String>) -> Self {
146 self.jwt_issuer = issuer;
147 self
148 }
149}
150
151pub 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 let token = header.strip_prefix("Bearer ").unwrap();
166 validate_jwt(token, &auth_layer).await?
167 }
168 Some(header) if header.starts_with("ApiKey ") => {
169 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 return Err(AuthError::UnsupportedAuthMethod);
176 }
177 Some(_) => {
178 return Err(AuthError::InvalidFormat);
179 }
180 None => {
181 return Err(AuthError::MissingHeader);
182 }
183 };
184
185 if !db::is_tenant_active(&auth_layer.pool, auth_context.tenant_id).await? {
187 return Err(AuthError::TenantSuspended);
188 }
189
190 request.extensions_mut().insert(auth_context);
192
193 Ok(next.run(request).await)
194}
195
196async 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 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 scopes: claims.scopes.unwrap_or_default(),
224 })
225}
226
227async 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 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#[derive(Debug, Deserialize)]
257struct JwtClaims {
258 sub: String,
260 tenant_id: Option<Uuid>,
262 role: Option<String>,
264 scopes: Option<Vec<String>>,
266}
267
268fn 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#[derive(Debug)]
307pub enum AuthError {
308 MissingHeader,
310 InvalidFormat,
312 InvalidToken(String),
314 TokenExpired,
316 MissingClaim(String),
318 InvalidApiKey,
320 ApiKeyDisabled,
322 ApiKeyExpired,
324 TenantSuspended,
326 JwtNotConfigured,
329 UnsupportedAuthMethod,
331 Database(sqlx::Error),
334 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 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 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#[derive(Clone, Debug)]
423pub struct Auth(
424 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 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 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 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 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 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 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 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}