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 multi-step join ticket).
23 ///
24 /// ## Intended pattern for two-step join flows
25 ///
26 /// The host generates a random flow ID at the start of the flow and
27 /// stores it in a short-lived bearer cookie (the "join ticket"):
28 ///
29 /// ```rust,ignore
30 /// // Step 1 — POST /join: validate the invite code, issue a join ticket.
31 /// let flow_id = CodeId::new(generate_random_hex(&mut rng));
32 /// // Set `__join_ticket` cookie to flow_id.expose() (the plaintext).
33 /// // The cookie is HttpOnly, Secure, SameSite=Strict, short TTL.
34 ///
35 /// // Also issue a CSRF form token bound to this flow:
36 /// let token = form_token_mgr.issue(
37 /// &mut rng,
38 /// TokenSubject::Flow(flow_id.clone()),
39 /// "join_profile", // purpose
40 /// None, // bound_resource (or Some(community_id))
41 /// ).await?;
42 /// // Embed token.expose() in the profile form as a hidden field.
43 ///
44 /// // Step 2 — POST /join/profile: consume the form token.
45 /// // Read flow_id from the `__join_ticket` cookie.
46 /// let flow_id = CodeId::new(join_ticket_cookie_value.into());
47 /// let result = form_token_mgr.consume(
48 /// raw_form_token,
49 /// &TokenSubject::Flow(flow_id),
50 /// "join_profile",
51 /// None,
52 /// ).await?;
53 /// // Ok(None) → Proceed; Ok(Some(_)) → Replay; Err → Invalid/expired.
54 /// ```
55 ///
56 /// `SecretDomain::FlowTicket` is used when the host wants to hash the
57 /// join-ticket cookie value itself (making it a codlet-managed HMAC
58 /// lookup rather than a raw random string). This is optional — the host
59 /// may manage the ticket cookie independently and use `TokenSubject::Flow`
60 /// only for the CSRF form token layer.
61 Flow(crate::secret::CodeId),
62}
63
64impl TokenSubject {
65 /// A stable string representation persisted in the store. This is not a
66 /// security boundary on its own; the store's consume WHERE clause enforces
67 /// the binding.
68 #[must_use]
69 pub fn as_binding_str(&self) -> String {
70 match self {
71 TokenSubject::Anonymous => "anon".to_string(),
72 TokenSubject::Authenticated(s) => format!("auth:{}", s.as_str()),
73 TokenSubject::Flow(f) => format!("flow:{}", f.as_str()),
74 }
75 }
76}
77
78/// A consumed token record with an optional replay reference.
79#[derive(Debug, Clone)]
80pub struct ConsumedTokenRecord {
81 /// Whether the token has been consumed.
82 pub consumed: bool,
83 /// Optional result reference for idempotency replay (RFC-007 §4,
84 /// `set_result`). `None` if the result was not yet stored.
85 pub result_ref: Option<String>,
86 /// Whether the binding checked in the consume WHERE clause matched.
87 pub binding_ok: bool,
88}
89
90/// Parameters for inserting a new form token.
91pub struct FormTokenRecord {
92 /// Domain-separated HMAC of the token secret.
93 pub lookup_key: LookupKey,
94 /// Key version that produced `lookup_key`.
95 pub key_version: KeyVersion,
96 /// Subject binding (never an empty string).
97 pub subject: TokenSubject,
98 /// Purpose label, stable across the token's lifetime.
99 pub purpose: String,
100 /// Optional bound resource (HMAC of a domain object, not plaintext).
101 pub bound_resource: Option<String>,
102 /// Issuance time as Unix seconds (UTC).
103 pub issued_at: u64,
104 /// Expiry as Unix seconds (UTC).
105 pub expires_at: u64,
106}
107
108/// Form-token storage (RFC-007).
109///
110/// The consume operation must be atomic: a conditional UPDATE sets `consumed_at`
111/// only when the token is unconsumed, unexpired, and bindings match. The
112/// affected-row count drives [`TokenConsumeOutcome`] via
113/// [`crate::state::classify_token_consume`].
114pub trait FormTokenStore {
115 /// Insert a new form token record.
116 fn insert_form_token(
117 &self,
118 record: FormTokenRecord,
119 ) -> impl Future<Output = Result<(), StoreError>>;
120
121 /// Attempt to atomically consume a form token.
122 ///
123 /// The adapter must:
124 /// 1. Run the conditional UPDATE (sets `consumed_at`).
125 /// 2. If `changed == 0`, run a follow-up SELECT to classify why.
126 /// 3. Call [`crate::state::classify_token_consume`] with the results.
127 /// 4. Return the outcome plus any stored `result_ref` for replays.
128 fn consume_form_token(
129 &self,
130 lookup_key: &LookupKey,
131 subject: &TokenSubject,
132 purpose: &str,
133 bound_resource: Option<&str>,
134 now: u64,
135 ) -> impl Future<Output = Result<(TokenConsumeOutcome, Option<String>), StoreError>>;
136
137 /// Store a result reference on a consumed token for idempotency replay.
138 fn set_token_result(
139 &self,
140 lookup_key: &LookupKey,
141 result_ref: &str,
142 ) -> impl Future<Output = Result<(), StoreError>>;
143}