Skip to main content

postrust_auth/
lib.rs

1//! JWT authentication for Postrust.
2//!
3//! Provides JWT token validation and role extraction for PostgreSQL RLS.
4
5#![warn(missing_docs)]
6
7mod cache;
8mod claims;
9pub mod hasura;
10mod jwt;
11
12pub use cache::JwtCache;
13pub use claims::Claims;
14pub use hasura::{HasuraAuthConfig, HasuraIdentity, SecretOutcome, TokenRole};
15pub use jwt::validate_token;
16
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20/// Authentication result containing role and claims.
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct AuthResult {
23    /// PostgreSQL role to use
24    pub role: String,
25    /// All JWT claims
26    pub claims: HashMap<String, serde_json::Value>,
27}
28
29impl AuthResult {
30    /// Create an anonymous auth result.
31    pub fn anonymous(anon_role: &str) -> Self {
32        Self {
33            role: anon_role.to_string(),
34            claims: HashMap::new(),
35        }
36    }
37
38    /// Get a claim value.
39    pub fn get_claim(&self, key: &str) -> Option<&serde_json::Value> {
40        self.claims.get(key)
41    }
42
43    /// Get claims as JSON for GUC.
44    pub fn claims_json(&self) -> String {
45        serde_json::to_string(&self.claims).unwrap_or_else(|_| "{}".to_string())
46    }
47
48    /// Build transaction-local PostgreSQL settings for the verified claims.
49    ///
50    /// The complete JSON document preserves every claim. Claims with names
51    /// valid for custom PostgreSQL settings are also exposed individually for
52    /// policies using `request.jwt.claims.<name>`.
53    pub fn claim_settings(&self) -> Vec<(String, String)> {
54        let mut claims = Claims::from(self.claims.clone());
55        claims.set("role", serde_json::Value::String(self.role.clone()));
56        let mut settings = vec![("request.jwt.claims".to_string(), claims.to_json())];
57        settings.extend(claims.prefixed_entries("request.jwt.claims."));
58        settings
59    }
60}
61
62/// JWT configuration.
63#[derive(Clone, Debug)]
64pub struct JwtConfig {
65    /// Secret key for HS256/HS384/HS512
66    pub secret: Option<String>,
67    /// Whether secret is base64 encoded
68    pub secret_is_base64: bool,
69    /// Required audience claim
70    pub audience: Option<String>,
71    /// Claim key containing the role
72    pub role_claim_key: String,
73    /// Default role for anonymous requests
74    pub anon_role: Option<String>,
75}
76
77impl Default for JwtConfig {
78    fn default() -> Self {
79        Self {
80            secret: None,
81            secret_is_base64: false,
82            audience: None,
83            role_claim_key: "role".to_string(),
84            anon_role: None,
85        }
86    }
87}
88
89/// What was wrong with a token's claims.
90///
91/// Separate from the failures above because a token whose claims are wrong was
92/// read successfully: the signature verified, the server has the right key,
93/// and what remains is a disagreement about the claims themselves. A client
94/// that cannot tell those apart cannot tell "rotate your key" from "your clock
95/// is wrong".
96#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
97pub enum ClaimFault {
98    /// `exp` is in the past.
99    #[error("JWT expired")]
100    Expired,
101
102    /// `nbf` is in the future: the token is valid, but not yet.
103    #[error("JWT not yet valid")]
104    NotYetValid,
105
106    /// `iat` is in the future, which usually means the two clocks disagree
107    /// rather than that the token is forged.
108    #[error("JWT issued at future")]
109    IssuedInFuture,
110
111    /// `aud` is present and does not list the audience this server accepts.
112    #[error("JWT not in audience")]
113    NotInAudience,
114
115    /// The payload decoded but is not a JSON object of claims.
116    #[error("Parsing claims failed")]
117    Unparsable,
118
119    /// A timestamp claim that is present and is not a timestamp.
120    #[error("The JWT '{0}' claim must be a number")]
121    NotANumber(&'static str),
122
123    /// `aud` is present and is neither a string nor an array of strings.
124    #[error("The JWT 'aud' claim must be a string or an array of strings")]
125    AudienceNotStrings,
126}
127
128/// JWT validation error.
129#[derive(Debug, thiserror::Error)]
130pub enum JwtError {
131    /// Nothing said who is asking, and there is no anonymous role to be.
132    #[error("Anonymous access is disabled")]
133    NoIdentity,
134
135    /// The `Authorization` header is not a bearer token this server can read.
136    #[error("Unsupported token type")]
137    InvalidHeaderFormat,
138
139    /// A token was presented and no secret is configured to verify it with.
140    #[error("Server lacks JWT secret")]
141    SecretMissing,
142
143    /// The signature did not verify under the key that was selected.
144    #[error("JWT cryptographic operation failed")]
145    InvalidSignature,
146
147    /// No key this server holds could decode the token.
148    #[error("No suitable key or wrong key type")]
149    NoSuitableKey,
150
151    /// The token was read, and its claims were not acceptable.
152    #[error("{0}")]
153    Claim(#[from] ClaimFault),
154}
155
156/// Extract and validate JWT from Authorization header.
157pub fn authenticate(auth_header: Option<&str>, config: &JwtConfig) -> Result<AuthResult, JwtError> {
158    // If no auth header, use anonymous role if configured
159    let token = match auth_header {
160        Some(header) => extract_bearer_token(header)?,
161        None => {
162            return match &config.anon_role {
163                Some(role) => Ok(AuthResult::anonymous(role)),
164                None => Err(JwtError::NoIdentity),
165            };
166        }
167    };
168
169    // Validate token
170    validate_token(token, config)
171}
172
173/// Extract Bearer token from Authorization header.
174fn extract_bearer_token(header: &str) -> Result<&str, JwtError> {
175    let header = header.trim();
176
177    if let Some(token) = header.strip_prefix("Bearer ") {
178        Ok(token.trim())
179    } else if let Some(token) = header.strip_prefix("bearer ") {
180        Ok(token.trim())
181    } else {
182        Err(JwtError::InvalidHeaderFormat)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_extract_bearer_token() {
192        assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
193        assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123");
194        assert!(extract_bearer_token("Basic abc123").is_err());
195    }
196
197    #[test]
198    fn test_auth_result_anonymous() {
199        let result = AuthResult::anonymous("anon");
200        assert_eq!(result.role, "anon");
201        assert!(result.claims.is_empty());
202    }
203
204    #[test]
205    fn auth_result_claim_settings_include_json_and_individual_claims() {
206        let result = AuthResult {
207            role: "authenticated_user".to_string(),
208            claims: HashMap::from([
209                ("role".to_string(), serde_json::json!("untrusted_role")),
210                ("user_id".to_string(), serde_json::json!("user-1")),
211                ("org_class".to_string(), serde_json::json!(["defense"])),
212            ]),
213        };
214
215        let settings = result.claim_settings();
216        let setting = |name: &str| {
217            settings
218                .iter()
219                .find(|(key, _)| key == name)
220                .map(|(_, value)| value.as_str())
221        };
222        let claims: serde_json::Value =
223            serde_json::from_str(setting("request.jwt.claims").unwrap()).unwrap();
224
225        assert_eq!(claims["role"], serde_json::json!("authenticated_user"));
226        assert_eq!(
227            setting("request.jwt.claims.role"),
228            Some("authenticated_user")
229        );
230        assert_eq!(setting("request.jwt.claims.user_id"), Some("user-1"));
231        assert_eq!(
232            setting("request.jwt.claims.org_class"),
233            Some(r#"["defense"]"#)
234        );
235    }
236
237    #[test]
238    fn test_authenticate_no_header_with_anon() {
239        let config = JwtConfig {
240            anon_role: Some("web_anon".to_string()),
241            ..Default::default()
242        };
243
244        let result = authenticate(None, &config).unwrap();
245        assert_eq!(result.role, "web_anon");
246    }
247
248    #[test]
249    fn test_authenticate_no_header_no_anon() {
250        let config = JwtConfig::default();
251        assert!(authenticate(None, &config).is_err());
252    }
253}