use serde::Serialize;
use std::marker::PhantomData;
use crate::id_token::claims::AddressClaim;
use crate::id_token::scopes::ScopeSet;
use crate::id_token::{IssueConfig, IssueError, IssueRequest};
use super::hash_binding;
#[derive(Serialize)]
#[serde(untagged)]
enum AudOnWire<'a> {
One(&'a str),
Many(&'a [String]),
}
#[derive(Serialize)]
pub(crate) struct IssuePayload<'a, S: ScopeSet> {
iss: &'a str,
sub: &'a str,
aud: AudOnWire<'a>,
exp: i64,
iat: i64,
nonce: &'a str,
cat: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
auth_time: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
acr: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
amr: Option<&'a [String]>,
#[serde(skip_serializing_if = "Option::is_none")]
azp: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
at_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
c_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
email_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
given_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
family_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
middle_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
preferred_username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
picture: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
website: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
gender: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
birthdate: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
zoneinfo: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
locale: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
phone_number: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
phone_number_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<&'a AddressClaim>,
#[serde(skip)]
_scope: PhantomData<S>,
}
impl<'a, S: ScopeSet> IssuePayload<'a, S> {
pub(crate) fn build(
req: &'a IssueRequest<S>,
cfg: &'a IssueConfig,
now: i64,
) -> Result<Self, IssueError> {
let allowlist = S::names();
let pii_emit_intent: &[(&'static str, bool)] = &[
("email", req.email.is_some()),
("email_verified", req.email_verified.is_some()),
("name", req.name.is_some()),
("given_name", req.given_name.is_some()),
("family_name", req.family_name.is_some()),
("middle_name", req.middle_name.is_some()),
("nickname", req.nickname.is_some()),
("preferred_username", req.preferred_username.is_some()),
("profile", req.profile.is_some()),
("picture", req.picture.is_some()),
("website", req.website.is_some()),
("gender", req.gender.is_some()),
("birthdate", req.birthdate.is_some()),
("zoneinfo", req.zoneinfo.is_some()),
("locale", req.locale.is_some()),
("updated_at", req.updated_at.is_some()),
("phone_number", req.phone_number.is_some()),
("phone_number_verified", req.phone_number_verified.is_some()),
("address", req.address.is_some()),
];
for (name, populated) in pii_emit_intent {
if *populated && !allowlist.contains(name) {
return Err(IssueError::EmissionDisallowed((*name).to_string()));
}
}
let exp = now + req.ttl.as_secs() as i64;
let aud = serialize_aud_choice(&cfg.audiences);
let at_hash = cfg
.for_access_token
.as_ref()
.map(|s| hash_binding::compute(s.as_bytes()));
let c_hash = cfg
.for_authorization_code
.as_ref()
.map(|s| hash_binding::compute(s.as_bytes()));
Ok(Self {
iss: &cfg.issuer,
sub: &req.sub,
aud,
exp,
iat: now,
nonce: cfg.nonce.as_str(),
cat: cfg.cat,
auth_time: req.auth_time,
acr: req.acr.as_deref(),
amr: req.amr.as_deref(),
azp: req.azp.as_deref(),
at_hash,
c_hash,
email: req.email.as_deref(),
email_verified: req.email_verified,
name: req.name.as_deref(),
given_name: req.given_name.as_deref(),
family_name: req.family_name.as_deref(),
middle_name: req.middle_name.as_deref(),
nickname: req.nickname.as_deref(),
preferred_username: req.preferred_username.as_deref(),
profile: req.profile.as_deref(),
picture: req.picture.as_deref(),
website: req.website.as_deref(),
gender: req.gender.as_deref(),
birthdate: req.birthdate.as_deref(),
zoneinfo: req.zoneinfo.as_deref(),
locale: req.locale.as_deref(),
updated_at: req.updated_at,
phone_number: req.phone_number.as_deref(),
phone_number_verified: req.phone_number_verified,
address: req.address.as_ref(),
_scope: PhantomData,
})
}
}
fn serialize_aud_choice(audiences: &[String]) -> AudOnWire<'_> {
if audiences.len() == 1 {
AudOnWire::One(&audiences[0])
} else {
AudOnWire::Many(audiences)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::id_token::scopes::{Email, EmailProfile, Openid};
use crate::id_token::Nonce;
use std::time::Duration;
fn nonce() -> Nonce {
Nonce::new("rp-stored-nonce-test").unwrap()
}
fn cfg() -> IssueConfig {
IssueConfig::id_token(
"https://accounts.ppoppo.com",
"rp-client-1234",
"k4.test.0",
nonce(),
)
}
fn payload_json<S: ScopeSet>(req: &IssueRequest<S>, cfg: &IssueConfig, now: i64) -> serde_json::Value {
let payload = IssuePayload::build(req, cfg, now).expect("build must succeed");
serde_json::to_value(&payload).unwrap()
}
#[test]
fn openid_minimal_payload_round_trips() {
let req = IssueRequest::<Openid>::new("01HSAB00000000000000000000", Duration::from_secs(600));
let cfg = cfg();
let now = 1_700_000_000_i64;
let json = payload_json(&req, &cfg, now);
assert_eq!(json["iss"], "https://accounts.ppoppo.com");
assert_eq!(json["sub"], "01HSAB00000000000000000000");
assert_eq!(json["aud"], "rp-client-1234");
assert!(json["aud"].is_string(), "single aud must serialize as string");
assert_eq!(json["exp"], now + 600);
assert_eq!(json["iat"], now);
assert_eq!(json["nonce"], "rp-stored-nonce-test");
assert_eq!(json["cat"], "id");
for absent in [
"auth_time", "acr", "amr", "azp", "at_hash", "c_hash",
"email", "name", "phone_number", "address",
] {
assert!(
json.get(absent).is_none(),
"absent field {absent:?} must NOT appear on the wire"
);
}
}
#[test]
fn email_payload_includes_email_field() {
let req = IssueRequest::<Email>::new("01HSAB00000000000000000000", Duration::from_secs(600))
.with_email("user@example.com")
.with_email_verified(true);
let json = payload_json(&req, &cfg(), 1_700_000_000);
assert_eq!(json["email"], "user@example.com");
assert_eq!(json["email_verified"], true);
assert!(json.get("name").is_none());
assert!(json.get("phone_number").is_none());
}
#[test]
fn unknown_field_population_returns_emission_disallowed() {
let mut req = IssueRequest::<Openid>::new(
"01HSAB00000000000000000000",
Duration::from_secs(600),
);
req.email = Some("smuggled@example.com".to_string());
let cfg_binding = cfg();
let result = IssuePayload::build(&req, &cfg_binding, 1_700_000_000);
assert_eq!(
result.err(),
Some(IssueError::EmissionDisallowed("email".to_string())),
"β1 runtime guard MUST fire on a populated field outside S::names()"
);
}
#[test]
fn at_hash_emitted_when_cfg_has_access_token() {
const ACCESS_TOKEN: &str = "fake.access.token.three.dots";
let req = IssueRequest::<Openid>::new("01HSAB00000000000000000000", Duration::from_secs(600));
let cfg = cfg().with_access_token_for_at_hash(ACCESS_TOKEN);
let json = payload_json(&req, &cfg, 1_700_000_000);
let expected = hash_binding::compute(ACCESS_TOKEN.as_bytes());
assert_eq!(json["at_hash"], expected);
assert!(json.get("c_hash").is_none());
}
#[test]
fn c_hash_emitted_when_cfg_has_authorization_code() {
const AUTH_CODE: &str = "oauth2-authorization-code-test";
let req = IssueRequest::<Openid>::new("01HSAB00000000000000000000", Duration::from_secs(600));
let cfg = cfg().with_authorization_code_for_c_hash(AUTH_CODE);
let json = payload_json(&req, &cfg, 1_700_000_000);
let expected = hash_binding::compute(AUTH_CODE.as_bytes());
assert_eq!(json["c_hash"], expected);
assert!(json.get("at_hash").is_none());
}
#[test]
fn email_profile_includes_email_and_profile_fields() {
let req = IssueRequest::<EmailProfile>::new(
"01HSAB00000000000000000000",
Duration::from_secs(600),
)
.with_email("u@example.com")
.with_name("Test User")
.with_locale("en-US");
let json = payload_json(&req, &cfg(), 1_700_000_000);
assert_eq!(json["email"], "u@example.com");
assert_eq!(json["name"], "Test User");
assert_eq!(json["locale"], "en-US");
}
#[test]
fn aud_emits_array_when_multi_audience() {
let req = IssueRequest::<Openid>::new("01HSAB00000000000000000000", Duration::from_secs(600));
let cfg = cfg().with_audiences(vec!["primary".to_string(), "secondary".to_string()]);
let json = payload_json(&req, &cfg, 1_700_000_000);
let aud = &json["aud"];
assert!(aud.is_array(), "multi aud must serialize as array");
assert_eq!(aud[0], "primary");
assert_eq!(aud[1], "secondary");
}
}