Skip to main content

authkestra_engine/token/
sd_jwt.rs

1//! SD-JWT (Selective Disclosure for JWTs) issuance and verification, per
2//! `draft-ietf-oauth-selective-disclosure-jwt`.
3//!
4//! An SD-JWT lets an issuer mint a single signed token that carries some
5//! claims in the clear and others only as *digests* (`_sd[]`), plus a
6//! separate list of *Disclosures* — `[salt, claim_name, claim_value]`
7//! triples — that reveal what each digest stands for. A holder decides,
8//! per presentation, which Disclosures to forward alongside the JWT; a
9//! verifier can only recover the claims for the Disclosures it was handed,
10//! and can cryptographically prove every disclosed value was actually
11//! vouched for by the issuer (its digest is in `_sd[]`, which is inside
12//! the signed payload) without the issuer needing to mint one token per
13//! disclosure combination.
14//!
15//! # What this module does and does not implement
16//!
17//! In scope: issuing and verifying **flat, top-level, object-property**
18//! Disclosures, serialized in SD-JWT compact form (`<jwt>~<d1>~<d2>~`).
19//!
20//! Deliberately out of scope (spec features this module does not touch):
21//! - **Key Binding JWT (KB-JWT)** — holder proof-of-possession. This module
22//!   verifies the issuer's signature and the Disclosure digests only; it
23//!   has no notion of a holder key or a `~<kb-jwt>` suffix.
24//! - **Array-element and recursive/nested Disclosures** — only flat
25//!   top-level object properties are supported, matching every consumer
26//!   this crate has today.
27//! - **SD-JWT VC** (`vc+sd-jwt`) — no `vct`/type metadata handling.
28//!
29//! None of these are hard to misuse into thinking they're covered — there
30//! is simply no code path for them. A caller needing KB-JWT or nested
31//! disclosures needs to build that on top, not assume it's already here.
32//!
33//! # Security properties this module enforces (and why)
34//!
35//! - **`_sd_alg` is never silently defaulted to `sha-256` when present and
36//!   unrecognized.** A verifier that treats an unknown digest algorithm as
37//!   "must mean sha-256" is an algorithm-confusion bug: an attacker who
38//!   controls (or can influence) the claimed `_sd_alg` could otherwise
39//!   coax a verifier into hashing Disclosures with a weaker/attacker-
40//!   favorable function while the verifier's logic still believes it's
41//!   checking sha-256 digests. This module fails closed instead: an
42//!   absent `_sd_alg` defaults to sha-256 (per spec, the assumed default),
43//!   but a *present-and-different* value is rejected outright.
44//! - **A presented Disclosure whose digest is not found in `_sd[]` fails
45//!   the whole verification**, not just that one claim. Accepting it would
46//!   let a holder (or a network attacker who can append to the compact
47//!   form) inject arbitrary claims the issuer never signed for — the
48//!   entire point of `_sd[]` living inside the signed JWT payload is that
49//!   only digests the issuer actually put there are trustworthy.
50//! - **Duplicate digests in `_sd[]` are rejected.** They serve no
51//!   legitimate purpose (each Disclosure is independently salted, so two
52//!   honestly-generated Disclosures never collide) and are a cheap way to
53//!   smuggle a second, attacker-chosen Disclosure past the "digest found"
54//!   check above once one legitimate Disclosure's digest becomes known.
55//! - **A disclosed claim can never shadow a registered top-level JWT claim
56//!   (`iss`, `sub`, `aud`, `exp`, `iat`, `nbf`, `jti`, `scope`) or an
57//!   already-present `extra` claim.** Selective disclosure is additive by
58//!   design; letting a Disclosure silently overwrite `aud` or `exp` would
59//!   let a holder forge the very claims the issuer's signature is supposed
60//!   to pin down.
61
62use super::{Claims, TokenManager};
63use crate::auth::error::AuthError;
64use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
65use jsonwebtoken::Header;
66use rand::RngCore;
67use serde_json::Value;
68use sha2::{Digest, Sha256};
69use std::collections::{HashMap, HashSet};
70
71/// The only digest algorithm this module issues or accepts. Per
72/// `draft-ietf-oauth-selective-disclosure-jwt`, `_sd_alg` is OPTIONAL and
73/// `sha-256` is the assumed default when it's absent — but see the module
74/// docs above for why a *present* value that isn't this one is rejected,
75/// never coerced into this one.
76const SD_ALG_SHA256: &str = "sha-256";
77
78/// The SD-JWT mechanism's own bookkeeping keys, which a Disclosure is
79/// never allowed to introduce: they are what the verifier checks
80/// Disclosures *against*, so a Disclosure that could rewrite them would be
81/// grading its own homework.
82const SD_JWT_BOOKKEEPING_CLAIM_NAMES: &[&str] = &["_sd", "_sd_alg"];
83
84/// True for a claim name a Disclosure is never allowed to introduce: a
85/// registered top-level [`Claims`] field (forging one would let a holder
86/// rewrite the token's own identity/validity claims), or an SD-JWT
87/// bookkeeping key.
88///
89/// The registered-field half is read from [`super::NAMED_CLAIM_FIELDS`]
90/// rather than re-listed here. A second hand-maintained copy is exactly
91/// the drift #283 is about — a `Claims` field added in a minor release
92/// would otherwise have to be remembered in two places, and the one that
93/// got forgotten would fail silently.
94fn is_reserved_claim_name(name: &str) -> bool {
95    super::NAMED_CLAIM_FIELDS.contains(&name)
96        // `jti` is absent from NAMED_CLAIM_FIELDS because `take_jti` makes
97        // it a supported *issuance* override. That does not make it
98        // disclosable: nothing removes it on the verify side, so a
99        // Disclosure naming it would shadow the signed `jti`.
100        || name == "jti"
101        || SD_JWT_BOOKKEEPING_CLAIM_NAMES.contains(&name)
102}
103
104/// A claim an issuer wants to make selectively disclosable, instead of
105/// stamping it directly onto the JWT payload.
106///
107/// Handed to [`TokenManager::issue_sd_jwt`] in a batch; each one becomes
108/// one Disclosure (with its own fresh salt — see
109/// [`generate_disclosure_salt`]) and one digest in the issued token's
110/// `_sd[]`.
111#[derive(Debug, Clone, PartialEq, Eq)]
112#[non_exhaustive]
113pub struct DisclosableClaim {
114    /// The claim name, e.g. `"email"`. Must not collide with a reserved
115    /// name (see [`is_reserved_claim_name`]) — [`TokenManager::issue_sd_jwt`]
116    /// does not currently validate this at issuance time (that check is
117    /// enforced on the verify side, where it actually matters for
118    /// security); an issuer accidentally naming a Disclosure `"aud"`
119    /// simply produces a Disclosure no verifier using this module will
120    /// ever accept.
121    pub name: String,
122    /// The claim value. Any JSON value is accepted (object, array,
123    /// string, number, bool, null) — this module does not interpret it.
124    pub value: Value,
125}
126
127impl DisclosableClaim {
128    /// Convenience constructor so callers don't have to name the struct
129    /// fields at every call site.
130    ///
131    /// # Examples
132    ///
133    /// ```rust
134    /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
135    /// let claim = DisclosableClaim::new("email", "user@example.com");
136    /// assert_eq!(claim.name, "email");
137    /// ```
138    pub fn new(name: impl Into<String>, value: impl Into<Value>) -> Self {
139        Self {
140            name: name.into(),
141            value: value.into(),
142        }
143    }
144}
145
146/// The result of issuing an SD-JWT: the signed JWT, the SD-JWT compact
147/// serialization ready to hand to a holder, and the raw Disclosure strings
148/// (in case the caller wants to persist or selectively re-forward a subset
149/// later, e.g. to build a holder-controlled presentation).
150#[derive(Debug, Clone, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct IssuedSdJwt {
153    /// The Issuer-signed JWT alone — three dot-separated segments, no `~`.
154    /// Useful for callers that want to store the JWT and Disclosures
155    /// separately rather than as one compact string.
156    pub jwt: String,
157    /// SD-JWT compact serialization: `<jwt>~<disclosure_1>~...~<disclosure_n>~`
158    /// (a trailing `~` and no Key Binding JWT segment, since KB-JWT is out
159    /// of scope for this module — see the module docs). If
160    /// `disclosable_claims` was empty, this equals `jwt` with no `~`
161    /// appended at all, matching a plain (non-SD) JWT.
162    pub compact: String,
163    /// The base64url-encoded Disclosure strings, in the same order as the
164    /// `disclosable_claims` they were built from.
165    pub disclosures: Vec<String>,
166}
167
168/// The result of verifying a presented SD-JWT compact form: the validated
169/// JWT claims (signature, `iss`/`aud`/`exp` already checked by
170/// [`TokenManager::validate_token`]) plus whatever claims the presented
171/// Disclosures actually proved out.
172///
173/// `disclosed_claims` only contains claims from Disclosures that were
174/// *both* presented *and* verified against `_sd[]` — a claim whose digest
175/// the issuer never signed for cannot appear here (see
176/// [`TokenManager::validate_sd_jwt`]'s rejection rules).
177#[derive(Debug, Clone)]
178#[non_exhaustive]
179pub struct VerifiedSdJwt {
180    /// The underlying JWT claims, already validated (signature, issuer,
181    /// audience, expiry) by [`TokenManager::validate_token`].
182    pub claims: Claims,
183    /// Claim name -> value, recovered from the presented Disclosures that
184    /// verified successfully.
185    pub disclosed_claims: HashMap<String, Value>,
186}
187
188/// Generates a fresh, cryptographically random salt for one Disclosure.
189///
190/// Per `draft-ietf-oauth-selective-disclosure-jwt` §5.2.1, each Disclosure
191/// needs its own salt with "sufficient entropy" — the spec's own examples
192/// use 128 bits. This uses the workspace's existing CSPRNG (`rand`, the
193/// same `rand::rng()` source already used for OAuth `state`/`nonce` and
194/// AES-GCM nonces elsewhere in this crate — see `auth::state::OAuth2State`)
195/// rather than pulling in a dedicated RNG dependency. Reusing a salt across
196/// Disclosures — e.g. deriving it from the claim name/value instead of
197/// generating it fresh — would let two verifiers who both learn the same
198/// claim name/value pair recognize they're looking at the same subject
199/// even without ever seeing the digest, defeating the unlinkability this
200/// mechanism exists to provide.
201fn generate_disclosure_salt() -> String {
202    let mut salt_bytes = [0u8; 16]; // 128 bits, matching the spec's own examples.
203    rand::rng().fill_bytes(&mut salt_bytes);
204    URL_SAFE_NO_PAD.encode(salt_bytes)
205}
206
207/// Base64url (no padding) of the SHA-256 digest of an encoded Disclosure
208/// string — the value that goes into `_sd[]`, per §5.2.1.
209fn disclosure_digest(encoded_disclosure: &str) -> String {
210    URL_SAFE_NO_PAD.encode(Sha256::digest(encoded_disclosure.as_bytes()))
211}
212
213/// Builds one Disclosure — `base64url(json([salt, name, value]))` — and its
214/// digest, from a [`DisclosableClaim`].
215fn encode_disclosure(claim: &DisclosableClaim) -> Result<(String, String), AuthError> {
216    let salt = generate_disclosure_salt();
217    let triple = serde_json::json!([salt, claim.name, claim.value]);
218    let bytes = serde_json::to_vec(&triple)
219        .map_err(|e| AuthError::Token(format!("failed to encode SD-JWT disclosure: {e}")))?;
220    let encoded = URL_SAFE_NO_PAD.encode(bytes);
221    let digest = disclosure_digest(&encoded);
222    Ok((encoded, digest))
223}
224
225/// Splits an SD-JWT compact form into its JWT segment and its Disclosure
226/// strings. Tolerates a plain (non-SD) JWT with no `~` at all — the whole
227/// input is then returned as the JWT with an empty Disclosure list — and a
228/// trailing `~` with nothing after it (an empty final segment from
229/// `split('~')`, filtered out).
230fn split_sd_jwt(compact: &str) -> (&str, Vec<String>) {
231    let mut parts = compact.split('~');
232    let jwt = parts.next().unwrap_or(compact);
233    let disclosures = parts
234        .filter(|segment| !segment.is_empty())
235        .map(str::to_owned)
236        .collect();
237    (jwt, disclosures)
238}
239
240/// Decodes one Disclosure string into its `(claim_name, claim_value)` pair,
241/// without checking it against any `_sd[]` digest set — that check is the
242/// caller's job (see [`verify_disclosures`]). Rejects anything that isn't
243/// valid base64url JSON, or whose decoded array isn't exactly the
244/// `[salt, name, value]` triple the spec requires (the salt itself is
245/// discarded here; its only job was to make the digest unguessable).
246fn decode_disclosure(encoded: &str) -> Result<(String, Value), AuthError> {
247    let bytes = URL_SAFE_NO_PAD
248        .decode(encoded)
249        .map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure encoding: {e}")))?;
250    let triple: Vec<Value> = serde_json::from_slice(&bytes)
251        .map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure JSON: {e}")))?;
252    if triple.len() != 3 {
253        return Err(AuthError::Token(
254            "SD-JWT disclosure must be a [salt, claim_name, claim_value] triple".to_string(),
255        ));
256    }
257    let mut fields = triple.into_iter();
258    let _salt = fields.next();
259    let name = fields
260        .next()
261        .and_then(|v| v.as_str().map(str::to_owned))
262        .ok_or_else(|| {
263            AuthError::Token("SD-JWT disclosure claim name must be a JSON string".to_string())
264        })?;
265    let value = fields.next().unwrap_or(Value::Null);
266    Ok((name, value))
267}
268
269/// Checks the presented Disclosures against the validated JWT's `_sd[]`/
270/// `_sd_alg`, per the security rules documented on the module itself.
271/// Returns the recovered `name -> value` map, or the first rejection
272/// reason encountered.
273fn verify_disclosures(
274    claims: &Claims,
275    disclosure_strings: &[String],
276) -> Result<HashMap<String, Value>, AuthError> {
277    if disclosure_strings.is_empty() {
278        return Ok(HashMap::new());
279    }
280
281    if let Some(alg_value) = claims.extra.get("_sd_alg") {
282        let alg = alg_value
283            .as_str()
284            .ok_or_else(|| AuthError::Token("_sd_alg claim must be a JSON string".to_string()))?;
285        if alg != SD_ALG_SHA256 {
286            tracing::warn!(
287                sd_alg = %alg,
288                "rejecting SD-JWT: unrecognized _sd_alg, refusing to default to sha-256"
289            );
290            return Err(AuthError::Token(format!(
291                "unsupported SD-JWT _sd_alg '{alg}': only '{SD_ALG_SHA256}' is supported, \
292                 and an unrecognized value is rejected rather than assumed to mean sha-256"
293            )));
294        }
295    }
296
297    let sd_entries = claims
298        .extra
299        .get("_sd")
300        .and_then(Value::as_array)
301        .cloned()
302        .unwrap_or_default();
303
304    let mut known_digests: HashSet<String> = HashSet::with_capacity(sd_entries.len());
305    for entry in &sd_entries {
306        let digest = entry
307            .as_str()
308            .ok_or_else(|| AuthError::Token("_sd entries must be JSON strings".to_string()))?
309            .to_string();
310        if !known_digests.insert(digest.clone()) {
311            tracing::warn!(digest = %digest, "rejecting SD-JWT: duplicate digest in _sd[]");
312            return Err(AuthError::Token(format!(
313                "duplicate digest in SD-JWT _sd[]: {digest}"
314            )));
315        }
316    }
317
318    let mut disclosed = HashMap::with_capacity(disclosure_strings.len());
319    for encoded in disclosure_strings {
320        let digest = disclosure_digest(encoded);
321        if !known_digests.contains(&digest) {
322            tracing::warn!(
323                digest = %digest,
324                "rejecting SD-JWT: presented disclosure digest not found in _sd[]"
325            );
326            return Err(AuthError::Token(
327                "presented SD-JWT disclosure digest is not present in _sd[]".to_string(),
328            ));
329        }
330
331        let (name, value) = decode_disclosure(encoded)?;
332        if is_reserved_claim_name(&name) || claims.extra.contains_key(&name) {
333            tracing::warn!(
334                claim_name = %name,
335                "rejecting SD-JWT: disclosed claim shadows a registered or already-present claim"
336            );
337            return Err(AuthError::Token(format!(
338                "SD-JWT disclosure claim name '{name}' shadows a registered or already-present claim"
339            )));
340        }
341
342        disclosed.insert(name, value);
343    }
344
345    tracing::debug!(
346        disclosed_count = disclosed.len(),
347        "verified SD-JWT disclosures"
348    );
349    Ok(disclosed)
350}
351
352impl TokenManager {
353    /// Issues an SD-JWT: a JWT whose payload carries `_sd[]` digests (and
354    /// `_sd_alg`) for each of `disclosable_claims`, plus the matching
355    /// Disclosure strings, serialized to SD-JWT compact form.
356    ///
357    /// Works with whichever signing algorithm this `TokenManager` was
358    /// constructed with — HS256 ([`TokenManager::new`]), RS256
359    /// ([`TokenManager::new_asymmetric`]), or Ed25519
360    /// ([`TokenManager::new_ed25519`]) — since the SD-JWT mechanism only
361    /// concerns the *payload* (which claims are digested vs. plain), not
362    /// how the JWT itself gets signed.
363    ///
364    /// `sub`/`expires_in_secs`/`aud`/`scope` populate the same standard
365    /// claims as [`TokenManager::issue_client_token_with_extra`]; `extra`
366    /// is stamped the same way (including the `extra["jti"]` override —
367    /// see [`super::take_jti`]). If `disclosable_claims` is empty, the
368    /// result is a plain JWT: no `_sd`/`_sd_alg` claims are added, and
369    /// `compact == jwt` with no trailing `~`.
370    ///
371    /// Reusing a claim name across `disclosable_claims`, or clashing with
372    /// a key already in `extra`, is not rejected at issuance — each
373    /// becomes its own Disclosure/digest, and a verifier will happily
374    /// accept whichever ones it's shown. Callers that need "exactly one
375    /// value per name" are responsible for enforcing that themselves; nothing
376    /// about the wire format requires it.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`AuthError::Token`] without minting anything if `extra`
381    /// carries a key that collides with a named `Claims` field — see
382    /// [`TokenManager::issue_user_token_with_extra`] for the full rule
383    /// (#283). This applies to `extra` only; `disclosable_claims` names
384    /// are still not validated at issuance, as described above.
385    ///
386    /// # Examples
387    ///
388    /// ```rust
389    /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
390    /// # use authkestra_engine::TokenManager;
391    /// # use std::collections::HashMap;
392    /// let manager = TokenManager::new(b"example-secret", Some("issuer".to_string()));
393    /// let issued = manager.issue_sd_jwt(
394    ///     "user-1".to_string(),
395    ///     3600,
396    ///     None,
397    ///     None,
398    ///     vec![DisclosableClaim::new("email", "user@example.com")],
399    ///     HashMap::new(),
400    /// )?;
401    /// assert_eq!(issued.disclosures.len(), 1);
402    /// assert!(issued.compact.starts_with(&issued.jwt));
403    /// # Ok::<(), authkestra_engine::AuthError>(())
404    /// ```
405    #[tracing::instrument(skip(self, extra, disclosable_claims), fields(sub = %sub, disclosure_count = disclosable_claims.len()))]
406    pub fn issue_sd_jwt(
407        &self,
408        sub: String,
409        expires_in_secs: u64,
410        aud: Option<String>,
411        scope: Option<String>,
412        disclosable_claims: Vec<DisclosableClaim>,
413        mut extra: HashMap<String, Value>,
414    ) -> Result<IssuedSdJwt, AuthError> {
415        let now = chrono::Utc::now().timestamp() as usize;
416        let expiration = now + expires_in_secs as usize;
417        let jti = super::take_jti(&mut extra);
418        super::reject_named_claim_collisions(&extra)?;
419
420        let mut digests = Vec::with_capacity(disclosable_claims.len());
421        let mut disclosures = Vec::with_capacity(disclosable_claims.len());
422        for claim in &disclosable_claims {
423            let (encoded, digest) = encode_disclosure(claim)?;
424            digests.push(Value::String(digest));
425            disclosures.push(encoded);
426        }
427
428        if !disclosures.is_empty() {
429            tracing::debug!(
430                disclosure_count = disclosures.len(),
431                "stamping _sd/_sd_alg claims onto SD-JWT"
432            );
433            extra.insert("_sd".to_string(), Value::Array(digests));
434            extra.insert(
435                "_sd_alg".to_string(),
436                Value::String(SD_ALG_SHA256.to_string()),
437            );
438        }
439
440        let claims = Claims {
441            iss: self.issuer.clone(),
442            sub,
443            aud: aud.map(super::Audience::from),
444            exp: expiration,
445            iat: now,
446            nbf: Some(now),
447            jti: Some(jti),
448            scope,
449            identity: None,
450            extra,
451        };
452
453        let mut header = Header::new(self.alg);
454        if let Some(ref kid) = self.kid {
455            header.kid = Some(kid.clone());
456        }
457
458        let jwt = jsonwebtoken::encode(&header, &claims, &self.encoding_key)
459            .map_err(|e| AuthError::Token(e.to_string()))?;
460
461        let mut compact = jwt.clone();
462        for disclosure in &disclosures {
463            compact.push('~');
464            compact.push_str(disclosure);
465        }
466        if !disclosures.is_empty() {
467            compact.push('~');
468        }
469
470        tracing::info!("issued SD-JWT");
471        Ok(IssuedSdJwt {
472            jwt,
473            compact,
474            disclosures,
475        })
476    }
477
478    /// Verifies a presented SD-JWT compact form (`<jwt>~<d1>~...~`, or a
479    /// plain JWT with no `~` segments): validates the underlying JWT
480    /// exactly as [`TokenManager::validate_token`] does (signature,
481    /// issuer, audience, expiry), then checks every presented Disclosure
482    /// against the validated `_sd[]`/`_sd_alg`, per the module-level
483    /// security rules.
484    ///
485    /// Rejects the whole presentation — not just the offending claim — if
486    /// any Disclosure fails: digest not found in `_sd[]`, a duplicate
487    /// digest in `_sd[]`, an unrecognized (present-and-different)
488    /// `_sd_alg`, or a disclosed claim name that shadows a registered or
489    /// already-present claim. See the module docs for why each of these
490    /// has to fail closed rather than degrading gracefully.
491    ///
492    /// # Examples
493    ///
494    /// ```rust
495    /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
496    /// # use authkestra_engine::TokenManager;
497    /// # use std::collections::HashMap;
498    /// let manager = TokenManager::new(b"example-secret", Some("issuer".to_string()));
499    /// let issued = manager.issue_sd_jwt(
500    ///     "user-1".to_string(),
501    ///     3600,
502    ///     None,
503    ///     None,
504    ///     vec![DisclosableClaim::new("email", "user@example.com")],
505    ///     HashMap::new(),
506    /// )?;
507    ///
508    /// // A holder can present the full compact form...
509    /// let verified = manager.validate_sd_jwt(&issued.compact, None)?;
510    /// assert_eq!(
511    ///     verified.disclosed_claims.get("email"),
512    ///     Some(&serde_json::Value::String("user@example.com".to_string()))
513    /// );
514    ///
515    /// // ...or withhold the Disclosure entirely and present the bare JWT.
516    /// let bare = manager.validate_sd_jwt(&issued.jwt, None)?;
517    /// assert!(bare.disclosed_claims.is_empty());
518    /// # Ok::<(), authkestra_engine::AuthError>(())
519    /// ```
520    #[tracing::instrument(skip(self, presented))]
521    pub fn validate_sd_jwt(
522        &self,
523        presented: &str,
524        expected_aud: Option<&str>,
525    ) -> Result<VerifiedSdJwt, AuthError> {
526        let (jwt, disclosure_strings) = split_sd_jwt(presented);
527        let claims = self.validate_token(jwt, expected_aud)?;
528        let disclosed_claims = verify_disclosures(&claims, &disclosure_strings)?;
529        Ok(VerifiedSdJwt {
530            claims,
531            disclosed_claims,
532        })
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::collections::HashMap;
540
541    /// Throwaway Ed25519 private key (PKCS#8 PEM), test-only. Same key used
542    /// in `token::mod`'s own test suite.
543    const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
544MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
545-----END PRIVATE KEY-----";
546
547    fn hs256_manager() -> TokenManager {
548        TokenManager::new(b"sd-jwt-test-secret", Some("issuer".to_string()))
549    }
550
551    fn ed25519_manager() -> TokenManager {
552        TokenManager::new_ed25519(
553            TEST_ED25519_PRIVATE_KEY_PEM,
554            Some("issuer".to_string()),
555            Some("ed25519-kid".to_string()),
556        )
557        .expect("test Ed25519 key must construct a TokenManager")
558    }
559
560    fn sample_disclosures() -> Vec<DisclosableClaim> {
561        vec![
562            DisclosableClaim::new("email", Value::String("user@example.com".to_string())),
563            DisclosableClaim::new("is_over_18", Value::Bool(true)),
564        ]
565    }
566
567    /// Round trip: issue with disclosures, verify presenting all of them,
568    /// recover both claim values — on HS256.
569    #[test]
570    fn hs256_round_trip_issue_and_verify_all_disclosures() {
571        let manager = hs256_manager();
572        let issued = manager
573            .issue_sd_jwt(
574                "user-1".to_string(),
575                3600,
576                Some("client-1".to_string()),
577                None,
578                sample_disclosures(),
579                HashMap::new(),
580            )
581            .expect("issuance should succeed");
582
583        assert_eq!(issued.disclosures.len(), 2);
584        assert!(issued.compact.starts_with(&issued.jwt));
585        assert!(issued.compact.ends_with('~'));
586
587        let verified = manager
588            .validate_sd_jwt(&issued.compact, Some("client-1"))
589            .expect("verification should succeed");
590
591        assert_eq!(verified.claims.sub, "user-1");
592        assert_eq!(
593            verified.disclosed_claims.get("email"),
594            Some(&Value::String("user@example.com".to_string()))
595        );
596        assert_eq!(
597            verified.disclosed_claims.get("is_over_18"),
598            Some(&Value::Bool(true))
599        );
600    }
601
602    /// Same round trip, on the Ed25519 signer — proves the SD-JWT
603    /// mechanism composes with every signing algorithm this crate
604    /// supports, not just HS256.
605    #[test]
606    fn ed25519_round_trip_issue_and_verify_all_disclosures() {
607        let manager = ed25519_manager();
608        let issued = manager
609            .issue_sd_jwt(
610                "user-2".to_string(),
611                3600,
612                None,
613                None,
614                sample_disclosures(),
615                HashMap::new(),
616            )
617            .expect("issuance should succeed");
618
619        let verified = manager
620            .validate_sd_jwt(&issued.compact, None)
621            .expect("verification should succeed");
622
623        assert_eq!(verified.claims.sub, "user-2");
624        assert_eq!(verified.disclosed_claims.len(), 2);
625    }
626
627    /// A holder is allowed to withhold a Disclosure: presenting only one
628    /// of two issued Disclosures verifies fine, and only that one claim is
629    /// recovered.
630    #[test]
631    fn selective_presentation_of_a_subset_of_disclosures_succeeds() {
632        let manager = hs256_manager();
633        let issued = manager
634            .issue_sd_jwt(
635                "user-1".to_string(),
636                3600,
637                None,
638                None,
639                sample_disclosures(),
640                HashMap::new(),
641            )
642            .expect("issuance should succeed");
643
644        // Hand-build a presentation carrying only the first disclosure.
645        let partial = format!("{}~{}~", issued.jwt, issued.disclosures[0]);
646
647        let verified = manager
648            .validate_sd_jwt(&partial, None)
649            .expect("presenting a subset of disclosures should still verify");
650
651        assert_eq!(verified.disclosed_claims.len(), 1);
652        assert!(verified.disclosed_claims.contains_key("email"));
653        assert!(!verified.disclosed_claims.contains_key("is_over_18"));
654    }
655
656    /// Presenting zero disclosures (a bare JWT, no `~`) against a token
657    /// that does carry `_sd[]` must still verify — the standard claims are
658    /// unaffected, and `disclosed_claims` is simply empty.
659    #[test]
660    fn presenting_the_bare_jwt_with_no_disclosures_still_verifies() {
661        let manager = hs256_manager();
662        let issued = manager
663            .issue_sd_jwt(
664                "user-1".to_string(),
665                3600,
666                None,
667                None,
668                sample_disclosures(),
669                HashMap::new(),
670            )
671            .expect("issuance should succeed");
672
673        let verified = manager
674            .validate_sd_jwt(&issued.jwt, None)
675            .expect("bare JWT without disclosures should still verify");
676
677        assert_eq!(verified.claims.sub, "user-1");
678        assert!(verified.disclosed_claims.is_empty());
679    }
680
681    /// Issuing with an empty disclosure list produces a plain JWT: no
682    /// `_sd`/`_sd_alg` claims, and `compact == jwt` (no trailing `~`).
683    #[test]
684    fn issuing_with_no_disclosures_yields_a_plain_jwt() {
685        let manager = hs256_manager();
686        let issued = manager
687            .issue_sd_jwt(
688                "user-1".to_string(),
689                3600,
690                None,
691                None,
692                Vec::new(),
693                HashMap::new(),
694            )
695            .expect("issuance should succeed");
696
697        assert_eq!(issued.compact, issued.jwt);
698        assert!(!issued.compact.contains('~'));
699
700        let verified = manager
701            .validate_sd_jwt(&issued.compact, None)
702            .expect("plain JWT should still verify via validate_sd_jwt");
703        assert!(!verified.claims.extra.contains_key("_sd"));
704        assert!(!verified.claims.extra.contains_key("_sd_alg"));
705    }
706
707    /// Security rule: an unrecognized `_sd_alg` must be rejected outright,
708    /// never treated as though it meant sha-256. Constructed by hand since
709    /// `issue_sd_jwt` itself only ever stamps `"sha-256"`.
710    #[test]
711    fn unrecognized_sd_alg_is_rejected_not_defaulted() {
712        let manager = hs256_manager();
713        let mut extra = HashMap::new();
714        let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
715            "email",
716            Value::String("user@example.com".to_string()),
717        ))
718        .unwrap();
719        extra.insert("_sd".to_string(), serde_json::json!([digest]));
720        extra.insert("_sd_alg".to_string(), serde_json::json!("sha-1"));
721
722        let jwt = manager
723            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
724            .expect("hand-built token should issue");
725        let presented = format!("{jwt}~{encoded}~");
726
727        let err = manager
728            .validate_sd_jwt(&presented, None)
729            .expect_err("an unrecognized _sd_alg must be rejected");
730        assert!(
731            err.to_string().contains("_sd_alg"),
732            "error should mention _sd_alg, got: {err}"
733        );
734    }
735
736    /// Security rule: a presented disclosure whose digest is absent from
737    /// `_sd[]` must be rejected — a holder cannot inject a claim the
738    /// issuer never signed for.
739    #[test]
740    fn disclosure_digest_not_in_sd_is_rejected() {
741        let manager = hs256_manager();
742        let issued = manager
743            .issue_sd_jwt(
744                "user-1".to_string(),
745                3600,
746                None,
747                None,
748                sample_disclosures(),
749                HashMap::new(),
750            )
751            .expect("issuance should succeed");
752
753        // Forge a disclosure for a claim the issuer never included.
754        let (forged_encoded, _forged_digest) = encode_disclosure(&DisclosableClaim::new(
755            "role",
756            Value::String("admin".to_string()),
757        ))
758        .unwrap();
759        let forged = format!("{}~{forged_encoded}~", issued.jwt);
760
761        let err = manager
762            .validate_sd_jwt(&forged, None)
763            .expect_err("a disclosure not backed by a digest in _sd[] must be rejected");
764        assert!(
765            err.to_string().contains("_sd[]") || err.to_string().contains("not present"),
766            "unexpected error message: {err}"
767        );
768    }
769
770    /// Security rule: duplicate digests inside `_sd[]` are rejected, even
771    /// before any disclosure is checked against them.
772    #[test]
773    fn duplicate_digest_in_sd_is_rejected() {
774        let manager = hs256_manager();
775        let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
776            "email",
777            Value::String("user@example.com".to_string()),
778        ))
779        .unwrap();
780
781        let mut extra = HashMap::new();
782        extra.insert(
783            "_sd".to_string(),
784            serde_json::json!([digest.clone(), digest]),
785        );
786        extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
787
788        let jwt = manager
789            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
790            .expect("hand-built token should issue");
791        let presented = format!("{jwt}~{encoded}~");
792
793        let err = manager
794            .validate_sd_jwt(&presented, None)
795            .expect_err("duplicate digests in _sd[] must be rejected");
796        assert!(
797            err.to_string().contains("duplicate"),
798            "unexpected error message: {err}"
799        );
800    }
801
802    /// Security rule: a disclosed claim cannot shadow a registered
803    /// top-level claim (`sub`, in this case) — the token would otherwise
804    /// let a holder present a forged `sub` that a naive verifier merges
805    /// over the signed one.
806    #[test]
807    fn disclosed_claim_cannot_shadow_registered_claim_name() {
808        let manager = hs256_manager();
809        let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
810            "sub",
811            Value::String("attacker".to_string()),
812        ))
813        .unwrap();
814
815        let mut extra = HashMap::new();
816        extra.insert("_sd".to_string(), serde_json::json!([digest]));
817        extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
818
819        let jwt = manager
820            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
821            .expect("hand-built token should issue");
822        let presented = format!("{jwt}~{encoded}~");
823
824        let err = manager
825            .validate_sd_jwt(&presented, None)
826            .expect_err("a disclosure named 'sub' must be rejected");
827        assert!(
828            err.to_string().contains("shadow"),
829            "unexpected error message: {err}"
830        );
831    }
832
833    /// Same shadowing rule, but against an already-present `extra` claim
834    /// rather than a registered top-level one.
835    #[test]
836    fn disclosed_claim_cannot_shadow_already_present_extra_claim() {
837        let manager = hs256_manager();
838        let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
839            "org_id",
840            Value::String("attacker-org".to_string()),
841        ))
842        .unwrap();
843
844        let mut extra = HashMap::new();
845        extra.insert("org_id".to_string(), serde_json::json!("real-org"));
846        extra.insert("_sd".to_string(), serde_json::json!([digest]));
847        extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
848
849        let jwt = manager
850            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
851            .expect("hand-built token should issue");
852        let presented = format!("{jwt}~{encoded}~");
853
854        let err = manager
855            .validate_sd_jwt(&presented, None)
856            .expect_err("a disclosure shadowing an already-present extra claim must be rejected");
857        assert!(
858            err.to_string().contains("shadow"),
859            "unexpected error message: {err}"
860        );
861    }
862
863    /// A tampered disclosure (payload byte flipped after issuance) no
864    /// longer hashes to anything in `_sd[]`, so it's rejected the same way
865    /// an unbacked forged disclosure is — proving the digest check, not
866    /// just structural JSON validity, is what's enforced.
867    #[test]
868    fn tampered_disclosure_is_rejected() {
869        let manager = hs256_manager();
870        let issued = manager
871            .issue_sd_jwt(
872                "user-1".to_string(),
873                3600,
874                None,
875                None,
876                sample_disclosures(),
877                HashMap::new(),
878            )
879            .expect("issuance should succeed");
880
881        let mut tampered = issued.disclosures[0].clone();
882        let last = tampered.pop().unwrap();
883        let replacement = if last == 'A' { 'B' } else { 'A' };
884        tampered.push(replacement);
885
886        let presented = format!("{}~{tampered}~", issued.jwt);
887
888        let err = manager
889            .validate_sd_jwt(&presented, None)
890            .expect_err("a tampered disclosure must be rejected");
891        assert!(
892            err.to_string().contains("_sd[]")
893                || err.to_string().contains("not present")
894                || err.to_string().contains("disclosure"),
895            "unexpected error message: {err}"
896        );
897    }
898
899    /// A structurally invalid disclosure (not a 3-element array) is
900    /// rejected with a decoding error, distinct from — but still a hard
901    /// failure like — the digest-mismatch cases above. Built by hand with
902    /// a real backing digest so the failure is provably about shape, not
903    /// digest membership.
904    #[test]
905    fn malformed_disclosure_triple_is_rejected() {
906        let manager = hs256_manager();
907        let malformed_encoded =
908            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!(["salt-only"])).unwrap());
909        let digest = disclosure_digest(&malformed_encoded);
910
911        let mut extra = HashMap::new();
912        extra.insert("_sd".to_string(), serde_json::json!([digest]));
913        extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
914
915        let jwt = manager
916            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
917            .expect("hand-built token should issue");
918        let presented = format!("{jwt}~{malformed_encoded}~");
919
920        let err = manager
921            .validate_sd_jwt(&presented, None)
922            .expect_err("a malformed disclosure triple must be rejected");
923        assert!(
924            err.to_string().contains("triple"),
925            "unexpected error message: {err}"
926        );
927    }
928
929    /// `_sd_alg` absent entirely still verifies (defaults to sha-256 per
930    /// spec) — proving the "reject unrecognized _sd_alg" rule only fires
931    /// when a *different* value is actually present, not merely absent.
932    #[test]
933    fn missing_sd_alg_defaults_to_sha256_and_still_verifies() {
934        let manager = hs256_manager();
935        let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
936            "email",
937            Value::String("user@example.com".to_string()),
938        ))
939        .unwrap();
940
941        let mut extra = HashMap::new();
942        extra.insert("_sd".to_string(), serde_json::json!([digest]));
943        // Deliberately no "_sd_alg" entry.
944
945        let jwt = manager
946            .issue_client_token_with_extra("client-1", 3600, None, None, extra)
947            .expect("hand-built token should issue");
948        let presented = format!("{jwt}~{encoded}~");
949
950        let verified = manager
951            .validate_sd_jwt(&presented, None)
952            .expect("a missing _sd_alg should default to sha-256, not be rejected");
953        assert_eq!(
954            verified.disclosed_claims.get("email"),
955            Some(&Value::String("user@example.com".to_string()))
956        );
957    }
958
959    /// The `extra["jti"]` override documented on `issue_client_token_with_extra`
960    /// composes correctly through `issue_sd_jwt` too — proves this method
961    /// didn't bypass the shared `take_jti` plumbing.
962    #[test]
963    fn issue_sd_jwt_honors_extra_jti_override() {
964        let manager = hs256_manager();
965        let mut extra = HashMap::new();
966        extra.insert("jti".to_string(), serde_json::json!("caller-supplied-id"));
967
968        let issued = manager
969            .issue_sd_jwt("user-1".to_string(), 3600, None, None, Vec::new(), extra)
970            .expect("issuance should succeed");
971
972        let verified = manager
973            .validate_sd_jwt(&issued.compact, None)
974            .expect("verification should succeed");
975        assert_eq!(verified.claims.jti, Some("caller-supplied-id".to_string()));
976    }
977
978    /// Two Disclosures for the same claim name/value must still get
979    /// distinct salts (and thus distinct digests/encodings) — the whole
980    /// point of per-disclosure salting is that identical claim data
981    /// doesn't produce a recognizably identical Disclosure across issuances.
982    #[test]
983    fn disclosure_salts_are_unique_across_issuances() {
984        let claim = DisclosableClaim::new("email", Value::String("user@example.com".to_string()));
985        let (first, first_digest) = encode_disclosure(&claim).unwrap();
986        let (second, second_digest) = encode_disclosure(&claim).unwrap();
987
988        assert_ne!(first, second, "salts must differ across issuances");
989        assert_ne!(first_digest, second_digest);
990    }
991}