1use std::collections::BTreeSet;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::canonical::hash_json;
18use crate::event::SCHEMA_VERSION;
19
20pub const AUTH_PROTOCOL_VERSION: &str = "proofborne.auth.v1";
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TokenStatus {
30 #[default]
32 Active,
33 NeedsRefresh,
35 Expired,
37 Revoked,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum TokenLifecycleEvent {
45 Login,
47 RefreshSucceeded,
49 Expired,
51 Revoked,
53 RefreshUnavailable,
55}
56
57pub fn transition_token(
63 status: TokenStatus,
64 event: TokenLifecycleEvent,
65 refresh_available: bool,
66) -> TokenStatus {
67 match event {
68 TokenLifecycleEvent::Login | TokenLifecycleEvent::RefreshSucceeded => TokenStatus::Active,
69 TokenLifecycleEvent::Expired => TokenStatus::Expired,
70 TokenLifecycleEvent::Revoked => TokenStatus::Revoked,
71 TokenLifecycleEvent::RefreshUnavailable => {
72 if status == TokenStatus::Revoked {
73 TokenStatus::Revoked
74 } else if refresh_available {
75 TokenStatus::NeedsRefresh
76 } else {
77 TokenStatus::Expired
78 }
79 }
80 }
81}
82
83pub fn resolve_token_status(
90 status: TokenStatus,
91 expires_at: Option<DateTime<Utc>>,
92 refresh_available: bool,
93 now: DateTime<Utc>,
94) -> TokenStatus {
95 if status == TokenStatus::Revoked {
96 return TokenStatus::Revoked;
97 }
98 if status == TokenStatus::Active && expires_at.is_some_and(|expiry| expiry <= now) {
99 return if refresh_available {
100 TokenStatus::NeedsRefresh
101 } else {
102 TokenStatus::Expired
103 };
104 }
105 if status == TokenStatus::NeedsRefresh && !refresh_available {
106 return TokenStatus::Expired;
107 }
108 status
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum AuthReceiptOutcome {
115 Authorized,
117 CredentialUnavailable,
119 Expired,
121 Revoked,
123 ScopeInsufficient,
125 Cancelled,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct AuthBindings {
139 pub provider: String,
141 pub profile: String,
143 pub account_id: String,
145 pub credential_identity_hash: String,
147 #[serde(default)]
149 pub scopes: BTreeSet<String>,
150 pub token_status: TokenStatus,
152 pub refresh_available: bool,
154 pub observed_at: DateTime<Utc>,
156}
157
158impl AuthBindings {
159 pub fn new(
161 provider: impl Into<String>,
162 profile: impl Into<String>,
163 account_id: impl Into<String>,
164 credential_identity_hash: impl Into<String>,
165 scopes: BTreeSet<String>,
166 token_status: TokenStatus,
167 refresh_available: bool,
168 observed_at: DateTime<Utc>,
169 ) -> Result<Self, AuthError> {
170 let bindings = Self {
171 provider: provider.into(),
172 profile: profile.into(),
173 account_id: account_id.into(),
174 credential_identity_hash: credential_identity_hash.into(),
175 scopes,
176 token_status,
177 refresh_available,
178 observed_at,
179 };
180 bindings.validate()?;
181 Ok(bindings)
182 }
183
184 pub fn validate(&self) -> Result<(), AuthError> {
186 validate_identifier(&self.provider, "provider")?;
187 validate_identifier(&self.profile, "profile")?;
188 validate_identifier(&self.account_id, "account id")?;
189 validate_digest(&self.credential_identity_hash, "credential identity hash")?;
190 for scope in &self.scopes {
191 validate_scope(scope)?;
192 }
193 Ok(())
194 }
195
196 pub fn digest(&self) -> Result<String, AuthError> {
198 self.validate()?;
199 let value = serde_json::to_value(self).map_err(|_| AuthError::Serialization)?;
200 Ok(hash_json(&value))
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct AuthReceipt {
213 pub schema_version: String,
215 pub protocol_version: String,
217 pub bindings_hash: String,
219 pub step: u64,
221 pub workspace_generation: u64,
223 pub outcome: AuthReceiptOutcome,
225}
226
227impl AuthReceipt {
228 pub fn new(
230 bindings: &AuthBindings,
231 step: u64,
232 workspace_generation: u64,
233 outcome: AuthReceiptOutcome,
234 ) -> Result<Self, AuthError> {
235 let receipt = Self {
236 schema_version: SCHEMA_VERSION.to_owned(),
237 protocol_version: AUTH_PROTOCOL_VERSION.to_owned(),
238 bindings_hash: bindings.digest()?,
239 step,
240 workspace_generation,
241 outcome,
242 };
243 receipt.validate_outcome(bindings)?;
244 receipt.validate()?;
245 Ok(receipt)
246 }
247
248 fn validate_outcome(&self, bindings: &AuthBindings) -> Result<(), AuthError> {
250 match self.outcome {
251 AuthReceiptOutcome::Authorized => {
252 if bindings.token_status != TokenStatus::Active {
253 return Err(AuthError::OutcomeMismatch);
254 }
255 }
256 AuthReceiptOutcome::Expired => {
257 if bindings.token_status != TokenStatus::Expired {
258 return Err(AuthError::OutcomeMismatch);
259 }
260 }
261 AuthReceiptOutcome::Revoked => {
262 if bindings.token_status != TokenStatus::Revoked {
263 return Err(AuthError::OutcomeMismatch);
264 }
265 }
266 AuthReceiptOutcome::CredentialUnavailable
267 | AuthReceiptOutcome::ScopeInsufficient
268 | AuthReceiptOutcome::Cancelled => {}
269 }
270 Ok(())
271 }
272
273 pub fn validate(&self) -> Result<(), AuthError> {
275 if self.schema_version != SCHEMA_VERSION {
276 return Err(AuthError::UnsupportedSchema(self.schema_version.clone()));
277 }
278 if self.protocol_version != AUTH_PROTOCOL_VERSION {
279 return Err(AuthError::UnsupportedProtocol(
280 self.protocol_version.clone(),
281 ));
282 }
283 validate_digest(&self.bindings_hash, "bindings hash")
284 }
285
286 pub fn digest(&self) -> Result<String, AuthError> {
288 self.validate()?;
289 let value = serde_json::to_value(self).map_err(|_| AuthError::Serialization)?;
290 Ok(hash_json(&value))
291 }
292}
293
294#[derive(Debug, Error)]
296pub enum AuthError {
297 #[error("unsupported auth schema version: {0}")]
299 UnsupportedSchema(String),
300 #[error("unsupported auth protocol version: {0}")]
302 UnsupportedProtocol(String),
303 #[error("invalid auth binding: {0}")]
305 Invalid(String),
306 #[error("auth receipt outcome contradicts the token lifecycle state")]
308 OutcomeMismatch,
309 #[error("auth binding serialization failed")]
311 Serialization,
312}
313
314fn validate_identifier(value: &str, label: &str) -> Result<(), AuthError> {
315 if value.is_empty()
316 || value.len() > 128
317 || !value
318 .chars()
319 .all(|character| character.is_ascii_alphanumeric() || "-_./:".contains(character))
320 {
321 return Err(AuthError::Invalid(format!("{label}: {value}")));
322 }
323 Ok(())
324}
325
326fn validate_digest(value: &str, label: &str) -> Result<(), AuthError> {
327 if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
328 return Err(AuthError::Invalid(format!(
329 "{label} must be a 64-hex BLAKE3 digest"
330 )));
331 }
332 Ok(())
333}
334
335fn validate_scope(value: &str) -> Result<(), AuthError> {
336 if value.is_empty()
337 || value.len() > 256
338 || value
339 .chars()
340 .any(|character| character.is_control() || character.is_whitespace())
341 {
342 return Err(AuthError::Invalid(format!("invalid scope: {value}")));
343 }
344 Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::canonical::hash_bytes;
351
352 fn digest(seed: &str) -> String {
353 hash_bytes(seed.as_bytes())
354 }
355
356 fn bindings() -> AuthBindings {
357 AuthBindings::new(
358 "openai",
359 "primary",
360 "acct_123",
361 digest("keyring:primary"),
362 BTreeSet::from(["models.read".to_owned(), "chat.completions".to_owned()]),
363 TokenStatus::Active,
364 true,
365 Utc::now(),
366 )
367 .unwrap()
368 }
369
370 #[test]
371 fn authorized_receipt_is_valid() {
372 let receipt = AuthReceipt::new(&bindings(), 0, 0, AuthReceiptOutcome::Authorized).unwrap();
373 assert_eq!(receipt.schema_version, SCHEMA_VERSION);
374 assert_eq!(receipt.protocol_version, AUTH_PROTOCOL_VERSION);
375 assert_eq!(receipt.outcome, AuthReceiptOutcome::Authorized);
376 assert!(receipt.digest().is_ok());
377 }
378
379 #[test]
380 fn authorized_outcome_requires_active_token() {
381 let mut bindings = bindings();
382 bindings.token_status = TokenStatus::Expired;
383 assert!(matches!(
384 AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Authorized),
385 Err(AuthError::OutcomeMismatch)
386 ));
387 }
388
389 #[test]
390 fn expired_outcome_requires_expired_token() {
391 let mut bindings = bindings();
392 bindings.token_status = TokenStatus::Expired;
393 assert!(AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Expired).is_ok());
394 bindings.token_status = TokenStatus::Active;
395 assert!(matches!(
396 AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Expired),
397 Err(AuthError::OutcomeMismatch)
398 ));
399 }
400
401 #[test]
402 fn revoked_outcome_requires_revoked_token() {
403 let mut bindings = bindings();
404 bindings.token_status = TokenStatus::Revoked;
405 assert!(AuthReceipt::new(&bindings, 0, 0, AuthReceiptOutcome::Revoked).is_ok());
406 }
407
408 #[test]
409 fn protocol_and_schema_versions_are_committed() {
410 let mut receipt =
411 AuthReceipt::new(&bindings(), 0, 0, AuthReceiptOutcome::Authorized).unwrap();
412 receipt.protocol_version = "attacker.v1".to_owned();
413 assert!(matches!(
414 receipt.validate(),
415 Err(AuthError::UnsupportedProtocol(_))
416 ));
417 }
418
419 #[test]
420 fn bindings_canonicalize_scopes_and_digest() {
421 let base = bindings();
422 let left = base.clone();
423 let mut right = base;
424 right.scopes = BTreeSet::from(["chat.completions".to_owned(), "models.read".to_owned()]);
426 assert_eq!(left.digest().unwrap(), right.digest().unwrap());
427 }
428
429 #[test]
430 fn binding_material_is_committed_to_digest() {
431 let before = bindings().digest().unwrap();
432 let mut changed = bindings();
433 changed.account_id = "acct_other".to_owned();
434 let after = changed.digest().unwrap();
435 assert_ne!(before, after);
436 }
437
438 #[test]
439 fn binding_rejects_invalid_identity_hash() {
440 let mut bindings = bindings();
441 bindings.credential_identity_hash = "not-a-digest".to_owned();
442 assert!(matches!(bindings.validate(), Err(AuthError::Invalid(_))));
443 }
444
445 #[test]
446 fn binding_rejects_whitespace_scope() {
447 let mut bindings = bindings();
448 bindings.scopes.insert("bad scope".to_owned());
449 assert!(matches!(bindings.validate(), Err(AuthError::Invalid(_))));
450 }
451
452 #[test]
453 fn transition_login_sets_active() {
454 assert_eq!(
455 transition_token(TokenStatus::Expired, TokenLifecycleEvent::Login, true),
456 TokenStatus::Active
457 );
458 }
459
460 #[test]
461 fn transition_refresh_succeeds_to_active_without_refresh() {
462 assert_eq!(
464 transition_token(
465 TokenStatus::NeedsRefresh,
466 TokenLifecycleEvent::RefreshSucceeded,
467 false
468 ),
469 TokenStatus::Active
470 );
471 }
472
473 #[test]
474 fn transition_refresh_unavailable_fails_closed() {
475 assert_eq!(
476 transition_token(
477 TokenStatus::NeedsRefresh,
478 TokenLifecycleEvent::RefreshUnavailable,
479 false
480 ),
481 TokenStatus::Expired
482 );
483 assert_eq!(
484 transition_token(
485 TokenStatus::NeedsRefresh,
486 TokenLifecycleEvent::RefreshUnavailable,
487 true
488 ),
489 TokenStatus::NeedsRefresh
490 );
491 }
492
493 #[test]
494 fn transition_revoke_is_terminal() {
495 assert_eq!(
496 transition_token(TokenStatus::Active, TokenLifecycleEvent::Revoked, true),
497 TokenStatus::Revoked
498 );
499 assert_eq!(
501 transition_token(
502 TokenStatus::Revoked,
503 TokenLifecycleEvent::RefreshUnavailable,
504 true
505 ),
506 TokenStatus::Revoked
507 );
508 }
509
510 #[test]
511 fn resolve_expired_active_with_refresh_becomes_needs_refresh() {
512 let now = Utc::now();
513 assert_eq!(
514 resolve_token_status(
515 TokenStatus::Active,
516 Some(now - chrono::Duration::seconds(1)),
517 true,
518 now,
519 ),
520 TokenStatus::NeedsRefresh
521 );
522 assert_eq!(
523 resolve_token_status(
524 TokenStatus::Active,
525 Some(now - chrono::Duration::seconds(1)),
526 false,
527 now,
528 ),
529 TokenStatus::Expired
530 );
531 }
532
533 #[test]
534 fn resolve_needs_refresh_without_refresh_is_expired() {
535 let now = Utc::now();
536 assert_eq!(
537 resolve_token_status(TokenStatus::NeedsRefresh, None, false, now),
538 TokenStatus::Expired
539 );
540 }
541
542 #[test]
543 fn resolve_never_repromotes_revoked() {
544 let now = Utc::now();
545 assert_eq!(
546 resolve_token_status(TokenStatus::Revoked, None, true, now),
547 TokenStatus::Revoked
548 );
549 }
550}