use serde::Serialize;
use crate::access_token::{IssueConfig, IssueRequest};
#[derive(Serialize)]
#[serde(untagged)]
enum AudOnWire<'a> {
One(&'a str),
Many(&'a [String]),
}
#[derive(Serialize)]
pub(crate) struct IssuePayload<'a> {
iss: &'a str,
sub: &'a str,
aud: AudOnWire<'a>,
exp: i64,
iat: i64,
nbf: i64,
jti: String,
client_id: &'a str,
cat: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
account_type: Option<&'a str>,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
caps: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
scopes: &'a [String],
#[serde(skip_serializing_if = "u8_is_zero")]
dlg_depth: u8,
#[serde(skip_serializing_if = "bool_is_false")]
admin: bool,
#[serde(skip_serializing_if = "Option::is_none")]
active_ppnum: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
delegator: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
cid: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
sv: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
sid: Option<&'a str>,
}
fn u8_is_zero(value: &u8) -> bool {
*value == 0
}
fn bool_is_false(value: &bool) -> bool {
!*value
}
impl<'a> IssuePayload<'a> {
pub(crate) fn build(req: &'a IssueRequest, cfg: &'a IssueConfig, now: i64) -> Self {
let exp = now + req.ttl.as_secs() as i64;
let jti = req.jti.unwrap_or_else(ulid::Ulid::new).to_string();
let aud = serialize_aud_choice(&cfg.audiences);
Self {
iss: &cfg.issuer,
sub: &req.sub,
aud,
exp,
iat: now,
nbf: now,
jti,
client_id: &req.client_id,
cat: cfg.cat,
account_type: req.account_type.as_deref(),
caps: &req.caps,
scopes: &req.scopes,
dlg_depth: req.dlg_depth,
admin: req.admin,
active_ppnum: req.active_ppnum.as_deref(),
delegator: req.delegator.as_deref(),
cid: req.cid.as_deref(),
sv: req.sv,
sid: req.sid.as_deref(),
}
}
}
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 std::time::Duration;
fn payload_json(req: &IssueRequest, cfg: &IssueConfig, now: i64) -> serde_json::Value {
let payload = IssuePayload::build(req, cfg, now);
serde_json::to_value(&payload).unwrap()
}
#[test]
fn registered_non_aud_claims_round_trip() {
let req = IssueRequest::new("sub-x", "client-y", Duration::from_secs(3600));
let cfg = IssueConfig::access_token("https://accounts.ppoppo.com", "ppoppo", "kid-1");
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"], "sub-x");
assert_eq!(json["exp"], now + 3600);
assert_eq!(json["iat"], now);
assert_eq!(json["nbf"], now);
assert_eq!(json["client_id"], "client-y");
assert_eq!(json["cat"], "access");
let jti = json["jti"].as_str().expect("jti must serialize as string");
assert_eq!(jti.len(), 26, "jti must be ULID format (26 chars)");
}
#[test]
fn aud_emits_string_when_single_audience() {
let req = IssueRequest::new("sub", "client", Duration::from_secs(60));
let cfg = IssueConfig::access_token("iss", "ppoppo", "kid");
let json = payload_json(&req, &cfg, 1_700_000_000);
assert_eq!(json["aud"], "ppoppo");
assert!(json["aud"].is_string(), "single aud must be a string");
}
#[test]
fn aud_emits_array_when_multi_audience() {
let req = IssueRequest::new("sub", "client", Duration::from_secs(60));
let cfg = IssueConfig::access_token("iss", "primary", "kid")
.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 be an array");
assert_eq!(aud[0], "primary");
assert_eq!(aud[1], "secondary");
}
#[test]
fn pinned_jti_overrides_engine_generation() {
let id = ulid::Ulid::from_string("01HABC00000000000000000000").unwrap();
let req = IssueRequest::new("sub", "client", Duration::from_secs(60)).with_jti(id);
let cfg = IssueConfig::access_token("iss", "aud", "kid");
let json = payload_json(&req, &cfg, 1_700_000_000);
assert_eq!(json["jti"], "01HABC00000000000000000000");
}
}