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
49/// JWT configuration.
50#[derive(Clone, Debug)]
51pub struct JwtConfig {
52    /// Secret key for HS256/HS384/HS512
53    pub secret: Option<String>,
54    /// Whether secret is base64 encoded
55    pub secret_is_base64: bool,
56    /// Required audience claim
57    pub audience: Option<String>,
58    /// Claim key containing the role
59    pub role_claim_key: String,
60    /// Default role for anonymous requests
61    pub anon_role: Option<String>,
62}
63
64impl Default for JwtConfig {
65    fn default() -> Self {
66        Self {
67            secret: None,
68            secret_is_base64: false,
69            audience: None,
70            role_claim_key: "role".to_string(),
71            anon_role: None,
72        }
73    }
74}
75
76/// What was wrong with a token's claims.
77///
78/// Separate from the failures above because a token whose claims are wrong was
79/// read successfully: the signature verified, the server has the right key,
80/// and what remains is a disagreement about the claims themselves. A client
81/// that cannot tell those apart cannot tell "rotate your key" from "your clock
82/// is wrong".
83#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
84pub enum ClaimFault {
85    /// `exp` is in the past.
86    #[error("JWT expired")]
87    Expired,
88
89    /// `nbf` is in the future: the token is valid, but not yet.
90    #[error("JWT not yet valid")]
91    NotYetValid,
92
93    /// `iat` is in the future, which usually means the two clocks disagree
94    /// rather than that the token is forged.
95    #[error("JWT issued at future")]
96    IssuedInFuture,
97
98    /// `aud` is present and does not list the audience this server accepts.
99    #[error("JWT not in audience")]
100    NotInAudience,
101
102    /// The payload decoded but is not a JSON object of claims.
103    #[error("Parsing claims failed")]
104    Unparsable,
105
106    /// A timestamp claim that is present and is not a timestamp.
107    #[error("The JWT '{0}' claim must be a number")]
108    NotANumber(&'static str),
109
110    /// `aud` is present and is neither a string nor an array of strings.
111    #[error("The JWT 'aud' claim must be a string or an array of strings")]
112    AudienceNotStrings,
113}
114
115/// JWT validation error.
116#[derive(Debug, thiserror::Error)]
117pub enum JwtError {
118    /// Nothing said who is asking, and there is no anonymous role to be.
119    #[error("Anonymous access is disabled")]
120    NoIdentity,
121
122    /// The `Authorization` header is not a bearer token this server can read.
123    #[error("Unsupported token type")]
124    InvalidHeaderFormat,
125
126    /// A token was presented and no secret is configured to verify it with.
127    #[error("Server lacks JWT secret")]
128    SecretMissing,
129
130    /// The signature did not verify under the key that was selected.
131    #[error("JWT cryptographic operation failed")]
132    InvalidSignature,
133
134    /// No key this server holds could decode the token.
135    #[error("No suitable key or wrong key type")]
136    NoSuitableKey,
137
138    /// The token was read, and its claims were not acceptable.
139    #[error("{0}")]
140    Claim(#[from] ClaimFault),
141}
142
143/// Extract and validate JWT from Authorization header.
144pub fn authenticate(auth_header: Option<&str>, config: &JwtConfig) -> Result<AuthResult, JwtError> {
145    // If no auth header, use anonymous role if configured
146    let token = match auth_header {
147        Some(header) => extract_bearer_token(header)?,
148        None => {
149            return match &config.anon_role {
150                Some(role) => Ok(AuthResult::anonymous(role)),
151                None => Err(JwtError::NoIdentity),
152            };
153        }
154    };
155
156    // Validate token
157    validate_token(token, config)
158}
159
160/// Extract Bearer token from Authorization header.
161fn extract_bearer_token(header: &str) -> Result<&str, JwtError> {
162    let header = header.trim();
163
164    if let Some(token) = header.strip_prefix("Bearer ") {
165        Ok(token.trim())
166    } else if let Some(token) = header.strip_prefix("bearer ") {
167        Ok(token.trim())
168    } else {
169        Err(JwtError::InvalidHeaderFormat)
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_extract_bearer_token() {
179        assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
180        assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123");
181        assert!(extract_bearer_token("Basic abc123").is_err());
182    }
183
184    #[test]
185    fn test_auth_result_anonymous() {
186        let result = AuthResult::anonymous("anon");
187        assert_eq!(result.role, "anon");
188        assert!(result.claims.is_empty());
189    }
190
191    #[test]
192    fn test_authenticate_no_header_with_anon() {
193        let config = JwtConfig {
194            anon_role: Some("web_anon".to_string()),
195            ..Default::default()
196        };
197
198        let result = authenticate(None, &config).unwrap();
199        assert_eq!(result.role, "web_anon");
200    }
201
202    #[test]
203    fn test_authenticate_no_header_no_anon() {
204        let config = JwtConfig::default();
205        assert!(authenticate(None, &config).is_err());
206    }
207}