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}