use crate::access_token::{Act, AuthError, Claims, EntityType, VerifyConfig};
use crate::engine::raw::parse_payload_json;
pub(crate) fn run(
token: &str,
mut claims: Claims,
_cfg: &VerifyConfig,
) -> Result<Claims, AuthError> {
if ulid::Ulid::from_string(&claims.sub).is_err() {
return Err(AuthError::SubFormatInvalid);
}
let payload = parse_payload_json(token)?;
if let Some(value) = payload.get("entity_type") {
let s = value.as_str().ok_or(AuthError::EntityTypeInvalid)?;
let entity = EntityType::parse(s).ok_or(AuthError::EntityTypeInvalid)?;
if !entity.can_hold_credential() {
return Err(AuthError::EntityTypeInvalid);
}
claims.entity_type = Some(entity);
}
if let Some(value) = payload.get("caps") {
claims.caps = parse_string_array(value, AuthError::CapsShapeInvalid)?;
}
let admin = match payload.get("admin") {
None => false,
Some(v) => v.as_bool().ok_or(AuthError::AdminBandRejected)?,
};
let active_ppnum_str = payload.get("active_ppnum").and_then(|v| v.as_str());
if admin {
let ppnum = active_ppnum_str.ok_or(AuthError::AdminBandRejected)?;
if !is_in_admin_band(ppnum) {
return Err(AuthError::AdminBandRejected);
}
}
claims.admin = admin;
claims.active_ppnum = active_ppnum_str.map(String::from);
if let Some(value) = payload.get("act") {
let act: Act =
serde_json::from_value(value.clone()).map_err(|_| AuthError::ActShapeInvalid)?;
if act.depth() > MAX_ACT_DEPTH {
return Err(AuthError::ActTooDeep);
}
claims.act = Some(act);
}
if let Some(value) = payload.get("scopes") {
let scopes = parse_string_array(value, AuthError::ScopesShapeInvalid)?;
if scopes.len() > MAX_SCOPES {
return Err(AuthError::ScopesTooLong);
}
claims.scopes = scopes;
}
claims.cid = payload
.get("cid")
.and_then(|v| v.as_str())
.map(String::from);
claims.sid = payload
.get("sid")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(obj) = payload.as_object() {
for key in obj.keys() {
if !ALLOWED_CLAIMS.contains(&key.as_str()) {
return Err(AuthError::UnknownClaim(key.clone()));
}
}
}
Ok(claims)
}
const MAX_SCOPES: usize = 256;
const ALLOWED_CLAIMS: &[&str] = &[
"iss",
"sub",
"aud",
"exp",
"iat",
"nbf",
"jti",
"client_id",
"cat",
"entity_type",
"admin",
"caps",
"act",
"cid",
"sv",
"sid",
"active_ppnum",
"scopes",
];
const MAX_ACT_DEPTH: usize = 4;
const ADMIN_BAND_START: u16 = 100;
const ADMIN_BAND_END: u16 = 109;
fn is_in_admin_band(active_ppnum: &str) -> bool {
if active_ppnum.len() < 3 {
return false;
}
if !active_ppnum.chars().all(|c| c.is_ascii_digit()) {
return false;
}
match active_ppnum[..3].parse::<u16>() {
Ok(band) => (ADMIN_BAND_START..=ADMIN_BAND_END).contains(&band),
Err(_) => false,
}
}
fn parse_string_array(
value: &serde_json::Value,
on_invalid: AuthError,
) -> Result<Vec<String>, AuthError> {
let array = value.as_array().ok_or(on_invalid.clone())?;
let mut out = Vec::with_capacity(array.len());
for item in array {
let s = item.as_str().ok_or(on_invalid.clone())?;
out.push(s.to_string());
}
Ok(out)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn cfg() -> VerifyConfig {
VerifyConfig::access_token(
"https://accounts.ppoppo.com",
"ppoppo",
crate::access_token::EpochEnforcement::Unenforced {
reason: "test: epoch axis not under test",
},
)
}
fn claims_with_sub(sub: &str) -> Claims {
Claims {
iss: "https://accounts.ppoppo.com".to_string(),
sub: sub.to_string(),
exp: 9_999_999_999,
iat: 1_700_000_000,
nbf: None,
jti: "01HABC00000000000000000000".to_string(),
client_id: "ppoppo-internal".to_string(),
entity_type: None,
caps: Vec::new(),
scopes: Vec::new(),
admin: false,
active_ppnum: None,
act: None,
cid: None,
sid: None,
}
}
fn forge_payload(payload: serde_json::Value) -> String {
use base64::Engine;
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"alg":"EdDSA","typ":"at+jwt","kid":"k"}))
.unwrap(),
);
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
format!("{header}.{body}.<sig>")
}
fn payload_with_sub(sub: &str) -> serde_json::Value {
serde_json::json!({
"iss": "https://accounts.ppoppo.com",
"sub": sub,
"aud": "ppoppo",
"exp": 9_999_999_999i64,
"iat": 1_700_000_000i64,
"jti": "01HABC00000000000000000000",
"client_id": "ppoppo-internal",
"cat": "access",
})
}
#[test]
fn accepts_valid_ulid_sub() {
let claims = claims_with_sub("01HSAB00000000000000000000");
let token = forge_payload(payload_with_sub("01HSAB00000000000000000000"));
assert!(run(&token, claims, &cfg()).is_ok());
}
#[test]
fn rejects_too_short_sub() {
let claims = claims_with_sub("00000000000"); let token = forge_payload(payload_with_sub("00000000000"));
assert_eq!(
run(&token, claims, &cfg()),
Err(AuthError::SubFormatInvalid),
);
}
#[test]
fn rejects_non_crockford_alphabet() {
let claims = claims_with_sub("I1HSUB00000000000000000000");
let token = forge_payload(payload_with_sub("I1HSUB00000000000000000000"));
assert_eq!(
run(&token, claims, &cfg()),
Err(AuthError::SubFormatInvalid),
);
}
#[test]
fn account_type_populated_when_valid() {
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["entity_type"] = serde_json::json!("human");
let token = forge_payload(payload);
let claims = run(&token, claims, &cfg()).expect("M40 valid");
assert_eq!(claims.entity_type, Some(EntityType::Human));
}
#[test]
fn account_type_programmable_admitted() {
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["entity_type"] = serde_json::json!("programmable");
let token = forge_payload(payload);
let claims = run(&token, claims, &cfg()).expect("M40 programmable admitted");
assert_eq!(claims.entity_type, Some(EntityType::Programmable));
}
#[test]
fn entity_type_outside_the_vocabulary_rejected() {
for bad in ["robot", "delegated", "user", "bot"] {
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["entity_type"] = serde_json::json!(bad);
let token = forge_payload(payload);
assert_eq!(
run(&token, claims, &cfg()),
Err(AuthError::EntityTypeInvalid),
"{bad:?} is not an entity type; `delegated` in particular is a \
session mode carried by the actor claim, never a value here",
);
}
}
#[test]
fn entity_type_without_a_credential_flow_rejected() {
for ineligible in EntityType::ALL
.into_iter()
.filter(|e| !e.can_hold_credential())
{
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["entity_type"] = serde_json::json!(ineligible.as_str());
let token = forge_payload(payload);
assert_eq!(
run(&token, claims, &cfg()),
Err(AuthError::EntityTypeInvalid),
"{ineligible} parses but cannot hold a credential — admitting it \
would let PAS verify a token it refuses to mint",
);
}
}
fn act_chain(hops: usize) -> serde_json::Value {
let mut act = serde_json::json!({"sub": "01HSAB00000000000000000000"});
for _ in 1..hops {
act = serde_json::json!({"sub": "01HSAB00000000000000000000", "act": act});
}
act
}
fn run_with_act(act: serde_json::Value) -> Result<Claims, AuthError> {
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["act"] = act;
run(&forge_payload(payload), claims, &cfg())
}
#[test]
fn act_chain_within_the_bound_is_admitted() {
for hops in 1..=MAX_ACT_DEPTH {
let claims = run_with_act(act_chain(hops))
.unwrap_or_else(|e| panic!("{hops}-hop chain must admit, got {e:?}"));
assert_eq!(
claims.act.as_ref().map(Act::depth),
Some(hops),
"the surfaced chain must be the one on the wire"
);
}
}
#[test]
fn act_chain_past_the_bound_is_rejected() {
assert_eq!(
run_with_act(act_chain(MAX_ACT_DEPTH + 1)),
Err(AuthError::ActTooDeep),
);
}
#[test]
fn act_that_is_not_an_actor_object_is_rejected() {
for malformed in [
serde_json::json!("01HSAB00000000000000000000"), serde_json::json!(["01HSAB00000000000000000000"]),
serde_json::json!({}), serde_json::json!({"sub": 42}), serde_json::json!({"subject": "01HSA"}), ] {
assert_eq!(
run_with_act(malformed.clone()),
Err(AuthError::ActShapeInvalid),
"{malformed} is not an RFC 8693 actor object",
);
}
}
#[test]
fn pii_smuggled_inside_act_is_rejected() {
for smuggled in [
serde_json::json!({"sub": "01HSAB00000000000000000000", "email": "a@b.c"}),
serde_json::json!({
"sub": "01HSAB00000000000000000000",
"act": {"sub": "01HSAB00000000000000000000", "email": "a@b.c"},
}),
] {
assert_eq!(
run_with_act(smuggled.clone()),
Err(AuthError::ActShapeInvalid),
"{smuggled} would otherwise be seen by nothing — M45 cannot \
reach inside an object-valued claim",
);
}
}
#[test]
fn absurdly_deep_act_dies_in_the_payload_parse() {
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["act"] = act_chain(200);
let result = run(&forge_payload(payload), claims, &cfg());
assert!(
!matches!(result, Ok(_) | Err(AuthError::ActTooDeep)),
"a 200-deep chain must be refused while parsing the payload, not \
by walking it: {result:?}"
);
}
#[test]
fn every_credential_eligible_entity_type_admitted() {
for eligible in EntityType::ALL
.into_iter()
.filter(|e| e.can_hold_credential())
{
let claims = claims_with_sub("01HSAB00000000000000000000");
let mut payload = payload_with_sub("01HSAB00000000000000000000");
payload["entity_type"] = serde_json::json!(eligible.as_str());
let token = forge_payload(payload);
let claims = run(&token, claims, &cfg())
.unwrap_or_else(|e| panic!("{eligible} must be admitted, got {e:?}"));
assert_eq!(claims.entity_type, Some(eligible));
}
}
}