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;
16pub mod jwks;
17mod jwt;
18mod keys;
19
20pub use cache::{CacheOutcome, CacheStats, JwtCache};
21pub use claims::Claims;
22pub use hasura::{HasuraAuthConfig, HasuraIdentity, SecretOutcome, TokenRole};
23pub use jwks::Jwks;
24pub use jwt::validate_token;
25pub use keys::{JwtKeyError, JwtKeys, KeySet};
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30/// Authentication result containing role and claims.
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct AuthResult {
33    /// PostgreSQL role to use
34    pub role: String,
35    /// All JWT claims
36    pub claims: HashMap<String, serde_json::Value>,
37}
38
39impl AuthResult {
40    /// Create an anonymous auth result.
41    pub fn anonymous(anon_role: &str) -> Self {
42        Self {
43            role: anon_role.to_string(),
44            claims: HashMap::new(),
45        }
46    }
47
48    /// Get a claim value.
49    pub fn get_claim(&self, key: &str) -> Option<&serde_json::Value> {
50        self.claims.get(key)
51    }
52
53    /// Get claims as JSON for GUC.
54    pub fn claims_json(&self) -> String {
55        serde_json::to_string(&self.claims).unwrap_or_else(|_| "{}".to_string())
56    }
57
58    /// Build transaction-local PostgreSQL settings for the verified claims.
59    ///
60    /// The complete JSON document preserves every claim. Claims with names
61    /// valid for custom PostgreSQL settings are also exposed individually for
62    /// policies using `request.jwt.claims.<name>`.
63    pub fn claim_settings(&self) -> Vec<(String, String)> {
64        let mut claims = Claims::from(self.claims.clone());
65        claims.set("role", serde_json::Value::String(self.role.clone()));
66        let mut settings = vec![("request.jwt.claims".to_string(), claims.to_json())];
67        settings.extend(claims.prefixed_entries("request.jwt.claims."));
68        settings
69    }
70}
71
72/// JWT configuration.
73///
74/// `#[non_exhaustive]` from 3.0.0, so that verifying keys and claims of new
75/// kinds is a minor release: start from `JwtConfig::default()` and assign, or
76/// take `postrust_engine::jwt_config` for the one an `AppConfig` describes.
77#[derive(Clone, Debug)]
78#[non_exhaustive]
79pub struct JwtConfig {
80    /// The keys tokens are verified with, parsed from `PGRST_JWT_SECRET` by
81    /// [`JwtKeys::from_config`] when the configuration is read. `None` when
82    /// no secret is configured, and every token is then refused with
83    /// [`JwtError::SecretMissing`].
84    pub keys: Option<JwtKeys>,
85    /// Required audience claim
86    pub audience: Option<String>,
87    /// The issuer a token's `iss` must name (`PGRST_JWT_ISS`). When set, a
88    /// token with no `iss` is refused too.
89    pub issuer: Option<String>,
90    /// Claim key containing the role
91    pub role_claim_key: String,
92    /// Default role for anonymous requests
93    pub anon_role: Option<String>,
94    /// Where `keys` are fetched from and kept current, when
95    /// `PGRST_JWT_JWKS_URL` is set. Set it with [`JwtConfig::with_jwks`],
96    /// which points `keys` at the same set; a host then awaits
97    /// [`ensure_keys`] before verifying.
98    pub jwks: Option<Jwks>,
99}
100
101impl Default for JwtConfig {
102    fn default() -> Self {
103        Self {
104            keys: None,
105            audience: None,
106            issuer: None,
107            role_claim_key: "role".to_string(),
108            anon_role: None,
109            jwks: None,
110        }
111    }
112}
113
114impl JwtConfig {
115    /// Verify with the keys `jwks` keeps, which already include whatever
116    /// `keys` held when it was built ([`jwks::JwksBuilder::fixed`]).
117    pub fn with_jwks(mut self, jwks: Jwks) -> Self {
118        self.keys = Some(jwks.keys().clone());
119        self.jwks = Some(jwks);
120        self
121    }
122}
123
124/// Make sure the keys the request's token needs are loaded, before the
125/// synchronous [`authenticate`] (or [`JwtCache::authenticate`]) runs.
126///
127/// The one asynchronous step in verifying a token, and a no-op unless keys
128/// come from a JWKS URL ([`JwtConfig::jwks`]). Even then it awaits nothing
129/// for a token whose key is already held, which is every request but the few
130/// after the identity provider rotates a key. See [`jwks`] for when it
131/// fetches. Never fails: a token it could not find a key for is refused by
132/// the check that follows, as it would have been without it.
133///
134/// Every place a token is verified awaits this first: the REST pipeline, and
135/// the server's GraphQL handlers.
136pub async fn ensure_keys(auth_header: Option<&str>, config: &JwtConfig) {
137    let Some(jwks) = &config.jwks else {
138        return;
139    };
140    let Some(Ok(token)) = auth_header.map(extract_bearer_token) else {
141        return;
142    };
143    jwks.ensure_for_token(token).await;
144}
145
146/// What was wrong with a token's claims.
147///
148/// Separate from the failures above because a token whose claims are wrong was
149/// read successfully: the signature verified, the server has the right key,
150/// and what remains is a disagreement about the claims themselves. A client
151/// that cannot tell those apart cannot tell "rotate your key" from "your clock
152/// is wrong".
153#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
154pub enum ClaimFault {
155    /// `exp` is in the past.
156    #[error("JWT expired")]
157    Expired,
158
159    /// `nbf` is in the future: the token is valid, but not yet.
160    #[error("JWT not yet valid")]
161    NotYetValid,
162
163    /// `iat` is in the future, which usually means the two clocks disagree
164    /// rather than that the token is forged.
165    #[error("JWT issued at future")]
166    IssuedInFuture,
167
168    /// `aud` is present and does not list the audience this server accepts.
169    #[error("JWT not in audience")]
170    NotInAudience,
171
172    /// An issuer is configured and `iss` does not name it, or is absent.
173    #[error("JWT issuer is not the expected one")]
174    UnexpectedIssuer,
175
176    /// The payload decoded but is not a JSON object of claims.
177    #[error("Parsing claims failed")]
178    Unparsable,
179
180    /// A timestamp claim that is present and is not a timestamp.
181    #[error("The JWT '{0}' claim must be a number")]
182    NotANumber(&'static str),
183
184    /// `aud` is present and is neither a string nor an array of strings.
185    #[error("The JWT 'aud' claim must be a string or an array of strings")]
186    AudienceNotStrings,
187}
188
189/// JWT validation error.
190#[derive(Debug, thiserror::Error)]
191pub enum JwtError {
192    /// Nothing said who is asking, and there is no anonymous role to be.
193    #[error("Anonymous access is disabled")]
194    NoIdentity,
195
196    /// The `Authorization` header is not a bearer token this server can read.
197    #[error("Unsupported token type")]
198    InvalidHeaderFormat,
199
200    /// A token was presented and no secret is configured to verify it with.
201    #[error("Server lacks JWT secret")]
202    SecretMissing,
203
204    /// The signature did not verify under the key that was selected.
205    #[error("JWT cryptographic operation failed")]
206    InvalidSignature,
207
208    /// No key this server holds could decode the token.
209    #[error("No suitable key or wrong key type")]
210    NoSuitableKey,
211
212    /// The token was read, and its claims were not acceptable.
213    #[error("{0}")]
214    Claim(#[from] ClaimFault),
215}
216
217/// Extract and validate JWT from Authorization header.
218pub fn authenticate(auth_header: Option<&str>, config: &JwtConfig) -> Result<AuthResult, JwtError> {
219    // If no auth header, use anonymous role if configured
220    let token = match auth_header {
221        Some(header) => extract_bearer_token(header)?,
222        None => {
223            return match &config.anon_role {
224                Some(role) => Ok(AuthResult::anonymous(role)),
225                None => Err(JwtError::NoIdentity),
226            };
227        }
228    };
229
230    // Validate token
231    validate_token(token, config)
232}
233
234/// Extract Bearer token from Authorization header.
235fn extract_bearer_token(header: &str) -> Result<&str, JwtError> {
236    let header = header.trim();
237
238    if let Some(token) = header.strip_prefix("Bearer ") {
239        Ok(token.trim())
240    } else if let Some(token) = header.strip_prefix("bearer ") {
241        Ok(token.trim())
242    } else {
243        Err(JwtError::InvalidHeaderFormat)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_extract_bearer_token() {
253        assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
254        assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123");
255        assert!(extract_bearer_token("Basic abc123").is_err());
256    }
257
258    #[test]
259    fn test_auth_result_anonymous() {
260        let result = AuthResult::anonymous("anon");
261        assert_eq!(result.role, "anon");
262        assert!(result.claims.is_empty());
263    }
264
265    #[test]
266    fn auth_result_claim_settings_include_json_and_individual_claims() {
267        let result = AuthResult {
268            role: "authenticated_user".to_string(),
269            claims: HashMap::from([
270                ("role".to_string(), serde_json::json!("untrusted_role")),
271                ("user_id".to_string(), serde_json::json!("user-1")),
272                ("org_class".to_string(), serde_json::json!(["defense"])),
273            ]),
274        };
275
276        let settings = result.claim_settings();
277        let setting = |name: &str| {
278            settings
279                .iter()
280                .find(|(key, _)| key == name)
281                .map(|(_, value)| value.as_str())
282        };
283        let claims: serde_json::Value =
284            serde_json::from_str(setting("request.jwt.claims").unwrap()).unwrap();
285
286        assert_eq!(claims["role"], serde_json::json!("authenticated_user"));
287        assert_eq!(
288            setting("request.jwt.claims.role"),
289            Some("authenticated_user")
290        );
291        assert_eq!(setting("request.jwt.claims.user_id"), Some("user-1"));
292        assert_eq!(
293            setting("request.jwt.claims.org_class"),
294            Some(r#"["defense"]"#)
295        );
296    }
297
298    #[test]
299    fn test_authenticate_no_header_with_anon() {
300        let config = JwtConfig {
301            anon_role: Some("web_anon".to_string()),
302            ..Default::default()
303        };
304
305        let result = authenticate(None, &config).unwrap();
306        assert_eq!(result.role, "web_anon");
307    }
308
309    #[test]
310    fn test_authenticate_no_header_no_anon() {
311        let config = JwtConfig::default();
312        assert!(authenticate(None, &config).is_err());
313    }
314}