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