Skip to main content

aster_forge_auth/
jwt.rs

1//! Strict local-session JWT issuance and verification, backed by `jsonwebtoken`.
2
3use crate::{
4    AuthError, Clock, IdSource, RandomIdSource, RefreshJti, Result, SessionId, Subject, SystemClock,
5};
6use jsonwebtoken::{
7    Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode,
8};
9use serde::{Deserialize, Serialize};
10use std::{fmt, sync::Arc};
11
12/// Local session credential purpose; OAuth access and OIDC ID tokens use other contracts.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum AuthTokenType {
16    /// Short-lived request credential.
17    Access,
18    /// Single-use server-side session rotation credential.
19    Refresh,
20}
21
22/// Authentication restriction. Products decide which operations a restricted session may perform.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum Restriction {
26    /// Normal login.
27    None,
28    /// Only product-approved password-change operations are permitted.
29    MustChangePassword,
30}
31
32/// Account-wide generation; revocation must fail on overflow rather than reuse a version.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct SessionVersion(u64);
36impl SessionVersion {
37    /// Wraps a nonnegative product generation value.
38    #[must_use]
39    pub const fn new(value: u64) -> Self {
40        Self(value)
41    }
42    /// Returns the generation for product persistence.
43    #[must_use]
44    pub const fn value(self) -> u64 {
45        self.0
46    }
47    /// Advances without saturating or wrapping.
48    ///
49    /// # Errors
50    /// Returns `InvalidPolicy` when the generation is exhausted.
51    pub fn next(self) -> Result<Self> {
52        self.0
53            .checked_add(1)
54            .map(Self)
55            .ok_or(AuthError::InvalidPolicy)
56    }
57}
58
59/// Required local web-session claims. No product entities or permissions are embedded. Claims are
60/// signed, not encrypted, and must never carry secrets.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct TokenClaims {
63    /// Stable product subject.
64    pub sub: Subject,
65    /// Server-side session identity, shared by access and refresh credentials.
66    pub sid: SessionId,
67    /// Account-wide generation fence checked against current product state.
68    pub session_version: SessionVersion,
69    /// Credential purpose; verification always requires an expected purpose.
70    pub token_type: AuthTokenType,
71    /// Single-use refresh identity. Access credentials must omit it.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub jti: Option<RefreshJti>,
74    /// Explicit login restriction interpreted by product authorization.
75    pub restriction: Restriction,
76    /// Nonnegative Unix issuance timestamp in seconds.
77    pub iat: i64,
78    /// Exclusive Unix expiry timestamp, strictly after issuance.
79    pub exp: i64,
80    /// Optional inclusive not-before time, strictly before expiry.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub nbf: Option<i64>,
83    /// Required product signing-domain issuer.
84    pub iss: String,
85    /// Required local-session audience.
86    pub aud: String,
87}
88
89/// Signature/policy-verified claims. Private construction prevents labeling unchecked JSON as
90/// verified. Current account, session, and permissions still require product checks.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct VerifiedToken(TokenClaims);
93impl VerifiedToken {
94    /// Borrows verified claims without exposing the original credential.
95    #[must_use]
96    pub const fn claims(&self) -> &TokenClaims {
97        &self.0
98    }
99    /// Transfers claims to a product adapter.
100    #[must_use]
101    pub fn into_claims(self) -> TokenClaims {
102        self.0
103    }
104}
105
106/// Supported HMAC algorithms; headers cannot select a different key family.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum JwtAlgorithm {
109    /// HMAC-SHA-256, at least 32 key bytes.
110    Hs256,
111    /// HMAC-SHA-384, at least 48 key bytes.
112    Hs384,
113    /// HMAC-SHA-512, at least 64 key bytes.
114    Hs512,
115}
116impl JwtAlgorithm {
117    const fn upstream(self) -> Algorithm {
118        match self {
119            Self::Hs256 => Algorithm::HS256,
120            Self::Hs384 => Algorithm::HS384,
121            Self::Hs512 => Algorithm::HS512,
122        }
123    }
124    const fn minimum_key_bytes(self) -> usize {
125        match self {
126            Self::Hs256 => 32,
127            Self::Hs384 => 48,
128            Self::Hs512 => 64,
129        }
130    }
131}
132
133/// HMAC key wrapper. Products own entropy, loading, rotation, and isolation from AEAD keys.
134#[derive(Clone)]
135pub struct JwtKey(Arc<[u8]>);
136impl JwtKey {
137    /// Creates a key with the minimum HS256 size.
138    ///
139    /// # Errors
140    /// Returns `InvalidPolicy` for fewer than 32 bytes; this check cannot prove entropy.
141    pub fn hmac(secret: impl AsRef<[u8]>) -> Result<Self> {
142        let secret = secret.as_ref();
143        if secret.len() < 32 {
144            return Err(AuthError::InvalidPolicy);
145        }
146        Ok(Self(Arc::from(secret)))
147    }
148    fn check(&self, algorithm: JwtAlgorithm) -> Result<()> {
149        if self.0.len() < algorithm.minimum_key_bytes() {
150            return Err(AuthError::InvalidPolicy);
151        }
152        Ok(())
153    }
154}
155impl fmt::Debug for JwtKey {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.write_str("JwtKey(<redacted>)")
158    }
159}
160
161/// Issuance lifetimes and strict namespace. Namespace/session checks cannot be disabled. Old JWT
162/// formats require reauthentication rather than an implicit fallback parser.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct JwtPolicy {
165    issuer: String,
166    audience: String,
167    access_ttl_secs: u64,
168    refresh_ttl_secs: u64,
169    algorithm: JwtAlgorithm,
170    leeway_secs: u64,
171    max_token_bytes: usize,
172}
173impl JwtPolicy {
174    /// Creates a strict HS256 policy with no skew and a 16 KiB token budget.
175    ///
176    /// # Errors
177    /// Returns `InvalidPolicy` for invalid namespaces or zero/unrepresentable lifetimes.
178    pub fn new(
179        issuer: impl Into<String>,
180        audience: impl Into<String>,
181        access_ttl_secs: u64,
182        refresh_ttl_secs: u64,
183    ) -> Result<Self> {
184        let issuer = issuer.into();
185        let audience = audience.into();
186        crate::ids::validate_identifier(&issuer)?;
187        crate::ids::validate_identifier(&audience)?;
188        for ttl in [access_ttl_secs, refresh_ttl_secs] {
189            if ttl == 0 || i64::try_from(ttl).is_err() {
190                return Err(AuthError::InvalidPolicy);
191            }
192        }
193        Ok(Self {
194            issuer,
195            audience,
196            access_ttl_secs,
197            refresh_ttl_secs,
198            algorithm: JwtAlgorithm::Hs256,
199            leeway_secs: 0,
200            max_token_bytes: 16 * 1024,
201        })
202    }
203    /// Selects the sole accepted algorithm with its corresponding minimum key size.
204    #[must_use]
205    pub const fn with_algorithm(mut self, algorithm: JwtAlgorithm) -> Self {
206        self.algorithm = algorithm;
207        self
208    }
209    /// Sets maximum accepted clock skew.
210    ///
211    /// # Errors
212    /// Returns `InvalidPolicy` for unrepresentable skew.
213    pub fn with_leeway(mut self, seconds: u64) -> Result<Self> {
214        i64::try_from(seconds).map_err(|_| AuthError::InvalidPolicy)?;
215        self.leeway_secs = seconds;
216        Ok(self)
217    }
218    /// Sets a parser/issuance budget checked before header parsing.
219    ///
220    /// # Errors
221    /// Returns `InvalidPolicy` for a zero budget.
222    pub fn with_max_token_bytes(mut self, bytes: usize) -> Result<Self> {
223        if bytes == 0 {
224            return Err(AuthError::InvalidPolicy);
225        }
226        self.max_token_bytes = bytes;
227        Ok(self)
228    }
229    /// Returns the issuer.
230    #[must_use]
231    pub fn issuer(&self) -> &str {
232        &self.issuer
233    }
234    /// Returns the audience.
235    #[must_use]
236    pub fn audience(&self) -> &str {
237        &self.audience
238    }
239    /// Returns access lifetime in seconds.
240    #[must_use]
241    pub const fn access_ttl_secs(&self) -> u64 {
242        self.access_ttl_secs
243    }
244    /// Returns refresh lifetime in seconds.
245    #[must_use]
246    pub const fn refresh_ttl_secs(&self) -> u64 {
247        self.refresh_ttl_secs
248    }
249    /// Returns skew in seconds.
250    #[must_use]
251    pub const fn leeway_secs(&self) -> u64 {
252        self.leeway_secs
253    }
254    /// Returns parser budget in bytes.
255    #[must_use]
256    pub const fn max_token_bytes(&self) -> usize {
257        self.max_token_bytes
258    }
259}
260
261/// Candidate credentials; commit their refresh identity before releasing token strings.
262#[derive(Clone, PartialEq, Eq)]
263pub struct AuthTokenPair {
264    /// Access credential; never log it.
265    pub access_token: String,
266    /// Refresh credential; never log it.
267    pub refresh_token: String,
268    /// Refresh identity to commit into the store.
269    pub refresh_jti: RefreshJti,
270    /// Exclusive access expiry.
271    pub access_expires_at: i64,
272    /// Exclusive refresh expiry.
273    pub refresh_expires_at: i64,
274}
275impl fmt::Debug for AuthTokenPair {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        f.debug_struct("AuthTokenPair")
278            .field("access_expires_at", &self.access_expires_at)
279            .field("refresh_expires_at", &self.refresh_expires_at)
280            .finish_non_exhaustive()
281    }
282}
283
284/// Product-configured signer with injectable clock and ID source.
285#[derive(Clone)]
286pub struct JwtSigner {
287    key: JwtKey,
288    policy: JwtPolicy,
289    clock: Arc<dyn Clock>,
290    ids: Arc<dyn IdSource>,
291}
292impl fmt::Debug for JwtSigner {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.debug_struct("JwtSigner")
295            .field("policy", &self.policy)
296            .finish_non_exhaustive()
297    }
298}
299impl JwtSigner {
300    /// Creates an issuer with explicit policy, clock, and ID source.
301    #[must_use]
302    pub fn new(
303        key: JwtKey,
304        policy: JwtPolicy,
305        clock: Arc<dyn Clock>,
306        ids: Arc<dyn IdSource>,
307    ) -> Self {
308        Self {
309            key,
310            policy,
311            clock,
312            ids,
313        }
314    }
315    /// Creates a production issuer using system time and UUID v4.
316    #[must_use]
317    pub fn with_system_clock(key: JwtKey, policy: JwtPolicy) -> Self {
318        Self::new(key, policy, Arc::new(SystemClock), Arc::new(RandomIdSource))
319    }
320    /// Issues a pair bound to one subject, session, generation, and restriction.
321    ///
322    /// # Errors
323    /// Returns a mechanism error for invalid key size, clock, expiry overflow, ID generation,
324    /// encoding, or token size. Returns `CryptoProviderNotInitialized` until the product has
325    /// initialized the JWT backend at startup. Issuing alone does not create a persisted session.
326    pub fn issue(
327        &self,
328        subject: Subject,
329        session: SessionId,
330        version: SessionVersion,
331        restriction: Restriction,
332    ) -> Result<AuthTokenPair> {
333        crate::provider::ensure_initialized()?;
334        self.key.check(self.policy.algorithm)?;
335        let now = self.clock.now_unix_seconds()?;
336        if now < 0 {
337            return Err(AuthError::InvalidPolicy);
338        }
339        let expiry = |ttl| {
340            now.checked_add(i64::try_from(ttl).map_err(|_| AuthError::InvalidPolicy)?)
341                .ok_or(AuthError::InvalidPolicy)
342        };
343        let access_expires_at = expiry(self.policy.access_ttl_secs)?;
344        let refresh_expires_at = expiry(self.policy.refresh_ttl_secs)?;
345        let refresh_jti = self.ids.next_id()?;
346        let mut claims = TokenClaims {
347            sub: subject,
348            sid: session,
349            session_version: version,
350            token_type: AuthTokenType::Access,
351            jti: None,
352            restriction,
353            iat: now,
354            exp: access_expires_at,
355            nbf: None,
356            iss: self.policy.issuer.clone(),
357            aud: self.policy.audience.clone(),
358        };
359        let header = Header::new(self.policy.algorithm.upstream());
360        let key = EncodingKey::from_secret(&self.key.0);
361        let access_token = encode(&header, &claims, &key).map_err(|_| AuthError::Encoding)?;
362        claims.token_type = AuthTokenType::Refresh;
363        claims.jti = Some(refresh_jti.clone());
364        claims.exp = refresh_expires_at;
365        let refresh_token = encode(&header, &claims, &key).map_err(|_| AuthError::Encoding)?;
366        if access_token.len() > self.policy.max_token_bytes
367            || refresh_token.len() > self.policy.max_token_bytes
368        {
369            return Err(AuthError::TokenTooLarge);
370        }
371        Ok(AuthTokenPair {
372            access_token,
373            refresh_token,
374            refresh_jti,
375            access_expires_at,
376            refresh_expires_at,
377        })
378    }
379}
380
381/// Strict verifier: only signature-verified claims cross the validation boundary.
382#[derive(Clone)]
383pub struct JwtVerifier {
384    key: JwtKey,
385    policy: JwtPolicy,
386    clock: Arc<dyn Clock>,
387}
388impl fmt::Debug for JwtVerifier {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.debug_struct("JwtVerifier")
391            .field("policy", &self.policy)
392            .finish_non_exhaustive()
393    }
394}
395impl JwtVerifier {
396    pub(crate) const fn leeway_secs(&self) -> u64 {
397        self.policy.leeway_secs
398    }
399    /// Creates a verifier with an injectable clock.
400    #[must_use]
401    pub fn new(key: JwtKey, policy: JwtPolicy, clock: Arc<dyn Clock>) -> Self {
402        Self { key, policy, clock }
403    }
404    /// Creates a production verifier using system time.
405    #[must_use]
406    pub fn with_system_clock(key: JwtKey, policy: JwtPolicy) -> Self {
407        Self::new(key, policy, Arc::new(SystemClock))
408    }
409    /// Verifies signature, namespace, purpose, identifiers, and time boundaries.
410    ///
411    /// # Errors
412    /// Returns a classified error without token/key material. Products additionally check live
413    /// account/version state and live `sid` state when promising immediate session revocation.
414    /// Returns `CryptoProviderNotInitialized` until the product initializes the JWT backend.
415    pub fn verify(&self, token: &str, expected: AuthTokenType) -> Result<VerifiedToken> {
416        crate::provider::ensure_initialized()?;
417        self.key.check(self.policy.algorithm)?;
418        if token.len() > self.policy.max_token_bytes {
419            return Err(AuthError::TokenTooLarge);
420        }
421        let header = decode_header(token).map_err(|_| AuthError::InvalidToken)?;
422        if header.alg != self.policy.algorithm.upstream() {
423            return Err(AuthError::AlgorithmMismatch);
424        }
425        let mut validation = Validation::new(self.policy.algorithm.upstream());
426        // Upstream verifies signatures; these checks use an injected clock and stable classifications.
427        validation.validate_exp = false;
428        validation.validate_nbf = false;
429        validation.validate_aud = false;
430        let claims =
431            decode::<TokenClaims>(token, &DecodingKey::from_secret(&self.key.0), &validation)
432                .map_err(|_| AuthError::InvalidToken)?
433                .claims;
434        if claims.iss != self.policy.issuer {
435            return Err(AuthError::IssuerMismatch);
436        }
437        if claims.aud != self.policy.audience {
438            return Err(AuthError::AudienceMismatch);
439        }
440        if claims.token_type != expected {
441            return Err(AuthError::WrongTokenType);
442        }
443        match (expected, &claims.jti) {
444            (AuthTokenType::Refresh, None) => return Err(AuthError::MissingClaim),
445            (AuthTokenType::Access, Some(_)) => return Err(AuthError::InvalidToken),
446            _ => {}
447        }
448        if claims.iat < 0
449            || claims.exp <= claims.iat
450            || claims.nbf.is_some_and(|nbf| nbf < 0 || nbf >= claims.exp)
451        {
452            return Err(AuthError::InvalidToken);
453        }
454        let now = self.clock.now_unix_seconds()?;
455        if now < 0 {
456            return Err(AuthError::InvalidPolicy);
457        }
458        let leeway =
459            i64::try_from(self.policy.leeway_secs).map_err(|_| AuthError::InvalidPolicy)?;
460        // Widen arithmetic: expiry near i64::MAX cannot wrap when combined with clock skew.
461        let latest = i128::from(now) + i128::from(leeway);
462        if i128::from(claims.iat) > latest || claims.nbf.is_some_and(|nbf| i128::from(nbf) > latest)
463        {
464            return Err(AuthError::NotYetValid);
465        }
466        if i128::from(now) >= i128::from(claims.exp) + i128::from(leeway) {
467            return Err(AuthError::Expired);
468        }
469        Ok(VerifiedToken(claims))
470    }
471}