Skip to main content

arc_web/helpers/
jwt.rs

1use dotenv::dotenv;
2use jsonwebtoken::{
3    decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation,
4};
5use once_cell::sync::Lazy;
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::error::Error;
9use std::time::{SystemTime, UNIX_EPOCH};
10use uuid::Uuid;
11
12/// JWT token claims payload.
13///
14/// `sub` holds the aggregate UUID; `jti` is the unique token id used by the
15/// server-side session registry (HIPAA-4) for revocation.
16///
17/// `jti` is `Option` for the rollout window — tokens minted before HIPAA-4
18/// landed have no `jti`. Acceptance of those is governed by the
19/// `JWT_GRANDFATHER_LEGACY` env flag, enforced in the JWT middleware.
20#[derive(Debug, Serialize, Deserialize)]
21pub struct Claims {
22    /// Subject: aggregate UUID.
23    pub sub: String,
24    /// Expiration timestamp (seconds since UNIX epoch).
25    pub exp: usize,
26    /// JWT id (HIPAA-4 revocation key). `None` only for pre-HIPAA-4 tokens.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub jti: Option<Uuid>,
29}
30
31static JWT_SECRET: Lazy<Vec<u8>> = Lazy::new(|| {
32    dotenv().ok();
33    env::var("JWT_SECRET")
34        .expect("JWT_SECRET must be set")
35        .into_bytes()
36});
37
38static JWT_EXPIRY_HOURS: Lazy<u64> = Lazy::new(|| {
39    dotenv().ok();
40    env::var("JWT_EXPIRY_HOURS")
41        .unwrap_or_else(|_| "24".to_string())
42        .parse()
43        .expect("Invalid JWT_EXPIRY_HOURS")
44});
45
46pub fn get_jwt_secret() -> &'static [u8] {
47    &JWT_SECRET
48}
49
50pub fn get_jwt_expiry() -> u64 {
51    *JWT_EXPIRY_HOURS
52}
53
54/// Mint a signed JWT for the given aggregate UUID. Returns the token and the
55/// `jti` so the caller can record the session in the server-side store
56/// before handing the token to the client.
57pub fn create_token(aggregate_id: &str) -> Result<(String, Uuid), jsonwebtoken::errors::Error> {
58    let now_secs = SystemTime::now()
59        .duration_since(UNIX_EPOCH)
60        .unwrap()
61        .as_secs() as usize;
62    let exp = now_secs + (get_jwt_expiry() * 3600) as usize;
63    let jti = Uuid::new_v4();
64    let claims = Claims {
65        sub: aggregate_id.to_string(),
66        exp,
67        jti: Some(jti),
68    };
69    let token = encode(
70        &Header::default(),
71        &claims,
72        &EncodingKey::from_secret(get_jwt_secret()),
73    )?;
74    Ok((token, jti))
75}
76
77/// Decode and signature-verify a JWT, returning the full claims object so
78/// downstream code can apply HIPAA-4 jti policy.
79pub fn decode_token(token: &str) -> Result<Claims, Box<dyn Error + Send + Sync>> {
80    let validation = Validation::new(Algorithm::HS256);
81    let data: TokenData<Claims> = decode(
82        token,
83        &DecodingKey::from_secret(get_jwt_secret()),
84        &validation,
85    )?;
86    Ok(data.claims)
87}
88
89/// Backwards-compat shim used by tests. Returns just the aggregate UUID. New
90/// code should call [`decode_token`] and route through the session-aware
91/// middleware.
92#[allow(dead_code)]
93pub fn validate_token(token: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
94    Ok(decode_token(token)?.sub)
95}