Skip to main content

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}