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