use std::collections::HashSet;
use std::sync::Mutex;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::signing_role::{RoleTrustSet, SessionRole, SessionSigner, SigningRole as _};
const SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v2\0";
const AUTHORIZED_SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v3\0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSubject {
Persona {
persona_id: String,
},
Wallet {
wallet_address: String,
persona_id: Option<String>,
},
}
impl SessionSubject {
#[must_use]
pub fn persona_id(&self) -> Option<&str> {
match self {
Self::Persona { persona_id } => Some(persona_id),
Self::Wallet { persona_id, .. } => persona_id.as_deref(),
}
}
#[must_use]
pub fn wallet_address(&self) -> Option<&str> {
match self {
Self::Persona { .. } => None,
Self::Wallet { wallet_address, .. } => Some(wallet_address),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SessionScope {
ExplorerRead,
WalletManage,
AgentTurn,
}
impl SessionScope {
const fn as_str(self) -> &'static str {
match self {
Self::ExplorerRead => "explorer-read",
Self::WalletManage => "wallet-manage",
Self::AgentTurn => "agent-turn",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"explorer-read" => Some(Self::ExplorerRead),
"wallet-manage" => Some(Self::WalletManage),
"agent-turn" => Some(Self::AgentTurn),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionClaims {
pub issuer: String,
pub key_id: String,
pub subject: SessionSubject,
pub scopes: Vec<SessionScope>,
pub issued_ms: u64,
pub expires_ms: u64,
}
impl SessionClaims {
#[must_use]
pub fn has_scope(&self, scope: SessionScope) -> bool {
self.scopes.contains(&scope)
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct WirePayload {
issuer: String,
key_id: String,
#[serde(flatten)]
subject: WireSubject,
scopes: Vec<String>,
issued_ms: u64,
expires_ms: u64,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "sub_kind", rename_all = "snake_case")]
enum WireSubject {
Persona {
persona_id: String,
},
Wallet {
wallet_address: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
persona_id: Option<String>,
},
}
impl From<&SessionSubject> for WireSubject {
fn from(subject: &SessionSubject) -> Self {
match subject {
SessionSubject::Persona { persona_id } => Self::Persona {
persona_id: persona_id.clone(),
},
SessionSubject::Wallet {
wallet_address,
persona_id,
} => Self::Wallet {
wallet_address: wallet_address.clone(),
persona_id: persona_id.clone(),
},
}
}
}
impl From<WireSubject> for SessionSubject {
fn from(wire: WireSubject) -> Self {
match wire {
WireSubject::Persona { persona_id } => Self::Persona { persona_id },
WireSubject::Wallet {
wallet_address,
persona_id,
} => Self::Wallet {
wallet_address,
persona_id,
},
}
}
}
fn session_canonical(payload: &WirePayload) -> Vec<u8> {
let body =
serde_json::to_string(payload).expect("WirePayload serializes (no non-finite floats)");
let mut bytes = Vec::with_capacity(SESSION_DOMAIN_PREFIX.len() + body.len());
bytes.extend_from_slice(SESSION_DOMAIN_PREFIX);
bytes.extend_from_slice(body.as_bytes());
bytes
}
#[must_use]
pub fn mint_session(
signer: &SessionSigner,
subject: &SessionSubject,
scopes: &[SessionScope],
now_ms: u64,
ttl_ms: u64,
) -> String {
assert!(
!scopes.is_empty(),
"mint_session requires at least one scope"
);
let issued_ms = now_ms;
let expires_ms = now_ms.saturating_add(ttl_ms);
let payload = WirePayload {
issuer: SessionRole::ISSUER.to_owned(),
key_id: signer.identity().key_id().to_owned(),
subject: subject.into(),
scopes: scopes.iter().map(|s| s.as_str().to_owned()).collect(),
issued_ms,
expires_ms,
};
let payload_json =
serde_json::to_string(&payload).expect("WirePayload serializes (no non-finite floats)");
let signed_bytes = session_canonical(&payload);
let signature = signer.sign(&signed_bytes);
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload_json.as_bytes()),
URL_SAFE_NO_PAD.encode(signature)
)
}
fn decode_session_payload(token: &str) -> Option<(WirePayload, Vec<SessionScope>, Vec<u8>)> {
let (payload_b64, signature_b64) = token.split_once('.')?;
if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
return None;
}
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
let payload: WirePayload = serde_json::from_slice(&payload_bytes).ok()?;
let scopes = payload
.scopes
.iter()
.map(|s| SessionScope::from_str(s))
.collect::<Option<Vec<_>>>()?;
Some((payload, scopes, signature))
}
#[must_use]
pub fn verify_session(
signer_public_key: &[u8],
token: &str,
now_ms: u64,
denylist: &RevokedTokens,
) -> Option<SessionClaims> {
let trusted_signers =
RoleTrustSet::<SessionRole>::from_public_keys(vec![signer_public_key.to_vec()]).ok()?;
verify_session_with_trust(&trusted_signers, token, now_ms, denylist)
}
#[must_use]
pub fn verify_session_with_trust(
trusted_signers: &RoleTrustSet<SessionRole>,
token: &str,
now_ms: u64,
denylist: &RevokedTokens,
) -> Option<SessionClaims> {
let (payload, scopes, signature) = decode_session_payload(token)?;
let signed_bytes = session_canonical(&payload);
if payload.issuer != SessionRole::ISSUER
|| !trusted_signers.verify(&payload.key_id, &signed_bytes, &signature)
{
return None;
}
if now_ms >= payload.expires_ms {
return None;
}
if denylist.contains(token) {
return None;
}
Some(SessionClaims {
issuer: payload.issuer,
key_id: payload.key_id,
subject: payload.subject.into(),
scopes,
issued_ms: payload.issued_ms,
expires_ms: payload.expires_ms,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizedSessionClaims {
pub issuer: String,
pub key_id: String,
pub session_id: String,
pub authorization_epoch: u64,
pub subject: SessionSubject,
pub scopes: Vec<SessionScope>,
pub issued_ms: u64,
pub expires_ms: u64,
}
impl AuthorizedSessionClaims {
#[must_use]
pub fn has_scope(&self, scope: SessionScope) -> bool {
self.scopes.contains(&scope)
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct AuthorizedWirePayload {
issuer: String,
key_id: String,
session_id: String,
authorization_epoch: u64,
#[serde(flatten)]
subject: WireSubject,
scopes: Vec<String>,
issued_ms: u64,
expires_ms: u64,
}
fn authorized_session_canonical(payload: &AuthorizedWirePayload) -> Vec<u8> {
let body = serde_json::to_string(payload)
.expect("AuthorizedWirePayload serializes (no non-finite floats)");
let mut bytes = Vec::with_capacity(AUTHORIZED_SESSION_DOMAIN_PREFIX.len() + body.len());
bytes.extend_from_slice(AUTHORIZED_SESSION_DOMAIN_PREFIX);
bytes.extend_from_slice(body.as_bytes());
bytes
}
#[must_use]
pub fn mint_authorized_session(
signer: &SessionSigner,
session_id: &str,
authorization_epoch: u64,
subject: &SessionSubject,
scopes: &[SessionScope],
now_ms: u64,
ttl_ms: u64,
) -> String {
assert!(!session_id.is_empty(), "authorized session id is not empty");
assert!(
!scopes.is_empty(),
"mint_authorized_session requires at least one scope"
);
let payload = AuthorizedWirePayload {
issuer: SessionRole::ISSUER.to_owned(),
key_id: signer.identity().key_id().to_owned(),
session_id: session_id.to_owned(),
authorization_epoch,
subject: subject.into(),
scopes: scopes
.iter()
.map(|scope| scope.as_str().to_owned())
.collect(),
issued_ms: now_ms,
expires_ms: now_ms.saturating_add(ttl_ms),
};
let body = serde_json::to_string(&payload)
.expect("AuthorizedWirePayload serializes (no non-finite floats)");
let signature = signer.sign(&authorized_session_canonical(&payload));
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(body.as_bytes()),
URL_SAFE_NO_PAD.encode(signature)
)
}
#[must_use]
pub fn verify_authorized_session(
signer_public_key: &[u8],
token: &str,
now_ms: u64,
) -> Option<AuthorizedSessionClaims> {
let trusted_signers =
RoleTrustSet::<SessionRole>::from_public_keys(vec![signer_public_key.to_vec()]).ok()?;
verify_authorized_session_with_trust(&trusted_signers, token, now_ms)
}
#[must_use]
pub fn verify_authorized_session_with_trust(
trusted_signers: &RoleTrustSet<SessionRole>,
token: &str,
now_ms: u64,
) -> Option<AuthorizedSessionClaims> {
let (payload_b64, signature_b64) = token.split_once('.')?;
if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
return None;
}
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
let payload: AuthorizedWirePayload = serde_json::from_slice(&payload_bytes).ok()?;
if payload.session_id.is_empty() || now_ms >= payload.expires_ms {
return None;
}
let scopes = payload
.scopes
.iter()
.map(|scope| SessionScope::from_str(scope))
.collect::<Option<Vec<_>>>()?;
if scopes.is_empty()
|| payload.issuer != SessionRole::ISSUER
|| !trusted_signers.verify(
&payload.key_id,
&authorized_session_canonical(&payload),
&signature,
)
{
return None;
}
Some(AuthorizedSessionClaims {
issuer: payload.issuer,
key_id: payload.key_id,
session_id: payload.session_id,
authorization_epoch: payload.authorization_epoch,
subject: payload.subject.into(),
scopes,
issued_ms: payload.issued_ms,
expires_ms: payload.expires_ms,
})
}
pub struct RevokedTokens {
tokens: Mutex<HashSet<String>>,
}
impl RevokedTokens {
#[must_use]
pub fn new() -> Self {
Self {
tokens: Mutex::new(HashSet::new()),
}
}
pub fn revoke(&self, token: &str) {
if let Ok(mut tokens) = self.tokens.lock() {
tokens.insert(token.to_owned());
}
}
#[must_use]
fn contains(&self, token: &str) -> bool {
self.tokens
.lock()
.is_ok_and(|tokens| tokens.contains(token))
}
pub fn reap(&self, now_ms: u64) {
if let Ok(mut tokens) = self.tokens.lock() {
tokens.retain(|token| {
decode_session_payload(token)
.is_some_and(|(payload, _, _)| now_ms < payload.expires_ms)
});
}
}
#[cfg(test)]
fn len(&self) -> usize {
self.tokens.lock().map_or(0, |t| t.len())
}
}
impl Default for RevokedTokens {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
const NOW: u64 = 1_700_000_000_000;
const TEST_TTL_MS: u64 = 8 * 60 * 60 * 1000;
fn test_signer() -> SessionSigner {
SessionSigner::from_key_bytes(&[7u8; 32]).expect("valid 32-byte ed25519 key material")
}
fn persona_subject(persona_id: &str) -> SessionSubject {
SessionSubject::Persona {
persona_id: persona_id.to_owned(),
}
}
fn mint_persona_session(
signer: &SessionSigner,
persona_id: &str,
now_ms: u64,
ttl_ms: u64,
) -> String {
mint_session(
signer,
&persona_subject(persona_id),
&[SessionScope::ExplorerRead],
now_ms,
ttl_ms,
)
}
fn mint_authorized_persona(signer: &SessionSigner) -> String {
mint_authorized_session(
signer,
"session-1",
7,
&persona_subject("persona-1"),
&[SessionScope::ExplorerRead, SessionScope::AgentTurn],
NOW,
TEST_TTL_MS,
)
}
#[test]
fn authorized_bearer_round_trips_every_state_binding() {
let signer = test_signer();
let token = mint_authorized_persona(&signer);
let claims = verify_authorized_session(&signer.public_key_bytes(), &token, NOW)
.expect("fresh State-backed bearer verifies cryptographically");
assert_eq!(claims.session_id, "session-1");
assert_eq!(claims.authorization_epoch, 7);
assert_eq!(claims.subject, persona_subject("persona-1"));
assert!(claims.has_scope(SessionScope::ExplorerRead));
assert!(claims.has_scope(SessionScope::AgentTurn));
assert_eq!(claims.issued_ms, NOW);
assert_eq!(claims.expires_ms, NOW + TEST_TTL_MS);
}
#[test]
fn authorized_wallet_keeps_wallet_as_its_independent_root() {
let signer = test_signer();
let subject = SessionSubject::Wallet {
wallet_address: "0xabc".into(),
persona_id: Some("persona-1".into()),
};
let token = mint_authorized_session(
&signer,
"wallet-session",
3,
&subject,
&[SessionScope::WalletManage, SessionScope::ExplorerRead],
NOW,
TEST_TTL_MS,
);
let claims = verify_authorized_session(&signer.public_key_bytes(), &token, NOW)
.expect("wallet bearer verifies");
assert_eq!(claims.subject.wallet_address(), Some("0xabc"));
assert_eq!(claims.subject.persona_id(), Some("persona-1"));
assert_eq!(claims.authorization_epoch, 3);
}
#[test]
fn every_authority_field_is_covered_by_the_signature() {
let signer = test_signer();
let token = mint_authorized_persona(&signer);
let (payload_b64, signature) = token.split_once('.').expect("token shape");
let original: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).expect("payload base64"))
.expect("payload json");
let mutations = [
("session_id", serde_json::json!("session-2")),
("authorization_epoch", serde_json::json!(8)),
("persona_id", serde_json::json!("persona-2")),
("scopes", serde_json::json!(["wallet-manage"])),
("issued_ms", serde_json::json!(NOW + 1)),
("expires_ms", serde_json::json!(NOW + TEST_TTL_MS + 1)),
];
for (field, replacement) in mutations {
let mut changed = original.clone();
changed[field] = replacement;
let forged = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&changed).expect("json")),
signature
);
assert!(
verify_authorized_session(&signer.public_key_bytes(), &forged, NOW).is_none(),
"changing {field} kept the signature valid"
);
}
}
#[test]
fn valid_signatures_with_unknown_role_identity_fail_closed() {
let signer = test_signer();
let token = mint_authorized_persona(&signer);
let (payload_b64, _) = token.split_once('.').expect("token shape");
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("payload base64");
for (issuer, key_id) in [
(Some("polychrome.control.approval"), None),
(None, Some("unknown-key-id")),
] {
let mut payload: AuthorizedWirePayload =
serde_json::from_slice(&payload_bytes).expect("payload json");
if let Some(issuer) = issuer {
payload.issuer = issuer.to_owned();
}
if let Some(key_id) = key_id {
payload.key_id = key_id.to_owned();
}
let body = serde_json::to_vec(&payload).expect("payload json");
let signature = signer.sign(&authorized_session_canonical(&payload));
let forged = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(body),
URL_SAFE_NO_PAD.encode(signature)
);
assert!(verify_authorized_session(&signer.public_key_bytes(), &forged, NOW).is_none());
}
}
#[test]
fn v2_and_v3_are_never_cross_accepted() {
let signer = test_signer();
let v2 = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
let v3 = mint_authorized_persona(&signer);
assert!(verify_authorized_session(&signer.public_key_bytes(), &v2, NOW).is_none());
assert!(
verify_session(&signer.public_key_bytes(), &v3, NOW, &RevokedTokens::new()).is_none()
);
}
#[test]
fn authorized_bearer_expiry_and_malformed_shapes_fail_closed() {
let signer = test_signer();
let token = mint_authorized_session(
&signer,
"session-1",
0,
&persona_subject("persona-1"),
&[SessionScope::ExplorerRead],
NOW,
1,
);
assert!(verify_authorized_session(&signer.public_key_bytes(), &token, NOW).is_some());
assert!(verify_authorized_session(&signer.public_key_bytes(), &token, NOW + 1).is_none());
for malformed in ["", ".", "a.b.c", "not-base64.signature"] {
assert!(
verify_authorized_session(&signer.public_key_bytes(), malformed, NOW).is_none()
);
}
}
#[test]
fn tampered_payload_is_rejected() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
let (payload_b64, sig_b64) = token.split_once('.').expect("shape");
let mut payload = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
let last = payload.len() - 1;
payload[last] ^= 0xFF;
let tampered = format!("{}.{sig_b64}", URL_SAFE_NO_PAD.encode(payload));
assert!(
verify_session(&signer.public_key_bytes(), &tampered, NOW, &denylist).is_none(),
"a tampered payload must not verify"
);
}
#[test]
fn tampered_signature_is_rejected() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
let (payload_b64, sig_b64) = token.split_once('.').expect("shape");
let mut sig = URL_SAFE_NO_PAD.decode(sig_b64).expect("decodes");
let last = sig.len() - 1;
sig[last] ^= 0xFF;
let tampered = format!("{payload_b64}.{}", URL_SAFE_NO_PAD.encode(sig));
assert!(
verify_session(&signer.public_key_bytes(), &tampered, NOW, &denylist).is_none(),
"a tampered signature must not verify"
);
}
#[test]
fn expired_session_is_rejected() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let token = mint_persona_session(&signer, "persona-1", NOW, 1_000);
assert!(
verify_session(&signer.public_key_bytes(), &token, NOW + 1_000, &denylist).is_none(),
"now_ms == expires_ms must already be rejected (boundary, not just past it)"
);
assert!(
verify_session(&signer.public_key_bytes(), &token, NOW + 999, &denylist).is_some(),
"one ms before expiry must still verify"
);
}
#[test]
fn domain_confusion_is_rejected() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let (payload, signature, _pk) = crate::approval::response_payload(
"req-1",
"some_tool",
"{}",
"",
true,
false,
&[],
"persona-1",
"",
"default",
"",
"",
"conv-1",
"nonce-1",
&signer.relabel_for_test(),
);
let _ = payload; let forged = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(
serde_json::json!({
"issuer": SessionRole::ISSUER,
"key_id": signer.identity().key_id(),
"sub_kind": "persona",
"persona_id": "persona-1",
"scopes": ["explorer-read"],
"issued_ms": NOW,
"expires_ms": NOW + TEST_TTL_MS,
})
.to_string()
),
URL_SAFE_NO_PAD.encode(signature)
);
assert!(
verify_session(&signer.public_key_bytes(), &forged, NOW, &denylist).is_none(),
"an approval_response signature must not verify as a session token"
);
}
#[test]
fn a_v1_session_token_from_before_adr_0007_no_longer_verifies() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let old_prefix = b"polychrome.explorer-session.v1\0";
let old_body = serde_json::json!({
"persona_id": "persona-1",
"issued_ms": NOW,
"expires_ms": NOW + TEST_TTL_MS,
})
.to_string();
let mut signed_bytes = Vec::new();
signed_bytes.extend_from_slice(old_prefix);
signed_bytes.extend_from_slice(old_body.as_bytes());
let signature = signer.sign(&signed_bytes);
let old_token = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(old_body.as_bytes()),
URL_SAFE_NO_PAD.encode(signature)
);
assert!(
verify_session(&signer.public_key_bytes(), &old_token, NOW, &denylist).is_none(),
"a pre-ADR-0007 v1 session token must not verify under the new prefix/shape"
);
}
#[test]
fn a_wallet_session_with_no_linked_persona_verifies_wallet_manage_only() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let subject = SessionSubject::Wallet {
wallet_address: "0xabc".to_owned(),
persona_id: None,
};
let token = mint_session(
&signer,
&subject,
&[SessionScope::WalletManage],
NOW,
TEST_TTL_MS,
);
let claims = verify_session(&signer.public_key_bytes(), &token, NOW, &denylist)
.expect("a fresh wallet-only session verifies");
assert_eq!(claims.subject.persona_id(), None);
assert_eq!(claims.subject.wallet_address(), Some("0xabc"));
assert!(claims.has_scope(SessionScope::WalletManage));
assert!(!claims.has_scope(SessionScope::ExplorerRead));
}
#[test]
fn a_wallet_session_with_a_linked_persona_can_carry_both_scopes() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let subject = SessionSubject::Wallet {
wallet_address: "0xabc".to_owned(),
persona_id: Some("persona-1".to_owned()),
};
let token = mint_session(
&signer,
&subject,
&[SessionScope::ExplorerRead, SessionScope::WalletManage],
NOW,
TEST_TTL_MS,
);
let claims =
verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
assert_eq!(claims.subject.persona_id(), Some("persona-1"));
assert!(claims.has_scope(SessionScope::ExplorerRead));
assert!(claims.has_scope(SessionScope::WalletManage));
}
#[test]
fn an_agent_turn_scope_round_trips_through_its_wire_string() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let token = mint_session(
&signer,
&persona_subject("persona-1"),
&[SessionScope::ExplorerRead, SessionScope::AgentTurn],
NOW,
TEST_TTL_MS,
);
let (payload_b64, _) = token.split_once('.').expect("shape");
let payload = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
let json: serde_json::Value = serde_json::from_slice(&payload).expect("json");
assert_eq!(
json["scopes"],
serde_json::json!(["explorer-read", "agent-turn"]),
"the on-the-wire scope string must be exactly `agent-turn`"
);
let claims =
verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
assert!(claims.has_scope(SessionScope::AgentTurn));
}
#[test]
fn explorer_read_alone_does_not_carry_agent_turn() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
let claims =
verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
assert!(claims.has_scope(SessionScope::ExplorerRead));
assert!(
!claims.has_scope(SessionScope::AgentTurn),
"an explorer-read session must never imply turn dispatch"
);
}
#[test]
fn an_unrecognized_scope_string_fails_the_whole_token_closed() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let body = serde_json::json!({
"issuer": SessionRole::ISSUER,
"key_id": signer.identity().key_id(),
"sub_kind": "persona",
"persona_id": "persona-1",
"scopes": ["explorer-read", "some-future-scope"],
"issued_ms": NOW,
"expires_ms": NOW + TEST_TTL_MS,
})
.to_string();
let mut signed_bytes = Vec::new();
signed_bytes.extend_from_slice(SESSION_DOMAIN_PREFIX);
signed_bytes.extend_from_slice(body.as_bytes());
let signature = signer.sign(&signed_bytes);
let token = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(body.as_bytes()),
URL_SAFE_NO_PAD.encode(signature)
);
assert!(
verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).is_none(),
"an unrecognized scope must refuse the whole token, not drop just that scope"
);
}
#[test]
fn a_session_minted_before_rotation_verifies_only_with_historical_trust() {
let retired = SessionSigner::from_seed(91);
let current = SessionSigner::from_seed(92);
let token = mint_authorized_session(
&retired,
"session-before-rotation",
4,
&persona_subject("persona-1"),
&[SessionScope::ExplorerRead],
NOW,
TEST_TTL_MS,
);
let current_only = RoleTrustSet::<SessionRole>::current(¤t);
let historical =
RoleTrustSet::<SessionRole>::checked(vec![current.identity(), retired.identity()])
.expect("valid rotation history");
assert!(verify_authorized_session_with_trust(¤t_only, &token, NOW).is_none());
assert!(verify_authorized_session_with_trust(&historical, &token, NOW).is_some());
}
#[test]
#[should_panic(expected = "at least one scope")]
fn mint_session_panics_on_an_empty_scope_list() {
let signer = test_signer();
let _ = mint_session(
&signer,
&persona_subject("persona-1"),
&[],
NOW,
TEST_TTL_MS,
);
}
#[test]
fn reap_evicts_an_expired_denylisted_token_but_keeps_a_live_one() {
let signer = test_signer();
let denylist = RevokedTokens::new();
let expired = mint_persona_session(&signer, "persona-1", NOW, 1_000);
let live = mint_persona_session(&signer, "persona-2", NOW, TEST_TTL_MS);
denylist.revoke(&expired);
denylist.revoke(&live);
assert_eq!(
denylist.len(),
2,
"both revoked tokens are stored before reap"
);
denylist.reap(NOW + 1_000);
assert_eq!(
denylist.len(),
1,
"reap must drop the token whose own expiry has passed and keep the still-live one"
);
}
#[test]
fn reap_drops_an_unparseable_denylisted_entry() {
let denylist = RevokedTokens::new();
denylist.revoke("not-a-real-session-token");
assert_eq!(denylist.len(), 1);
denylist.reap(NOW);
assert_eq!(
denylist.len(),
0,
"an unparseable entry is reaped unconditionally"
);
}
}