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