Skip to main content

oauth_toolkit/
jwt.rs

1//! JWT helpers: HS256 (shared-secret) and ES256/RS256 (PEM-key,
2//! asymmetric) — the asymmetric pair is what any estate OIDC issuer
3//! needs (research loop wave 3b prerequisite).
4
5use serde::{Deserialize, Serialize};
6
7/// Errors for JWT operations.
8#[derive(Debug, thiserror::Error)]
9pub enum JwtError {
10    /// Token has expired.
11    #[error("token expired")]
12    Expired,
13
14    /// Token is invalid.
15    #[error("invalid token: {0}")]
16    Invalid(String),
17
18    /// A required claim is missing.
19    #[error("missing claim: {0}")]
20    MissingClaim(String),
21}
22
23/// Encode a claims struct as an HS256 JWT.
24pub fn encode_hs256(claims: &impl Serialize, secret: &str) -> Result<String, JwtError> {
25    let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
26    jsonwebtoken::encode(
27        &header,
28        claims,
29        &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
30    )
31    .map_err(|e| JwtError::Invalid(e.to_string()))
32}
33
34/// Decode and validate an HS256 JWT, returning typed claims.
35pub fn decode_hs256<T: for<'de> Deserialize<'de>>(
36    token: &str,
37    secret: &str,
38    issuer: Option<&str>,
39    audience: Option<&str>,
40) -> Result<T, JwtError> {
41    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
42
43    if let Some(iss) = issuer {
44        validation.set_issuer(&[iss]);
45    }
46    if let Some(aud) = audience {
47        validation.set_audience(&[aud]);
48    }
49
50    jsonwebtoken::decode::<T>(
51        token,
52        &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
53        &validation,
54    )
55    .map(|data| data.claims)
56    .map_err(|e| match *e.kind() {
57        jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
58        _ => JwtError::Invalid(e.to_string()),
59    })
60}
61
62
63/// Encode a claims struct as an ES256 JWT (P-256 ECDSA, PKCS#8 PEM key).
64///
65/// # Errors
66/// Returns [`JwtError::Invalid`] for PEM parse or signing failures.
67pub fn encode_es256(claims: &impl Serialize, private_key_pem: &str) -> Result<String, JwtError> {
68    let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
69    let key = jsonwebtoken::EncodingKey::from_ec_pem(private_key_pem.as_bytes())
70        .map_err(|e| JwtError::Invalid(format!("bad ES256 private PEM: {e}")))?;
71    jsonwebtoken::encode(&header, claims, &key).map_err(|e| JwtError::Invalid(e.to_string()))
72}
73
74/// Decode and validate an ES256 JWT, returning typed claims.
75///
76/// # Errors
77/// Returns [`JwtError::Expired`] for expired tokens and
78/// [`JwtError::Invalid`] for any signature/claim failure.
79pub fn decode_es256<T: for<'de> Deserialize<'de>>(
80    token: &str,
81    public_key_pem: &str,
82    issuer: Option<&str>,
83    audience: Option<&str>,
84) -> Result<T, JwtError> {
85    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
86    if let Some(iss) = issuer {
87        validation.set_issuer(&[iss]);
88    }
89    if let Some(aud) = audience {
90        validation.set_audience(&[aud]);
91    }
92    let key = jsonwebtoken::DecodingKey::from_ec_pem(public_key_pem.as_bytes())
93        .map_err(|e| JwtError::Invalid(format!("bad ES256 public PEM: {e}")))?;
94    jsonwebtoken::decode::<T>(token, &key, &validation)
95        .map(|data| data.claims)
96        .map_err(|e| match *e.kind() {
97            jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
98            _ => JwtError::Invalid(e.to_string()),
99        })
100}
101
102/// Encode a claims struct as an RS256 JWT (RSA, PKCS#8 PEM key).
103///
104/// # Errors
105/// Returns [`JwtError::Invalid`] for PEM parse or signing failures.
106pub fn encode_rs256(claims: &impl Serialize, private_key_pem: &str) -> Result<String, JwtError> {
107    let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
108    let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
109        .map_err(|e| JwtError::Invalid(format!("bad RS256 private PEM: {e}")))?;
110    jsonwebtoken::encode(&header, claims, &key).map_err(|e| JwtError::Invalid(e.to_string()))
111}
112
113/// Decode and validate an RS256 JWT, returning typed claims.
114///
115/// # Errors
116/// Returns [`JwtError::Expired`] for expired tokens and
117/// [`JwtError::Invalid`] for any signature/claim failure.
118pub fn decode_rs256<T: for<'de> Deserialize<'de>>(
119    token: &str,
120    public_key_pem: &str,
121    issuer: Option<&str>,
122    audience: Option<&str>,
123) -> Result<T, JwtError> {
124    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
125    if let Some(iss) = issuer {
126        validation.set_issuer(&[iss]);
127    }
128    if let Some(aud) = audience {
129        validation.set_audience(&[aud]);
130    }
131    let key = jsonwebtoken::DecodingKey::from_rsa_pem(public_key_pem.as_bytes())
132        .map_err(|e| JwtError::Invalid(format!("bad RS256 public PEM: {e}")))?;
133    jsonwebtoken::decode::<T>(token, &key, &validation)
134        .map(|data| data.claims)
135        .map_err(|e| match *e.kind() {
136            jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
137            _ => JwtError::Invalid(e.to_string()),
138        })
139}
140
141/// Extract a Bearer token from an Authorization header value.
142pub fn extract_bearer_token(header_value: &str) -> Option<&str> {
143    header_value.strip_prefix("Bearer ").map(|t| t.trim())
144}
145
146/// Build a Set-Cookie header value for an auth token.
147pub fn build_auth_cookie(cookie_name: &str, token: &str, max_age_secs: i64) -> String {
148    format!("{cookie_name}={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}")
149}
150
151#[cfg(test)]
152// Test code: unwrap/expect are the idiomatic way to assert setup success.
153#[allow(clippy::unwrap_used, clippy::expect_used)]
154mod tests {
155    use super::*;
156    use serde::{Deserialize, Serialize};
157
158    #[derive(Debug, Serialize, Deserialize, PartialEq)]
159    struct TestClaims {
160        sub: String,
161        exp: usize,
162        iat: usize,
163    }
164
165    #[test]
166    fn encode_decode_roundtrip() {
167        let claims = TestClaims {
168            sub: "user123".into(),
169            exp: 9999999999,
170            iat: 1000000000,
171        };
172        let token = encode_hs256(&claims, "secret-key-1234567890123456").unwrap();
173        let decoded: TestClaims =
174            decode_hs256(&token, "secret-key-1234567890123456", None, None).unwrap();
175        assert_eq!(decoded, claims);
176    }
177
178    #[test]
179    fn decode_wrong_secret_fails() {
180        let claims = TestClaims {
181            sub: "user123".into(),
182            exp: 9999999999,
183            iat: 1000000000,
184        };
185        let token = encode_hs256(&claims, "secret1").unwrap();
186        assert!(decode_hs256::<TestClaims>(&token, "secret2", None, None).is_err());
187    }
188
189    #[test]
190    fn extract_bearer_token_valid() {
191        assert_eq!(extract_bearer_token("Bearer abc123"), Some("abc123"));
192    }
193
194    #[test]
195    fn extract_bearer_token_missing() {
196        assert!(extract_bearer_token("Basic abc123").is_none());
197    }
198
199    #[test]
200    fn build_auth_cookie_format() {
201        let cookie = build_auth_cookie("session", "tok123", 3600);
202        assert!(cookie.contains("session=tok123"));
203        assert!(cookie.contains("HttpOnly"));
204        assert!(cookie.contains("Secure"));
205        assert!(cookie.contains("Max-Age=3600"));
206    }
207
208    #[derive(Debug, Serialize, Deserialize, PartialEq)]
209    struct EsClaims {
210        sub: String,
211        iss: String,
212        aud: String,
213        exp: usize,
214    }
215
216    const TEST_ES256_PRIV: &str = r##"-----BEGIN PRIVATE KEY-----
217MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxvCgr98XLeY5qBtV
218EUAHmzqfBkJclip1tDcOaOnZRnWhRANCAARciewarZqgQwFGY5sTdhAs6Bl8KIjz
219KJiV2d3HJBDLGgb/HCrFnhPOFPR2wzgrDrIQvBTQ+dvaEp9KA7EONjR3
220-----END PRIVATE KEY-----"##;
221    const TEST_ES256_PUB: &str =r##"-----BEGIN PUBLIC KEY-----
222MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXInsGq2aoEMBRmObE3YQLOgZfCiI
2238yiYldndxyQQyxoG/xwqxZ4TzhT0dsM4Kw6yELwU0Pnb2hKfSgOxDjY0dw==
224-----END PUBLIC KEY-----
225"##;
226
227    fn es_claims() -> EsClaims {
228        EsClaims {
229            sub: "wyatt".to_string(),
230            iss: "https://authbox.wyattau.com".to_string(),
231            aud: "hosting-api".to_string(),
232            exp: (std::time::SystemTime::now()
233                .duration_since(std::time::UNIX_EPOCH)
234                .unwrap_or_default()
235                .as_secs() as usize)
236                + 600,
237        }
238    }
239
240    #[test]
241    fn es256_roundtrip_and_rejection() {
242        let token = encode_es256(&es_claims(), TEST_ES256_PRIV).unwrap();
243        let decoded: EsClaims = decode_es256(
244            &token,
245            TEST_ES256_PUB,
246            Some("https://authbox.wyattau.com"),
247            Some("hosting-api"),
248        )
249        .unwrap();
250        assert_eq!(decoded, es_claims());
251
252        // Tampered payload must fail.
253        let mut parts: Vec<&str> = token.split('.').collect();
254        parts[1] = "eyJzdWIiOiJldmlsIn0";
255        let tampered = parts.join(".");
256        assert!(decode_es256::<EsClaims>(&tampered, TEST_ES256_PUB, None, None).is_err());
257    }
258}