use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::errors::ErrorKind;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::auth::identity::anonymous::AnonymousIdentityProvider;
use crate::auth::identity::temporary::TemporaryIdentityProvider;
use crate::local::{ExpClaims, LocalJwt, LocalJwtError};
use crate::models::identity::Identity;
pub const VISITOR_SUBJECT_PREFIX: &str = "visitor:";
pub const TICKET_TOKEN_TYPE: &str = "temporary";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VisitorTicketClaims {
pub sub: String,
pub typ: String,
pub tier: String,
pub iat: i64,
pub exp: i64,
}
impl ExpClaims for VisitorTicketClaims {
fn exp(&self) -> i64 {
self.exp
}
}
impl VisitorTicketClaims {
pub fn ticket_id(&self) -> Result<Uuid, VisitorTicketError> {
let raw = self
.sub
.strip_prefix(VISITOR_SUBJECT_PREFIX)
.ok_or_else(|| {
VisitorTicketError::Invalid(format!(
"subject {:?} lacks the {VISITOR_SUBJECT_PREFIX:?} prefix",
self.sub
))
})?;
Uuid::parse_str(raw)
.map_err(|e| VisitorTicketError::Invalid(format!("subject {raw:?} is not a UUID: {e}")))
}
}
#[derive(Debug, thiserror::Error)]
pub enum VisitorTicketError {
#[error("visitor ticket token expired")]
Expired,
#[error("visitor ticket principal expired (inactivity window elapsed)")]
PrincipalExpired,
#[error("visitor ticket revoked")]
Revoked,
#[error("unknown visitor ticket")]
UnknownTicket,
#[error("invalid visitor ticket: {0}")]
Invalid(String),
#[error("visitor ticket mint failed: {0}")]
Mint(String),
#[error("visitor ticket store failed: {0}")]
Store(#[from] anyhow::Error),
}
impl From<LocalJwtError> for VisitorTicketError {
fn from(err: LocalJwtError) -> Self {
match err {
LocalJwtError::MissingExp => {
VisitorTicketError::Invalid("claims without a positive exp".into())
}
LocalJwtError::Sign(e) => VisitorTicketError::Mint(format!("sign failed: {e}")),
LocalJwtError::Verify(e) => {
if matches!(e.kind(), ErrorKind::ExpiredSignature) {
VisitorTicketError::Expired
} else {
VisitorTicketError::Invalid(format!("signature verification failed: {e}"))
}
}
}
}
}
fn temporary_id(identity: &Identity) -> Option<Uuid> {
match identity {
Identity::Temporary { id, .. } => Some(*id),
_ => None,
}
}
#[derive(Clone)]
pub struct VisitorTicketConfig {
pub secret: Vec<u8>,
pub access_ttl: Duration,
pub inactivity_window: Duration,
pub tier: String,
}
impl fmt::Debug for VisitorTicketConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VisitorTicketConfig")
.field("secret", &"[redacted]")
.field("access_ttl", &self.access_ttl)
.field("inactivity_window", &self.inactivity_window)
.field("tier", &self.tier)
.finish()
}
}
impl VisitorTicketConfig {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self {
secret: secret.into(),
access_ttl: Duration::hours(24),
inactivity_window: Duration::days(90),
tier: "anon".to_string(),
}
}
pub fn with_access_ttl(mut self, access_ttl: Duration) -> Self {
self.access_ttl = access_ttl;
self
}
pub fn with_inactivity_window(mut self, inactivity_window: Duration) -> Self {
self.inactivity_window = inactivity_window;
self
}
pub fn with_tier(mut self, tier: impl Into<String>) -> Self {
self.tier = tier.into();
self
}
}
#[derive(Debug, Clone)]
pub struct IssuedTicket {
pub token: String,
pub ticket_id: Uuid,
pub identity: Identity,
pub access_expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct VerifiedTicket {
pub claims: VisitorTicketClaims,
pub ticket_id: Uuid,
pub identity: Identity,
pub principal_expires_at: DateTime<Utc>,
}
pub struct VisitorTickets<S: TemporaryIdentityProvider> {
jwt: LocalJwt,
store: Arc<S>,
config: VisitorTicketConfig,
}
impl<S: TemporaryIdentityProvider> Clone for VisitorTickets<S> {
fn clone(&self) -> Self {
Self {
jwt: self.jwt.clone(),
store: Arc::clone(&self.store),
config: self.config.clone(),
}
}
}
impl<S: TemporaryIdentityProvider> VisitorTickets<S> {
pub fn new(store: Arc<S>, config: VisitorTicketConfig) -> Self {
debug_assert!(
config.access_ttl > Duration::zero(),
"access_ttl must be positive"
);
debug_assert!(
config.inactivity_window > Duration::zero(),
"inactivity_window must be positive"
);
Self {
jwt: LocalJwt::from_secret(config.secret.clone()),
store,
config,
}
}
pub async fn issue(&self) -> Result<IssuedTicket, VisitorTicketError> {
let identity = self
.store
.create_temporary(Utc::now() + self.config.inactivity_window)
.await?;
let Some(ticket_id) = temporary_id(&identity) else {
return Err(VisitorTicketError::Store(anyhow!(
"store returned a non-temporary identity from create_temporary: {identity:?}"
)));
};
self.mint(ticket_id, identity)
}
pub async fn verify(&self, token: &str) -> Result<VerifiedTicket, VisitorTicketError> {
let claims: VisitorTicketClaims = self.jwt.verify(token)?;
self.admit(claims).await
}
pub async fn renew(&self, token: &str) -> Result<IssuedTicket, VisitorTicketError> {
let claims: VisitorTicketClaims = self.jwt.verify_lenient(token)?;
let verified = self.admit(claims).await?;
self.mint(verified.ticket_id, verified.identity)
}
pub async fn revoke(&self, ticket_id: Uuid) -> Result<(), VisitorTicketError> {
self.store.revoke_temporary(ticket_id).await?;
Ok(())
}
async fn admit(
&self,
claims: VisitorTicketClaims,
) -> Result<VerifiedTicket, VisitorTicketError> {
if claims.typ != TICKET_TOKEN_TYPE {
return Err(VisitorTicketError::Invalid(format!(
"unexpected token type {:?}; tickets carry {TICKET_TOKEN_TYPE:?}",
claims.typ
)));
}
let ticket_id = claims.ticket_id()?;
let identity = self
.store
.get_temporary(ticket_id)
.await?
.ok_or(VisitorTicketError::UnknownTicket)?;
if self.store.is_revoked(ticket_id).await? {
return Err(VisitorTicketError::Revoked);
}
let Identity::Temporary { expires_at, .. } = &identity else {
return Err(VisitorTicketError::Store(anyhow!(
"store returned a non-temporary identity for ticket {ticket_id}"
)));
};
let principal_expires_at = *expires_at;
if principal_expires_at < Utc::now() {
return Err(VisitorTicketError::PrincipalExpired);
}
Ok(VerifiedTicket {
claims,
ticket_id,
identity,
principal_expires_at,
})
}
fn mint(
&self,
ticket_id: Uuid,
identity: Identity,
) -> Result<IssuedTicket, VisitorTicketError> {
let now = Utc::now();
let access_expires_at = now + self.config.access_ttl;
let claims = VisitorTicketClaims {
sub: format!("{VISITOR_SUBJECT_PREFIX}{ticket_id}"),
typ: TICKET_TOKEN_TYPE.to_string(),
tier: self.config.tier.clone(),
iat: now.timestamp(),
exp: access_expires_at.timestamp(),
};
let token = self.jwt.sign(&claims)?;
Ok(IssuedTicket {
token,
ticket_id,
identity,
access_expires_at,
})
}
}
#[async_trait]
impl<S: TemporaryIdentityProvider> AnonymousIdentityProvider for VisitorTickets<S> {
async fn create_anonymous(&self) -> Result<Identity> {
Ok(self.issue().await?.identity)
}
async fn get_anonymous(&self, id: Uuid) -> Result<Option<Identity>> {
self.store.get_temporary(id).await
}
}
#[derive(Clone, Default)]
pub struct InMemoryVisitorTicketStore {
identities: Arc<RwLock<HashMap<Uuid, Identity>>>,
revoked: Arc<RwLock<HashSet<Uuid>>>,
}
impl InMemoryVisitorTicketStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl TemporaryIdentityProvider for InMemoryVisitorTicketStore {
async fn create_temporary(&self, expires_at: DateTime<Utc>) -> Result<Identity> {
let id = Uuid::now_v7();
let identity = Identity::Temporary { id, expires_at };
self.identities.write().await.insert(id, identity.clone());
Ok(identity)
}
async fn get_temporary(&self, id: Uuid) -> Result<Option<Identity>> {
Ok(self.identities.read().await.get(&id).cloned())
}
async fn is_expired(&self, id: Uuid) -> Result<bool> {
Ok(self
.identities
.read()
.await
.get(&id)
.is_some_and(|identity| match identity {
Identity::Temporary { expires_at, .. } => *expires_at < Utc::now(),
_ => false,
}))
}
async fn revoke_temporary(&self, id: Uuid) -> Result<()> {
self.revoked.write().await.insert(id);
Ok(())
}
async fn is_revoked(&self, id: Uuid) -> Result<bool> {
Ok(self.revoked.read().await.contains(&id))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> VisitorTicketConfig {
VisitorTicketConfig::new("visitor-ticket-test-secret")
}
fn fixture() -> (
VisitorTickets<InMemoryVisitorTicketStore>,
InMemoryVisitorTicketStore,
) {
let store = InMemoryVisitorTicketStore::new();
let tickets = VisitorTickets::new(Arc::new(store.clone()), test_config());
(tickets, store)
}
fn claims_for(ticket_id: Uuid, exp: DateTime<Utc>) -> VisitorTicketClaims {
VisitorTicketClaims {
sub: format!("{VISITOR_SUBJECT_PREFIX}{ticket_id}"),
typ: TICKET_TOKEN_TYPE.to_string(),
tier: "anon".to_string(),
iat: Utc::now().timestamp(),
exp: exp.timestamp(),
}
}
fn hand_mint(config: &VisitorTicketConfig, claims: &VisitorTicketClaims) -> String {
LocalJwt::from_secret(config.secret.clone())
.sign(claims)
.unwrap()
}
async fn lapse_principal(store: &InMemoryVisitorTicketStore, ticket_id: Uuid) {
let mut identities = store.identities.write().await;
identities.insert(
ticket_id,
Identity::Temporary {
id: ticket_id,
expires_at: Utc::now() - Duration::hours(1),
},
);
}
#[tokio::test]
async fn issued_ticket_carries_the_spec_wire_format() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
let claims = LocalJwt::from_secret(test_config().secret)
.verify::<VisitorTicketClaims>(&issued.token)
.unwrap();
assert_eq!(claims.sub, format!("visitor:{}", issued.ticket_id));
assert_eq!(claims.typ, TICKET_TOKEN_TYPE);
assert_eq!(claims.tier, "anon");
assert_eq!(claims.exp - claims.iat, Duration::hours(24).num_seconds());
assert_eq!(issued.access_expires_at.timestamp(), claims.exp);
}
#[tokio::test]
async fn verify_resolves_a_fresh_ticket() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
let verified = tickets.verify(&issued.token).await.unwrap();
assert_eq!(verified.ticket_id, issued.ticket_id);
assert_eq!(verified.claims.sub, format!("visitor:{}", issued.ticket_id));
assert!(matches!(verified.identity, Identity::Temporary { .. }));
assert!(verified.principal_expires_at > Utc::now() + Duration::days(89));
}
#[tokio::test]
async fn expired_jwt_is_rejected_at_the_gate() {
let (tickets, _) = fixture();
let claims = claims_for(Uuid::now_v7(), Utc::now() - Duration::minutes(5));
let token = hand_mint(&test_config(), &claims);
assert!(matches!(
tickets.verify(&token).await,
Err(VisitorTicketError::Expired)
));
}
#[tokio::test]
async fn principal_expiry_is_enforced_even_while_jwt_is_fresh() {
let (tickets, store) = fixture();
let issued = tickets.issue().await.unwrap();
lapse_principal(&store, issued.ticket_id).await;
let claims = claims_for(issued.ticket_id, Utc::now() + Duration::hours(1));
let token = hand_mint(&test_config(), &claims);
assert!(matches!(
tickets.verify(&token).await,
Err(VisitorTicketError::PrincipalExpired)
));
}
#[tokio::test]
async fn revoked_ticket_is_rejected_and_renewal_is_refused() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
tickets.revoke(issued.ticket_id).await.unwrap();
tickets.revoke(issued.ticket_id).await.unwrap();
let claims = claims_for(issued.ticket_id, Utc::now() + Duration::hours(1));
let fresh = hand_mint(&test_config(), &claims);
assert!(matches!(
tickets.verify(&fresh).await,
Err(VisitorTicketError::Revoked)
));
assert!(matches!(
tickets.renew(&issued.token).await,
Err(VisitorTicketError::Revoked)
));
}
#[tokio::test]
async fn unknown_ticket_never_downgrades_to_anonymous() {
let (tickets, _) = fixture();
let claims = claims_for(Uuid::now_v7(), Utc::now() + Duration::hours(1));
let token = hand_mint(&test_config(), &claims);
assert!(matches!(
tickets.verify(&token).await,
Err(VisitorTicketError::UnknownTicket)
));
}
#[tokio::test]
async fn malformed_subjects_and_wrong_types_are_rejected() {
let (tickets, _) = fixture();
let now = Utc::now();
let fresh_exp = now + Duration::hours(1);
let no_prefix = VisitorTicketClaims {
sub: Uuid::now_v7().to_string(),
typ: TICKET_TOKEN_TYPE.to_string(),
tier: "anon".to_string(),
iat: now.timestamp(),
exp: fresh_exp.timestamp(),
};
let bad_uuid = VisitorTicketClaims {
sub: "visitor:not-a-uuid".to_string(),
typ: TICKET_TOKEN_TYPE.to_string(),
tier: "anon".to_string(),
iat: now.timestamp(),
exp: fresh_exp.timestamp(),
};
let wrong_type = VisitorTicketClaims {
sub: format!("{VISITOR_SUBJECT_PREFIX}{}", Uuid::now_v7()),
typ: "access".to_string(),
tier: "anon".to_string(),
iat: now.timestamp(),
exp: fresh_exp.timestamp(),
};
for claims in [no_prefix, bad_uuid, wrong_type] {
let token = hand_mint(&test_config(), &claims);
assert!(
matches!(
tickets.verify(&token).await,
Err(VisitorTicketError::Invalid(_))
),
"expected Invalid for {claims:?}"
);
}
}
#[tokio::test]
async fn tampered_and_foreign_key_tokens_are_rejected() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
let mut forged = issued.token.clone();
forged.replace_range(0..1, "X");
assert!(matches!(
tickets.verify(&forged).await,
Err(VisitorTicketError::Invalid(_))
));
let foreign_secret = VisitorTicketConfig::new("not-the-issuer-secret");
let claims = claims_for(issued.ticket_id, Utc::now() + Duration::hours(1));
let foreign = hand_mint(&foreign_secret, &claims);
assert!(matches!(
tickets.verify(&foreign).await,
Err(VisitorTicketError::Invalid(_))
));
}
#[tokio::test]
async fn renewal_rolls_the_chain_within_the_window() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
let renewed = tickets.renew(&issued.token).await.unwrap();
assert_eq!(renewed.ticket_id, issued.ticket_id);
let verified = tickets.verify(&renewed.token).await.unwrap();
assert_eq!(verified.ticket_id, issued.ticket_id);
assert_eq!(
verified.claims.sub,
format!("{VISITOR_SUBJECT_PREFIX}{}", renewed.ticket_id)
);
}
#[tokio::test]
async fn renewal_survives_jwt_expiry_within_the_window() {
let (tickets, _) = fixture();
let issued = tickets.issue().await.unwrap();
let stale = claims_for(issued.ticket_id, Utc::now() - Duration::minutes(5));
let token = hand_mint(&test_config(), &stale);
assert!(matches!(
tickets.verify(&token).await,
Err(VisitorTicketError::Expired)
));
let renewed = tickets.renew(&token).await.unwrap();
assert_eq!(renewed.ticket_id, issued.ticket_id);
assert!(tickets.verify(&renewed.token).await.is_ok());
}
#[tokio::test]
async fn renewal_refused_after_principal_expiry() {
let (tickets, store) = fixture();
let issued = tickets.issue().await.unwrap();
lapse_principal(&store, issued.ticket_id).await;
assert!(matches!(
tickets.renew(&issued.token).await,
Err(VisitorTicketError::PrincipalExpired)
));
}
#[tokio::test]
async fn renewal_restamps_the_current_policy_tier() {
let store = InMemoryVisitorTicketStore::new();
let anon = VisitorTickets::new(
Arc::new(store.clone()),
VisitorTicketConfig::new("visitor-ticket-test-secret").with_tier("anon"),
);
let expert = VisitorTickets::new(
Arc::new(store.clone()),
VisitorTicketConfig::new("visitor-ticket-test-secret").with_tier("expert"),
);
let issued = anon.issue().await.unwrap();
let renewed = expert.renew(&issued.token).await.unwrap();
let claims = LocalJwt::from_secret(test_config().secret)
.verify::<VisitorTicketClaims>(&renewed.token)
.unwrap();
assert_eq!(claims.tier, "expert");
}
#[tokio::test]
async fn anonymous_spi_entry_issues_a_visitor_principal() {
let (tickets, _) = fixture();
let anonymous: &dyn AnonymousIdentityProvider = &tickets;
let identity = anonymous.create_anonymous().await.unwrap();
let Identity::Temporary { id, .. } = &identity else {
panic!("the anonymous entry must yield a temporary visitor principal");
};
assert_eq!(anonymous.get_anonymous(*id).await.unwrap(), Some(identity));
}
#[tokio::test]
async fn store_expiry_boundary_is_exclusive() {
let store = InMemoryVisitorTicketStore::new();
let alive = store
.create_temporary(Utc::now() + Duration::hours(1))
.await
.unwrap();
let Identity::Temporary { id: alive_id, .. } = &alive else {
panic!("expected a temporary identity");
};
assert!(!store.is_expired(*alive_id).await.unwrap());
let dead = store
.create_temporary(Utc::now() - Duration::seconds(1))
.await
.unwrap();
let Identity::Temporary { id: dead_id, .. } = &dead else {
panic!("expected a temporary identity");
};
assert!(store.is_expired(*dead_id).await.unwrap());
}
#[tokio::test]
async fn config_debug_never_leaks_the_secret() {
let rendered = format!("{:?}", test_config());
assert!(rendered.contains("[redacted]"), "{rendered}");
assert!(
!rendered.contains("visitor-ticket-test-secret"),
"{rendered}"
);
}
}