Skip to main content

proofborne_core/
auth.rs

1//! Proof-carrying authentication contract.
2//!
3//! This module defines the secret-free, versioned `proofborne.auth.v1` bindings
4//! and receipts that let later surfaces (CLI login, keychain lifecycle, remote
5//! OAuth/coding-plan login, and account-aware routing) record an authentication
6//! fact as durable, hash-chained evidence without ever persisting credential
7//! material. Secret values live only in environment variables and the OS
8//! keychain; the public binding records a secret-free identity hash and a
9//! resolved token lifecycle state.
10
11use 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
20/// Version of the proof-carrying authentication protocol.
21pub const AUTH_PROTOCOL_VERSION: &str = "proofborne.auth.v1";
22
23/// Token lifecycle state visible to routing and evidence.
24///
25/// This is a lifecycle fact, not the token value. The runtime derives it from
26/// the credential store and provider metadata; no secret is ever serialized.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TokenStatus {
30    /// The credential is present and not yet expired.
31    #[default]
32    Active,
33    /// A refresh credential is present and a refresh attempt is required.
34    NeedsRefresh,
35    /// The token is past its recorded expiry.
36    Expired,
37    /// The credential was locally revoked or the provider reported a revoke.
38    Revoked,
39}
40
41/// A discrete lifecycle event that moves a token between states.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum TokenLifecycleEvent {
45    /// A fresh access/refresh pair was obtained.
46    Login,
47    /// A refresh attempt succeeded with a new token.
48    RefreshSucceeded,
49    /// The token reached its recorded expiry.
50    Expired,
51    /// The credential was locally or provider-side revoked.
52    Revoked,
53    /// A refresh was attempted but no valid refresh credential exists.
54    RefreshUnavailable,
55}
56
57/// Deterministically advances a token lifecycle on a discrete event.
58///
59/// The refresh-unavailable guard blocks a `NeedsRefresh` credential from
60/// becoming `Active` when no refresh token is present, matching the fail-closed
61/// behaviour the routing layer relies on.
62pub 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
83/// Derives the effective token status from recorded lifecycle facts.
84///
85/// An `Active` token past `expires_at` becomes `NeedsRefresh` when a refresh
86/// credential exists (recoverable) and `Expired` otherwise. A recorded
87/// `NeedsRefresh` with no refresh credential is fail-closed to `Expired` rather
88/// than presented as usable. Never re-promotes a `Revoked` credential.
89pub 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/// Terminal authorization outcome of one auth check, as durable evidence.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum AuthReceiptOutcome {
115    /// The account was authorized for the requested profile/model.
116    Authorized,
117    /// The required credential could not be resolved.
118    CredentialUnavailable,
119    /// The token is expired and cannot be refreshed.
120    Expired,
121    /// The account's token was revoked.
122    Revoked,
123    /// The claimed scopes are insufficient for the requested route.
124    ScopeInsufficient,
125    /// Cooperative cancellation interrupted the auth check.
126    Cancelled,
127}
128
129/// Secret-free identity and token-state binding for one provider account.
130///
131/// The `credential_identity_hash` is a digest over the public credential
132/// identity (for example `keyring:primary` or `env:OPENAI_API_KEY`), never the
133/// secret value. It is stable under secret rotation but changes when the
134/// credential identity or provider/profile changes, matching the existing
135/// routing authority binding.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct AuthBindings {
139    /// Provider adapter name.
140    pub provider: String,
141    /// Provider profile name.
142    pub profile: String,
143    /// Public provider account identifier.
144    pub account_id: String,
145    /// Secret-free digest of the credential identity.
146    pub credential_identity_hash: String,
147    /// Canonically ordered granted scopes.
148    #[serde(default)]
149    pub scopes: BTreeSet<String>,
150    /// Resolved token lifecycle state.
151    pub token_status: TokenStatus,
152    /// Whether a refresh token is present and can be used.
153    pub refresh_available: bool,
154    /// When the binding was produced.
155    pub observed_at: DateTime<Utc>,
156}
157
158impl AuthBindings {
159    /// Builds a binding after validating every material field.
160    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    /// Validates identity, scope, and digest-length invariants.
185    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    /// Computes a canonical BLAKE3 digest over the validated binding JSON.
197    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/// Proof-carrying auth check bound to one routing step and workspace authority.
205///
206/// This mirrors the `RoutingReceipt` shape: it is secret-free, versioned, and
207/// binds a canonical `AuthBindings` digest to an explicit step and workspace
208/// generation so a later offline verifier can reject tampered or replayed auth
209/// evidence without any provider access.
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct AuthReceipt {
213    /// Public schemas identifier, matching `SCHEMA_VERSION`.
214    pub schema_version: String,
215    /// Authentication protocol identifier, matching `AUTH_PROTOCOL_VERSION`.
216    pub protocol_version: String,
217    /// Content-addressed digest of the bound `AuthBindings`.
218    pub bindings_hash: String,
219    /// Monotonic routing step this auth check served.
220    pub step: u64,
221    /// Monotonic workspace generation this auth check served.
222    pub workspace_generation: u64,
223    /// Terminal authorization outcome.
224    pub outcome: AuthReceiptOutcome,
225}
226
227impl AuthReceipt {
228    /// Constructs a receipt, validating bindings and outcome consistency.
229    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    /// Validates that the outcome is consistent with the token lifecycle state.
249    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    /// Validates versioning and binding digest format.
274    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    /// Computes a canonical BLAKE3 digest over the validated receipt JSON.
287    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/// Authentication contract failures.
295#[derive(Debug, Error)]
296pub enum AuthError {
297    /// An unsupported schema version was supplied.
298    #[error("unsupported auth schema version: {0}")]
299    UnsupportedSchema(String),
300    /// An unsupported protocol version was supplied.
301    #[error("unsupported auth protocol version: {0}")]
302    UnsupportedProtocol(String),
303    /// A material field failed validation.
304    #[error("invalid auth binding: {0}")]
305    Invalid(String),
306    /// The receipt outcome contradicts the token lifecycle state.
307    #[error("auth receipt outcome contradicts the token lifecycle state")]
308    OutcomeMismatch,
309    /// The contract could not be serialized for digesting.
310    #[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        // Scope ordering must not alter the canonical digest.
425        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        // A fresh access token is usable even when no further refresh is stored.
463        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        // RefreshUnavailable never re-promotes a revoked credential.
500        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}