use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenRole {
User,
Client,
Workload,
#[serde(untagged)]
Custom(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenKind {
Jwt,
Opaque,
SpiffeJwt,
Ucan,
TxnToken,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DelegationMode {
OnBehalfOfUser,
AsGateway,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawInboundToken {
#[serde(skip)]
pub token: Zeroizing<String>,
pub source_header: String,
pub kind: TokenKind,
}
impl RawInboundToken {
pub fn new(
token: impl Into<String>,
source_header: impl Into<String>,
kind: TokenKind,
) -> Self {
Self {
token: Zeroizing::new(token.into()),
source_header: source_header.into(),
kind,
}
}
}
#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub struct DelegationKey {
pub subject_id: String,
pub audience: String,
pub scopes: Vec<String>,
pub mode: DelegationMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawDelegatedToken {
#[serde(skip)]
pub token: Zeroizing<String>,
pub outbound_header: String,
pub audience: String,
pub scopes: Vec<String>,
pub expires_at: DateTime<Utc>,
}
impl RawDelegatedToken {
pub fn new(
token: impl Into<String>,
outbound_header: impl Into<String>,
audience: impl Into<String>,
scopes: Vec<String>,
expires_at: DateTime<Utc>,
) -> Self {
Self {
token: Zeroizing::new(token.into()),
outbound_header: outbound_header.into(),
audience: audience.into(),
scopes,
expires_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RawCredentialsExtension {
#[serde(default)]
pub inbound_tokens: HashMap<TokenRole, RawInboundToken>,
#[serde(default)]
pub delegated_tokens: HashMap<DelegationKey, RawDelegatedToken>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_inbound_token_serializes_without_secret() {
let tok = RawInboundToken::new(
"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig",
"Authorization",
TokenKind::Jwt,
);
let json = serde_json::to_string(&tok).unwrap();
assert!(
!json.contains("eyJhbGciOiJSUzI1NiJ9"),
"raw token leaked into serialized form: {}",
json
);
assert!(json.contains("Authorization"));
assert!(json.contains("jwt"));
}
#[test]
fn raw_inbound_token_deserializes_with_empty_token() {
let json = r#"{"source_header":"Authorization","kind":"jwt"}"#;
let tok: RawInboundToken = serde_json::from_str(json).unwrap();
assert_eq!(&*tok.token, "");
assert_eq!(tok.source_header, "Authorization");
assert!(matches!(tok.kind, TokenKind::Jwt));
}
#[test]
fn raw_delegated_token_serializes_without_secret() {
let tok = RawDelegatedToken::new(
"minted-secret-bytes",
"Authorization",
"https://downstream.example.com",
vec!["read".into()],
Utc::now(),
);
let json = serde_json::to_string(&tok).unwrap();
assert!(
!json.contains("minted-secret-bytes"),
"delegated token leaked: {}",
json
);
assert!(json.contains("downstream.example.com"));
}
#[test]
fn token_role_custom_is_hashmap_compatible() {
let mut map: HashMap<TokenRole, &str> = HashMap::new();
map.insert(TokenRole::Custom("partner".into()), "p");
assert_eq!(map.get(&TokenRole::Custom("partner".into())), Some(&"p"));
assert_eq!(map.get(&TokenRole::Custom("other".into())), None);
}
#[test]
fn delegation_key_hash_eq_consistency() {
let k1 = DelegationKey {
subject_id: "alice".into(),
audience: "https://api.example.com".into(),
scopes: vec!["read".into(), "write".into()],
mode: DelegationMode::OnBehalfOfUser,
};
let k2 = DelegationKey {
subject_id: "alice".into(),
audience: "https://api.example.com".into(),
scopes: vec!["read".into(), "write".into()],
mode: DelegationMode::OnBehalfOfUser,
};
assert_eq!(k1, k2);
let k3 = DelegationKey {
scopes: vec!["write".into(), "read".into()],
..k1.clone()
};
assert_ne!(k1, k3);
}
#[test]
fn extension_round_trip_drops_tokens() {
let mut ext = RawCredentialsExtension::default();
ext.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("user-jwt", "X-User-Token", TokenKind::Jwt),
);
let json = serde_json::to_string(&ext).unwrap();
assert!(!json.contains("user-jwt"));
let restored: RawCredentialsExtension = serde_json::from_str(&json).unwrap();
let restored_tok = restored.inbound_tokens.get(&TokenRole::User).unwrap();
assert_eq!(&*restored_tok.token, "");
assert_eq!(restored_tok.source_header, "X-User-Token");
}
}