Skip to main content

codlet_core/auth/
token.rs

1//! Form-token manager (RFC-013 §3).
2//!
3//! [`FormTokenManager`] wraps the primitives needed to issue single-use form
4//! tokens (CSRF protection + idempotency replay) and consume them atomically.
5
6use crate::audit::{AuditSink, CodeAuthEvent};
7use crate::clock::Clock;
8use crate::hashing::{KeyProvider, SecretDomain, SecretHasher};
9use crate::rng::RandomSource;
10use crate::secret::FormTokenSecret;
11use crate::state::TokenConsumeOutcome;
12use crate::store::code::expires_at_from_ttl;
13use crate::store::token::{FormTokenRecord, FormTokenStore, TokenSubject};
14
15use super::error::FormTokenError;
16
17/// Manages form-token issuance and consumption (RFC-013 §3).
18pub struct FormTokenManager<TS, K, C, A> {
19    store: TS,
20    hasher: SecretHasher<K>,
21    clock: C,
22    audit: A,
23    ttl: std::time::Duration,
24}
25
26impl<TS, K, C, A> FormTokenManager<TS, K, C, A>
27where
28    TS: FormTokenStore,
29    K: KeyProvider,
30    C: Clock,
31    A: AuditSink,
32{
33    /// Construct a form-token manager with the given token TTL.
34    ///
35    /// A TTL of one hour matches the source service's `FORM_TOKEN_TTL_SECONDS`.
36    #[must_use]
37    pub fn new(
38        store: TS,
39        hasher: SecretHasher<K>,
40        clock: C,
41        audit: A,
42        ttl: std::time::Duration,
43    ) -> Self {
44        Self {
45            store,
46            hasher,
47            clock,
48            audit,
49            ttl,
50        }
51    }
52
53    /// Issue a new form token for `subject` and `purpose`.
54    ///
55    /// Returns a [`FormTokenSecret`] (plaintext) to embed in the form or
56    /// a short-lived cookie. The secret is never persisted; only its HMAC
57    /// lookup key is stored (INV-1).
58    ///
59    /// # Errors
60    /// Returns [`FormTokenError::Internal`] on RNG, hasher, or store failure.
61    pub async fn issue<R: RandomSource>(
62        &self,
63        rng: &mut R,
64        subject: TokenSubject,
65        purpose: impl Into<String>,
66        bound_resource: Option<String>,
67    ) -> Result<FormTokenSecret, FormTokenError> {
68        // 32 random bytes, hex-encoded → 64-char URL-safe token.
69        let mut raw = [0u8; 32];
70        rng.fill_bytes(&mut raw)
71            .map_err(|e| FormTokenError::Internal {
72                cause: format!("rng: {e}"),
73                public: crate::error::PublicFormError::TemporarilyUnavailable,
74            })?;
75        let secret_hex = hex_lower(&raw);
76        let secret = FormTokenSecret::new(secret_hex.clone());
77
78        let (lookup_key, key_version) = self
79            .hasher
80            .lookup_key(SecretDomain::FormToken, secret.expose())
81            .map_err(FormTokenError::from_key)?;
82
83        let now = self.clock.unix_now();
84        let purpose = purpose.into();
85
86        self.store
87            .insert_form_token(FormTokenRecord {
88                lookup_key,
89                key_version,
90                subject,
91                purpose,
92                bound_resource,
93                issued_at: now,
94                expires_at: expires_at_from_ttl(now, self.ttl),
95            })
96            .await
97            .map_err(FormTokenError::from_store)?;
98
99        Ok(secret)
100    }
101
102    /// Consume a form token submitted by the client.
103    ///
104    /// Returns `Ok(None)` on `Proceed` (first winner), `Ok(Some(result_ref))`
105    /// on `Replay` (idempotent second submit), or [`FormTokenError::Invalid`]
106    /// on any rejection.
107    ///
108    /// Emits [`CodeAuthEvent::FormTokenReplay`] on replay.
109    ///
110    /// # Errors
111    /// Returns [`FormTokenError::Invalid`] when the token is not accepted.
112    /// Returns [`FormTokenError::Internal`] on store/key failure.
113    pub async fn consume(
114        &self,
115        raw_token: &str,
116        subject: &TokenSubject,
117        purpose: &str,
118        bound_resource: Option<&str>,
119    ) -> Result<Option<String>, FormTokenError> {
120        // Derive one candidate per held key (RFC-A) so tokens written under
121        // previous keys remain consumable during the rotation grace period.
122        let candidates: Vec<_> = self
123            .hasher
124            .lookup_key_candidates(SecretDomain::FormToken, raw_token)
125            .map_err(FormTokenError::from_key)?
126            .into_iter()
127            .map(|(lk, _)| lk)
128            .collect();
129
130        let now = self.clock.unix_now();
131        let (outcome, result_ref) = self
132            .store
133            .consume_form_token(&candidates, subject, purpose, bound_resource, now)
134            .await
135            .map_err(FormTokenError::from_store)?;
136
137        match outcome {
138            TokenConsumeOutcome::Proceed => Ok(None),
139            TokenConsumeOutcome::Replay => {
140                self.audit.record(CodeAuthEvent::FormTokenReplay {
141                    purpose: purpose.to_string(),
142                });
143                Ok(result_ref)
144            }
145            TokenConsumeOutcome::Invalid => Err(FormTokenError::Invalid {
146                public: crate::error::PublicFormError::ExpiredOrInvalid,
147            }),
148        }
149    }
150
151    /// Store a result reference on a consumed token for idempotency replay.
152    ///
153    /// # Errors
154    /// Returns [`FormTokenError::Internal`] on store failure.
155    pub async fn set_result(
156        &self,
157        raw_token: &str,
158        result_ref: &str,
159    ) -> Result<(), FormTokenError> {
160        // Derive one candidate per held key (RFC-A) so tokens written under
161        // previous keys remain consumable during the rotation grace period.
162        let candidates: Vec<_> = self
163            .hasher
164            .lookup_key_candidates(SecretDomain::FormToken, raw_token)
165            .map_err(FormTokenError::from_key)?
166            .into_iter()
167            .map(|(lk, _)| lk)
168            .collect();
169        self.store
170            .set_token_result(&candidates, result_ref)
171            .await
172            .map_err(FormTokenError::from_store)
173    }
174}
175
176fn hex_lower(bytes: &[u8]) -> String {
177    const HEX: &[u8; 16] = b"0123456789abcdef";
178    let mut s = String::with_capacity(bytes.len() * 2);
179    for &b in bytes {
180        s.push(HEX[(b >> 4) as usize] as char);
181        s.push(HEX[(b & 0xf) as usize] as char);
182    }
183    s
184}