Skip to main content

oauth_toolkit/
jwt.rs

1//! Lightweight JWT helpers for HS256 operations.
2
3use serde::{Deserialize, Serialize};
4
5/// Errors for JWT operations.
6#[derive(Debug, thiserror::Error)]
7pub enum JwtError {
8    /// Token has expired.
9    #[error("token expired")]
10    Expired,
11
12    /// Token is invalid.
13    #[error("invalid token: {0}")]
14    Invalid(String),
15
16    /// A required claim is missing.
17    #[error("missing claim: {0}")]
18    MissingClaim(String),
19}
20
21/// Encode a claims struct as an HS256 JWT.
22pub fn encode_hs256(claims: &impl Serialize, secret: &str) -> Result<String, JwtError> {
23    let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
24    jsonwebtoken::encode(
25        &header,
26        claims,
27        &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
28    )
29    .map_err(|e| JwtError::Invalid(e.to_string()))
30}
31
32/// Decode and validate an HS256 JWT, returning typed claims.
33pub fn decode_hs256<T: for<'de> Deserialize<'de>>(
34    token: &str,
35    secret: &str,
36    issuer: Option<&str>,
37    audience: Option<&str>,
38) -> Result<T, JwtError> {
39    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
40
41    if let Some(iss) = issuer {
42        validation.set_issuer(&[iss]);
43    }
44    if let Some(aud) = audience {
45        validation.set_audience(&[aud]);
46    }
47
48    jsonwebtoken::decode::<T>(
49        token,
50        &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
51        &validation,
52    )
53    .map(|data| data.claims)
54    .map_err(|e| match *e.kind() {
55        jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
56        _ => JwtError::Invalid(e.to_string()),
57    })
58}
59
60/// Extract a Bearer token from an Authorization header value.
61pub fn extract_bearer_token(header_value: &str) -> Option<&str> {
62    header_value.strip_prefix("Bearer ").map(|t| t.trim())
63}
64
65/// Build a Set-Cookie header value for an auth token.
66pub fn build_auth_cookie(cookie_name: &str, token: &str, max_age_secs: i64) -> String {
67    format!("{cookie_name}={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}")
68}
69
70#[cfg(test)]
71// Test code: unwrap/expect are the idiomatic way to assert setup success.
72#[allow(clippy::unwrap_used, clippy::expect_used)]
73mod tests {
74    use super::*;
75    use serde::{Deserialize, Serialize};
76
77    #[derive(Debug, Serialize, Deserialize, PartialEq)]
78    struct TestClaims {
79        sub: String,
80        exp: usize,
81        iat: usize,
82    }
83
84    #[test]
85    fn encode_decode_roundtrip() {
86        let claims = TestClaims {
87            sub: "user123".into(),
88            exp: 9999999999,
89            iat: 1000000000,
90        };
91        let token = encode_hs256(&claims, "secret-key-1234567890123456").unwrap();
92        let decoded: TestClaims =
93            decode_hs256(&token, "secret-key-1234567890123456", None, None).unwrap();
94        assert_eq!(decoded, claims);
95    }
96
97    #[test]
98    fn decode_wrong_secret_fails() {
99        let claims = TestClaims {
100            sub: "user123".into(),
101            exp: 9999999999,
102            iat: 1000000000,
103        };
104        let token = encode_hs256(&claims, "secret1").unwrap();
105        assert!(decode_hs256::<TestClaims>(&token, "secret2", None, None).is_err());
106    }
107
108    #[test]
109    fn extract_bearer_token_valid() {
110        assert_eq!(extract_bearer_token("Bearer abc123"), Some("abc123"));
111    }
112
113    #[test]
114    fn extract_bearer_token_missing() {
115        assert!(extract_bearer_token("Basic abc123").is_none());
116    }
117
118    #[test]
119    fn build_auth_cookie_format() {
120        let cookie = build_auth_cookie("session", "tok123", 3600);
121        assert!(cookie.contains("session=tok123"));
122        assert!(cookie.contains("HttpOnly"));
123        assert!(cookie.contains("Secure"));
124        assert!(cookie.contains("Max-Age=3600"));
125    }
126}