use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
pub const DEFAULT_RESTRICTED_SESSION_TOKEN_TTL_MS: u64 = 15 * 60 * 1000;
pub const TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE: &str = "openrtc:endpoint-ticket:v1";
const TOKEN_PAYLOAD_NONCE_BYTES: usize = 16;
pub fn now_unix_ms() -> u64 {
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now().max(0.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct GrantScope(pub String);
impl GrantScope {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl From<&str> for GrantScope {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for GrantScope {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&GrantScope> for GrantScope {
fn from(value: &GrantScope) -> Self {
value.clone()
}
}
impl AsRef<str> for GrantScope {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl PartialEq<&str> for GrantScope {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl PartialEq<String> for GrantScope {
fn eq(&self, other: &String) -> bool {
self.as_str() == other
}
}
impl std::fmt::Display for GrantScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone)]
pub struct SessionToken {
pub token: String,
pub scope: GrantScope,
pub max_connections: u32,
pub use_count: u32,
pub expires_at_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionAdmissionMechanism {
SessionToken,
TrustedNativeBinding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionAdmission {
Pending,
Accepted {
mechanism: SessionAdmissionMechanism,
scope: Option<GrantScope>,
authoritative_device_id: Option<String>,
},
Rejected {
reason: String,
},
}
pub fn token_fingerprint(token: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(token.as_bytes());
hex::encode(&digest[..8])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NativeTrustedConnectionContext {
pub connection_id: String,
pub remote_node_id: Option<String>,
pub known_device_id: Option<String>,
pub claimed_device_id: Option<String>,
}
pub type NativeTrustedConnectionVerifier =
Arc<dyn Fn(&NativeTrustedConnectionContext) -> Option<String> + Send + Sync>;
pub struct AdmissionResponseGuard {
connection_id: String,
activities: Arc<RwLock<HashMap<String, AdmissionActivity>>>,
}
pub struct AdmissionPresentationGuard {
connection_id: String,
activities: Arc<RwLock<HashMap<String, AdmissionActivity>>>,
}
impl Drop for AdmissionPresentationGuard {
fn drop(&mut self) {
if let Ok(mut activities) = self.activities.write() {
if let Some(activity) = activities.get_mut(&self.connection_id) {
activity.presentation_active = false;
if activity.response_writers == 0
&& !activity.presentation_active
&& !activity.retirement_active
{
activities.remove(&self.connection_id);
}
}
}
}
}
impl AdmissionResponseGuard {
pub fn connection_id(&self) -> &str {
&self.connection_id
}
}
impl Drop for AdmissionResponseGuard {
fn drop(&mut self) {
if let Ok(mut activities) = self.activities.write() {
if let Some(activity) = activities.get_mut(&self.connection_id) {
if activity.response_writers > 0 {
activity.response_writers -= 1;
}
if activity.response_writers == 0
&& !activity.presentation_active
&& !activity.retirement_active
{
activities.remove(&self.connection_id);
}
}
}
}
}
#[derive(Debug, Default)]
struct AdmissionActivity {
response_writers: u32,
presentation_active: bool,
retirement_active: bool,
}
pub struct AdmissionRetirementGuard {
connection_id: String,
activities: Arc<RwLock<HashMap<String, AdmissionActivity>>>,
}
impl Drop for AdmissionRetirementGuard {
fn drop(&mut self) {
if let Ok(mut activities) = self.activities.write() {
if let Some(activity) = activities.get_mut(&self.connection_id) {
activity.retirement_active = false;
if activity.response_writers == 0 && !activity.presentation_active {
activities.remove(&self.connection_id);
}
}
}
}
}
#[derive(Default, Clone)]
pub struct SessionTokenRegistry {
inner: Arc<RwLock<HashMap<String, SessionToken>>>,
validated_connections: Arc<RwLock<HashMap<String, GrantScope>>>,
inbound_token_admissions: Arc<RwLock<HashMap<String, (GrantScope, String)>>>,
admissions: Arc<RwLock<HashMap<String, SessionAdmission>>>,
admission_fingerprints: Arc<RwLock<HashMap<String, String>>>,
application_security_epoch_fingerprints: Arc<RwLock<HashMap<String, String>>>,
admission_activities: Arc<RwLock<HashMap<String, AdmissionActivity>>>,
seen_payload_nonces: Arc<RwLock<HashMap<String, String>>>,
native_trusted_connection_verifier: Arc<RwLock<Option<NativeTrustedConnectionVerifier>>>,
}
impl SessionTokenRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&self, token: String, scope: impl Into<GrantScope>, max_connections: u32) {
self.register_with_expiry_ms(token, scope, max_connections, None);
}
pub fn register_with_expiry_ms(
&self,
token: String,
scope: impl Into<GrantScope>,
max_connections: u32,
expires_at_ms: Option<u64>,
) {
let scope = scope.into();
if token.trim().is_empty() || scope.as_str().trim().is_empty() {
return;
}
let entry = SessionToken {
token: token.clone(),
scope,
max_connections,
use_count: 0,
expires_at_ms,
};
if let Ok(mut map) = self.inner.write() {
map.insert(token, entry);
}
}
pub fn validate_and_consume(&self, token: &str) -> Result<GrantScope, String> {
self.validate_and_consume_for_connection(token, None)
}
pub fn validate_and_consume_for_connection(
&self,
token: &str,
connection_id: Option<&str>,
) -> Result<GrantScope, String> {
self.validate_and_consume_for_connection_with_payload(token, connection_id, None)
}
pub fn validate_and_consume_for_connection_with_payload(
&self,
token: &str,
connection_id: Option<&str>,
payload_suffix: Option<&str>,
) -> Result<GrantScope, String> {
let presented_fp = if !token.is_empty() {
Some(token_fingerprint(token))
} else {
None
};
let cached_inbound = if let Some(conn_id) = connection_id {
if let SessionAdmission::Rejected { reason } = self.admission(conn_id) {
return Err(reason);
}
self.inbound_token_admissions
.read()
.ok()
.and_then(|admissions| admissions.get(conn_id).cloned())
} else {
None
};
let mut map = self
.inner
.write()
.map_err(|_| "registry lock poisoned".to_string())?;
if map.is_empty() {
return if token.trim().is_empty() {
Ok(GrantScope::new(String::new()))
} else {
Err("unknown session token".to_string())
};
}
let entry = map
.get_mut(token)
.ok_or_else(|| "unknown session token".to_string())?;
if let Some(suffix) = payload_suffix {
self.validate_presented_payload_suffix(
token,
connection_id,
suffix,
&entry.scope,
entry.max_connections,
)?;
}
if entry
.expires_at_ms
.is_some_and(|expires_at_ms| expires_at_ms <= now_unix_ms())
{
map.remove(token);
return Err("session token expired".to_string());
}
if let Some((cached_scope, cached_fp)) = cached_inbound {
if presented_fp.as_deref() == Some(cached_fp.as_str()) {
if cached_scope == entry.scope {
return Ok(cached_scope);
}
return Err("session token scope changed after admission".to_string());
}
}
if entry.max_connections > 0 && entry.use_count >= entry.max_connections {
return Err(format!(
"session token exhausted (max={} used={})",
entry.max_connections, entry.use_count
));
}
let scope = entry.scope.clone();
entry.use_count = entry.use_count.saturating_add(1);
drop(map);
if let Some(conn_id) = connection_id {
if let Ok(mut conns) = self.validated_connections.write() {
conns.insert(conn_id.to_string(), scope.clone());
}
if let Some(fp) = presented_fp.as_ref() {
if let Ok(mut inbound) = self.inbound_token_admissions.write() {
inbound.insert(conn_id.to_string(), (scope.clone(), fp.clone()));
}
}
self.mark_accepted(
conn_id,
SessionAdmissionMechanism::SessionToken,
Some(scope.clone()),
None,
);
if let Some(fp) = presented_fp.as_ref() {
if let Ok(mut fps) = self.admission_fingerprints.write() {
fps.insert(conn_id.to_string(), fp.clone());
}
}
}
Ok(scope)
}
pub fn validate_with_cached_response(
&self,
token: &str,
connection_id: &str,
) -> Result<GrantScope, String> {
self.validate_with_cached_response_and_payload(token, connection_id, None)
}
pub fn validate_with_cached_response_and_payload(
&self,
token: &str,
connection_id: &str,
payload_suffix: Option<&str>,
) -> Result<GrantScope, String> {
self.validate_and_consume_for_connection_with_payload(
token,
Some(connection_id),
payload_suffix,
)
}
pub fn begin_admission_response(&self, connection_id: &str) -> AdmissionResponseGuard {
self.try_begin_admission_response(connection_id)
.expect("admission retirement is not active")
}
pub fn try_begin_admission_response(
&self,
connection_id: &str,
) -> Option<AdmissionResponseGuard> {
let mut activities = self.admission_activities.write().ok()?;
let activity = activities.entry(connection_id.to_string()).or_default();
if activity.retirement_active {
return None;
}
activity.response_writers = activity.response_writers.saturating_add(1);
Some(AdmissionResponseGuard {
connection_id: connection_id.to_string(),
activities: self.admission_activities.clone(),
})
}
pub fn try_begin_admission_presentation(
&self,
connection_id: &str,
) -> Option<AdmissionPresentationGuard> {
let mut activities = self.admission_activities.write().ok()?;
let activity = activities.entry(connection_id.to_string()).or_default();
if activity.retirement_active || activity.presentation_active {
return None;
}
activity.presentation_active = true;
Some(AdmissionPresentationGuard {
connection_id: connection_id.to_string(),
activities: self.admission_activities.clone(),
})
}
pub fn try_begin_admission_retirement(
&self,
connection_id: &str,
) -> Option<AdmissionRetirementGuard> {
let mut activities = self.admission_activities.write().ok()?;
let activity = activities.entry(connection_id.to_string()).or_default();
if activity.retirement_active
|| activity.response_writers > 0
|| activity.presentation_active
{
return None;
}
activity.retirement_active = true;
Some(AdmissionRetirementGuard {
connection_id: connection_id.to_string(),
activities: self.admission_activities.clone(),
})
}
pub fn is_session_token_admission_in_flight(&self, connection_id: &str) -> bool {
self.admission_activities
.read()
.ok()
.and_then(|activities| {
activities
.get(connection_id)
.map(|activity| activity.response_writers > 0 || activity.presentation_active)
})
.unwrap_or(false)
}
pub fn is_session_token_presentation_in_flight(&self, connection_id: &str) -> bool {
self.admission_activities
.read()
.ok()
.and_then(|activities| {
activities
.get(connection_id)
.map(|activity| activity.presentation_active)
})
.unwrap_or(false)
}
pub fn admission_fingerprint(&self, connection_id: &str) -> Option<String> {
self.admission_fingerprints
.read()
.ok()
.and_then(|fps| fps.get(connection_id).cloned())
}
pub(crate) fn application_security_epoch_fingerprint(
&self,
connection_id: &str,
) -> Option<String> {
self.application_security_epoch_fingerprints
.read()
.ok()
.and_then(|fingerprints| fingerprints.get(connection_id).cloned())
}
pub(crate) fn commit_application_security_epoch(&self, connection_id: &str) {
let Some(fingerprint) = self.admission_fingerprint(connection_id) else {
return;
};
if let Ok(mut committed) = self.application_security_epoch_fingerprints.write() {
committed.insert(connection_id.to_string(), fingerprint);
}
}
pub fn revoke(&self, token: &str) -> Vec<String> {
if let Ok(mut map) = self.inner.write() {
map.remove(token);
}
let fingerprint = token_fingerprint(token);
let nonce_key_prefix = format!("{}:", fingerprint);
if let Ok(mut seen) = self.seen_payload_nonces.write() {
seen.retain(|key, _| !key.starts_with(&nonce_key_prefix));
}
let mut affected = Vec::new();
if let Ok(mut inbound) = self.inbound_token_admissions.write() {
inbound.retain(|connection_id, (_, admitted_fingerprint)| {
if admitted_fingerprint == &fingerprint {
affected.push(connection_id.clone());
false
} else {
true
}
});
}
if let Ok(mut validated) = self.validated_connections.write() {
for connection_id in &affected {
validated.remove(connection_id);
}
}
if let Ok(mut admissions) = self.admissions.write() {
for connection_id in &affected {
admissions.insert(
connection_id.clone(),
SessionAdmission::Rejected {
reason: "session token revoked".to_string(),
},
);
}
}
if let Ok(mut fingerprints) = self.admission_fingerprints.write() {
for connection_id in &affected {
fingerprints.remove(connection_id);
}
}
if let Ok(mut fingerprints) = self.application_security_epoch_fingerprints.write() {
for connection_id in &affected {
fingerprints.remove(connection_id);
}
}
affected.sort();
affected.dedup();
affected
}
pub fn revoke_by_scope(&self, scope: impl AsRef<str>) -> Vec<String> {
let scope = scope.as_ref();
if let Ok(mut map) = self.inner.write() {
map.retain(|_, v| v.scope.as_str() != scope);
}
let mut affected = std::collections::HashSet::new();
if let Ok(mut conns) = self.validated_connections.write() {
conns.retain(|conn_id, conn_scope| {
if conn_scope.as_str() == scope {
affected.insert(conn_id.clone());
false
} else {
true
}
});
}
if let Ok(mut admissions) = self.admissions.write() {
admissions.retain(|connection_id, admission| {
let matches_scope = matches!(
admission,
SessionAdmission::Accepted {
scope: Some(admission_scope),
..
} if admission_scope.as_str() == scope
);
if matches_scope {
affected.insert(connection_id.clone());
}
!matches_scope
});
}
if let Ok(mut inbound) = self.inbound_token_admissions.write() {
for connection_id in &affected {
inbound.remove(connection_id);
}
}
if let Ok(mut fps) = self.admission_fingerprints.write() {
for connection_id in &affected {
fps.remove(connection_id);
}
}
if let Ok(mut fps) = self.application_security_epoch_fingerprints.write() {
for connection_id in &affected {
fps.remove(connection_id);
}
}
let mut affected = affected.into_iter().collect::<Vec<_>>();
affected.sort();
affected
}
pub fn clear(&self) {
if let Ok(mut map) = self.inner.write() {
map.clear();
}
if let Ok(mut conns) = self.validated_connections.write() {
conns.clear();
}
if let Ok(mut admissions) = self.admissions.write() {
admissions.clear();
}
if let Ok(mut inbound) = self.inbound_token_admissions.write() {
inbound.clear();
}
if let Ok(mut fps) = self.admission_fingerprints.write() {
fps.clear();
}
if let Ok(mut fps) = self.application_security_epoch_fingerprints.write() {
fps.clear();
}
if let Ok(mut activities) = self.admission_activities.write() {
activities.clear();
}
if let Ok(mut seen) = self.seen_payload_nonces.write() {
seen.clear();
}
}
pub fn is_empty(&self) -> bool {
self.inner.read().map(|m| m.is_empty()).unwrap_or(true)
}
pub fn admission(&self, connection_id: &str) -> SessionAdmission {
self.admissions
.read()
.ok()
.and_then(|admissions| admissions.get(connection_id).cloned())
.unwrap_or(SessionAdmission::Pending)
}
pub fn is_session_token_admitted_for_connection(&self, connection_id: &str) -> bool {
self.inbound_token_admissions
.read()
.map(|admissions| admissions.contains_key(connection_id))
.unwrap_or(false)
}
pub fn mark_rejected(&self, connection_id: &str, reason: impl Into<String>) {
if let Ok(mut admissions) = self.admissions.write() {
admissions.insert(
connection_id.to_string(),
SessionAdmission::Rejected {
reason: reason.into(),
},
);
}
}
pub fn mark_accepted(
&self,
connection_id: &str,
mechanism: SessionAdmissionMechanism,
scope: Option<GrantScope>,
authoritative_device_id: Option<String>,
) {
if let Ok(mut admissions) = self.admissions.write() {
admissions.insert(
connection_id.to_string(),
SessionAdmission::Accepted {
mechanism,
scope,
authoritative_device_id,
},
);
}
}
pub fn bind_connection_scope(
&self,
connection_id: &str,
scope: impl Into<GrantScope>,
authoritative_device_id: Option<String>,
) {
let scope = scope.into();
if let Ok(mut conns) = self.validated_connections.write() {
conns.insert(connection_id.to_string(), scope.clone());
}
self.mark_accepted(
connection_id,
SessionAdmissionMechanism::SessionToken,
Some(scope),
authoritative_device_id,
);
}
pub fn forget_connection(&self, connection_id: &str) {
if let Ok(mut conns) = self.validated_connections.write() {
conns.remove(connection_id);
}
if let Ok(mut admissions) = self.admissions.write() {
admissions.remove(connection_id);
}
if let Ok(mut inbound) = self.inbound_token_admissions.write() {
inbound.remove(connection_id);
}
if let Ok(mut fps) = self.admission_fingerprints.write() {
fps.remove(connection_id);
}
if let Ok(mut fps) = self.application_security_epoch_fingerprints.write() {
fps.remove(connection_id);
}
if let Ok(mut activities) = self.admission_activities.write() {
activities.remove(connection_id);
}
}
pub fn set_native_trusted_connection_verifier(
&self,
verifier: Option<NativeTrustedConnectionVerifier>,
) {
if let Ok(mut slot) = self.native_trusted_connection_verifier.write() {
*slot = verifier;
}
}
pub fn evaluate_trusted_native_connection(
&self,
context: &NativeTrustedConnectionContext,
) -> Option<String> {
self.native_trusted_connection_verifier
.read()
.ok()
.and_then(|slot| slot.as_ref().and_then(|verifier| verifier(context)))
}
fn validate_presented_payload_suffix(
&self,
token: &str,
connection_id: Option<&str>,
payload_suffix: &str,
expected_scope: &GrantScope,
max_connections: u32,
) -> Result<(), String> {
let payload = decode_token_payload(payload_suffix)
.ok_or_else(|| "invalid session token payload".to_string())?;
if payload.token != token {
return Err("session token payload mismatch".to_string());
}
if !token_payload_metadata_is_valid(&payload) {
return Err("invalid session token payload metadata".to_string());
}
if &payload.scope != expected_scope || payload.max_connections != max_connections {
return Err("session token payload grant mismatch".to_string());
}
if max_connections != 1 {
return Ok(());
}
let Some(nonce) = payload.nonce.as_deref() else {
return Ok(());
};
let Some(connection_id) = connection_id else {
return Ok(());
};
let replay_key = format!("{}:{}", token_fingerprint(token), nonce);
let mut seen = self
.seen_payload_nonces
.write()
.map_err(|_| "registry lock poisoned".to_string())?;
match seen.get(&replay_key) {
Some(existing_connection_id) if existing_connection_id != connection_id => {
Err("session token payload nonce replayed".to_string())
}
Some(_) => Ok(()),
None => {
seen.insert(replay_key, connection_id.to_string());
Ok(())
}
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TokenPayload {
#[serde(rename = "t")]
pub token: String,
#[serde(rename = "s")]
pub scope: GrantScope,
#[serde(rename = "m")]
pub max_connections: u32,
#[serde(rename = "h", default, skip_serializing_if = "Option::is_none")]
pub ticket_hash: Option<String>,
#[serde(rename = "e", default, skip_serializing_if = "Option::is_none")]
pub expires_at_ms: Option<u64>,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(rename = "n", default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
pub fn encode_token_payload(
token: &str,
scope: impl Into<GrantScope>,
max_connections: u32,
) -> String {
let scope = scope.into();
let payload = TokenPayload {
token: token.to_string(),
scope,
max_connections,
ticket_hash: None,
expires_at_ms: None,
audience: None,
nonce: None,
};
let json = serde_json::to_string(&payload).unwrap_or_default();
URL_SAFE_NO_PAD.encode(json.as_bytes())
}
pub fn endpoint_ticket_hash(iroh_ticket: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(iroh_ticket.as_bytes());
hex::encode(digest)
}
pub fn encode_token_payload_for_ticket(
iroh_ticket: &str,
token: &str,
scope: impl Into<GrantScope>,
max_connections: u32,
) -> String {
encode_token_payload_for_ticket_with_expiry(iroh_ticket, token, scope, max_connections, None)
}
pub fn encode_token_payload_for_ticket_with_expiry(
iroh_ticket: &str,
token: &str,
scope: impl Into<GrantScope>,
max_connections: u32,
expires_at_ms: Option<u64>,
) -> String {
let scope = scope.into();
let payload = TokenPayload {
token: token.to_string(),
scope,
max_connections,
ticket_hash: Some(endpoint_ticket_hash(iroh_ticket)),
expires_at_ms,
audience: Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE.to_string()),
nonce: Some(generate_payload_nonce()),
};
let json = serde_json::to_string(&payload).unwrap_or_default();
URL_SAFE_NO_PAD.encode(json.as_bytes())
}
pub fn decode_token_payload(encoded: &str) -> Option<TokenPayload> {
let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn token_payload_matches_ticket(iroh_ticket: &str, payload: &TokenPayload) -> bool {
let ticket_matches = payload
.ticket_hash
.as_deref()
.map_or(true, |hash| hash == endpoint_ticket_hash(iroh_ticket));
let not_expired = payload
.expires_at_ms
.map_or(true, |expires_at_ms| expires_at_ms > now_unix_ms());
ticket_matches && not_expired && token_payload_metadata_is_valid(payload)
}
pub fn token_payload_metadata_is_valid(payload: &TokenPayload) -> bool {
let audience_matches = payload.audience.as_deref().map_or(true, |audience| {
audience == TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE
});
let nonce_is_valid = payload
.nonce
.as_deref()
.map_or(true, is_valid_payload_nonce);
let not_expired = payload
.expires_at_ms
.map_or(true, |expires_at_ms| expires_at_ms > now_unix_ms());
audience_matches && nonce_is_valid && not_expired
}
pub fn decode_token_payload_for_ticket(iroh_ticket: &str, encoded: &str) -> Option<TokenPayload> {
let payload = decode_token_payload(encoded)?;
token_payload_matches_ticket(iroh_ticket, &payload).then_some(payload)
}
pub fn split_compound_ticket(compound: &str) -> (&str, Option<&str>) {
match compound.rfind('.') {
Some(pos) => (&compound[..pos], Some(&compound[pos + 1..])),
None => (compound, None),
}
}
pub fn build_compound_ticket(
iroh_ticket: &str,
token: &str,
scope: impl Into<GrantScope>,
max_connections: u32,
) -> String {
build_compound_ticket_with_expiry(iroh_ticket, token, scope, max_connections, None)
}
pub fn build_compound_ticket_with_expiry(
iroh_ticket: &str,
token: &str,
scope: impl Into<GrantScope>,
max_connections: u32,
expires_at_ms: Option<u64>,
) -> String {
let scope = scope.into();
let suffix = encode_token_payload_for_ticket_with_expiry(
iroh_ticket,
token,
scope,
max_connections,
expires_at_ms,
);
format!("{}.{}", iroh_ticket, suffix)
}
pub fn generate_token() -> String {
let mut bytes = [0u8; 24]; getrandom::getrandom(&mut bytes).expect("getrandom failed");
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn generate_payload_nonce() -> String {
let mut bytes = [0u8; TOKEN_PAYLOAD_NONCE_BYTES];
getrandom::getrandom(&mut bytes).expect("getrandom failed");
URL_SAFE_NO_PAD.encode(bytes)
}
fn is_valid_payload_nonce(nonce: &str) -> bool {
URL_SAFE_NO_PAD
.decode(nonce)
.map(|bytes| bytes.len() == TOKEN_PAYLOAD_NONCE_BYTES)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_no_dot() {
let ticket = "endpointabcdef123456";
let (iroh, token) = split_compound_ticket(ticket);
assert_eq!(iroh, "endpointabcdef123456");
assert!(token.is_none());
}
#[test]
fn test_split_with_dot() {
let encoded = encode_token_payload("my-token", "share", 5);
let compound = format!("endpointabcdef123456.{}", encoded);
let (iroh, token_str) = split_compound_ticket(&compound);
assert_eq!(iroh, "endpointabcdef123456");
let payload = decode_token_payload(token_str.unwrap()).unwrap();
assert_eq!(payload.token, "my-token");
assert_eq!(payload.scope, "share");
assert_eq!(payload.max_connections, 5);
}
#[test]
fn test_split_uses_last_dot_for_ticket_with_embedded_dots() {
let encoded = encode_token_payload("my-token", "trusted", 0);
let iroh_ticket = "ticket.with.embedded.dots";
let compound = format!("{}.{}", iroh_ticket, encoded);
let (recovered_ticket, token_str) = split_compound_ticket(&compound);
assert_eq!(recovered_ticket, iroh_ticket);
let payload = decode_token_payload(token_str.expect("token suffix")).expect("payload");
assert_eq!(payload.token, "my-token");
assert_eq!(payload.scope, "trusted");
}
#[test]
fn test_registry_empty_gate() {
let registry = SessionTokenRegistry::new();
assert!(registry.validate_and_consume("").is_ok());
assert_eq!(
registry.validate_and_consume("anything").unwrap_err(),
"unknown session token",
);
}
#[test]
fn test_registry_validate_consume() {
let registry = SessionTokenRegistry::new();
registry.register("tok1".to_string(), "share".to_string(), 2);
assert_eq!(registry.validate_and_consume("tok1").unwrap(), "share");
assert_eq!(registry.validate_and_consume("tok1").unwrap(), "share");
assert!(registry.validate_and_consume("tok1").is_err());
}
#[test]
fn test_registry_unlimited() {
let registry = SessionTokenRegistry::new();
registry.register("tok2".to_string(), "magic-link".to_string(), 0);
for _ in 0..100 {
assert!(registry.validate_and_consume("tok2").is_ok());
}
}
#[test]
fn test_registry_rejects_expired_token() {
let registry = SessionTokenRegistry::new();
registry.register_with_expiry_ms(
"expired-token".to_string(),
"share".to_string(),
0,
Some(now_unix_ms().saturating_sub(1)),
);
registry.register("sentinel".to_string(), "sentinel".to_string(), 0);
assert_eq!(
registry.validate_and_consume("expired-token").unwrap_err(),
"session token expired"
);
assert_eq!(
registry.validate_and_consume("expired-token").unwrap_err(),
"unknown session token"
);
}
#[test]
fn test_revoke_by_scope() {
let registry = SessionTokenRegistry::new();
registry.register("tok1".to_string(), "share".to_string(), 0);
registry.register("tok2".to_string(), "share".to_string(), 0);
registry.register("tok3".to_string(), "other".to_string(), 0);
registry.revoke_by_scope("share");
assert!(registry.validate_and_consume("tok1").is_err());
assert!(registry.validate_and_consume("tok2").is_err());
assert!(registry.validate_and_consume("tok3").is_ok());
}
#[test]
fn test_generate_token_length() {
let t = generate_token();
assert_eq!(t.len(), 32);
}
#[test]
fn test_build_and_split_compound_ticket_roundtrip() {
let iroh_ticket = "abcdef1234567890abcdef";
let token = "my-secret-token";
let scope = "share";
let max_conn = 3;
let compound = build_compound_ticket(iroh_ticket, token, scope, max_conn);
assert_eq!(compound.matches('.').count(), 1);
let (recovered_ticket, suffix) = split_compound_ticket(&compound);
assert_eq!(recovered_ticket, iroh_ticket);
let payload = decode_token_payload(suffix.unwrap()).unwrap();
assert_eq!(payload.token, token);
assert_eq!(payload.scope, scope);
assert_eq!(payload.max_connections, max_conn);
assert_eq!(
payload.ticket_hash.as_deref(),
Some(endpoint_ticket_hash(iroh_ticket).as_str())
);
assert_eq!(
payload.audience.as_deref(),
Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE)
);
assert!(payload.nonce.as_deref().is_some_and(is_valid_payload_nonce));
assert!(token_payload_matches_ticket(iroh_ticket, &payload));
assert!(
decode_token_payload_for_ticket("different-endpoint-ticket", suffix.unwrap()).is_none()
);
}
#[test]
fn test_bound_compound_ticket_rejects_wrong_audience_and_bad_nonce() {
let iroh_ticket = "endpoint-ticket";
let encoded = encode_token_payload_for_ticket(iroh_ticket, "token", "share", 1);
let mut payload = decode_token_payload(&encoded).unwrap();
payload.audience = Some("openrtc:other-audience:v1".to_string());
assert!(!token_payload_matches_ticket(iroh_ticket, &payload));
payload.audience = Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE.to_string());
payload.nonce = Some("not-valid-base64url-nonce".to_string());
assert!(!token_payload_matches_ticket(iroh_ticket, &payload));
}
#[test]
fn test_bound_compound_ticket_nonces_are_unique() {
let first = build_compound_ticket("endpoint-ticket", "token-one", "share", 1);
let second = build_compound_ticket("endpoint-ticket", "token-two", "share", 1);
let (_, first_suffix) = split_compound_ticket(&first);
let (_, second_suffix) = split_compound_ticket(&second);
let first_payload = decode_token_payload(first_suffix.unwrap()).unwrap();
let second_payload = decode_token_payload(second_suffix.unwrap()).unwrap();
assert_ne!(first_payload.nonce, second_payload.nonce);
assert!(first_payload
.nonce
.as_deref()
.is_some_and(is_valid_payload_nonce));
assert!(second_payload
.nonce
.as_deref()
.is_some_and(is_valid_payload_nonce));
}
#[test]
fn test_single_use_payload_nonce_replay_rejected_across_connections() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 1);
let suffix = encode_token_payload_for_ticket("endpoint-ticket", "tok", "share", 1);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-A"),
Some(&suffix),
)
.unwrap(),
"share"
);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-A"),
Some(&suffix),
)
.unwrap(),
"share"
);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-B"),
Some(&suffix),
)
.unwrap_err(),
"session token payload nonce replayed"
);
}
#[test]
fn test_multi_connection_payload_nonce_allowed_across_connections() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "user-device".to_string(), 0);
let suffix = encode_token_payload_for_ticket("endpoint-ticket", "tok", "user-device", 0);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-A"),
Some(&suffix),
)
.unwrap(),
"user-device"
);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-B"),
Some(&suffix),
)
.unwrap(),
"user-device"
);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-C"),
Some(&suffix),
)
.unwrap(),
"user-device"
);
}
#[test]
fn test_payload_metadata_must_match_presented_token() {
let registry = SessionTokenRegistry::new();
registry.register("real-token".to_string(), "share".to_string(), 0);
let suffix = encode_token_payload_for_ticket("endpoint-ticket", "other-token", "share", 0);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"real-token",
Some("conn-A"),
Some(&suffix),
)
.unwrap_err(),
"session token payload mismatch"
);
}
#[test]
fn test_payload_grant_must_match_registered_capability() {
let registry = SessionTokenRegistry::new();
registry.register("real-token".to_string(), "share:read".to_string(), 1);
let suffix =
encode_token_payload_for_ticket("endpoint-ticket", "real-token", "share:write", 2);
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"real-token",
Some("conn-A"),
Some(&suffix),
)
.unwrap_err(),
"session token payload grant mismatch"
);
}
#[test]
fn test_bound_compound_ticket_rejects_suffix_graft() {
let first_ticket = "endpoint-ticket-one";
let second_ticket = "endpoint-ticket-two";
let first_compound = build_compound_ticket(first_ticket, "token-one", "share", 1);
let second_compound = build_compound_ticket(second_ticket, "token-two", "share", 1);
let (_, first_suffix) = split_compound_ticket(&first_compound);
let grafted = format!("{}.{}", second_ticket, first_suffix.unwrap());
let (grafted_ticket, grafted_suffix) = split_compound_ticket(&grafted);
assert_eq!(grafted_ticket, second_ticket);
assert!(
decode_token_payload_for_ticket(grafted_ticket, grafted_suffix.unwrap()).is_none(),
"a suffix minted for one endpoint ticket must not validate on another endpoint ticket"
);
let (_, second_suffix) = split_compound_ticket(&second_compound);
let payload =
decode_token_payload_for_ticket(second_ticket, second_suffix.unwrap()).unwrap();
assert_eq!(payload.token, "token-two");
}
#[test]
fn test_compound_ticket_payload_carries_expiry() {
let expires_at_ms = now_unix_ms().saturating_add(DEFAULT_RESTRICTED_SESSION_TOKEN_TTL_MS);
let compound = build_compound_ticket_with_expiry(
"endpoint-ticket",
"token-with-expiry",
"share",
1,
Some(expires_at_ms),
);
let (iroh_ticket, suffix) = split_compound_ticket(&compound);
let payload = decode_token_payload_for_ticket(iroh_ticket, suffix.unwrap()).unwrap();
assert_eq!(payload.token, "token-with-expiry");
assert_eq!(payload.expires_at_ms, Some(expires_at_ms));
assert_eq!(
payload.audience.as_deref(),
Some(TOKEN_PAYLOAD_ENDPOINT_TICKET_AUDIENCE)
);
assert!(payload.nonce.as_deref().is_some_and(is_valid_payload_nonce));
}
#[test]
fn test_build_compound_ticket_then_registry_validate() {
let registry = SessionTokenRegistry::new();
let token = generate_token();
let scope = "share";
registry.register(token.clone(), scope.to_string(), 1);
let compound = build_compound_ticket("irohticketbase32", &token, scope, 1);
let (_iroh, suffix) = split_compound_ticket(&compound);
let payload = decode_token_payload(suffix.unwrap()).unwrap();
assert!(token_payload_matches_ticket("irohticketbase32", &payload));
let result = registry.validate_and_consume(&payload.token);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "share");
let result2 = registry.validate_and_consume(&payload.token);
assert!(result2.is_err());
}
#[test]
fn test_revoke_single_token() {
let registry = SessionTokenRegistry::new();
registry.register("tok-a".to_string(), "share".to_string(), 0);
registry.register("tok-b".to_string(), "share".to_string(), 0);
registry
.validate_and_consume_for_connection("tok-a", Some("conn-a"))
.expect("initial admission");
assert_eq!(registry.revoke("tok-a"), vec!["conn-a"]);
assert!(registry.validate_and_consume("tok-a").is_err());
assert!(registry.validate_and_consume("tok-b").is_ok());
assert!(matches!(
registry.admission("conn-a"),
SessionAdmission::Rejected { ref reason } if reason == "session token revoked"
));
}
#[test]
fn test_registry_clear() {
let registry = SessionTokenRegistry::new();
registry.register("tok-x".to_string(), "share".to_string(), 0);
assert!(!registry.is_empty());
registry.clear();
assert!(registry.is_empty());
assert!(registry.validate_and_consume("").is_ok());
assert!(registry.validate_and_consume("anything").is_err());
}
#[test]
fn test_ts_encoded_payload_is_decodable() {
let json = r#"{"t":"test-token","s":"share","m":0}"#;
let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
let payload = decode_token_payload(&encoded).unwrap();
assert_eq!(payload.token, "test-token");
assert_eq!(payload.scope, "share");
assert_eq!(payload.max_connections, 0);
}
#[test]
fn test_generated_token_uniqueness() {
let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect();
let unique: std::collections::HashSet<&String> = tokens.iter().collect();
assert_eq!(unique.len(), 100, "generated tokens must be unique");
}
#[test]
fn test_revoke_by_scope_returns_affected_connections() {
let registry = SessionTokenRegistry::new();
registry.register("tok-share-1".to_string(), "share".to_string(), 0);
registry.register("tok-share-2".to_string(), "share".to_string(), 0);
registry.register("tok-other".to_string(), "other".to_string(), 0);
registry
.validate_and_consume_for_connection("tok-share-1", Some("conn-A"))
.unwrap();
registry
.validate_and_consume_for_connection("tok-share-2", Some("conn-B"))
.unwrap();
registry
.validate_and_consume_for_connection("tok-other", Some("conn-C"))
.unwrap();
let mut affected = registry.revoke_by_scope("share");
affected.sort();
assert_eq!(affected, vec!["conn-A", "conn-B"]);
let affected2 = registry.revoke_by_scope("other");
assert_eq!(affected2, vec!["conn-C"]);
}
#[test]
fn test_revoke_scoped_guest_scope_isolates_grants() {
let registry = SessionTokenRegistry::new();
registry.register("tok-a".to_string(), "scoped:grant-a".to_string(), 0);
registry.register("tok-b".to_string(), "scoped:grant-b".to_string(), 0);
registry
.validate_and_consume_for_connection("tok-a", Some("conn-a"))
.unwrap();
registry
.validate_and_consume_for_connection("tok-b", Some("conn-b"))
.unwrap();
let affected = registry.revoke_by_scope("scoped:grant-a");
assert_eq!(affected, vec!["conn-a"]);
assert!(registry.validate_and_consume("tok-b").is_ok());
assert!(registry.validate_and_consume("tok-a").is_err());
}
#[test]
fn test_validate_for_connection_rejects_without_token() {
let registry = SessionTokenRegistry::new();
registry.register("real-tok".to_string(), "share".to_string(), 0);
let result = registry.validate_and_consume_for_connection("", Some("conn-X"));
assert!(result.is_err());
let result = registry.validate_and_consume_for_connection("wrong-tok", Some("conn-Y"));
assert!(result.is_err());
let result = registry.validate_and_consume_for_connection("real-tok", Some("conn-Z"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "share");
}
#[test]
fn test_connection_admission_tracks_token_acceptance_and_revoke() {
let registry = SessionTokenRegistry::new();
registry.register("real-tok".to_string(), "share".to_string(), 0);
assert_eq!(registry.admission("conn-1"), SessionAdmission::Pending);
registry
.validate_and_consume_for_connection("real-tok", Some("conn-1"))
.unwrap();
assert_eq!(
registry.admission("conn-1"),
SessionAdmission::Accepted {
mechanism: SessionAdmissionMechanism::SessionToken,
scope: Some(GrantScope::from("share")),
authoritative_device_id: None,
}
);
assert_eq!(registry.revoke_by_scope("share"), vec!["conn-1"]);
assert_eq!(registry.admission("conn-1"), SessionAdmission::Pending);
}
#[test]
fn is_session_token_admitted_for_connection_tracks_first_vs_duplicate_presentation() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 0);
assert!(
!registry.is_session_token_admitted_for_connection("conn-dup"),
"before first consume, not yet session-token admitted"
);
registry
.validate_and_consume_for_connection("tok", Some("conn-dup"))
.unwrap();
assert!(
registry.is_session_token_admitted_for_connection("conn-dup"),
"after first consume, duplicate token frames must be detectable for WebRTC gating"
);
registry.forget_connection("conn-dup");
assert!(
!registry.is_session_token_admitted_for_connection("conn-dup"),
"forget clears admission so a reconnect can run first-presentation recovery again"
);
}
#[test]
fn outbound_host_approval_does_not_impersonate_inbound_token_admission() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "user-device".to_string(), 0);
registry.bind_connection_scope("conn-bilateral", "user-device", None);
assert!(matches!(
registry.admission("conn-bilateral"),
SessionAdmission::Accepted {
mechanism: SessionAdmissionMechanism::SessionToken,
..
}
));
assert!(
!registry.is_session_token_admitted_for_connection("conn-bilateral"),
"outbound approval and inbound token validation are independent"
);
assert_eq!(
registry.revoke_by_scope("user-device"),
vec!["conn-bilateral".to_string()],
"logical scope revocation still owns the remote-host-approved direction",
);
assert!(matches!(
registry.admission("conn-bilateral"),
SessionAdmission::Pending,
));
}
#[test]
fn test_native_trusted_verifier_can_accept_authoritative_device() {
let registry = SessionTokenRegistry::new();
registry.register("sentinel".to_string(), "share".to_string(), 0);
registry.set_native_trusted_connection_verifier(Some(Arc::new(|context| {
if context.remote_node_id.as_deref() == Some("node-123")
&& context.claimed_device_id.as_deref() == Some("device-abc")
{
Some("device-abc".to_string())
} else {
None
}
})));
let context = NativeTrustedConnectionContext {
connection_id: "conn-native".to_string(),
remote_node_id: Some("node-123".to_string()),
known_device_id: None,
claimed_device_id: Some("device-abc".to_string()),
};
let authoritative = registry.evaluate_trusted_native_connection(&context);
assert_eq!(authoritative.as_deref(), Some("device-abc"));
registry.mark_accepted(
"conn-native",
SessionAdmissionMechanism::TrustedNativeBinding,
None,
authoritative,
);
assert_eq!(
registry.admission("conn-native"),
SessionAdmission::Accepted {
mechanism: SessionAdmissionMechanism::TrustedNativeBinding,
scope: None,
authoritative_device_id: Some("device-abc".to_string()),
}
);
}
#[test]
fn test_native_host_web_client_full_flow() {
let registry = SessionTokenRegistry::new();
let host_token = generate_token();
registry.register(host_token.clone(), "share".to_string(), 0);
let compound = build_compound_ticket("nativeticketbase32", &host_token, "share", 0);
let (_iroh_ticket, suffix) = split_compound_ticket(&compound);
let payload = decode_token_payload(suffix.unwrap()).unwrap();
let result =
registry.validate_and_consume_for_connection(&payload.token, Some("web-client-conn-1"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "share");
}
#[test]
fn test_client_without_token_rejected() {
let registry = SessionTokenRegistry::new();
registry.register(generate_token(), "share".to_string(), 0);
let result = registry.validate_and_consume_for_connection("", Some("bad-conn"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("unknown"));
}
#[test]
fn test_client_with_wrong_token_rejected() {
let registry = SessionTokenRegistry::new();
let real_token = generate_token();
registry.register(real_token, "share".to_string(), 0);
let result = registry
.validate_and_consume_for_connection("completely-wrong-token", Some("bad-conn"));
assert!(result.is_err());
}
#[test]
fn test_revoke_scope_disconnects_and_blocks() {
let registry = SessionTokenRegistry::new();
let tok1 = generate_token();
let tok2 = generate_token();
registry.register(tok1.clone(), "share".to_string(), 0);
registry.register(tok2.clone(), "share".to_string(), 0);
registry.register("sentinel".to_string(), "sentinel".to_string(), 0);
assert!(registry
.validate_and_consume_for_connection(&tok1, Some("conn-web-1"))
.is_ok());
assert!(registry
.validate_and_consume_for_connection(&tok2, Some("conn-web-2"))
.is_ok());
let mut affected = registry.revoke_by_scope("share");
affected.sort();
assert_eq!(affected, vec!["conn-web-1", "conn-web-2"]);
let result = registry.validate_and_consume_for_connection(&tok1, Some("conn-web-3"));
assert!(result.is_err());
let result = registry.validate_and_consume_for_connection(&tok2, Some("conn-web-4"));
assert!(result.is_err());
}
#[test]
fn test_revoke_scope_only_affects_matching_scope() {
let registry = SessionTokenRegistry::new();
let share_tok = generate_token();
let other_tok = generate_token();
registry.register(share_tok.clone(), "share".to_string(), 0);
registry.register(other_tok.clone(), "other".to_string(), 0);
assert!(registry
.validate_and_consume_for_connection(&share_tok, Some("share-conn"))
.is_ok());
assert!(registry
.validate_and_consume_for_connection(&other_tok, Some("other-conn"))
.is_ok());
let affected = registry.revoke_by_scope("share");
assert_eq!(affected, vec!["share-conn"]);
let new_other_tok = generate_token();
registry.register(new_other_tok.clone(), "other".to_string(), 0);
assert!(registry
.validate_and_consume_for_connection(&new_other_tok, Some("other-conn-2"))
.is_ok());
}
#[test]
fn test_max_connections_enforced_per_token() {
let registry = SessionTokenRegistry::new();
let token = generate_token();
registry.register(token.clone(), "share".to_string(), 1);
assert!(registry
.validate_and_consume_for_connection(&token, Some("conn-1"))
.is_ok());
let result = registry.validate_and_consume_for_connection(&token, Some("conn-2"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("exhausted"));
}
#[test]
fn max_connections_is_atomic_under_concurrent_presentations() {
let registry = SessionTokenRegistry::new();
let token = generate_token();
registry.register(token.clone(), "share".to_string(), 1);
let start = Arc::new(std::sync::Barrier::new(16));
let handles = (0..16)
.map(|index| {
let registry = registry.clone();
let token = token.clone();
let start = start.clone();
std::thread::spawn(move || {
start.wait();
registry
.validate_and_consume_for_connection(
&token,
Some(&format!("concurrent-{index}")),
)
.is_ok()
})
})
.collect::<Vec<_>>();
let accepted = handles
.into_iter()
.map(|handle| handle.join().expect("validation thread"))
.filter(|accepted| *accepted)
.count();
assert_eq!(
accepted, 1,
"single-use capability must admit exactly one connection"
);
}
#[test]
fn empty_token_or_scope_never_activates_a_grant() {
let registry = SessionTokenRegistry::new();
registry.register(String::new(), "user-device".to_string(), 0);
registry.register("token".to_string(), String::new(), 0);
assert!(registry.is_empty());
assert!(registry.validate_and_consume("").is_ok());
assert_eq!(
registry.validate_and_consume("token").unwrap_err(),
"unknown session token"
);
}
#[test]
fn test_empty_registry_accepts_only_tokenless_manual_connections() {
let registry = SessionTokenRegistry::new();
assert!(registry
.validate_and_consume_for_connection("any-token", Some("conn-1"))
.is_err());
assert!(registry
.validate_and_consume_for_connection("", Some("conn-2"))
.is_ok());
}
#[test]
fn test_validate_for_connection_is_idempotent_after_accept() {
let registry = SessionTokenRegistry::new();
let token = generate_token();
registry.register(token.clone(), "share".to_string(), 1);
assert!(registry
.validate_and_consume_for_connection(&token, Some("conn-1"))
.is_ok());
assert!(registry
.validate_and_consume_for_connection(&token, Some("conn-1"))
.is_ok());
let result = registry.validate_and_consume_for_connection(&token, Some("conn-2"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("exhausted"));
}
#[test]
fn test_web_host_native_client_flow() {
let registry = SessionTokenRegistry::new();
let host_token = generate_token();
registry.register(host_token.clone(), "share".to_string(), 0);
let compound = build_compound_ticket("wasmticketbase32", &host_token, "share", 0);
let (iroh_ticket, suffix) = split_compound_ticket(&compound);
assert_eq!(iroh_ticket, "wasmticketbase32");
let payload = decode_token_payload(suffix.unwrap()).unwrap();
assert_eq!(payload.scope, "share");
let result = registry
.validate_and_consume_for_connection(&payload.token, Some("native-client-conn"));
assert!(result.is_ok());
registry.register("sentinel".to_string(), "sentinel".to_string(), 0);
registry.revoke_by_scope("share");
let new_attempt = registry
.validate_and_consume_for_connection(&payload.token, Some("native-client-conn-2"));
assert!(new_attempt.is_err());
}
#[test]
fn test_grant_scope_serde_roundtrip() {
let scope = GrantScope::new("share");
let json = serde_json::to_string(&scope).unwrap();
assert_eq!(json, r#""share""#); let deserialized: GrantScope = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, "share");
}
#[test]
fn test_grant_scope_equality() {
let scope = GrantScope::new("share");
assert_eq!(scope, "share");
assert_eq!(scope, "share".to_string());
assert_eq!(scope, GrantScope::from("share"));
assert_ne!(scope, "other");
}
#[test]
fn test_forget_connection_cleans_up() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 0);
registry
.validate_and_consume_for_connection("tok", Some("conn-forget"))
.unwrap();
assert_eq!(
registry.admission("conn-forget"),
SessionAdmission::Accepted {
mechanism: SessionAdmissionMechanism::SessionToken,
scope: Some(GrantScope::from("share")),
authoritative_device_id: None,
}
);
registry.forget_connection("conn-forget");
assert_eq!(registry.admission("conn-forget"), SessionAdmission::Pending);
}
#[test]
fn test_forget_connection_does_not_clear_payload_nonce_replay_memory() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 1);
let suffix = encode_token_payload_for_ticket("endpoint-ticket", "tok", "share", 1);
registry
.validate_and_consume_for_connection_with_payload("tok", Some("conn-A"), Some(&suffix))
.expect("first presentation");
registry.forget_connection("conn-A");
assert_eq!(
registry
.validate_and_consume_for_connection_with_payload(
"tok",
Some("conn-B"),
Some(&suffix),
)
.unwrap_err(),
"session token payload nonce replayed"
);
}
#[test]
fn test_mark_rejected_and_readback() {
let registry = SessionTokenRegistry::new();
registry.mark_rejected("conn-bad", "invalid token");
assert_eq!(
registry.admission("conn-bad"),
SessionAdmission::Rejected {
reason: "invalid token".to_string(),
}
);
}
#[test]
fn test_trusted_verifier_returns_none_does_not_admit() {
let registry = SessionTokenRegistry::new();
registry.register("sentinel".to_string(), "share".to_string(), 0);
registry.set_native_trusted_connection_verifier(Some(Arc::new(|_context| {
None })));
let context = NativeTrustedConnectionContext {
connection_id: "conn-untrusted".to_string(),
remote_node_id: Some("unknown-node".to_string()),
known_device_id: None,
claimed_device_id: Some("fake-device".to_string()),
};
let result = registry.evaluate_trusted_native_connection(&context);
assert!(result.is_none());
assert_eq!(
registry.admission("conn-untrusted"),
SessionAdmission::Pending
);
}
#[test]
fn test_validate_without_connection_id_does_not_track_admission() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 0);
let result = registry.validate_and_consume("tok");
assert!(result.is_ok());
}
#[test]
fn test_revoke_by_scope_with_no_matching_connections() {
let registry = SessionTokenRegistry::new();
registry.register("tok-a".to_string(), "share".to_string(), 0);
registry.register("tok-b".to_string(), "other".to_string(), 0);
registry
.validate_and_consume_for_connection("tok-b", Some("conn-other"))
.unwrap();
let affected = registry.revoke_by_scope("share");
assert!(affected.is_empty());
assert_eq!(
registry.admission("conn-other"),
SessionAdmission::Accepted {
mechanism: SessionAdmissionMechanism::SessionToken,
scope: Some(GrantScope::from("other")),
authoritative_device_id: None,
}
);
}
#[test]
fn test_clear_removes_all_admission_state() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "share".to_string(), 0);
registry
.validate_and_consume_for_connection("tok", Some("conn-1"))
.unwrap();
registry.mark_rejected("conn-2", "bad");
assert!(matches!(
registry.admission("conn-1"),
SessionAdmission::Accepted { .. }
));
assert!(matches!(
registry.admission("conn-2"),
SessionAdmission::Rejected { .. }
));
registry.clear();
assert_eq!(registry.admission("conn-1"), SessionAdmission::Pending);
assert_eq!(registry.admission("conn-2"), SessionAdmission::Pending);
assert!(registry.is_empty());
}
#[test]
fn validate_with_cached_response_is_idempotent_for_same_token() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "scoped:abc".to_string(), 1);
let first = registry
.validate_with_cached_response("tok", "conn-A")
.expect("first admission");
assert_eq!(first, "scoped:abc");
let second = registry
.validate_with_cached_response("tok", "conn-A")
.expect("replay must succeed deterministically");
assert_eq!(second, "scoped:abc");
assert_eq!(first, second);
}
#[test]
fn validate_with_cached_response_is_not_idempotent_across_connection_ids() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "scoped:abc".to_string(), 1);
registry
.validate_with_cached_response("tok", "conn-A")
.expect("first connection accepted");
let err = registry
.validate_with_cached_response("tok", "conn-B")
.expect_err("second connection rejected");
assert!(err.contains("exhausted"), "expected exhausted, got: {err}");
}
#[test]
fn validate_with_cached_response_rejects_different_token_on_admitted_connection() {
let registry = SessionTokenRegistry::new();
registry.register("real-tok".to_string(), "scoped:abc".to_string(), 0);
registry
.validate_with_cached_response("real-tok", "conn-X")
.expect("first admission");
let err = registry
.validate_with_cached_response("forged-tok", "conn-X")
.expect_err("rotated/forged token must be re-validated");
assert!(
err.contains("unknown"),
"expected 'unknown session token', got: {err}",
);
}
#[test]
fn token_fingerprint_is_deterministic_and_short() {
let fp1 = token_fingerprint("hello-world");
let fp2 = token_fingerprint("hello-world");
assert_eq!(fp1, fp2);
assert_eq!(fp1.len(), 16);
assert_ne!(fp1, "hello-world");
assert!(fp1.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(fp1, token_fingerprint("hello-worldX"));
}
#[test]
fn in_flight_admission_guard_tracks_concurrent_writers() {
let registry = SessionTokenRegistry::new();
assert!(!registry.is_session_token_admission_in_flight("conn-1"));
let g1 = registry.begin_admission_response("conn-1");
assert!(registry.is_session_token_admission_in_flight("conn-1"));
let g2 = registry.begin_admission_response("conn-1");
assert!(registry.is_session_token_admission_in_flight("conn-1"));
drop(g1);
assert!(
registry.is_session_token_admission_in_flight("conn-1"),
"still in-flight while second guard is alive",
);
drop(g2);
assert!(!registry.is_session_token_admission_in_flight("conn-1"));
}
#[test]
fn in_flight_admission_guard_is_per_connection() {
let registry = SessionTokenRegistry::new();
let _g = registry.begin_admission_response("conn-A");
assert!(registry.is_session_token_admission_in_flight("conn-A"));
assert!(!registry.is_session_token_admission_in_flight("conn-B"));
}
#[test]
fn admission_response_and_terminal_retirement_are_mutually_exclusive() {
let registry = SessionTokenRegistry::new();
let response = registry
.try_begin_admission_response("conn-gated")
.expect("response should acquire an idle connection");
assert!(registry
.try_begin_admission_retirement("conn-gated")
.is_none());
drop(response);
let retirement = registry
.try_begin_admission_retirement("conn-gated")
.expect("retirement should acquire after response flush");
assert!(registry
.try_begin_admission_response("conn-gated")
.is_none());
drop(retirement);
assert!(registry
.try_begin_admission_response("conn-gated")
.is_some());
}
#[test]
fn outbound_admission_presentation_is_single_flight_and_blocks_retirement() {
let registry = SessionTokenRegistry::new();
let presentation = registry
.try_begin_admission_presentation("conn-present")
.expect("first presenter should acquire the connection");
assert!(registry.is_session_token_presentation_in_flight("conn-present"));
assert!(registry.is_session_token_admission_in_flight("conn-present"));
assert!(registry
.try_begin_admission_presentation("conn-present")
.is_none());
assert!(registry
.try_begin_admission_retirement("conn-present")
.is_none());
drop(presentation);
assert!(!registry.is_session_token_presentation_in_flight("conn-present"));
assert!(registry
.try_begin_admission_presentation("conn-present")
.is_some());
}
#[test]
fn admission_response_and_outbound_presentation_preserve_each_others_ownership() {
let registry = SessionTokenRegistry::new();
let response = registry
.try_begin_admission_response("conn-bilateral")
.expect("response writer should acquire an idle connection");
let presentation = registry
.try_begin_admission_presentation("conn-bilateral")
.expect("opposite-direction presenter may coexist");
drop(response);
assert!(registry.is_session_token_presentation_in_flight("conn-bilateral"));
assert!(registry.is_session_token_admission_in_flight("conn-bilateral"));
assert!(registry
.try_begin_admission_retirement("conn-bilateral")
.is_none());
let response = registry
.try_begin_admission_response("conn-bilateral")
.expect("response writer can reacquire while presentation remains active");
drop(presentation);
assert!(!registry.is_session_token_presentation_in_flight("conn-bilateral"));
assert!(registry.is_session_token_admission_in_flight("conn-bilateral"));
assert!(registry
.try_begin_admission_retirement("conn-bilateral")
.is_none());
drop(response);
assert!(!registry.is_session_token_admission_in_flight("conn-bilateral"));
assert!(registry
.try_begin_admission_retirement("conn-bilateral")
.is_some());
}
#[test]
fn forget_connection_clears_phase1_state() {
let registry = SessionTokenRegistry::new();
registry.register("tok".to_string(), "scoped:abc".to_string(), 0);
registry
.validate_with_cached_response("tok", "conn-A")
.expect("admit");
assert!(registry.admission_fingerprint("conn-A").is_some());
let _g = registry.begin_admission_response("conn-A");
assert!(registry.is_session_token_admission_in_flight("conn-A"));
drop(_g);
registry.forget_connection("conn-A");
assert_eq!(registry.admission("conn-A"), SessionAdmission::Pending);
assert!(registry.admission_fingerprint("conn-A").is_none());
assert!(!registry.is_session_token_admission_in_flight("conn-A"));
}
#[test]
fn test_token_payload_with_grant_scope_cross_language_compat() {
let json = r#"{"t":"test-token","s":"share","m":0}"#;
let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
let payload = decode_token_payload(&encoded).unwrap();
assert_eq!(payload.scope, GrantScope::from("share"));
let re_encoded = encode_token_payload(
&payload.token,
payload.scope.clone(),
payload.max_connections,
);
let re_decoded = decode_token_payload(&re_encoded).unwrap();
assert_eq!(re_decoded.token, "test-token");
assert_eq!(re_decoded.scope, "share");
}
}