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