Skip to main content

authkestra_engine/token/
mod.rs

1use crate::auth::{error::AuthError, state::Identity};
2
3use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7pub mod cert_binding;
8pub mod dpop;
9pub mod sd_jwt;
10
11/// The `aud` (audience) claim, per RFC 7519 §4.1.3: either a single
12/// case-sensitive string, or a JSON array of such strings.
13///
14/// `#[serde(untagged)]` tries variants in declaration order on
15/// deserialization, so a bare JSON string matches [`Audience::Single`]
16/// first, and only a JSON array falls through to [`Audience::Multiple`].
17/// Serialization is not affected by declaration order — each variant
18/// serializes as its own shape — so a token minted with a single audience
19/// still serializes as a bare string, not a one-element array, matching
20/// every token this crate has ever issued.
21#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
22#[serde(untagged)]
23pub enum Audience {
24    Single(String),
25    Multiple(Vec<String>),
26}
27
28impl Audience {
29    /// True if `value` is present in this audience, whichever shape it was
30    /// deserialized from. This is the "matches ANY" membership test that
31    /// replaces exact-string-equality comparisons against `aud`.
32    pub fn contains(&self, value: &str) -> bool {
33        match self {
34            Audience::Single(s) => s == value,
35            Audience::Multiple(values) => values.iter().any(|s| s == value),
36        }
37    }
38}
39
40/// Renders the audience for logging and display: a single audience as the
41/// bare string, multiple as comma-separated values.
42///
43/// This exists so that code reading an `aud` claim has an ergonomic path
44/// back to a string. Without it, every `format!("{}", claims.aud)` and
45/// `tracing::info!(aud = %claims.aud)` at a call site that previously held
46/// a `String` is a hard compile error with no one-line replacement. Use
47/// [`Audience::contains`] for membership tests — do **not** parse this
48/// output back apart, since an audience value may itself contain a comma.
49impl std::fmt::Display for Audience {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Audience::Single(s) => f.write_str(s),
53            Audience::Multiple(values) => {
54                for (i, value) in values.iter().enumerate() {
55                    if i > 0 {
56                        f.write_str(", ")?;
57                    }
58                    f.write_str(value)?;
59                }
60                Ok(())
61            }
62        }
63    }
64}
65
66/// Compares against a single audience string, so `claims.aud == "client"`
67/// keeps compiling where `aud` used to be a `String`. Note this is exact
68/// equality against a *single*-valued audience, not membership — a
69/// [`Audience::Multiple`] never equals a bare `str`, even one it contains.
70/// Use [`Audience::contains`] when you mean "is this one of the audiences".
71impl PartialEq<str> for Audience {
72    fn eq(&self, other: &str) -> bool {
73        matches!(self, Audience::Single(s) if s == other)
74    }
75}
76
77impl PartialEq<&str> for Audience {
78    fn eq(&self, other: &&str) -> bool {
79        self == *other
80    }
81}
82
83impl PartialEq<String> for Audience {
84    fn eq(&self, other: &String) -> bool {
85        self == other.as_str()
86    }
87}
88
89impl From<String> for Audience {
90    fn from(value: String) -> Self {
91        Audience::Single(value)
92    }
93}
94
95impl From<&str> for Audience {
96    fn from(value: &str) -> Self {
97        Audience::Single(value.to_string())
98    }
99}
100
101/// Removes a caller-supplied `jti` from `extra`, if present as a JSON
102/// string, so it can be assigned to the named `Claims::jti` field instead
103/// of staying in the flattened `extra` map. Falls back to a generated
104/// UUIDv4 when `extra` has no usable `"jti"` entry — either absent, or
105/// present but not a JSON string (RFC 7519 §4.1.7 requires `jti` to be a
106/// string).
107///
108/// This is the fix for #212: `Claims::extra` is `#[serde(flatten)]`ed
109/// alongside the named `jti` field, so a `"jti"` entry left in `extra`
110/// would serialize twice under the same key — a payload `TokenManager`
111/// could mint but never decode again (`serde_json` hard-errors on a
112/// literal duplicate field, flattened or not). Removing the key here,
113/// unconditionally, guarantees the wire payload can never carry two `jti`
114/// entries, and doubles as the override mechanism: a caller who needs a
115/// non-UUID id format (e.g. CUID2) supplies it via `extra["jti"]` and it
116/// is used verbatim.
117fn take_jti(extra: &mut HashMap<String, serde_json::Value>) -> String {
118    match extra.remove("jti") {
119        Some(serde_json::Value::String(jti)) => jti,
120        _ => uuid::Uuid::new_v4().to_string(),
121    }
122}
123
124/// Every named field [`Claims`] serializes at the top level of the JWT
125/// payload. `Claims::extra` is `#[serde(flatten)]`ed alongside them, so any
126/// of these names *also* appearing in `extra` gets written twice under the
127/// same key.
128///
129/// `jti` is deliberately **absent**: [`take_jti`] removes it from `extra`
130/// before any collision check runs, which makes it a supported override
131/// rather than a collision (#212).
132///
133/// `identity` **is** included even though it is
134/// `skip_serializing_if = "Option::is_none"`, so an `extra["identity"]`
135/// only duplicates on the paths that actually carry an identity. Rejecting
136/// it unconditionally keeps one rule for every constructor instead of one
137/// that silently depends on which `issue_*` was called; on the paths where
138/// it does not duplicate, `extra["identity"]` is captured by the named
139/// field on the way back in anyway, never by `extra`, so nothing could
140/// have been reading it from where it was put.
141pub(crate) const NAMED_CLAIM_FIELDS: &[&str] = &[
142    "iss", "sub", "aud", "exp", "iat", "nbf", "scope", "identity",
143];
144
145/// Rejects any `extra` key that collides with a named [`Claims`] field.
146///
147/// This is the fix for #283, the general case #212 left open. `#212` only
148/// removed `jti`; every other name in [`NAMED_CLAIM_FIELDS`] still
149/// serialized twice, producing a payload `encode` accepts but no
150/// `serde_json`-based decoder — including this crate's own
151/// [`TokenManager::validate_token`] — can read back, because `serde_json`
152/// hard-errors on a literal duplicate field whether or not it arrived via
153/// a flattened map.
154///
155/// Failing here, at issuance, rather than silently stripping the keys is
156/// the deliberate choice: the natural adapter for an embedder with an
157/// existing typed claims struct is `extra = serde_json::to_value(&claims)?`,
158/// which collides on `iss`/`sub`/`aud` and is silently wrong. Left to
159/// strip, that mistake becomes a token missing the claims its author
160/// believed it set; left as-is, it becomes every resource server rejecting
161/// every token, diagnosed a process away from its cause. Returning an
162/// error puts the failure at the call site that caused it.
163///
164/// All collisions are reported at once, in [`NAMED_CLAIM_FIELDS`]
165/// declaration order, so the message is deterministic despite `extra`
166/// being a [`HashMap`] and so a caller flattening a whole struct fixes
167/// every key in one pass instead of one error at a time.
168fn reject_named_claim_collisions(
169    extra: &HashMap<String, serde_json::Value>,
170) -> Result<(), AuthError> {
171    let collisions: Vec<&str> = NAMED_CLAIM_FIELDS
172        .iter()
173        .copied()
174        .filter(|name| extra.contains_key(*name))
175        .collect();
176
177    if collisions.is_empty() {
178        return Ok(());
179    }
180
181    let joined = collisions.join(", ");
182    tracing::warn!(
183        colliding_keys = %joined,
184        "rejecting token issuance: `extra` keys collide with named Claims fields"
185    );
186    Err(AuthError::Token(format!(
187        "extra claim key(s) [{joined}] collide with named claims and would be serialized \
188         twice, producing a token that cannot be decoded again; remove them from `extra` \
189         (`jti` is the one supported override)"
190    )))
191}
192
193#[derive(Debug, Serialize, Deserialize, Clone)]
194#[non_exhaustive]
195pub struct Claims {
196    // Standard OIDC claims
197    pub iss: Option<String>,
198    pub sub: String,
199    pub aud: Option<Audience>,
200    pub exp: usize,
201    pub iat: usize,
202    pub nbf: Option<usize>,
203    pub jti: Option<String>,
204
205    // Engine-specific core fields
206    pub scope: Option<String>,
207    /// Optional identity data for user-centric tokens.
208    /// If None, this is likely a machine-to-machine token.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub identity: Option<Identity>,
211
212    // Isolated custom claims
213    #[serde(flatten)]
214    pub extra: HashMap<String, serde_json::Value>,
215}
216
217#[derive(Clone)]
218#[non_exhaustive]
219pub struct TokenManager {
220    encoding_key: EncodingKey,
221    decoding_key: DecodingKey,
222    issuer: Option<String>,
223    kid: Option<String>,
224    alg: Algorithm,
225    public_jwk: Option<crate::token::jwk::Jwk>,
226}
227
228impl TokenManager {
229    /// Creates a TokenManager for symmetric signing (HS256).
230    pub fn new(secret: &[u8], issuer: Option<String>) -> Self {
231        Self {
232            encoding_key: EncodingKey::from_secret(secret),
233            decoding_key: DecodingKey::from_secret(secret),
234            issuer,
235            kid: None,
236            alg: Algorithm::HS256,
237            public_jwk: None,
238        }
239    }
240
241    /// Creates a TokenManager for asymmetric signing (RS256).
242    /// `private_key_pem` must be a valid RSA private key in PEM format.
243    /// OP/external verification should use this path; internal resource servers
244    /// can continue to use `new` (HS256).
245    pub fn new_asymmetric(
246        private_key_pem: &[u8],
247        issuer: Option<String>,
248        kid: Option<String>,
249    ) -> Result<Self, AuthError> {
250        let encoding_key = EncodingKey::from_rsa_pem(private_key_pem)
251            .map_err(|e| AuthError::Token(e.to_string()))?;
252
253        let pem_str = std::str::from_utf8(private_key_pem)
254            .map_err(|_| AuthError::Token("Invalid PEM UTF-8".into()))?;
255
256        use rsa::pkcs1::DecodeRsaPrivateKey;
257        use rsa::pkcs8::DecodePrivateKey;
258        let rsa_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem_str)
259            .or_else(|_| rsa::RsaPrivateKey::from_pkcs1_pem(pem_str))
260            .map_err(|e| AuthError::Token(format!("Failed to parse RSA key: {}", e)))?;
261
262        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
263        use rsa::traits::PublicKeyParts;
264
265        let n = URL_SAFE_NO_PAD.encode(rsa_key.n().to_bytes_be());
266        let e = URL_SAFE_NO_PAD.encode(rsa_key.e().to_bytes_be());
267
268        let kid_val = kid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
269
270        let jwk = crate::token::jwk::Jwk {
271            kid: Some(kid_val.clone()),
272            kty: "RSA".to_string(),
273            alg: Some("RS256".to_string()),
274            n: Some(n),
275            e: Some(e),
276            crv: None,
277            x: None,
278        };
279
280        // The decoding key must come from the PUBLIC half. `DecodingKey::from_rsa_pem`
281        // expects a public-key PEM; handed a private one it still constructs, but every
282        // later `validate_token` fails with `InvalidSignature`. Deriving it from the JWK
283        // we just built keeps both halves provably in sync with what `/jwks` publishes.
284        let decoding_key = jwk.to_decoding_key()?;
285
286        Ok(Self {
287            encoding_key,
288            decoding_key,
289            issuer,
290            kid: Some(kid_val),
291            alg: Algorithm::RS256,
292            public_jwk: Some(jwk),
293        })
294    }
295
296    /// Creates a TokenManager for asymmetric signing with Ed25519 (EdDSA).
297    /// `private_key_pem` must be a valid Ed25519 private key in PKCS#8 PEM
298    /// format (`-----BEGIN PRIVATE KEY-----`), e.g. as produced by
299    /// `openssl genpkey -algorithm ed25519`.
300    ///
301    /// Mirrors `new_asymmetric` (RS256): OP/external verification should use
302    /// this path when downstream resource servers require EdDSA-signed
303    /// tokens; internal resource servers can continue to use `new` (HS256).
304    /// The published JWK (`public_jwk`) is the OKP shape from RFC 8037, so
305    /// pair this with #188 (`Jwk`'s OKP support) to publish a verifiable
306    /// `/jwks.json` for the resulting deployment.
307    pub fn new_ed25519(
308        private_key_pem: &[u8],
309        issuer: Option<String>,
310        kid: Option<String>,
311    ) -> Result<Self, AuthError> {
312        let encoding_key = EncodingKey::from_ed_pem(private_key_pem)
313            .map_err(|e| AuthError::Token(e.to_string()))?;
314
315        let pem_str = std::str::from_utf8(private_key_pem)
316            .map_err(|_| AuthError::Token("Invalid PEM UTF-8".into()))?;
317
318        use ed25519_dalek::pkcs8::DecodePrivateKey;
319        let signing_key = ed25519_dalek::SigningKey::from_pkcs8_pem(pem_str)
320            .map_err(|e| AuthError::Token(format!("Failed to parse Ed25519 key: {}", e)))?;
321
322        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
323        let x = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
324
325        let kid_val = kid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
326
327        let jwk = crate::token::jwk::Jwk {
328            kid: Some(kid_val.clone()),
329            kty: "OKP".to_string(),
330            alg: Some("EdDSA".to_string()),
331            n: None,
332            e: None,
333            crv: Some("Ed25519".to_string()),
334            x: Some(x),
335        };
336
337        // Same rationale as `new_asymmetric`: derive the decoding key from
338        // the JWK we just built (the public half) rather than from the
339        // private PEM, so both provably agree with what `/jwks` publishes.
340        // See the regression note on that constructor and the test below
341        // named after it.
342        let decoding_key = jwk.to_decoding_key()?;
343
344        Ok(Self {
345            encoding_key,
346            decoding_key,
347            issuer,
348            kid: Some(kid_val),
349            alg: Algorithm::EdDSA,
350            public_jwk: Some(jwk),
351        })
352    }
353
354    pub fn public_jwk(&self) -> Option<crate::token::jwk::Jwk> {
355        self.public_jwk.clone()
356    }
357
358    pub fn with_issuer(mut self, issuer: String) -> Self {
359        self.issuer = Some(issuer);
360        self
361    }
362
363    /// Issues a token for a user identity.
364    pub fn issue_user_token(
365        &self,
366        identity: Identity,
367        expires_in_secs: u64,
368        scope: Option<String>,
369        aud: Option<String>,
370    ) -> Result<String, AuthError> {
371        self.issue_user_token_with_extra(identity, expires_in_secs, scope, aud, HashMap::new())
372    }
373
374    /// Issues a token for a user identity, stamping the given `extra` claims
375    /// onto the token in addition to the standard/core claims.
376    ///
377    /// This lets a host application (e.g. a resource server built on top of
378    /// this engine) attach domain-specific claims — such as `api_key_id`,
379    /// `project_id`, or `roles` — so downstream consumers (an API gateway or
380    /// authorization proxy) can read them directly off the token without a
381    /// database round-trip. Keys in `extra` take precedence over any
382    /// same-named field set elsewhere in `extra` by this method.
383    ///
384    /// # Errors
385    ///
386    /// `extra` **cannot** override the top-level standard claims, and
387    /// trying is an error rather than a silent no-op: if `extra` contains
388    /// any of [`NAMED_CLAIM_FIELDS`] (`iss`, `sub`, `aud`, `exp`, `iat`,
389    /// `nbf`, `scope`, `identity`) this returns
390    /// [`AuthError::Token`] naming every colliding key, and mints nothing.
391    /// Those names are `#[serde(flatten)]`-adjacent to the named `Claims`
392    /// fields, so letting them through would serialize each twice and
393    /// produce a token this crate could sign but never decode (#283). If
394    /// you are flattening an existing claims struct into `extra`, strip
395    /// those keys first — the standard claims are set from this method's
396    /// own parameters.
397    ///
398    /// `jti` is the one supported override: if `extra["jti"]` is a JSON
399    /// string, it is removed from `extra` and used verbatim as the token's
400    /// `jti` claim instead of a generated UUIDv4 — see [`take_jti`] for why
401    /// this has to happen this way rather than leaving the key in `extra`.
402    /// `nbf` (not-before, set to issuance time) and `identity` are
403    /// unconditional parts of this token's contract and have no opt-out.
404    pub fn issue_user_token_with_extra(
405        &self,
406        identity: Identity,
407        expires_in_secs: u64,
408        scope: Option<String>,
409        aud: Option<String>,
410        mut extra: HashMap<String, serde_json::Value>,
411    ) -> Result<String, AuthError> {
412        let now = chrono::Utc::now().timestamp() as usize;
413        let expiration = now + expires_in_secs as usize;
414        let jti = take_jti(&mut extra);
415        reject_named_claim_collisions(&extra)?;
416
417        let claims = Claims {
418            iss: self.issuer.clone(),
419            sub: identity.external_id.clone(),
420            aud: aud.map(Audience::from),
421            exp: expiration,
422            iat: now,
423            nbf: Some(now),
424            jti: Some(jti),
425            scope,
426            identity: Some(identity),
427            extra,
428        };
429
430        let mut header = Header::new(self.alg);
431        if let Some(ref kid) = self.kid {
432            header.kid = Some(kid.clone());
433        }
434
435        encode(&header, &claims, &self.encoding_key).map_err(|e| AuthError::Token(e.to_string()))
436    }
437
438    /// Issues an OIDC-conformant ID token.
439    pub fn issue_id_token(
440        &self,
441        identity: Identity,
442        client_id: &str,
443        nonce: Option<String>,
444        expires_in_secs: u64,
445    ) -> Result<String, AuthError> {
446        self.issue_id_token_with_extra(identity, client_id, nonce, expires_in_secs, HashMap::new())
447    }
448
449    /// Issues an OIDC-conformant ID token, stamping the given `extra` claims
450    /// onto the token in addition to the standard/core claims.
451    ///
452    /// `nonce` is a reserved claim key: `extra` is merged into the token
453    /// first, then the explicit `nonce` parameter is applied on top. So if
454    /// `nonce` is `Some(_)`, it always wins over any `"nonce"` entry passed
455    /// in `extra`. If `nonce` is `None`, an `extra["nonce"]` value (if any)
456    /// is left as-is. This preserves OIDC `nonce` semantics — it reflects
457    /// what the client sent in the authorization request — and keeps it from
458    /// being accidentally clobbered by unrelated custom claims.
459    ///
460    /// `jti` is likewise reserved: see
461    /// [`Self::issue_user_token_with_extra`] for how `extra["jti"]`
462    /// overrides the generated one. `nbf` and `identity` are unconditional
463    /// on this path too, with no opt-out.
464    ///
465    /// # Errors
466    ///
467    /// Returns [`AuthError::Token`] without minting anything if `extra`
468    /// carries a key that collides with a named `Claims` field — see
469    /// [`Self::issue_user_token_with_extra`] for the full rule and why it
470    /// is an error rather than a silent strip.
471    pub fn issue_id_token_with_extra(
472        &self,
473        identity: Identity,
474        client_id: &str,
475        nonce: Option<String>,
476        expires_in_secs: u64,
477        mut extra: HashMap<String, serde_json::Value>,
478    ) -> Result<String, AuthError> {
479        let now = chrono::Utc::now().timestamp() as usize;
480        let expiration = now + expires_in_secs as usize;
481        let jti = take_jti(&mut extra);
482        reject_named_claim_collisions(&extra)?;
483
484        let mut claims = Claims {
485            iss: self.issuer.clone(),
486            sub: identity.external_id.clone(),
487            aud: Some(Audience::from(client_id)),
488            exp: expiration,
489            iat: now,
490            nbf: Some(now),
491            jti: Some(jti),
492            scope: None,
493            identity: Some(identity),
494            extra,
495        };
496
497        if let Some(n) = nonce {
498            claims
499                .extra
500                .insert("nonce".to_string(), serde_json::Value::String(n));
501        }
502
503        let mut header = Header::new(self.alg);
504        if let Some(ref kid) = self.kid {
505            header.kid = Some(kid.clone());
506        }
507
508        encode(&header, &claims, &self.encoding_key).map_err(|e| AuthError::Token(e.to_string()))
509    }
510
511    /// Issues a machine-to-machine (M2M) token for a client.
512    pub fn issue_client_token(
513        &self,
514        client_id: &str,
515        expires_in_secs: u64,
516        scope: Option<String>,
517        aud: Option<String>,
518    ) -> Result<String, AuthError> {
519        self.issue_client_token_with_extra(client_id, expires_in_secs, scope, aud, HashMap::new())
520    }
521
522    /// Issues a machine-to-machine (M2M) token for a client, stamping the
523    /// given `extra` claims onto the token in addition to the standard/core
524    /// claims. See [`Self::issue_user_token_with_extra`] for the rationale,
525    /// including the `extra["jti"]` override.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`AuthError::Token`] without minting anything if `extra`
530    /// carries a key that collides with a named `Claims` field — see
531    /// [`Self::issue_user_token_with_extra`].
532    pub fn issue_client_token_with_extra(
533        &self,
534        client_id: &str,
535        expires_in_secs: u64,
536        scope: Option<String>,
537        aud: Option<String>,
538        mut extra: HashMap<String, serde_json::Value>,
539    ) -> Result<String, AuthError> {
540        let now = chrono::Utc::now().timestamp() as usize;
541        let expiration = now + expires_in_secs as usize;
542        let jti = take_jti(&mut extra);
543        reject_named_claim_collisions(&extra)?;
544
545        let claims = Claims {
546            iss: self.issuer.clone(),
547            sub: client_id.to_string(),
548            aud: aud.map(Audience::from),
549            exp: expiration,
550            iat: now,
551            nbf: Some(now),
552            jti: Some(jti),
553            scope,
554            identity: None,
555            extra,
556        };
557
558        let mut header = Header::new(self.alg);
559        if let Some(ref kid) = self.kid {
560            header.kid = Some(kid.clone());
561        }
562
563        encode(&header, &claims, &self.encoding_key).map_err(|e| AuthError::Token(e.to_string()))
564    }
565
566    /// Issues a token with an explicit `typ` header and no `aud`, for
567    /// callers minting something that is not a standard OIDC ID/access/user
568    /// token and needs its own wire-format `typ` so verifiers can tell it
569    /// apart from those (e.g. `authkestra-op`'s device/service attestations,
570    /// whose contract requires `typ: "webank-attest+jws"` rather than the
571    /// default `"JWT"`). Additive alongside the `issue_*_token*` family
572    /// above; those are unchanged. `extra["jti"]` is honored the same way
573    /// as [`Self::issue_user_token_with_extra`].
574    ///
575    /// # Errors
576    ///
577    /// Returns [`AuthError::Token`] without minting anything if `extra`
578    /// carries a key that collides with a named `Claims` field — see
579    /// [`Self::issue_user_token_with_extra`].
580    pub fn issue_custom_token(
581        &self,
582        sub: String,
583        expires_in_secs: u64,
584        typ: &str,
585        mut extra: HashMap<String, serde_json::Value>,
586    ) -> Result<String, AuthError> {
587        let now = chrono::Utc::now().timestamp() as usize;
588        let jti = take_jti(&mut extra);
589        reject_named_claim_collisions(&extra)?;
590
591        let claims = Claims {
592            iss: self.issuer.clone(),
593            sub,
594            aud: None,
595            exp: now + expires_in_secs as usize,
596            iat: now,
597            nbf: Some(now),
598            jti: Some(jti),
599            scope: None,
600            identity: None,
601            extra,
602        };
603
604        let mut header = Header::new(self.alg);
605        header.typ = Some(typ.to_string());
606        if let Some(ref kid) = self.kid {
607            header.kid = Some(kid.clone());
608        }
609
610        encode(&header, &claims, &self.encoding_key).map_err(|e| AuthError::Token(e.to_string()))
611    }
612
613    pub fn validate_token(
614        &self,
615        token: &str,
616        expected_aud: Option<&str>,
617    ) -> Result<Claims, AuthError> {
618        let mut validation = Validation::new(self.alg);
619        if let Some(aud) = expected_aud {
620            validation.set_audience(&[aud]);
621        } else {
622            validation.validate_aud = false;
623        }
624        if let Some(ref iss) = self.issuer {
625            validation.set_issuer(&[iss]);
626        }
627
628        let token_data = decode::<Claims>(token, &self.decoding_key, &validation)
629            .map_err(|e| AuthError::Token(e.to_string()))?;
630
631        Ok(token_data.claims)
632    }
633}
634
635#[cfg(test)]
636mod tests {
637
638    use super::*;
639    use crate::auth::state::Identity;
640    use std::collections::HashMap;
641
642    /// Throwaway RSA-2048 private key, test-only.
643    const TEST_RSA_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
644MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDA5hJIcQ+2rxMz
645VM8ZH5WAmguCr0xmNDAdy0IzzsUeFLG7BebB7izOkU36J4t8t5tUaQwrBMnx2Fvt
646VqJjbdE242UDpvWF/8m9zJ2HR5298cbwT5cGMKLB0HWzDMahugs+Bbh2lCgwyLZk
647Tr3Diwxp5SwFew/Wb+Ke9cNG9Hu5IFH3BCuJ839d9hfqisIeYrBPfb52xxckM37R
6487zSGu/eDP/HZAeLkQuptZJW4A3u7xni14u4qyqXDqsHsYFNgJaxMSAwWgBRY6HNu
649TnvBArTXCiVfL+F73B2L6mdYr64g+QS9nK9v97MlJu/E3mSduz54pren4mpCHc9m
650/S2+VjCZAgMBAAECggEAASC9qQbGnL7XuExRDOIn/m4bWx92ehjo0lCTibhpY3LW
651umbSbpfbhmmuSj3CjW9VZsaM3hBTgSjoTX72lbY/eIUXD7c0memUK5pV4XcEIrQw
652AZlPIye6ckx4I7ZGnKasO8FoAel9dd7DXw36AuBK3LBzJwtzkEFsBc0e3/wixqmG
653UJBbbt/+5ya7CxyjuePaQhKtkLD5R6DpvN2XnCYq5nHJNJdvSVg1pOzsTHYIf+Ee
6542Rz42fGsfFKqeEQCcBFRZaGb/ELeP4c6UZdktZAvmHb1p1fursVZc6X9JXmiJ2OJ
655Kv2H2tMKuysP8L0fXFOMgkH2SVt6rcdHkO6xhlhWsQKBgQDqR8rAJeEE5BFoXA8T
656VVW6CLMlW51x4ey7PEGOaYh39dTG2Q+GZQBZ9G+SZk3f5Y85UCACSyc//4qaz/c3
6570nWsegZ+JPyymmuc79wzIAFFvXB7pL6wyn0Ed1P620kOZTtA8iBcXrsuxL+KP7iu
658MXfWmU1QiZpbndILtyDnY+70uwKBgQDSyCljWkydQCaPU+fiAXLxP8CvcJTSSNQD
659mVUlwJ+OpHnU+Alsi1rBavMgUtLlYbFqzH7NmYrLC8Yadq3ZOwLt0VEK0r8qstAL
6607QCDUD2WNuQjpZupRnXuMUl3iXB96i2gb+VQKGuUAJvVWjdIbYa4+Gu+sBMfcDcX
661dBihDLuEuwKBgAgX4tEwfc2Fc3R/eaXZVNTQaB/qQk4k1+C//CPHUYeTXn5gEUE7
662S//PiesszZPmgkQgmHp7zidP1KH0fT3Yb2g97ut8q54f54fMYXcCrAiUusYKsuu4
663kwkMdkI8QRHWPW3I74VBYIYFFfjYqrCZ1OH8+cbGeiagFRmCggh8U0zxAoGAVW3u
6646Ge22Z0gg8LcHsu7jG/sZq7Ygool8/d3fT+e669Z+ak2GJo6hF4WgClRdMqtn72W
665PzpV+ImjFyK2v26dd0n48MwN0v56N/ss1Av3iiRhPtlmR6tZLNspDZvUzhPVvkrb
666xCs9vtSoVEamVWKe0eVNthGjDoDqs0TInq2MavUCgYB6REavSJs/CLkSS7iimjxZ
667G7g5YQi9/p1lXLOEUDiwEmvRr0XTwzzxUsIc535IXhh/ZUYpthenW+qBBzn85pEC
668TowIqciHu5redqlQ8rITA8/AOY98vaDIhppDg1rfpnHHaZHFbXD/keYAEbhBtbvf
669a0QMqKUcs8+YTy5R5K6qtw==
670-----END PRIVATE KEY-----";
671
672    /// Throwaway Ed25519 private key (PKCS#8 PEM), test-only. Generated with
673    /// `openssl genpkey -algorithm ed25519`.
674    const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
675MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
676-----END PRIVATE KEY-----";
677
678    /// A second, distinct throwaway Ed25519 private key, test-only — used to
679    /// prove a token signed by one key is rejected by another key's manager.
680    const TEST_ED25519_PRIVATE_KEY_PEM_B: &[u8] = b"-----BEGIN PRIVATE KEY-----
681MC4CAQAwBQYDK2VwBCIEIPlsnSfvh53rJ+Tlbo8e7cgq2mIkWQ1NCM5paVeinUh8
682-----END PRIVATE KEY-----";
683
684    #[test]
685    fn test_claims_serialization() {
686        let mut extra = HashMap::new();
687        extra.insert(
688            "custom".to_string(),
689            serde_json::Value::String("value".to_string()),
690        );
691
692        let claims = Claims {
693            iss: Some("issuer".to_string()),
694            sub: "user123".to_string(),
695            aud: Some(Audience::from("audience")),
696            exp: 1000,
697            iat: 500,
698            nbf: Some(500),
699            jti: Some("jti".to_string()),
700            scope: Some("openid profile".to_string()),
701            identity: Some(Identity {
702                provider_id: "google".to_string(),
703                external_id: "user123".to_string(),
704                email: Some("user@example.com".to_string()),
705                username: Some("user".to_string()),
706                attributes: HashMap::new(),
707            }),
708            extra,
709        };
710
711        let serialized = serde_json::to_string(&claims).unwrap();
712        let deserialized: Claims = serde_json::from_str(&serialized).unwrap();
713
714        assert_eq!(deserialized.iss, claims.iss);
715        assert_eq!(deserialized.sub, claims.sub);
716        assert_eq!(deserialized.extra.get("custom").unwrap(), "value");
717    }
718
719    #[test]
720    fn test_token_manager_issuance() {
721        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
722        let identity = Identity {
723            provider_id: "mock".to_string(),
724            external_id: "user123".to_string(),
725            email: None,
726            username: None,
727            attributes: HashMap::new(),
728        };
729
730        let token = manager
731            .issue_user_token(identity, 3600, None, None)
732            .unwrap();
733        let claims = manager.validate_token(&token, None).unwrap();
734
735        assert_eq!(claims.iss, Some("issuer".to_string()));
736        assert_eq!(claims.sub, "user123");
737        assert!(claims.jti.is_some());
738        assert!(claims.nbf.is_some());
739    }
740
741    #[test]
742    fn test_token_manager_asymmetric_issuance() {
743        let manager = TokenManager::new_asymmetric(
744            TEST_RSA_PRIVATE_KEY_PEM,
745            Some("issuer".to_string()),
746            Some("my-kid-123".to_string()),
747        )
748        .unwrap();
749
750        let identity = Identity {
751            provider_id: "mock".to_string(),
752            external_id: "user123".to_string(),
753            email: None,
754            username: None,
755            attributes: HashMap::new(),
756        };
757
758        let token = manager
759            .issue_user_token(identity, 3600, None, None)
760            .unwrap();
761
762        // Decode directly via jsonwebtoken to prove independent verification
763        let jwk = manager.public_jwk().unwrap();
764        assert_eq!(jwk.kid.as_deref(), Some("my-kid-123"));
765
766        let decoding_key = jwk.to_decoding_key().unwrap();
767        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
768        validation.set_issuer(&["issuer"]);
769
770        let token_data =
771            jsonwebtoken::decode::<Claims>(&token, &decoding_key, &validation).unwrap();
772        assert_eq!(token_data.claims.sub, "user123");
773        assert_eq!(token_data.header.kid.as_deref(), Some("my-kid-123"));
774    }
775
776    /// Regression test for the asymmetric decoding key being derived from the
777    /// private half instead of the public one.
778    ///
779    /// `test_token_manager_asymmetric_issuance` above verifies via the
780    /// published JWK, which exercises `Jwk::to_decoding_key` rather than the
781    /// manager's own `decoding_key` — so it stays green either way. This one
782    /// goes through `validate_token`, which is the path `/reissue` and
783    /// `/userinfo` actually take.
784    #[test]
785    fn test_asymmetric_manager_validates_its_own_tokens() {
786        let manager = TokenManager::new_asymmetric(
787            TEST_RSA_PRIVATE_KEY_PEM,
788            Some("issuer".to_string()),
789            Some("my-kid-123".to_string()),
790        )
791        .unwrap();
792
793        let identity = Identity {
794            provider_id: "mock".to_string(),
795            external_id: "user123".to_string(),
796            email: None,
797            username: None,
798            attributes: HashMap::new(),
799        };
800
801        let token = manager
802            .issue_user_token(identity, 3600, None, None)
803            .unwrap();
804
805        let claims = manager.validate_token(&token, None).unwrap();
806        assert_eq!(claims.sub, "user123");
807        assert_eq!(claims.iss, Some("issuer".to_string()));
808
809        // Same round trip for the attestation shape `/reissue` presents.
810        let attestation = manager
811            .issue_custom_token(
812                "device-1".to_string(),
813                60,
814                "webank-attest+jws",
815                HashMap::new(),
816            )
817            .unwrap();
818
819        let attest_claims = manager.validate_token(&attestation, None).unwrap();
820        assert_eq!(attest_claims.sub, "device-1");
821    }
822
823    #[test]
824    fn test_issue_id_token() {
825        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
826        let identity = Identity {
827            provider_id: "mock".to_string(),
828            external_id: "user123".to_string(),
829            email: None,
830            username: None,
831            attributes: HashMap::new(),
832        };
833
834        let token = manager
835            .issue_id_token(identity, "client-1", Some("nonce123".to_string()), 3600)
836            .unwrap();
837
838        let claims = manager.validate_token(&token, None).unwrap();
839
840        assert_eq!(claims.iss, Some("issuer".to_string()));
841        assert_eq!(claims.sub, "user123");
842        assert_eq!(claims.aud, Some(Audience::from("client-1")));
843        assert_eq!(claims.extra.get("nonce").unwrap(), "nonce123");
844    }
845    #[test]
846    fn test_token_manager_audience_validation() {
847        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
848        let identity = Identity {
849            provider_id: "mock".to_string(),
850            external_id: "user123".to_string(),
851            email: None,
852            username: None,
853            attributes: HashMap::new(),
854        };
855
856        // Issue token for "client-1"
857        let token = manager
858            .issue_id_token(identity, "client-1", None, 3600)
859            .unwrap();
860
861        // Validate with correct audience
862        let claims = manager.validate_token(&token, Some("client-1")).unwrap();
863        assert_eq!(claims.aud, Some(Audience::from("client-1")));
864
865        // Validate with incorrect audience (should fail)
866        let err = manager
867            .validate_token(&token, Some("client-2"))
868            .unwrap_err();
869        assert!(err.to_string().contains("InvalidAudience"));
870    }
871
872    #[test]
873    fn test_issue_custom_token_sets_typ_and_no_aud() {
874        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
875
876        let mut extra = HashMap::new();
877        extra.insert("cnf".to_string(), serde_json::json!({"jkt": "abc"}));
878
879        let token = manager
880            .issue_custom_token("device-1".to_string(), 60, "webank-attest+jws", extra)
881            .unwrap();
882
883        let header = jsonwebtoken::decode_header(&token).unwrap();
884        assert_eq!(header.typ.as_deref(), Some("webank-attest+jws"));
885
886        let claims = manager.validate_token(&token, None).unwrap();
887        assert_eq!(claims.sub, "device-1");
888        assert_eq!(claims.aud, None);
889        assert_eq!(claims.extra.get("cnf").unwrap()["jkt"], "abc");
890    }
891
892    /// Same jti-override coverage as
893    /// `test_issue_user_token_with_extra_jti_override_no_duplicate_key`,
894    /// exercised on `issue_custom_token`.
895    #[test]
896    fn test_issue_custom_token_jti_override_round_trips() {
897        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
898
899        let mut extra = HashMap::new();
900        extra.insert(
901            "jti".to_string(),
902            serde_json::json!("caller-supplied-cuid2"),
903        );
904
905        let token = manager
906            .issue_custom_token("device-1".to_string(), 60, "webank-attest+jws", extra)
907            .unwrap();
908        let claims = manager
909            .validate_token(&token, None)
910            .expect("TokenManager must be able to decode the token it just issued");
911
912        assert_eq!(claims.jti, Some("caller-supplied-cuid2".to_string()));
913        assert!(!claims.extra.contains_key("jti"));
914    }
915
916    #[test]
917    fn test_issue_user_token_with_extra_round_trips_custom_claims() {
918        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
919        let identity = Identity {
920            provider_id: "mock".to_string(),
921            external_id: "user123".to_string(),
922            email: None,
923            username: None,
924            attributes: HashMap::new(),
925        };
926
927        let mut extra = HashMap::new();
928        extra.insert("api_key_id".to_string(), serde_json::json!("key-abc"));
929        extra.insert("project_id".to_string(), serde_json::json!("proj-42"));
930
931        let token = manager
932            .issue_user_token_with_extra(identity, 3600, None, None, extra)
933            .unwrap();
934        let claims = manager.validate_token(&token, None).unwrap();
935
936        assert_eq!(
937            claims.extra.get("api_key_id"),
938            Some(&serde_json::json!("key-abc"))
939        );
940        assert_eq!(
941            claims.extra.get("project_id"),
942            Some(&serde_json::json!("proj-42"))
943        );
944    }
945
946    #[test]
947    fn test_issue_user_token_wrapper_still_has_empty_extra() {
948        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
949        let identity = Identity {
950            provider_id: "mock".to_string(),
951            external_id: "user123".to_string(),
952            email: None,
953            username: None,
954            attributes: HashMap::new(),
955        };
956
957        let token = manager
958            .issue_user_token(identity, 3600, None, None)
959            .unwrap();
960        let claims = manager.validate_token(&token, None).unwrap();
961
962        assert!(claims.extra.is_empty());
963    }
964
965    /// Regression test for #212: `extra["jti"]` used to be merged onto the
966    /// token alongside the named `Claims::jti` field via
967    /// `#[serde(flatten)]`, producing a wire payload with two `"jti"` keys
968    /// that `TokenManager` itself could not decode (serde hard-errors on a
969    /// literal duplicate field). This asserts both halves of that bug are
970    /// gone: the payload has exactly one `jti` key, and it carries the
971    /// caller's value, not a generated one.
972    #[test]
973    fn test_issue_user_token_with_extra_jti_override_no_duplicate_key() {
974        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
975        let identity = Identity {
976            provider_id: "mock".to_string(),
977            external_id: "user123".to_string(),
978            email: None,
979            username: None,
980            attributes: HashMap::new(),
981        };
982
983        let mut extra = HashMap::new();
984        extra.insert(
985            "jti".to_string(),
986            serde_json::json!("caller-supplied-cuid2"),
987        );
988
989        let token = manager
990            .issue_user_token_with_extra(identity, 3600, None, None, extra)
991            .unwrap();
992
993        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
994        let payload_b64 = token.split('.').nth(1).unwrap();
995        let payload_json = String::from_utf8(URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
996        assert_eq!(
997            payload_json.matches("\"jti\"").count(),
998            1,
999            "wire payload must contain exactly one jti key, got: {payload_json}"
1000        );
1001
1002        let claims = manager
1003            .validate_token(&token, None)
1004            .expect("TokenManager must be able to decode the token it just issued");
1005        assert_eq!(claims.jti, Some("caller-supplied-cuid2".to_string()));
1006    }
1007
1008    /// Proves the default path (no `jti` in `extra`) is unchanged: a
1009    /// generated UUIDv4 is still stamped when the caller doesn't supply one.
1010    #[test]
1011    fn test_issue_user_token_with_extra_default_jti_is_still_generated_uuid() {
1012        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1013        let identity = Identity {
1014            provider_id: "mock".to_string(),
1015            external_id: "user123".to_string(),
1016            email: None,
1017            username: None,
1018            attributes: HashMap::new(),
1019        };
1020
1021        let token = manager
1022            .issue_user_token_with_extra(identity, 3600, None, None, HashMap::new())
1023            .unwrap();
1024        let claims = manager.validate_token(&token, None).unwrap();
1025
1026        let jti = claims.jti.expect("jti must still be generated by default");
1027        assert!(
1028            uuid::Uuid::parse_str(&jti).is_ok(),
1029            "default-path jti must still be a UUID, got: {jti}"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_issue_client_token_with_extra_round_trips_custom_claims() {
1035        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1036
1037        let mut extra = HashMap::new();
1038        extra.insert("roles".to_string(), serde_json::json!(["admin", "billing"]));
1039
1040        let token = manager
1041            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
1042            .unwrap();
1043        let claims = manager.validate_token(&token, None).unwrap();
1044
1045        assert_eq!(
1046            claims.extra.get("roles"),
1047            Some(&serde_json::json!(["admin", "billing"]))
1048        );
1049    }
1050
1051    #[test]
1052    fn test_issue_client_token_wrapper_still_has_empty_extra() {
1053        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1054
1055        let token = manager
1056            .issue_client_token("client-1", 3600, None, None)
1057            .unwrap();
1058        let claims = manager.validate_token(&token, None).unwrap();
1059
1060        assert!(claims.extra.is_empty());
1061    }
1062
1063    /// Same jti-override coverage as
1064    /// `test_issue_user_token_with_extra_jti_override_no_duplicate_key`,
1065    /// exercised on the client (M2M) token path.
1066    #[test]
1067    fn test_issue_client_token_with_extra_jti_override_round_trips() {
1068        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1069
1070        let mut extra = HashMap::new();
1071        extra.insert(
1072            "jti".to_string(),
1073            serde_json::json!("caller-supplied-cuid2"),
1074        );
1075
1076        let token = manager
1077            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
1078            .unwrap();
1079        let claims = manager
1080            .validate_token(&token, None)
1081            .expect("TokenManager must be able to decode the token it just issued");
1082
1083        assert_eq!(claims.jti, Some("caller-supplied-cuid2".to_string()));
1084        assert!(!claims.extra.contains_key("jti"));
1085    }
1086
1087    #[test]
1088    fn test_issue_id_token_with_extra_round_trips_custom_claims() {
1089        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1090        let identity = Identity {
1091            provider_id: "mock".to_string(),
1092            external_id: "user123".to_string(),
1093            email: None,
1094            username: None,
1095            attributes: HashMap::new(),
1096        };
1097
1098        let mut extra = HashMap::new();
1099        extra.insert("org_id".to_string(), serde_json::json!("org-7"));
1100
1101        let token = manager
1102            .issue_id_token_with_extra(
1103                identity,
1104                "client-1",
1105                Some("nonce123".to_string()),
1106                3600,
1107                extra,
1108            )
1109            .unwrap();
1110        let claims = manager.validate_token(&token, None).unwrap();
1111
1112        assert_eq!(
1113            claims.extra.get("org_id"),
1114            Some(&serde_json::json!("org-7"))
1115        );
1116        assert_eq!(
1117            claims.extra.get("nonce"),
1118            Some(&serde_json::json!("nonce123"))
1119        );
1120    }
1121
1122    #[test]
1123    fn test_issue_id_token_with_extra_explicit_nonce_wins_over_extra_nonce() {
1124        // Documents the precedence chosen in `issue_id_token_with_extra`:
1125        // the explicit `nonce` parameter always overrides a `"nonce"` entry
1126        // supplied via `extra`.
1127        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1128        let identity = Identity {
1129            provider_id: "mock".to_string(),
1130            external_id: "user123".to_string(),
1131            email: None,
1132            username: None,
1133            attributes: HashMap::new(),
1134        };
1135
1136        let mut extra = HashMap::new();
1137        extra.insert(
1138            "nonce".to_string(),
1139            serde_json::json!("attacker-supplied-nonce"),
1140        );
1141
1142        let token = manager
1143            .issue_id_token_with_extra(
1144                identity,
1145                "client-1",
1146                Some("real-nonce".to_string()),
1147                3600,
1148                extra,
1149            )
1150            .unwrap();
1151        let claims = manager.validate_token(&token, None).unwrap();
1152
1153        assert_eq!(
1154            claims.extra.get("nonce"),
1155            Some(&serde_json::json!("real-nonce"))
1156        );
1157    }
1158
1159    /// Same jti-override coverage as
1160    /// `test_issue_user_token_with_extra_jti_override_no_duplicate_key`,
1161    /// exercised on the id-token path.
1162    #[test]
1163    fn test_issue_id_token_with_extra_jti_override_round_trips() {
1164        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1165        let identity = Identity {
1166            provider_id: "mock".to_string(),
1167            external_id: "user123".to_string(),
1168            email: None,
1169            username: None,
1170            attributes: HashMap::new(),
1171        };
1172
1173        let mut extra = HashMap::new();
1174        extra.insert(
1175            "jti".to_string(),
1176            serde_json::json!("caller-supplied-cuid2"),
1177        );
1178
1179        let token = manager
1180            .issue_id_token_with_extra(identity, "client-1", None, 3600, extra)
1181            .unwrap();
1182        let claims = manager
1183            .validate_token(&token, None)
1184            .expect("TokenManager must be able to decode the token it just issued");
1185
1186        assert_eq!(claims.jti, Some("caller-supplied-cuid2".to_string()));
1187        assert!(!claims.extra.contains_key("jti"));
1188    }
1189
1190    #[test]
1191    fn test_token_manager_ed25519_issuance() {
1192        let manager = TokenManager::new_ed25519(
1193            TEST_ED25519_PRIVATE_KEY_PEM,
1194            Some("issuer".to_string()),
1195            Some("my-ed25519-kid".to_string()),
1196        )
1197        .unwrap();
1198
1199        let identity = Identity {
1200            provider_id: "mock".to_string(),
1201            external_id: "user123".to_string(),
1202            email: None,
1203            username: None,
1204            attributes: HashMap::new(),
1205        };
1206
1207        let token = manager
1208            .issue_user_token(identity, 3600, None, None)
1209            .unwrap();
1210
1211        let header = jsonwebtoken::decode_header(&token).unwrap();
1212        assert_eq!(header.alg, Algorithm::EdDSA);
1213        assert_eq!(header.kid.as_deref(), Some("my-ed25519-kid"));
1214    }
1215
1216    /// Proves #187 and #188 actually compose end-to-end: mint a token with
1217    /// the Ed25519 constructor, then verify it using ONLY the decoding key
1218    /// derived from the published JWK (the OKP shape `/jwks.json` would
1219    /// serve) — not the manager's own internal decoding key. This is the
1220    /// round trip a real resource server performs against a real JWKS
1221    /// endpoint.
1222    #[test]
1223    fn test_ed25519_token_round_trips_via_published_jwk() {
1224        let manager = TokenManager::new_ed25519(
1225            TEST_ED25519_PRIVATE_KEY_PEM,
1226            Some("issuer".to_string()),
1227            Some("my-ed25519-kid".to_string()),
1228        )
1229        .unwrap();
1230
1231        let identity = Identity {
1232            provider_id: "mock".to_string(),
1233            external_id: "user123".to_string(),
1234            email: None,
1235            username: None,
1236            attributes: HashMap::new(),
1237        };
1238
1239        let token = manager
1240            .issue_user_token(identity, 3600, None, None)
1241            .unwrap();
1242
1243        let jwk = manager.public_jwk().unwrap();
1244        assert_eq!(jwk.kid.as_deref(), Some("my-ed25519-kid"));
1245
1246        let decoding_key = jwk.to_decoding_key().unwrap();
1247        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
1248        validation.set_issuer(&["issuer"]);
1249
1250        let token_data =
1251            jsonwebtoken::decode::<Claims>(&token, &decoding_key, &validation).unwrap();
1252        assert_eq!(token_data.claims.sub, "user123");
1253        assert_eq!(token_data.header.kid.as_deref(), Some("my-ed25519-kid"));
1254    }
1255
1256    /// Same shape as `test_asymmetric_manager_validates_its_own_tokens`
1257    /// (RS256): goes through `validate_token`, the path `/reissue` and
1258    /// `/userinfo` actually take, rather than the published-JWK path above.
1259    #[test]
1260    fn test_ed25519_manager_validates_its_own_tokens() {
1261        let manager = TokenManager::new_ed25519(
1262            TEST_ED25519_PRIVATE_KEY_PEM,
1263            Some("issuer".to_string()),
1264            Some("my-ed25519-kid".to_string()),
1265        )
1266        .unwrap();
1267
1268        let identity = Identity {
1269            provider_id: "mock".to_string(),
1270            external_id: "user123".to_string(),
1271            email: None,
1272            username: None,
1273            attributes: HashMap::new(),
1274        };
1275
1276        let token = manager
1277            .issue_user_token(identity, 3600, None, None)
1278            .unwrap();
1279
1280        let claims = manager.validate_token(&token, None).unwrap();
1281        assert_eq!(claims.sub, "user123");
1282        assert_eq!(claims.iss, Some("issuer".to_string()));
1283    }
1284
1285    /// `public_jwk()` for an Ed25519 manager must emit spec-correct OKP JSON
1286    /// (RFC 8037 §2): `kty: "OKP"`, `crv: "Ed25519"`, `x` present and
1287    /// base64url-encoded to exactly 32 bytes, and no stray RSA fields (`n`,
1288    /// `e`) on the wire.
1289    #[test]
1290    fn test_ed25519_public_jwk_is_spec_correct_okp_json() {
1291        let manager = TokenManager::new_ed25519(
1292            TEST_ED25519_PRIVATE_KEY_PEM,
1293            Some("issuer".to_string()),
1294            Some("my-ed25519-kid".to_string()),
1295        )
1296        .unwrap();
1297
1298        let jwk = manager.public_jwk().unwrap();
1299        assert_eq!(jwk.kty, "OKP");
1300        assert_eq!(jwk.alg.as_deref(), Some("EdDSA"));
1301        assert_eq!(jwk.crv.as_deref(), Some("Ed25519"));
1302        assert!(jwk.n.is_none());
1303        assert!(jwk.e.is_none());
1304
1305        let x = jwk.x.as_ref().expect("OKP JWK must have 'x'");
1306        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
1307        let decoded = URL_SAFE_NO_PAD
1308            .decode(x)
1309            .expect("'x' must be valid base64url (unpadded)");
1310        assert_eq!(decoded.len(), 32, "Ed25519 public key must be 32 bytes");
1311
1312        let value = serde_json::to_value(&jwk).unwrap();
1313        let obj = value.as_object().unwrap();
1314        assert_eq!(obj.get("kty").unwrap(), "OKP");
1315        assert_eq!(obj.get("crv").unwrap(), "Ed25519");
1316        assert_eq!(obj.get("alg").unwrap(), "EdDSA");
1317        assert!(obj.contains_key("x"));
1318        assert!(
1319            !obj.contains_key("n"),
1320            "OKP JWK must not serialize the RSA 'n' field, got: {}",
1321            value
1322        );
1323        assert!(
1324            !obj.contains_key("e"),
1325            "OKP JWK must not serialize the RSA 'e' field, got: {}",
1326            value
1327        );
1328    }
1329
1330    /// Same spec-correctness check for the RSA shape, unchanged by the OKP
1331    /// addition: no stray `crv`/`x` fields on the wire.
1332    #[test]
1333    fn test_rsa_public_jwk_still_omits_okp_fields() {
1334        let manager = TokenManager::new_asymmetric(
1335            TEST_RSA_PRIVATE_KEY_PEM,
1336            Some("issuer".to_string()),
1337            Some("my-kid-123".to_string()),
1338        )
1339        .unwrap();
1340
1341        let jwk = manager.public_jwk().unwrap();
1342        let value = serde_json::to_value(&jwk).unwrap();
1343        let obj = value.as_object().unwrap();
1344        assert_eq!(obj.get("kty").unwrap(), "RSA");
1345        assert!(obj.contains_key("n"));
1346        assert!(obj.contains_key("e"));
1347        assert!(
1348            !obj.contains_key("crv"),
1349            "RSA JWK must not serialize the OKP 'crv' field, got: {}",
1350            value
1351        );
1352        assert!(
1353            !obj.contains_key("x"),
1354            "RSA JWK must not serialize the OKP 'x' field, got: {}",
1355            value
1356        );
1357    }
1358
1359    /// Wrong-key rejection: a token signed by one Ed25519 manager must be
1360    /// rejected when verified against a *different* Ed25519 manager's
1361    /// published JWK — proving the JWKS round trip actually checks the
1362    /// signature rather than trivially accepting any well-formed EdDSA JWT.
1363    #[test]
1364    fn test_ed25519_token_rejected_by_wrong_key_jwk() {
1365        let signer = TokenManager::new_ed25519(
1366            TEST_ED25519_PRIVATE_KEY_PEM,
1367            Some("issuer".to_string()),
1368            Some("kid-a".to_string()),
1369        )
1370        .unwrap();
1371        let other = TokenManager::new_ed25519(
1372            TEST_ED25519_PRIVATE_KEY_PEM_B,
1373            Some("issuer".to_string()),
1374            Some("kid-b".to_string()),
1375        )
1376        .unwrap();
1377
1378        let identity = Identity {
1379            provider_id: "mock".to_string(),
1380            external_id: "user123".to_string(),
1381            email: None,
1382            username: None,
1383            attributes: HashMap::new(),
1384        };
1385
1386        let token = signer.issue_user_token(identity, 3600, None, None).unwrap();
1387
1388        // Verifying with the signer's own manager still works.
1389        assert!(signer.validate_token(&token, None).is_ok());
1390
1391        // Verifying with a different key's manager must fail.
1392        let err = other.validate_token(&token, None).unwrap_err();
1393        assert!(err.to_string().contains("InvalidSignature"));
1394
1395        // Same result going through the published-JWK path a real resource
1396        // server would use.
1397        let wrong_jwk = other.public_jwk().unwrap();
1398        let decoding_key = wrong_jwk.to_decoding_key().unwrap();
1399        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
1400        validation.set_issuer(&["issuer"]);
1401        let err = jsonwebtoken::decode::<Claims>(&token, &decoding_key, &validation).unwrap_err();
1402        assert_eq!(
1403            err.kind(),
1404            &jsonwebtoken::errors::ErrorKind::InvalidSignature
1405        );
1406    }
1407
1408    /// A tampered payload (claims byte flipped after signing, signature
1409    /// left as-is) must be rejected, whichever algorithm signed it —
1410    /// guards against a validator that only checks structural shape.
1411    #[test]
1412    fn test_ed25519_tampered_token_rejected() {
1413        let manager = TokenManager::new_ed25519(
1414            TEST_ED25519_PRIVATE_KEY_PEM,
1415            Some("issuer".to_string()),
1416            Some("my-ed25519-kid".to_string()),
1417        )
1418        .unwrap();
1419
1420        let identity = Identity {
1421            provider_id: "mock".to_string(),
1422            external_id: "user123".to_string(),
1423            email: None,
1424            username: None,
1425            attributes: HashMap::new(),
1426        };
1427
1428        let token = manager
1429            .issue_user_token(identity, 3600, None, None)
1430            .unwrap();
1431
1432        let mut parts: Vec<&str> = token.split('.').collect();
1433        assert_eq!(parts.len(), 3);
1434        // Corrupt a single character in the base64url payload segment.
1435        let mut payload = parts[1].to_string();
1436        let last = payload.pop().unwrap();
1437        let replacement = if last == 'A' { 'B' } else { 'A' };
1438        payload.push(replacement);
1439        parts[1] = &payload;
1440        let tampered = parts.join(".");
1441
1442        let err = manager.validate_token(&tampered, None).unwrap_err();
1443        assert!(
1444            err.to_string().contains("InvalidSignature") || err.to_string().contains("Json"),
1445            "unexpected error for tampered token: {err}"
1446        );
1447    }
1448
1449    /// #206 repro: a stock Keycloak realm with two `oidc-audience-mapper`
1450    /// entries mints `"aud"` as a JSON array (RFC 7519 §4.1.3 allows this).
1451    /// `validate_token` calls `jsonwebtoken::decode::<Claims>`, which
1452    /// deserializes the payload into `Claims` before any of
1453    /// `jsonwebtoken`'s own validation runs — so this fails at
1454    /// deserialization, not at a validation check, and `expected_aud: None`
1455    /// (which turns `Validation::validate_aud` off) makes no difference.
1456    #[test]
1457    fn test_validate_token_accepts_array_aud() {
1458        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1459
1460        let raw_claims = serde_json::json!({
1461            "iss": "issuer",
1462            "sub": "user123",
1463            "aud": ["client-1", "client-2"],
1464            "exp": (chrono::Utc::now().timestamp() as usize) + 3600,
1465            "iat": chrono::Utc::now().timestamp() as usize,
1466        });
1467        let token = jsonwebtoken::encode(
1468            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
1469            &raw_claims,
1470            &jsonwebtoken::EncodingKey::from_secret(b"secret"),
1471        )
1472        .unwrap();
1473
1474        let claims = manager
1475            .validate_token(&token, None)
1476            .expect("multi-audience subject token must deserialize");
1477        let aud = claims.aud.expect("aud claim must be present");
1478        assert!(aud.contains("client-1"));
1479        assert!(aud.contains("client-2"));
1480        assert!(!aud.contains("client-3"));
1481    }
1482
1483    /// Companion to `test_validate_token_accepts_array_aud`: a subject token
1484    /// with a plain string `aud` (the shape every token this crate has ever
1485    /// issued) must keep working exactly as before the `Audience` enum was
1486    /// introduced.
1487    #[test]
1488    fn test_validate_token_still_accepts_string_aud() {
1489        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1490        let identity = Identity {
1491            provider_id: "mock".to_string(),
1492            external_id: "user123".to_string(),
1493            email: None,
1494            username: None,
1495            attributes: HashMap::new(),
1496        };
1497
1498        let token = manager
1499            .issue_id_token(identity, "client-1", None, 3600)
1500            .unwrap();
1501
1502        let claims = manager.validate_token(&token, None).unwrap();
1503        let aud = claims.aud.expect("aud claim must be present");
1504        assert!(aud.contains("client-1"));
1505        assert!(!aud.contains("client-2"));
1506    }
1507
1508    /// Serialization symmetry guarantee: a single audience must still
1509    /// serialize as a bare JSON string, not a one-element array, so every
1510    /// token this crate has ever issued (and any consumer relying on that
1511    /// shape) is unaffected by `Audience` gaining array support.
1512    #[test]
1513    fn test_audience_single_round_trips_as_bare_string() {
1514        let single = Audience::Single("client-1".to_string());
1515        let serialized = serde_json::to_string(&single).unwrap();
1516        assert_eq!(serialized, "\"client-1\"");
1517
1518        let deserialized: Audience = serde_json::from_str(&serialized).unwrap();
1519        assert_eq!(deserialized, single);
1520
1521        let multiple = Audience::Multiple(vec!["client-1".to_string(), "client-2".to_string()]);
1522        let serialized_multi = serde_json::to_string(&multiple).unwrap();
1523        assert_eq!(serialized_multi, "[\"client-1\",\"client-2\"]");
1524
1525        let deserialized_multi: Audience = serde_json::from_str(&serialized_multi).unwrap();
1526        assert_eq!(deserialized_multi, multiple);
1527    }
1528
1529    fn test_identity() -> Identity {
1530        Identity {
1531            provider_id: "mock".to_string(),
1532            external_id: "user123".to_string(),
1533            email: None,
1534            username: None,
1535            attributes: HashMap::new(),
1536        }
1537    }
1538
1539    /// The exact reproduction from #283, verbatim: a caller flattens its
1540    /// own typed claims struct into `extra`, three of whose keys collide
1541    /// with named `Claims` fields. Before the fix this returned `Ok` and
1542    /// minted a payload with `"iss"`, `"sub"` and `"aud"` written twice,
1543    /// which `validate_token` then refused to decode — an issuer-side
1544    /// mistake whose only symptom appeared in a different process. It must
1545    /// now fail at issuance, naming every colliding key.
1546    #[test]
1547    fn test_issue_custom_token_rejects_extra_colliding_with_named_claims() {
1548        let manager =
1549            TokenManager::new(b"probe-secret", Some("https://issuer.example".to_string()));
1550
1551        let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
1552        extra.insert("iss".into(), serde_json::json!("https://caller.example"));
1553        extra.insert("sub".into(), serde_json::json!("caller-subject"));
1554        extra.insert("aud".into(), serde_json::json!("caller-audience"));
1555
1556        let err = manager
1557            .issue_custom_token("engine-subject".to_string(), 3600, "JWT", extra)
1558            .expect_err("extra colliding with named claims must not mint a token");
1559
1560        let msg = err.to_string();
1561        for key in ["iss", "sub", "aud"] {
1562            assert!(
1563                msg.contains(key),
1564                "error must name every colliding key; `{key}` missing from: {msg}"
1565            );
1566        }
1567    }
1568
1569    /// Every name in `NAMED_CLAIM_FIELDS` must be rejected, on every
1570    /// `extra`-taking issuance path. This is the assertion that keeps #283
1571    /// from regressing one field or one constructor at a time: adding a
1572    /// named field to `Claims` without adding it to `NAMED_CLAIM_FIELDS`
1573    /// leaves this test passing but the new field unguarded, which is why
1574    /// the companion test below pins the list against `Claims` itself.
1575    #[test]
1576    fn test_every_named_claim_field_is_rejected_on_every_issuance_path() {
1577        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1578
1579        for field in NAMED_CLAIM_FIELDS {
1580            let one = |value: serde_json::Value| {
1581                let mut extra = HashMap::new();
1582                extra.insert((*field).to_string(), value);
1583                extra
1584            };
1585            let probe = serde_json::json!("collides");
1586
1587            assert!(
1588                manager
1589                    .issue_user_token_with_extra(
1590                        test_identity(),
1591                        3600,
1592                        None,
1593                        None,
1594                        one(probe.clone())
1595                    )
1596                    .is_err(),
1597                "issue_user_token_with_extra must reject extra[{field}]"
1598            );
1599            assert!(
1600                manager
1601                    .issue_id_token_with_extra(
1602                        test_identity(),
1603                        "client-1",
1604                        None,
1605                        3600,
1606                        one(probe.clone())
1607                    )
1608                    .is_err(),
1609                "issue_id_token_with_extra must reject extra[{field}]"
1610            );
1611            assert!(
1612                manager
1613                    .issue_client_token_with_extra("client-1", 3600, None, None, one(probe.clone()))
1614                    .is_err(),
1615                "issue_client_token_with_extra must reject extra[{field}]"
1616            );
1617            assert!(
1618                manager
1619                    .issue_custom_token("sub".to_string(), 3600, "JWT", one(probe.clone()))
1620                    .is_err(),
1621                "issue_custom_token must reject extra[{field}]"
1622            );
1623            assert!(
1624                manager
1625                    .issue_sd_jwt(
1626                        "sub".to_string(),
1627                        3600,
1628                        None,
1629                        None,
1630                        Vec::new(),
1631                        one(probe.clone())
1632                    )
1633                    .is_err(),
1634                "issue_sd_jwt must reject extra[{field}]"
1635            );
1636        }
1637    }
1638
1639    /// Pins `NAMED_CLAIM_FIELDS` against what `Claims` actually serializes,
1640    /// rather than against a second hand-written list that would drift the
1641    /// same way the original one did. Serializing a `Claims` with an empty
1642    /// `extra` yields exactly the named keys; every one of them except
1643    /// `jti` (removed by `take_jti`, so a supported override rather than a
1644    /// collision) must be covered. A `Claims` field added later without a
1645    /// `NAMED_CLAIM_FIELDS` entry fails here.
1646    #[test]
1647    fn test_named_claim_fields_covers_every_serialized_claims_key() {
1648        let claims = Claims {
1649            iss: Some("issuer".to_string()),
1650            sub: "sub".to_string(),
1651            aud: Some(Audience::from("client-1")),
1652            exp: 0,
1653            iat: 0,
1654            nbf: Some(0),
1655            jti: Some("jti".to_string()),
1656            scope: Some("openid".to_string()),
1657            // `Some`, so the `skip_serializing_if` field is present in the
1658            // serialized form and therefore actually checked below.
1659            identity: Some(test_identity()),
1660            extra: HashMap::new(),
1661        };
1662
1663        let serialized = serde_json::to_value(&claims).unwrap();
1664        let keys: Vec<&str> = serialized
1665            .as_object()
1666            .expect("Claims serializes as a JSON object")
1667            .keys()
1668            .map(String::as_str)
1669            .collect();
1670
1671        for key in keys {
1672            assert!(
1673                key == "jti" || NAMED_CLAIM_FIELDS.contains(&key),
1674                "`Claims` serializes `{key}` but NAMED_CLAIM_FIELDS does not list it, so \
1675                 `extra[\"{key}\"]` would silently produce a duplicate key again (#283)"
1676            );
1677        }
1678    }
1679
1680    /// The fix must not narrow what `extra` is actually for. Non-colliding
1681    /// custom claims still round-trip, and `jti` is still the one
1682    /// documented override rather than being caught by the new check.
1683    #[test]
1684    fn test_non_colliding_extra_and_jti_override_still_work() {
1685        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1686
1687        let mut extra = HashMap::new();
1688        extra.insert("api_key_id".to_string(), serde_json::json!("key-abc"));
1689        extra.insert("cnf".to_string(), serde_json::json!({"jkt": "abc"}));
1690        extra.insert(
1691            "jti".to_string(),
1692            serde_json::json!("caller-supplied-cuid2"),
1693        );
1694
1695        let token = manager
1696            .issue_user_token_with_extra(test_identity(), 3600, None, None, extra)
1697            .expect("non-colliding extra plus a jti override must still issue");
1698
1699        let claims = manager
1700            .validate_token(&token, None)
1701            .expect("TokenManager must be able to decode the token it just issued");
1702
1703        assert_eq!(claims.jti, Some("caller-supplied-cuid2".to_string()));
1704        assert_eq!(
1705            claims.extra.get("api_key_id"),
1706            Some(&serde_json::json!("key-abc"))
1707        );
1708        assert_eq!(claims.extra.get("cnf").unwrap()["jkt"], "abc");
1709        assert!(!claims.extra.contains_key("jti"));
1710    }
1711
1712    /// `nonce` is applied to `extra` by `issue_id_token_with_extra` after
1713    /// the collision check, and is not a named `Claims` field. Pins that
1714    /// the new check did not accidentally make the OIDC `nonce` path
1715    /// unreachable.
1716    #[test]
1717    fn test_id_token_nonce_still_stamped_after_collision_check() {
1718        let manager = TokenManager::new(b"secret", Some("issuer".to_string()));
1719
1720        let token = manager
1721            .issue_id_token_with_extra(
1722                test_identity(),
1723                "client-1",
1724                Some("nonce123".to_string()),
1725                3600,
1726                HashMap::new(),
1727            )
1728            .expect("id token issuance must be unaffected");
1729
1730        let claims = manager.validate_token(&token, Some("client-1")).unwrap();
1731        assert_eq!(claims.extra.get("nonce").unwrap(), "nonce123");
1732    }
1733}
1734pub mod jwk;