postrust_auth/
lib.rs

1//! JWT authentication for Postrust.
2//!
3//! Provides JWT token validation and role extraction for PostgreSQL RLS.
4
5mod jwt;
6mod claims;
7
8pub use jwt::validate_token;
9pub use claims::Claims;
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Authentication result containing role and claims.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct AuthResult {
17    /// PostgreSQL role to use
18    pub role: String,
19    /// All JWT claims
20    pub claims: HashMap<String, serde_json::Value>,
21}
22
23impl AuthResult {
24    /// Create an anonymous auth result.
25    pub fn anonymous(anon_role: &str) -> Self {
26        Self {
27            role: anon_role.to_string(),
28            claims: HashMap::new(),
29        }
30    }
31
32    /// Get a claim value.
33    pub fn get_claim(&self, key: &str) -> Option<&serde_json::Value> {
34        self.claims.get(key)
35    }
36
37    /// Get claims as JSON for GUC.
38    pub fn claims_json(&self) -> String {
39        serde_json::to_string(&self.claims).unwrap_or_else(|_| "{}".to_string())
40    }
41}
42
43/// JWT configuration.
44#[derive(Clone, Debug)]
45pub struct JwtConfig {
46    /// Secret key for HS256/HS384/HS512
47    pub secret: Option<String>,
48    /// Whether secret is base64 encoded
49    pub secret_is_base64: bool,
50    /// Required audience claim
51    pub audience: Option<String>,
52    /// Claim key containing the role
53    pub role_claim_key: String,
54    /// Default role for anonymous requests
55    pub anon_role: Option<String>,
56}
57
58impl Default for JwtConfig {
59    fn default() -> Self {
60        Self {
61            secret: None,
62            secret_is_base64: false,
63            audience: None,
64            role_claim_key: "role".to_string(),
65            anon_role: None,
66        }
67    }
68}
69
70/// JWT validation error.
71#[derive(Debug, thiserror::Error)]
72pub enum JwtError {
73    #[error("Missing authorization header")]
74    MissingHeader,
75
76    #[error("Invalid authorization header format")]
77    InvalidHeaderFormat,
78
79    #[error("Token expired")]
80    Expired,
81
82    #[error("Token not yet valid")]
83    NotYetValid,
84
85    #[error("Invalid signature")]
86    InvalidSignature,
87
88    #[error("Invalid token: {0}")]
89    InvalidToken(String),
90
91    #[error("Missing role claim")]
92    MissingRole,
93
94    #[error("Invalid audience")]
95    InvalidAudience,
96}
97
98/// Extract and validate JWT from Authorization header.
99pub fn authenticate(
100    auth_header: Option<&str>,
101    config: &JwtConfig,
102) -> Result<AuthResult, JwtError> {
103    // If no auth header, use anonymous role if configured
104    let token = match auth_header {
105        Some(header) => extract_bearer_token(header)?,
106        None => {
107            return match &config.anon_role {
108                Some(role) => Ok(AuthResult::anonymous(role)),
109                None => Err(JwtError::MissingHeader),
110            };
111        }
112    };
113
114    // Validate token
115    validate_token(token, config)
116}
117
118/// Extract Bearer token from Authorization header.
119fn extract_bearer_token(header: &str) -> Result<&str, JwtError> {
120    let header = header.trim();
121
122    if let Some(token) = header.strip_prefix("Bearer ") {
123        Ok(token.trim())
124    } else if let Some(token) = header.strip_prefix("bearer ") {
125        Ok(token.trim())
126    } else {
127        Err(JwtError::InvalidHeaderFormat)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_extract_bearer_token() {
137        assert_eq!(
138            extract_bearer_token("Bearer abc123").unwrap(),
139            "abc123"
140        );
141        assert_eq!(
142            extract_bearer_token("bearer abc123").unwrap(),
143            "abc123"
144        );
145        assert!(extract_bearer_token("Basic abc123").is_err());
146    }
147
148    #[test]
149    fn test_auth_result_anonymous() {
150        let result = AuthResult::anonymous("anon");
151        assert_eq!(result.role, "anon");
152        assert!(result.claims.is_empty());
153    }
154
155    #[test]
156    fn test_authenticate_no_header_with_anon() {
157        let config = JwtConfig {
158            anon_role: Some("web_anon".to_string()),
159            ..Default::default()
160        };
161
162        let result = authenticate(None, &config).unwrap();
163        assert_eq!(result.role, "web_anon");
164    }
165
166    #[test]
167    fn test_authenticate_no_header_no_anon() {
168        let config = JwtConfig::default();
169        assert!(authenticate(None, &config).is_err());
170    }
171}