Skip to main content

arcly_http_core/auth/
jwt.rs

1//! JWT authentication service — sign, decode, and validate JSON Web Tokens.
2//!
3//! ## Usage
4//!
5//! Provide a `JwtService` instance in an `ArclyPlugin::on_init`:
6//!
7//! ```ignore
8//! ctx.provide(JwtService::new(JwtConfig {
9//!     secret: "change-in-prod".to_string(),
10//!     access_ttl_secs:  900,
11//!     refresh_ttl_secs: 604_800,
12//!     ..Default::default()
13//! }));
14//! ```
15//!
16//! Once provided, the HTTP and WebSocket boundaries automatically decode the
17//! `Authorization: Bearer <token>` header and populate `RequestContext::claims()`
18//! on every request — no per-handler boilerplate needed. Protect routes with
19//! `JWT_AUTH.check(&ctx)?` or `RoleGuard::require("admin").check(&ctx)?`.
20
21use std::sync::Arc;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
25use serde::{Deserialize, Serialize};
26
27use crate::web::context::Claims;
28
29// ─── Configuration ────────────────────────────────────────────────────────────
30
31/// Configuration for `JwtService`. Build once at startup and provide via DI.
32/// Token signing failed — malformed key material (typically a bad rotation
33/// payload). Map to a 500 at the route; the process must keep serving.
34#[derive(Debug)]
35pub struct JwtSignError(pub jsonwebtoken::errors::Error);
36
37impl std::fmt::Display for JwtSignError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "JWT signing failed: {}", self.0)
40    }
41}
42impl std::error::Error for JwtSignError {}
43
44pub struct JwtConfig {
45    /// For HMAC families (HS256/384/512): the raw shared secret.
46    /// For RSA (RS*/PS*) and ECDSA (ES*) families: the **PKCS#8 / SEC1 PEM
47    /// private key** used for signing.
48    pub secret: String,
49    /// PEM-encoded **public key** — required for RSA/ECDSA verification and for
50    /// publishing JWKS (`JwtService::jwks_json`). Ignored for HMAC families,
51    /// where the shared secret both signs and verifies.
52    pub public_key_pem: Option<String>,
53    /// The `kid` (key ID) advertised in the JWT header and JWKS document.
54    /// Required for OIDC so relying parties can select the right JWK.
55    pub key_id: Option<String>,
56    /// Signing algorithm. Defaults to `HS256`.
57    pub algorithm: Algorithm,
58    /// Lifetime of access tokens in seconds. Defaults to 900 (15 min).
59    pub access_ttl_secs: u64,
60    /// Lifetime of refresh tokens in seconds. Defaults to 604 800 (7 days).
61    pub refresh_ttl_secs: u64,
62}
63
64impl Default for JwtConfig {
65    fn default() -> Self {
66        Self {
67            secret: "change-me-in-production".to_string(),
68            public_key_pem: None,
69            key_id: None,
70            algorithm: Algorithm::HS256,
71            access_ttl_secs: 900,
72            refresh_ttl_secs: 604_800,
73        }
74    }
75}
76
77/// True for RSA / RSA-PSS / ECDSA families — anything that signs with a private
78/// key and verifies with a distinct public key.
79fn is_asymmetric(alg: Algorithm) -> bool {
80    matches!(
81        alg,
82        Algorithm::RS256
83            | Algorithm::RS384
84            | Algorithm::RS512
85            | Algorithm::PS256
86            | Algorithm::PS384
87            | Algorithm::PS512
88            | Algorithm::ES256
89            | Algorithm::ES384
90            | Algorithm::EdDSA
91    )
92}
93
94// ─── Internal claims struct ───────────────────────────────────────────────────
95
96/// Private claims struct used for `encode` / `decode`.
97/// Decoded into a `serde_json::Map` before being stored on `RequestContext`.
98#[derive(Debug, Serialize, Deserialize)]
99struct JwtClaims {
100    sub: String,
101    /// Omitted from refresh tokens (always empty there — no point encoding them).
102    #[serde(skip_serializing_if = "String::is_empty", default)]
103    role: String,
104    #[serde(skip_serializing_if = "String::is_empty", default)]
105    email: String,
106    /// "access" or "refresh"
107    #[serde(rename = "type")]
108    kind: String,
109    /// JWT ID — unique token identifier, used for refresh token rotation.
110    jti: String,
111    iat: u64,
112    exp: u64,
113    /// Fine-grained permissions (e.g. `["users:*", "orders:read"]`).
114    /// Omitted from refresh tokens and when no permissions are set.
115    #[serde(skip_serializing_if = "Vec::is_empty", default)]
116    perms: Vec<String>,
117    /// Home tenant of the principal. `TenantGuard` cross-checks this against
118    /// the request's resolved tenant, which (a) blocks forged tenant headers
119    /// and (b) makes dropping the header useless: a token bound to tenant A
120    /// can never act as the fallback tenant.
121    #[serde(skip_serializing_if = "String::is_empty", default)]
122    tenant: String,
123    /// RFC 9449 confirmation claim for sender-constrained (DPoP) tokens:
124    /// `{"jkt": "<JWK SHA-256 thumbprint>"}`. Present only for bound tokens; a
125    /// resource server rejects the token unless the request carries a DPoP proof
126    /// whose key hashes to this `jkt`.
127    #[serde(skip_serializing_if = "Option::is_none", default)]
128    cnf: Option<Cnf>,
129}
130
131/// The `cnf` (confirmation) claim body — currently only the JWK thumbprint.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Cnf {
134    pub jkt: String,
135}
136
137// ─── JwtService ───────────────────────────────────────────────────────────────
138
139/// Live signing/verification keys. Swapped atomically as one bundle on
140/// rotation so readers never observe a half-rotated state. `verify` keeps the
141/// previous key so tokens signed before rotation stay valid through their TTL.
142struct JwtKeyMaterial {
143    encoding: EncodingKey,
144    verify: Vec<DecodingKey>, // [current, previous?]
145    version: u64,
146}
147
148impl JwtKeyMaterial {
149    /// HMAC construction — signing and verification share one secret.
150    fn from_secret(secret: &[u8], version: u64, previous: Option<DecodingKey>) -> Self {
151        let mut verify = vec![DecodingKey::from_secret(secret)];
152        verify.extend(previous);
153        Self {
154            encoding: EncodingKey::from_secret(secret),
155            verify,
156            version,
157        }
158    }
159
160    /// Algorithm-aware construction. For HMAC families `signing` is the shared
161    /// secret and `public_pem` is ignored; for RSA/ECDSA `signing` is the
162    /// private-key PEM and `public_pem` (required) is the verification key.
163    fn build(
164        algorithm: Algorithm,
165        signing: &[u8],
166        public_pem: Option<&str>,
167        version: u64,
168        previous: Option<DecodingKey>,
169    ) -> Result<Self, JwtSignError> {
170        if !is_asymmetric(algorithm) {
171            return Ok(Self::from_secret(signing, version, previous));
172        }
173        let pub_pem = public_pem
174            .ok_or_else(|| JwtSignError(jwt_err()))?
175            .as_bytes();
176        let (encoding, decoding) = match algorithm {
177            Algorithm::ES256 | Algorithm::ES384 => (
178                EncodingKey::from_ec_pem(signing).map_err(JwtSignError)?,
179                DecodingKey::from_ec_pem(pub_pem).map_err(JwtSignError)?,
180            ),
181            Algorithm::EdDSA => (
182                EncodingKey::from_ed_pem(signing).map_err(JwtSignError)?,
183                DecodingKey::from_ed_pem(pub_pem).map_err(JwtSignError)?,
184            ),
185            // RS* / PS*
186            _ => (
187                EncodingKey::from_rsa_pem(signing).map_err(JwtSignError)?,
188                DecodingKey::from_rsa_pem(pub_pem).map_err(JwtSignError)?,
189            ),
190        };
191        let mut verify = vec![decoding];
192        verify.extend(previous);
193        Ok(Self {
194            encoding,
195            verify,
196            version,
197        })
198    }
199}
200
201fn jwt_err() -> jsonwebtoken::errors::Error {
202    // Stable, matchable error kind: an asymmetric algorithm was configured
203    // without the required public key.
204    jsonwebtoken::errors::ErrorKind::InvalidKeyFormat.into()
205}
206
207/// Signs and validates JWTs. Provide this into the DI container so the framework
208/// boundaries (`boundary.rs`, `ws.rs`) can auto-populate `RequestContext::claims()`
209/// on every incoming request.
210///
211/// ## Secret rotation
212///
213/// Keys live behind [`Rotating`](crate::auth::secrets::Rotating) (an
214/// `ArcSwap`): the request path pays one atomic pointer load, while
215/// [`rotate_secret`](Self::rotate_secret) — typically driven by a
216/// `SecretSource` watcher — swaps in a new bundle with no restart. The
217/// previous key is retained for verification, so live tokens (≤ TTL old)
218/// keep validating through the grace window.
219///
220/// Token *signing* returns `Result<_, JwtSignError>` — a malformed key from
221/// a bad rotation payload must surface as a 500 on the affected request,
222/// never as a process panic.
223pub struct JwtService {
224    keys: crate::auth::secrets::Rotating<JwtKeyMaterial>,
225    header: Header,
226    validation: Validation,
227    config: JwtConfig,
228}
229
230impl JwtService {
231    /// Build from config. **Panics** if an asymmetric algorithm is configured
232    /// with malformed / missing PEM key material — this is a boot-time
233    /// misconfiguration, not a per-request condition. Use
234    /// [`try_new`](Self::try_new) to handle it gracefully.
235    pub fn new(config: JwtConfig) -> Self {
236        Self::try_new(config).expect("JwtService: invalid key material")
237    }
238
239    /// Fallible constructor — returns `Err` instead of panicking on bad key
240    /// material (e.g. an ES256 config with an unparseable private-key PEM).
241    pub fn try_new(config: JwtConfig) -> Result<Self, JwtSignError> {
242        let material = JwtKeyMaterial::build(
243            config.algorithm,
244            config.secret.as_bytes(),
245            config.public_key_pem.as_deref(),
246            1,
247            None,
248        )?;
249        let keys = crate::auth::secrets::Rotating::new(material);
250        let mut header = Header::new(config.algorithm);
251        header.kid = config.key_id.clone();
252        let mut validation = Validation::new(config.algorithm);
253        validation.validate_exp = true;
254        Ok(Self {
255            keys,
256            header,
257            validation,
258            config,
259        })
260    }
261
262    /// Hot-swap the signing secret — no restart, no token mass-invalidation.
263    ///
264    /// New tokens sign with the new key immediately; tokens signed with the
265    /// previous key keep verifying until natural expiry. Versions are
266    /// monotonic: a stale (≤ current) version is ignored, making concurrent
267    /// watchers and duplicate delivery harmless.
268    ///
269    /// For HMAC families `new_secret` is the new shared secret. For asymmetric
270    /// families it is the new **private-key PEM**; the paired public key must be
271    /// supplied via [`rotate_keypair`](Self::rotate_keypair) instead — this
272    /// method assumes the configured public key still verifies.
273    pub fn rotate_secret(&self, new_secret: &[u8], version: u64) {
274        self.rotate_keypair(new_secret, self.config.public_key_pem.as_deref(), version);
275    }
276
277    /// Rotate an asymmetric key **pair** (private PEM + public PEM). Retains the
278    /// previous public key for the verification grace window, exactly like the
279    /// HMAC path. No-op on stale versions.
280    pub fn rotate_keypair(&self, new_signing: &[u8], new_public_pem: Option<&str>, version: u64) {
281        let current = self.keys.load();
282        if version <= current.version {
283            tracing::warn!(
284                current = current.version,
285                offered = version,
286                "ignoring stale JWT secret rotation",
287            );
288            return;
289        }
290        let previous = current.verify.first().cloned();
291        match JwtKeyMaterial::build(
292            self.config.algorithm,
293            new_signing,
294            new_public_pem,
295            version,
296            previous,
297        ) {
298            Ok(material) => {
299                self.keys.store(material);
300                tracing::info!(version, "JwtService signing key rotated");
301            }
302            Err(e) => {
303                tracing::error!(version, error = %e, "JWT key rotation rejected — keeping current key");
304            }
305        }
306    }
307
308    /// Publish the current public verification key as a JWKS document (JSON).
309    ///
310    /// Returns `None` for HMAC families (a shared secret must never be exposed)
311    /// or when no `public_key_pem` was configured. The OIDC provider serves this
312    /// at `/.well-known/jwks.json`. The JWK is derived from the configured
313    /// public PEM by the identity crate's OIDC layer; core stores the raw PEM
314    /// and `kid` so that layer can convert without re-parsing config.
315    pub fn public_key_pem(&self) -> Option<&str> {
316        if is_asymmetric(self.config.algorithm) {
317            self.config.public_key_pem.as_deref()
318        } else {
319            None
320        }
321    }
322
323    /// The advertised key id (`kid`), if configured.
324    pub fn key_id(&self) -> Option<&str> {
325        self.config.key_id.as_deref()
326    }
327
328    /// The signing algorithm name as it appears in a JWK / JOSE header
329    /// (`"HS256"`, `"RS256"`, `"ES256"`, …).
330    pub fn algorithm_name(&self) -> &'static str {
331        match self.config.algorithm {
332            Algorithm::HS256 => "HS256",
333            Algorithm::HS384 => "HS384",
334            Algorithm::HS512 => "HS512",
335            Algorithm::RS256 => "RS256",
336            Algorithm::RS384 => "RS384",
337            Algorithm::RS512 => "RS512",
338            Algorithm::PS256 => "PS256",
339            Algorithm::PS384 => "PS384",
340            Algorithm::PS512 => "PS512",
341            Algorithm::ES256 => "ES256",
342            Algorithm::ES384 => "ES384",
343            Algorithm::EdDSA => "EdDSA",
344        }
345    }
346
347    fn now() -> u64 {
348        SystemTime::now()
349            .duration_since(UNIX_EPOCH)
350            .map(|d| d.as_secs())
351            .unwrap_or(0)
352    }
353
354    /// Sign an **arbitrary claim set** with the current key and configured
355    /// algorithm (header carries the `kid`). Use for tokens the typed helpers
356    /// don't cover — notably OIDC `id_token`s, which need `aud`/`iss`/`nonce`.
357    ///
358    /// The caller owns the full claim set (including `iat`/`exp`); nothing is
359    /// injected. Verify later with [`decode`](Self::decode).
360    pub fn sign(&self, claims: &serde_json::Value) -> Result<String, JwtSignError> {
361        encode(&self.header, claims, &self.keys.load().encoding).map_err(JwtSignError)
362    }
363
364    /// Current unix time in seconds — handy for callers building claim sets to
365    /// pass to [`sign`](Self::sign).
366    pub fn unix_now() -> u64 {
367        Self::now()
368    }
369
370    /// Issue a signed **access token**.
371    ///
372    /// Claims: `sub` = user ID, `role`, `email`, `type = "access"`, `jti`, `iat`, `exp`.
373    ///
374    /// Signing can only fail on malformed key material (e.g. a bad rotation
375    /// payload) — propagate the error instead of panicking mid-traffic.
376    pub fn issue_access(&self, sub: &str, role: &str, email: &str) -> Result<String, JwtSignError> {
377        self.issue_access_with_perms(sub, role, email, &[])
378    }
379
380    /// Like [`Self::issue_access`] but embeds a `perms` claim (array of permission strings).
381    ///
382    /// Use this when the app maintains a permission map so that `PermissionGuard`
383    /// can do a zero-latency lookup without hitting the store on each request.
384    pub fn issue_access_with_perms(
385        &self,
386        sub: &str,
387        role: &str,
388        email: &str,
389        perms: &[String],
390    ) -> Result<String, JwtSignError> {
391        self.issue_access_bound(sub, role, email, perms, None)
392    }
393
394    /// Like [`Self::issue_access_with_perms`] but additionally **binds the token to
395    /// a tenant** via the `tenant` claim. `TenantGuard` then enforces that
396    /// requests carrying this token resolve to the same tenant — omitting or
397    /// forging the tenant header yields `403`, so a suspended tenant's users
398    /// cannot ride the fallback pool by dropping the header.
399    pub fn issue_access_bound(
400        &self,
401        sub: &str,
402        role: &str,
403        email: &str,
404        perms: &[String],
405        tenant: Option<&str>,
406    ) -> Result<String, JwtSignError> {
407        self.issue_access_bound_cnf(sub, role, email, perms, tenant, None)
408    }
409
410    /// Like [`Self::issue_access_bound`] but additionally **sender-constrains**
411    /// the token (RFC 9449 DPoP): `cnf_jkt` is the SHA-256 JWK thumbprint of the
412    /// client's proof-of-possession key. A resource server then accepts the
413    /// token only when the request carries a matching DPoP proof, so a stolen
414    /// bearer token is useless without the client's private key.
415    pub fn issue_access_bound_cnf(
416        &self,
417        sub: &str,
418        role: &str,
419        email: &str,
420        perms: &[String],
421        tenant: Option<&str>,
422        cnf_jkt: Option<&str>,
423    ) -> Result<String, JwtSignError> {
424        let now = Self::now();
425        let claims = JwtClaims {
426            sub: sub.to_owned(),
427            role: role.to_owned(),
428            email: email.to_owned(),
429            kind: "access".to_owned(),
430            jti: new_jti(),
431            iat: now,
432            exp: now + self.config.access_ttl_secs,
433            perms: perms.to_vec(),
434            tenant: tenant.unwrap_or("").to_owned(),
435            cnf: cnf_jkt.map(|jkt| Cnf {
436                jkt: jkt.to_owned(),
437            }),
438        };
439        encode(&self.header, &claims, &self.keys.load().encoding).map_err(JwtSignError)
440    }
441
442    /// Issue a signed **refresh token** with a unique `jti`.
443    ///
444    /// Claims: `sub`, `type = "refresh"`, `jti`, `iat`, `exp`.
445    /// The `jti` is returned alongside the token so the caller can persist it.
446    pub fn issue_refresh(&self, sub: &str) -> Result<(String, String), JwtSignError> {
447        let now = Self::now();
448        let jti = new_jti();
449        let claims = JwtClaims {
450            sub: sub.to_owned(),
451            role: String::new(),
452            email: String::new(),
453            kind: "refresh".to_owned(),
454            jti: jti.clone(),
455            iat: now,
456            exp: now + self.config.refresh_ttl_secs,
457            perms: Vec::new(),
458            tenant: String::new(),
459            cnf: None,
460        };
461        let token =
462            encode(&self.header, &claims, &self.keys.load().encoding).map_err(JwtSignError)?;
463        Ok((token, jti))
464    }
465
466    /// Validate signature + expiry and return the decoded claims as a JSON map.
467    ///
468    /// Returns `None` for any invalid token (expired, bad signature, malformed).
469    /// Does NOT enforce token type — use [`Self::decode_access`] at request boundaries.
470    pub fn decode(&self, token: &str) -> Option<Arc<Claims>> {
471        // Try current key first, then the retained previous key (rotation
472        // grace window). Bundle is one atomic load — keys can't mix versions.
473        let keys = self.keys.load();
474        let data = keys
475            .verify
476            .iter()
477            .find_map(|k| decode::<serde_json::Value>(token, k, &self.validation).ok())?;
478        let obj = data.claims.as_object()?.clone();
479        Some(Arc::new(obj))
480    }
481
482    /// Like [`decode`] but additionally requires `"type" == "access"`.
483    ///
484    /// Use this at request boundaries so refresh tokens cannot be passed as
485    /// access tokens to authenticate protected routes.
486    pub fn decode_access(&self, token: &str) -> Option<Arc<Claims>> {
487        let claims = self.decode(token)?;
488        if claims.get("type").and_then(|v| v.as_str()) != Some("access") {
489            return None;
490        }
491        Some(claims)
492    }
493
494    /// Validate a **refresh token** specifically.
495    ///
496    /// Returns `(subject, jti)` on success, `None` otherwise.
497    /// Callers must verify that the `jti` exists in their token store before
498    /// issuing a new pair.
499    pub fn validate_refresh(&self, token: &str) -> Option<(String, String)> {
500        let claims = self.decode(token)?;
501        if claims.get("type")?.as_str()? != "refresh" {
502            return None;
503        }
504        let sub = claims.get("sub")?.as_str()?.to_owned();
505        let jti = claims.get("jti")?.as_str()?.to_owned();
506        Some((sub, jti))
507    }
508
509    /// Lifetime of access tokens in seconds (used in `TokenResponse.expires_in`).
510    pub fn access_ttl_secs(&self) -> u64 {
511        self.config.access_ttl_secs
512    }
513
514    /// Lifetime of refresh tokens (used by token store for TTL).
515    pub fn refresh_ttl_secs(&self) -> u64 {
516        self.config.refresh_ttl_secs
517    }
518}
519
520/// Extract and decode an **access** Bearer token from request headers.
521///
522/// Shared by all three request boundaries (HTTP macro routes, plugin routes,
523/// WebSocket handshake) so a security fix here applies everywhere at once.
524///
525/// Returns `None` when:
526/// - No `JwtService` is registered in the container.
527/// - The `Authorization` header is absent or not valid UTF-8.
528/// - The token is missing, expired, or has an invalid signature.
529/// - The token `"type"` claim is not `"access"` (i.e. refresh tokens are rejected).
530pub fn decode_bearer_token(
531    headers: &http::HeaderMap,
532    container: &crate::core::engine::FrozenDiContainer,
533) -> Option<Arc<Claims>> {
534    let raw = headers.get("authorization")?.to_str().ok()?;
535    let token = raw.strip_prefix("Bearer ").unwrap_or(raw).trim();
536    if token.is_empty() {
537        return None;
538    }
539    container.try_get::<JwtService>()?.decode_access(token)
540}
541
542/// Generate a collision-resistant JWT ID without external deps.
543///
544/// Combines a monotonic process counter with current time and thread ID so
545/// two tokens issued concurrently on different threads in the same nanosecond
546/// still get distinct JTIs.
547fn new_jti() -> String {
548    use std::collections::hash_map::DefaultHasher;
549    use std::hash::{Hash, Hasher};
550    use std::sync::atomic::{AtomicU64, Ordering};
551
552    static COUNTER: AtomicU64 = AtomicU64::new(0);
553    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
554
555    let mut h1 = DefaultHasher::new();
556    SystemTime::now().hash(&mut h1);
557    seq.hash(&mut h1);
558
559    let mut h2 = DefaultHasher::new();
560    std::thread::current().id().hash(&mut h2);
561    seq.wrapping_add(1).hash(&mut h2);
562
563    format!("{:016x}{:016x}", h1.finish(), h2.finish())
564}