Skip to main content

stynx_code_bridge/infrastructure/
jwt_auth.rs

1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4use stynx_code_errors::{AppError, AppResult};
5use serde::{Deserialize, Serialize};
6use subtle::ConstantTimeEq;
7
8type HmacSha256 = Hmac<Sha256>;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct JwtClaims {
12    pub sub: String,
13    pub exp: u64,
14    pub iat: u64,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18struct JwtHeader {
19    alg: String,
20}
21
22pub struct JwtValidator {
23    secret: Vec<u8>,
24}
25
26impl JwtValidator {
27    pub fn from_env() -> AppResult<Self> {
28        let secret = std::env::var("STYNX_BRIDGE_SECRET")
29            .or_else(|_| std::env::var("CLAUDE_SERVER_TOKEN"))
30            .map_err(|_| AppError::Provider(
31                "STYNX_BRIDGE_SECRET (or legacy CLAUDE_SERVER_TOKEN) must be set for JWT signature verification".into(),
32            ))?;
33        if secret.len() < 32 {
34            return Err(AppError::Provider(
35                "STYNX_BRIDGE_SECRET must be at least 32 bytes for HS256 safety".into(),
36            ));
37        }
38        Ok(Self { secret: secret.into_bytes() })
39    }
40
41    pub fn with_secret(secret: impl Into<Vec<u8>>) -> Self {
42        Self { secret: secret.into() }
43    }
44
45    pub fn validate_token(&self, token: &str) -> AppResult<JwtClaims> {
46        let parts: Vec<&str> = token.splitn(3, '.').collect();
47        if parts.len() != 3 {
48            return Err(AppError::Unauthorized);
49        }
50
51        let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| AppError::Unauthorized)?;
52        let header: JwtHeader = serde_json::from_slice(&header_bytes).map_err(|_| AppError::Unauthorized)?;
53        if header.alg != "HS256" {
54            return Err(AppError::Unauthorized);
55        }
56
57        let signing_input = format!("{}.{}", parts[0], parts[1]);
58        let expected_sig = {
59            let mut mac = HmacSha256::new_from_slice(&self.secret)
60                .map_err(|_| AppError::Unauthorized)?;
61            mac.update(signing_input.as_bytes());
62            mac.finalize().into_bytes()
63        };
64
65        let provided_sig = URL_SAFE_NO_PAD
66            .decode(parts[2])
67            .map_err(|_| AppError::Unauthorized)?;
68
69        if expected_sig.ct_eq(&provided_sig).unwrap_u8() != 1 {
70            return Err(AppError::Unauthorized);
71        }
72
73        let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| AppError::Unauthorized)?;
74        let claims: JwtClaims = serde_json::from_slice(&payload_bytes).map_err(|_| AppError::Unauthorized)?;
75
76        let now = std::time::SystemTime::now()
77            .duration_since(std::time::UNIX_EPOCH)
78            .map(|d| d.as_secs())
79            .unwrap_or(0);
80        if claims.exp <= now {
81            return Err(AppError::Unauthorized);
82        }
83        if claims.iat > now + 60 {
84            return Err(AppError::Unauthorized);
85        }
86
87        Ok(claims)
88    }
89}