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
5mod claims;
6pub mod hasura;
7mod jwt;
8
9pub use claims::Claims;
10pub use hasura::{HasuraAuthConfig, HasuraIdentity, SecretOutcome, TokenRole};
11pub use jwt::validate_token;
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Authentication result containing role and claims.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct AuthResult {
19    /// PostgreSQL role to use
20    pub role: String,
21    /// All JWT claims
22    pub claims: HashMap<String, serde_json::Value>,
23}
24
25impl AuthResult {
26    /// Create an anonymous auth result.
27    pub fn anonymous(anon_role: &str) -> Self {
28        Self {
29            role: anon_role.to_string(),
30            claims: HashMap::new(),
31        }
32    }
33
34    /// Get a claim value.
35    pub fn get_claim(&self, key: &str) -> Option<&serde_json::Value> {
36        self.claims.get(key)
37    }
38
39    /// Get claims as JSON for GUC.
40    pub fn claims_json(&self) -> String {
41        serde_json::to_string(&self.claims).unwrap_or_else(|_| "{}".to_string())
42    }
43}
44
45/// JWT configuration.
46#[derive(Clone, Debug)]
47pub struct JwtConfig {
48    /// Secret key for HS256/HS384/HS512
49    pub secret: Option<String>,
50    /// Whether secret is base64 encoded
51    pub secret_is_base64: bool,
52    /// Required audience claim
53    pub audience: Option<String>,
54    /// Claim key containing the role
55    pub role_claim_key: String,
56    /// Default role for anonymous requests
57    pub anon_role: Option<String>,
58}
59
60impl Default for JwtConfig {
61    fn default() -> Self {
62        Self {
63            secret: None,
64            secret_is_base64: false,
65            audience: None,
66            role_claim_key: "role".to_string(),
67            anon_role: None,
68        }
69    }
70}
71
72/// What was wrong with a token's claims.
73///
74/// Separate from the failures above because a token whose claims are wrong was
75/// read successfully: the signature verified, the server has the right key,
76/// and what remains is a disagreement about the claims themselves. A client
77/// that cannot tell those apart cannot tell "rotate your key" from "your clock
78/// is wrong".
79#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
80pub enum ClaimFault {
81    #[error("JWT expired")]
82    Expired,
83
84    #[error("JWT not yet valid")]
85    NotYetValid,
86
87    #[error("JWT issued at future")]
88    IssuedInFuture,
89
90    #[error("JWT not in audience")]
91    NotInAudience,
92
93    #[error("Parsing claims failed")]
94    Unparsable,
95
96    /// A timestamp claim that is present and is not a timestamp.
97    #[error("The JWT '{0}' claim must be a number")]
98    NotANumber(&'static str),
99
100    #[error("The JWT 'aud' claim must be a string or an array of strings")]
101    AudienceNotStrings,
102}
103
104/// JWT validation error.
105#[derive(Debug, thiserror::Error)]
106pub enum JwtError {
107    /// Nothing said who is asking, and there is no anonymous role to be.
108    #[error("Anonymous access is disabled")]
109    NoIdentity,
110
111    #[error("Unsupported token type")]
112    InvalidHeaderFormat,
113
114    #[error("Server lacks JWT secret")]
115    SecretMissing,
116
117    #[error("JWT cryptographic operation failed")]
118    InvalidSignature,
119
120    /// No key this server holds could decode the token.
121    #[error("No suitable key or wrong key type")]
122    NoSuitableKey,
123
124    /// The token was read, and its claims were not acceptable.
125    #[error("{0}")]
126    Claim(#[from] ClaimFault),
127}
128
129/// Extract and validate JWT from Authorization header.
130pub fn authenticate(auth_header: Option<&str>, config: &JwtConfig) -> Result<AuthResult, JwtError> {
131    // If no auth header, use anonymous role if configured
132    let token = match auth_header {
133        Some(header) => extract_bearer_token(header)?,
134        None => {
135            return match &config.anon_role {
136                Some(role) => Ok(AuthResult::anonymous(role)),
137                None => Err(JwtError::NoIdentity),
138            };
139        }
140    };
141
142    // Validate token
143    validate_token(token, config)
144}
145
146/// Extract Bearer token from Authorization header.
147fn extract_bearer_token(header: &str) -> Result<&str, JwtError> {
148    let header = header.trim();
149
150    if let Some(token) = header.strip_prefix("Bearer ") {
151        Ok(token.trim())
152    } else if let Some(token) = header.strip_prefix("bearer ") {
153        Ok(token.trim())
154    } else {
155        Err(JwtError::InvalidHeaderFormat)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_extract_bearer_token() {
165        assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
166        assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123");
167        assert!(extract_bearer_token("Basic abc123").is_err());
168    }
169
170    #[test]
171    fn test_auth_result_anonymous() {
172        let result = AuthResult::anonymous("anon");
173        assert_eq!(result.role, "anon");
174        assert!(result.claims.is_empty());
175    }
176
177    #[test]
178    fn test_authenticate_no_header_with_anon() {
179        let config = JwtConfig {
180            anon_role: Some("web_anon".to_string()),
181            ..Default::default()
182        };
183
184        let result = authenticate(None, &config).unwrap();
185        assert_eq!(result.role, "web_anon");
186    }
187
188    #[test]
189    fn test_authenticate_no_header_no_anon() {
190        let config = JwtConfig::default();
191        assert!(authenticate(None, &config).is_err());
192    }
193}