codlet_core/store/token.rs
1//! Form-token storage trait (RFC-007).
2
3use std::future::Future;
4
5use crate::hashing::{KeyVersion, LookupKey};
6use crate::state::TokenConsumeOutcome;
7
8use super::error::StoreError;
9
10/// The subject binding for a form token (RFC-007 §13.3).
11///
12/// Explicit variants prevent the "empty string for anonymous" anti-pattern
13/// identified in RFC-007 §13.3. Bindings are persisted as part of the token
14/// record and checked on consume.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub enum TokenSubject {
18 /// Token issued before authentication (e.g. a join form).
19 Anonymous,
20 /// Token issued for an authenticated subject.
21 Authenticated(crate::secret::SubjectId),
22 /// Token bound to a transient flow ID (e.g. a join ticket).
23 Flow(crate::secret::CodeId),
24}
25
26impl TokenSubject {
27 /// A stable string representation persisted in the store. This is not a
28 /// security boundary on its own; the store's consume WHERE clause enforces
29 /// the binding.
30 #[must_use]
31 pub fn as_binding_str(&self) -> String {
32 match self {
33 TokenSubject::Anonymous => "anon".to_string(),
34 TokenSubject::Authenticated(s) => format!("auth:{}", s.as_str()),
35 TokenSubject::Flow(f) => format!("flow:{}", f.as_str()),
36 }
37 }
38}
39
40/// A consumed token record with an optional replay reference.
41#[derive(Debug, Clone)]
42pub struct ConsumedTokenRecord {
43 /// Whether the token has been consumed.
44 pub consumed: bool,
45 /// Optional result reference for idempotency replay (RFC-007 §4,
46 /// `set_result`). `None` if the result was not yet stored.
47 pub result_ref: Option<String>,
48 /// Whether the binding checked in the consume WHERE clause matched.
49 pub binding_ok: bool,
50}
51
52/// Parameters for inserting a new form token.
53pub struct FormTokenRecord {
54 /// Domain-separated HMAC of the token secret.
55 pub lookup_key: LookupKey,
56 /// Key version that produced `lookup_key`.
57 pub key_version: KeyVersion,
58 /// Subject binding (never an empty string).
59 pub subject: TokenSubject,
60 /// Purpose label, stable across the token's lifetime.
61 pub purpose: String,
62 /// Optional bound resource (HMAC of a domain object, not plaintext).
63 pub bound_resource: Option<String>,
64 /// Issuance time as Unix seconds (UTC).
65 pub issued_at: u64,
66 /// Expiry as Unix seconds (UTC).
67 pub expires_at: u64,
68}
69
70/// Form-token storage (RFC-007).
71///
72/// The consume operation must be atomic: a conditional UPDATE sets `consumed_at`
73/// only when the token is unconsumed, unexpired, and bindings match. The
74/// affected-row count drives [`TokenConsumeOutcome`] via
75/// [`crate::state::classify_token_consume`].
76pub trait FormTokenStore {
77 /// Insert a new form token record.
78 fn insert_form_token(
79 &self,
80 record: FormTokenRecord,
81 ) -> impl Future<Output = Result<(), StoreError>>;
82
83 /// Attempt to atomically consume a form token.
84 ///
85 /// The adapter must:
86 /// 1. Run the conditional UPDATE (sets `consumed_at`).
87 /// 2. If `changed == 0`, run a follow-up SELECT to classify why.
88 /// 3. Call [`crate::state::classify_token_consume`] with the results.
89 /// 4. Return the outcome plus any stored `result_ref` for replays.
90 fn consume_form_token(
91 &self,
92 lookup_key: &LookupKey,
93 subject: &TokenSubject,
94 purpose: &str,
95 bound_resource: Option<&str>,
96 now: u64,
97 ) -> impl Future<Output = Result<(TokenConsumeOutcome, Option<String>), StoreError>>;
98
99 /// Store a result reference on a consumed token for idempotency replay.
100 fn set_token_result(
101 &self,
102 lookup_key: &LookupKey,
103 result_ref: &str,
104 ) -> impl Future<Output = Result<(), StoreError>>;
105}