use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::canonical::hash_json;
use crate::event::SCHEMA_VERSION;
pub const AUTH_PROTOCOL_VERSION: &str = "proofborne.auth.v1";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenStatus {
#[default]
Active,
NeedsRefresh,
Expired,
Revoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenLifecycleEvent {
Login,
RefreshSucceeded,
Expired,
Revoked,
RefreshUnavailable,
}
pub fn transition_token(
status: TokenStatus,
event: TokenLifecycleEvent,
refresh_available: bool,
) -> TokenStatus {
match event {
TokenLifecycleEvent::Login | TokenLifecycleEvent::RefreshSucceeded => TokenStatus::Active,
TokenLifecycleEvent::Expired => TokenStatus::Expired,
TokenLifecycleEvent::Revoked => TokenStatus::Revoked,
TokenLifecycleEvent::RefreshUnavailable => {
if status == TokenStatus::Revoked {
TokenStatus::Revoked
} else if refresh_available {
TokenStatus::NeedsRefresh
} else {
TokenStatus::Expired
}
}
}
}
pub fn resolve_token_status(
status: TokenStatus,
expires_at: Option<DateTime<Utc>>,
refresh_available: bool,
now: DateTime<Utc>,
) -> TokenStatus {
if status == TokenStatus::Revoked {
return TokenStatus::Revoked;
}
if status == TokenStatus::Active && expires_at.is_some_and(|expiry| expiry <= now) {
return if refresh_available {
TokenStatus::NeedsRefresh
} else {
TokenStatus::Expired
};
}
if status == TokenStatus::NeedsRefresh && !refresh_available {
return TokenStatus::Expired;
}
status
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthReceiptOutcome {
Authorized,
CredentialUnavailable,
Expired,
Revoked,
ScopeInsufficient,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthBindings {
pub provider: String,
pub profile: String,
pub account_id: String,
pub credential_identity_hash: String,
#[serde(default)]
pub scopes: BTreeSet<String>,
pub token_status: TokenStatus,
pub refresh_available: bool,
pub observed_at: DateTime<Utc>,
}
impl AuthBindings {
pub fn new(
provider: impl Into<String>,
profile: impl Into<String>,
account_id: impl Into<String>,
credential_identity_hash: impl Into<String>,
scopes: BTreeSet<String>,
token_status: TokenStatus,
refresh_available: bool,
observed_at: DateTime<Utc>,
) -> Result<Self, AuthError> {
let bindings = Self {
provider: provider.into(),
profile: profile.into(),
account_id: account_id.into(),
credential_identity_hash: credential_identity_hash.into(),
scopes,
token_status,
refresh_available,
observed_at,
};
bindings.validate()?;
Ok(bindings)
}
pub fn validate(&self) -> Result<(), AuthError> {
validate_identifier(&self.provider, "provider")?;
validate_identifier(&self.profile, "profile")?;
validate_identifier(&self.account_id, "account id")?;
validate_digest(&self.credential_identity_hash, "credential identity hash")?;
for scope in &self.scopes {
validate_scope(scope)?;
}
Ok(())
}
pub fn digest(&self) -> Result<String, AuthError> {
self.validate()?;
let value = serde_json::to_value(self).map_err(|_| AuthError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthReceipt {
pub schema_version: String,
pub protocol_version: String,
pub bindings_hash: String,
pub step: u64,
pub workspace_generation: u64,
pub outcome: AuthReceiptOutcome,
}
impl AuthReceipt {
pub fn new(
bindings: &AuthBindings,
step: u64,
workspace_generation: u64,
outcome: AuthReceiptOutcome,
) -> Result<Self, AuthError> {
let receipt = Self {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AUTH_PROTOCOL_VERSION.to_owned(),
bindings_hash: bindings.digest()?,
step,
workspace_generation,
outcome,
};
receipt.validate_outcome(bindings)?;
receipt.validate()?;
Ok(receipt)
}
fn validate_outcome(&self, bindings: &AuthBindings) -> Result<(), AuthError> {
match self.outcome {
AuthReceiptOutcome::Authorized => {
if bindings.token_status != TokenStatus::Active {
return Err(AuthError::OutcomeMismatch);
}
}
AuthReceiptOutcome::Expired => {
if bindings.token_status != TokenStatus::Expired {
return Err(AuthError::OutcomeMismatch);
}
}
AuthReceiptOutcome::Revoked => {
if bindings.token_status != TokenStatus::Revoked {
return Err(AuthError::OutcomeMismatch);
}
}
AuthReceiptOutcome::CredentialUnavailable
| AuthReceiptOutcome::ScopeInsufficient
| AuthReceiptOutcome::Cancelled => {}
}
Ok(())
}
pub fn validate(&self) -> Result<(), AuthError> {
if self.schema_version != SCHEMA_VERSION {
return Err(AuthError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AUTH_PROTOCOL_VERSION {
return Err(AuthError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
validate_digest(&self.bindings_hash, "bindings hash")
}
pub fn digest(&self) -> Result<String, AuthError> {
self.validate()?;
let value = serde_json::to_value(self).map_err(|_| AuthError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("unsupported auth schema version: {0}")]
UnsupportedSchema(String),
#[error("unsupported auth protocol version: {0}")]
UnsupportedProtocol(String),
#[error("invalid auth binding: {0}")]
Invalid(String),
#[error("auth receipt outcome contradicts the token lifecycle state")]
OutcomeMismatch,
#[error("auth binding serialization failed")]
Serialization,
}
fn validate_identifier(value: &str, label: &str) -> Result<(), AuthError> {
if value.is_empty()
|| value.len() > 128
|| !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || "-_./:".contains(character))
{
return Err(AuthError::Invalid(format!("{label}: {value}")));
}
Ok(())
}
fn validate_digest(value: &str, label: &str) -> Result<(), AuthError> {
if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
return Err(AuthError::Invalid(format!(
"{label} must be a 64-hex BLAKE3 digest"
)));
}
Ok(())
}
fn validate_scope(value: &str) -> Result<(), AuthError> {
if value.is_empty()
|| value.len() > 256
|| value
.chars()
.any(|character| character.is_control() || character.is_whitespace())
{
return Err(AuthError::Invalid(format!("invalid scope: {value}")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::hash_bytes;
fn digest(seed: &str) -> String {
hash_bytes(seed.as_bytes())
}
fn bindings() -> AuthBindings {
AuthBindings::new(
"openai",
"primary",
"acct_123",
digest("keyring:primary"),
BTreeSet::from(["models.read".to_owned(), "chat.completions".to_owned()]),
TokenStatus::Active,
true,
Utc::now(),
)
.unwrap()
}
#[test]
fn authorized_receipt_is_valid() {
let receipt = AuthReceipt::new(&bindings(), 0, 0, AuthReceiptOutcome::Authorized).unwrap();
assert_eq!(receipt.schema_version, SCHEMA_VERSION);
assert_eq!(receipt.protocol_version, AUTH_PROTOCOL_VERSION);
assert_eq!(receipt.outcome, AuthReceiptOutcome::Authorized);
assert!(receipt.digest().is_ok());
}
#[test]
fn authorized_outcome_requires_active_token() {
let mut bindings = bindings();
bindings.token_status = TokenStatus::Expired;
assert!(matches!(
AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Authorized),
Err(AuthError::OutcomeMismatch)
));
}
#[test]
fn expired_outcome_requires_expired_token() {
let mut bindings = bindings();
bindings.token_status = TokenStatus::Expired;
assert!(AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Expired).is_ok());
bindings.token_status = TokenStatus::Active;
assert!(matches!(
AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Expired),
Err(AuthError::OutcomeMismatch)
));
}
#[test]
fn revoked_outcome_requires_revoked_token() {
let mut bindings = bindings();
bindings.token_status = TokenStatus::Revoked;
assert!(AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Revoked).is_ok());
}
#[test]
fn protocol_and_schema_versions_are_committed() {
let mut receipt =
AuthReceipt::new(&bindings(), 0, 0, AuthReceiptOutcome::Authorized).unwrap();
receipt.protocol_version = "attacker.v1".to_owned();
assert!(matches!(
receipt.validate(),
Err(AuthError::UnsupportedProtocol(_))
));
}
#[test]
fn bindings_canonicalize_scopes_and_digest() {
let base = bindings();
let left = base.clone();
let mut right = base;
right.scopes = BTreeSet::from(["chat.completions".to_owned(), "models.read".to_owned()]);
assert_eq!(left.digest().unwrap(), right.digest().unwrap());
}
#[test]
fn binding_material_is_committed_to_digest() {
let before = bindings().digest().unwrap();
let mut changed = bindings();
changed.account_id = "acct_other".to_owned();
let after = changed.digest().unwrap();
assert_ne!(before, after);
}
#[test]
fn binding_rejects_invalid_identity_hash() {
let mut bindings = bindings();
bindings.credential_identity_hash = "not-a-digest".to_owned();
assert!(matches!(bindings.validate(), Err(AuthError::Invalid(_))));
}
#[test]
fn binding_rejects_whitespace_scope() {
let mut bindings = bindings();
bindings.scopes.insert("bad scope".to_owned());
assert!(matches!(bindings.validate(), Err(AuthError::Invalid(_))));
}
#[test]
fn transition_login_sets_active() {
assert_eq!(
transition_token(TokenStatus::Expired, TokenLifecycleEvent::Login, true),
TokenStatus::Active
);
}
#[test]
fn transition_refresh_succeeds_to_active_without_refresh() {
assert_eq!(
transition_token(
TokenStatus::NeedsRefresh,
TokenLifecycleEvent::RefreshSucceeded,
false
),
TokenStatus::Active
);
}
#[test]
fn transition_refresh_unavailable_fails_closed() {
assert_eq!(
transition_token(
TokenStatus::NeedsRefresh,
TokenLifecycleEvent::RefreshUnavailable,
false
),
TokenStatus::Expired
);
assert_eq!(
transition_token(
TokenStatus::NeedsRefresh,
TokenLifecycleEvent::RefreshUnavailable,
true
),
TokenStatus::NeedsRefresh
);
}
#[test]
fn transition_revoke_is_terminal() {
assert_eq!(
transition_token(TokenStatus::Active, TokenLifecycleEvent::Revoked, true),
TokenStatus::Revoked
);
assert_eq!(
transition_token(
TokenStatus::Revoked,
TokenLifecycleEvent::RefreshUnavailable,
true
),
TokenStatus::Revoked
);
}
#[test]
fn resolve_expired_active_with_refresh_becomes_needs_refresh() {
let now = Utc::now();
assert_eq!(
resolve_token_status(
TokenStatus::Active,
Some(now - chrono::Duration::seconds(1)),
true,
now,
),
TokenStatus::NeedsRefresh
);
assert_eq!(
resolve_token_status(
TokenStatus::Active,
Some(now - chrono::Duration::seconds(1)),
false,
now,
),
TokenStatus::Expired
);
}
#[test]
fn resolve_needs_refresh_without_refresh_is_expired() {
let now = Utc::now();
assert_eq!(
resolve_token_status(TokenStatus::NeedsRefresh, None, false, now),
TokenStatus::Expired
);
}
#[test]
fn resolve_never_repromotes_revoked() {
let now = Utc::now();
assert_eq!(
resolve_token_status(TokenStatus::Revoked, None, true, now),
TokenStatus::Revoked
);
}
}