use super::{Claims, TokenManager};
use crate::auth::error::AuthError;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use jsonwebtoken::Header;
use rand::RngCore;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
const SD_ALG_SHA256: &str = "sha-256";
const RESERVED_CLAIM_NAMES: &[&str] = &[
"iss", "sub", "aud", "exp", "iat", "nbf", "jti", "scope", "identity", "_sd", "_sd_alg",
];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DisclosableClaim {
pub name: String,
pub value: Value,
}
impl DisclosableClaim {
pub fn new(name: impl Into<String>, value: impl Into<Value>) -> Self {
Self {
name: name.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IssuedSdJwt {
pub jwt: String,
pub compact: String,
pub disclosures: Vec<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct VerifiedSdJwt {
pub claims: Claims,
pub disclosed_claims: HashMap<String, Value>,
}
fn generate_disclosure_salt() -> String {
let mut salt_bytes = [0u8; 16]; rand::rng().fill_bytes(&mut salt_bytes);
URL_SAFE_NO_PAD.encode(salt_bytes)
}
fn disclosure_digest(encoded_disclosure: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(encoded_disclosure.as_bytes()))
}
fn encode_disclosure(claim: &DisclosableClaim) -> Result<(String, String), AuthError> {
let salt = generate_disclosure_salt();
let triple = serde_json::json!([salt, claim.name, claim.value]);
let bytes = serde_json::to_vec(&triple)
.map_err(|e| AuthError::Token(format!("failed to encode SD-JWT disclosure: {e}")))?;
let encoded = URL_SAFE_NO_PAD.encode(bytes);
let digest = disclosure_digest(&encoded);
Ok((encoded, digest))
}
fn split_sd_jwt(compact: &str) -> (&str, Vec<String>) {
let mut parts = compact.split('~');
let jwt = parts.next().unwrap_or(compact);
let disclosures = parts
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect();
(jwt, disclosures)
}
fn decode_disclosure(encoded: &str) -> Result<(String, Value), AuthError> {
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure encoding: {e}")))?;
let triple: Vec<Value> = serde_json::from_slice(&bytes)
.map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure JSON: {e}")))?;
if triple.len() != 3 {
return Err(AuthError::Token(
"SD-JWT disclosure must be a [salt, claim_name, claim_value] triple".to_string(),
));
}
let mut fields = triple.into_iter();
let _salt = fields.next();
let name = fields
.next()
.and_then(|v| v.as_str().map(str::to_owned))
.ok_or_else(|| {
AuthError::Token("SD-JWT disclosure claim name must be a JSON string".to_string())
})?;
let value = fields.next().unwrap_or(Value::Null);
Ok((name, value))
}
fn verify_disclosures(
claims: &Claims,
disclosure_strings: &[String],
) -> Result<HashMap<String, Value>, AuthError> {
if disclosure_strings.is_empty() {
return Ok(HashMap::new());
}
if let Some(alg_value) = claims.extra.get("_sd_alg") {
let alg = alg_value
.as_str()
.ok_or_else(|| AuthError::Token("_sd_alg claim must be a JSON string".to_string()))?;
if alg != SD_ALG_SHA256 {
tracing::warn!(
sd_alg = %alg,
"rejecting SD-JWT: unrecognized _sd_alg, refusing to default to sha-256"
);
return Err(AuthError::Token(format!(
"unsupported SD-JWT _sd_alg '{alg}': only '{SD_ALG_SHA256}' is supported, \
and an unrecognized value is rejected rather than assumed to mean sha-256"
)));
}
}
let sd_entries = claims
.extra
.get("_sd")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut known_digests: HashSet<String> = HashSet::with_capacity(sd_entries.len());
for entry in &sd_entries {
let digest = entry
.as_str()
.ok_or_else(|| AuthError::Token("_sd entries must be JSON strings".to_string()))?
.to_string();
if !known_digests.insert(digest.clone()) {
tracing::warn!(digest = %digest, "rejecting SD-JWT: duplicate digest in _sd[]");
return Err(AuthError::Token(format!(
"duplicate digest in SD-JWT _sd[]: {digest}"
)));
}
}
let mut disclosed = HashMap::with_capacity(disclosure_strings.len());
for encoded in disclosure_strings {
let digest = disclosure_digest(encoded);
if !known_digests.contains(&digest) {
tracing::warn!(
digest = %digest,
"rejecting SD-JWT: presented disclosure digest not found in _sd[]"
);
return Err(AuthError::Token(
"presented SD-JWT disclosure digest is not present in _sd[]".to_string(),
));
}
let (name, value) = decode_disclosure(encoded)?;
if RESERVED_CLAIM_NAMES.contains(&name.as_str()) || claims.extra.contains_key(&name) {
tracing::warn!(
claim_name = %name,
"rejecting SD-JWT: disclosed claim shadows a registered or already-present claim"
);
return Err(AuthError::Token(format!(
"SD-JWT disclosure claim name '{name}' shadows a registered or already-present claim"
)));
}
disclosed.insert(name, value);
}
tracing::debug!(
disclosed_count = disclosed.len(),
"verified SD-JWT disclosures"
);
Ok(disclosed)
}
impl TokenManager {
#[tracing::instrument(skip(self, extra, disclosable_claims), fields(sub = %sub, disclosure_count = disclosable_claims.len()))]
pub fn issue_sd_jwt(
&self,
sub: String,
expires_in_secs: u64,
aud: Option<String>,
scope: Option<String>,
disclosable_claims: Vec<DisclosableClaim>,
mut extra: HashMap<String, Value>,
) -> Result<IssuedSdJwt, AuthError> {
let now = chrono::Utc::now().timestamp() as usize;
let expiration = now + expires_in_secs as usize;
let jti = super::take_jti(&mut extra);
let mut digests = Vec::with_capacity(disclosable_claims.len());
let mut disclosures = Vec::with_capacity(disclosable_claims.len());
for claim in &disclosable_claims {
let (encoded, digest) = encode_disclosure(claim)?;
digests.push(Value::String(digest));
disclosures.push(encoded);
}
if !disclosures.is_empty() {
tracing::debug!(
disclosure_count = disclosures.len(),
"stamping _sd/_sd_alg claims onto SD-JWT"
);
extra.insert("_sd".to_string(), Value::Array(digests));
extra.insert(
"_sd_alg".to_string(),
Value::String(SD_ALG_SHA256.to_string()),
);
}
let claims = Claims {
iss: self.issuer.clone(),
sub,
aud: aud.map(super::Audience::from),
exp: expiration,
iat: now,
nbf: Some(now),
jti: Some(jti),
scope,
identity: None,
extra,
};
let mut header = Header::new(self.alg);
if let Some(ref kid) = self.kid {
header.kid = Some(kid.clone());
}
let jwt = jsonwebtoken::encode(&header, &claims, &self.encoding_key)
.map_err(|e| AuthError::Token(e.to_string()))?;
let mut compact = jwt.clone();
for disclosure in &disclosures {
compact.push('~');
compact.push_str(disclosure);
}
if !disclosures.is_empty() {
compact.push('~');
}
tracing::info!("issued SD-JWT");
Ok(IssuedSdJwt {
jwt,
compact,
disclosures,
})
}
#[tracing::instrument(skip(self, presented))]
pub fn validate_sd_jwt(
&self,
presented: &str,
expected_aud: Option<&str>,
) -> Result<VerifiedSdJwt, AuthError> {
let (jwt, disclosure_strings) = split_sd_jwt(presented);
let claims = self.validate_token(jwt, expected_aud)?;
let disclosed_claims = verify_disclosures(&claims, &disclosure_strings)?;
Ok(VerifiedSdJwt {
claims,
disclosed_claims,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
-----END PRIVATE KEY-----";
fn hs256_manager() -> TokenManager {
TokenManager::new(b"sd-jwt-test-secret", Some("issuer".to_string()))
}
fn ed25519_manager() -> TokenManager {
TokenManager::new_ed25519(
TEST_ED25519_PRIVATE_KEY_PEM,
Some("issuer".to_string()),
Some("ed25519-kid".to_string()),
)
.expect("test Ed25519 key must construct a TokenManager")
}
fn sample_disclosures() -> Vec<DisclosableClaim> {
vec![
DisclosableClaim::new("email", Value::String("user@example.com".to_string())),
DisclosableClaim::new("is_over_18", Value::Bool(true)),
]
}
#[test]
fn hs256_round_trip_issue_and_verify_all_disclosures() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
Some("client-1".to_string()),
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
assert_eq!(issued.disclosures.len(), 2);
assert!(issued.compact.starts_with(&issued.jwt));
assert!(issued.compact.ends_with('~'));
let verified = manager
.validate_sd_jwt(&issued.compact, Some("client-1"))
.expect("verification should succeed");
assert_eq!(verified.claims.sub, "user-1");
assert_eq!(
verified.disclosed_claims.get("email"),
Some(&Value::String("user@example.com".to_string()))
);
assert_eq!(
verified.disclosed_claims.get("is_over_18"),
Some(&Value::Bool(true))
);
}
#[test]
fn ed25519_round_trip_issue_and_verify_all_disclosures() {
let manager = ed25519_manager();
let issued = manager
.issue_sd_jwt(
"user-2".to_string(),
3600,
None,
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
let verified = manager
.validate_sd_jwt(&issued.compact, None)
.expect("verification should succeed");
assert_eq!(verified.claims.sub, "user-2");
assert_eq!(verified.disclosed_claims.len(), 2);
}
#[test]
fn selective_presentation_of_a_subset_of_disclosures_succeeds() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
None,
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
let partial = format!("{}~{}~", issued.jwt, issued.disclosures[0]);
let verified = manager
.validate_sd_jwt(&partial, None)
.expect("presenting a subset of disclosures should still verify");
assert_eq!(verified.disclosed_claims.len(), 1);
assert!(verified.disclosed_claims.contains_key("email"));
assert!(!verified.disclosed_claims.contains_key("is_over_18"));
}
#[test]
fn presenting_the_bare_jwt_with_no_disclosures_still_verifies() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
None,
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
let verified = manager
.validate_sd_jwt(&issued.jwt, None)
.expect("bare JWT without disclosures should still verify");
assert_eq!(verified.claims.sub, "user-1");
assert!(verified.disclosed_claims.is_empty());
}
#[test]
fn issuing_with_no_disclosures_yields_a_plain_jwt() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
None,
None,
Vec::new(),
HashMap::new(),
)
.expect("issuance should succeed");
assert_eq!(issued.compact, issued.jwt);
assert!(!issued.compact.contains('~'));
let verified = manager
.validate_sd_jwt(&issued.compact, None)
.expect("plain JWT should still verify via validate_sd_jwt");
assert!(!verified.claims.extra.contains_key("_sd"));
assert!(!verified.claims.extra.contains_key("_sd_alg"));
}
#[test]
fn unrecognized_sd_alg_is_rejected_not_defaulted() {
let manager = hs256_manager();
let mut extra = HashMap::new();
let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
"email",
Value::String("user@example.com".to_string()),
))
.unwrap();
extra.insert("_sd".to_string(), serde_json::json!([digest]));
extra.insert("_sd_alg".to_string(), serde_json::json!("sha-1"));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{encoded}~");
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("an unrecognized _sd_alg must be rejected");
assert!(
err.to_string().contains("_sd_alg"),
"error should mention _sd_alg, got: {err}"
);
}
#[test]
fn disclosure_digest_not_in_sd_is_rejected() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
None,
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
let (forged_encoded, _forged_digest) = encode_disclosure(&DisclosableClaim::new(
"role",
Value::String("admin".to_string()),
))
.unwrap();
let forged = format!("{}~{forged_encoded}~", issued.jwt);
let err = manager
.validate_sd_jwt(&forged, None)
.expect_err("a disclosure not backed by a digest in _sd[] must be rejected");
assert!(
err.to_string().contains("_sd[]") || err.to_string().contains("not present"),
"unexpected error message: {err}"
);
}
#[test]
fn duplicate_digest_in_sd_is_rejected() {
let manager = hs256_manager();
let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
"email",
Value::String("user@example.com".to_string()),
))
.unwrap();
let mut extra = HashMap::new();
extra.insert(
"_sd".to_string(),
serde_json::json!([digest.clone(), digest]),
);
extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{encoded}~");
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("duplicate digests in _sd[] must be rejected");
assert!(
err.to_string().contains("duplicate"),
"unexpected error message: {err}"
);
}
#[test]
fn disclosed_claim_cannot_shadow_registered_claim_name() {
let manager = hs256_manager();
let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
"sub",
Value::String("attacker".to_string()),
))
.unwrap();
let mut extra = HashMap::new();
extra.insert("_sd".to_string(), serde_json::json!([digest]));
extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{encoded}~");
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("a disclosure named 'sub' must be rejected");
assert!(
err.to_string().contains("shadow"),
"unexpected error message: {err}"
);
}
#[test]
fn disclosed_claim_cannot_shadow_already_present_extra_claim() {
let manager = hs256_manager();
let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
"org_id",
Value::String("attacker-org".to_string()),
))
.unwrap();
let mut extra = HashMap::new();
extra.insert("org_id".to_string(), serde_json::json!("real-org"));
extra.insert("_sd".to_string(), serde_json::json!([digest]));
extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{encoded}~");
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("a disclosure shadowing an already-present extra claim must be rejected");
assert!(
err.to_string().contains("shadow"),
"unexpected error message: {err}"
);
}
#[test]
fn tampered_disclosure_is_rejected() {
let manager = hs256_manager();
let issued = manager
.issue_sd_jwt(
"user-1".to_string(),
3600,
None,
None,
sample_disclosures(),
HashMap::new(),
)
.expect("issuance should succeed");
let mut tampered = issued.disclosures[0].clone();
let last = tampered.pop().unwrap();
let replacement = if last == 'A' { 'B' } else { 'A' };
tampered.push(replacement);
let presented = format!("{}~{tampered}~", issued.jwt);
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("a tampered disclosure must be rejected");
assert!(
err.to_string().contains("_sd[]")
|| err.to_string().contains("not present")
|| err.to_string().contains("disclosure"),
"unexpected error message: {err}"
);
}
#[test]
fn malformed_disclosure_triple_is_rejected() {
let manager = hs256_manager();
let malformed_encoded =
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!(["salt-only"])).unwrap());
let digest = disclosure_digest(&malformed_encoded);
let mut extra = HashMap::new();
extra.insert("_sd".to_string(), serde_json::json!([digest]));
extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{malformed_encoded}~");
let err = manager
.validate_sd_jwt(&presented, None)
.expect_err("a malformed disclosure triple must be rejected");
assert!(
err.to_string().contains("triple"),
"unexpected error message: {err}"
);
}
#[test]
fn missing_sd_alg_defaults_to_sha256_and_still_verifies() {
let manager = hs256_manager();
let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
"email",
Value::String("user@example.com".to_string()),
))
.unwrap();
let mut extra = HashMap::new();
extra.insert("_sd".to_string(), serde_json::json!([digest]));
let jwt = manager
.issue_client_token_with_extra("client-1", 3600, None, None, extra)
.expect("hand-built token should issue");
let presented = format!("{jwt}~{encoded}~");
let verified = manager
.validate_sd_jwt(&presented, None)
.expect("a missing _sd_alg should default to sha-256, not be rejected");
assert_eq!(
verified.disclosed_claims.get("email"),
Some(&Value::String("user@example.com".to_string()))
);
}
#[test]
fn issue_sd_jwt_honors_extra_jti_override() {
let manager = hs256_manager();
let mut extra = HashMap::new();
extra.insert("jti".to_string(), serde_json::json!("caller-supplied-id"));
let issued = manager
.issue_sd_jwt("user-1".to_string(), 3600, None, None, Vec::new(), extra)
.expect("issuance should succeed");
let verified = manager
.validate_sd_jwt(&issued.compact, None)
.expect("verification should succeed");
assert_eq!(verified.claims.jti, Some("caller-supplied-id".to_string()));
}
#[test]
fn disclosure_salts_are_unique_across_issuances() {
let claim = DisclosableClaim::new("email", Value::String("user@example.com".to_string()));
let (first, first_digest) = encode_disclosure(&claim).unwrap();
let (second, second_digest) = encode_disclosure(&claim).unwrap();
assert_ne!(first, second, "salts must differ across issuances");
assert_ne!(first_digest, second_digest);
}
}