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        let (lookup_key, _) = self
121            .hasher
122            .lookup_key(SecretDomain::FormToken, raw_token)
123            .map_err(FormTokenError::from_key)?;
124
125        let now = self.clock.unix_now();
126        let (outcome, result_ref) = self
127            .store
128            .consume_form_token(&lookup_key, subject, purpose, bound_resource, now)
129            .await
130            .map_err(FormTokenError::from_store)?;
131
132        match outcome {
133            TokenConsumeOutcome::Proceed => Ok(None),
134            TokenConsumeOutcome::Replay => {
135                self.audit.record(CodeAuthEvent::FormTokenReplay {
136                    purpose: purpose.to_string(),
137                });
138                Ok(result_ref)
139            }
140            TokenConsumeOutcome::Invalid => Err(FormTokenError::Invalid {
141                public: crate::error::PublicFormError::ExpiredOrInvalid,
142            }),
143        }
144    }
145
146    /// Store a result reference on a consumed token for idempotency replay.
147    ///
148    /// # Errors
149    /// Returns [`FormTokenError::Internal`] on store failure.
150    pub async fn set_result(
151        &self,
152        raw_token: &str,
153        result_ref: &str,
154    ) -> Result<(), FormTokenError> {
155        let (lookup_key, _) = self
156            .hasher
157            .lookup_key(SecretDomain::FormToken, raw_token)
158            .map_err(FormTokenError::from_key)?;
159        self.store
160            .set_token_result(&lookup_key, result_ref)
161            .await
162            .map_err(FormTokenError::from_store)
163    }
164}
165
166fn hex_lower(bytes: &[u8]) -> String {
167    const HEX: &[u8; 16] = b"0123456789abcdef";
168    let mut s = String::with_capacity(bytes.len() * 2);
169    for &b in bytes {
170        s.push(HEX[(b >> 4) as usize] as char);
171        s.push(HEX[(b & 0xf) as usize] as char);
172    }
173    s
174}