Skip to main content

codlet_core/auth/
code.rs

1//! Code authentication manager (RFC-013).
2//!
3//! [`CodeAuth`] composes the primitives from `code`, `hashing`, `rng`,
4//! `store`, `audit`, and `clock` into the safe redemption flow described in
5//! RFC-013 §10.3:
6//!
7//! 1. rate-limit check;
8//! 2. input normalization + validation;
9//! 3. code lookup (`find_redeemable`);
10//! 4. atomic claim (`claim_code`);
11//! 5. host callback (creates / resolves subject);
12//! 6. audit event;
13//! 7. return [`RedeemSuccess`].
14//!
15//! Steps 1–3 can fail without consuming the code.  Only step 4 is
16//! irreversible.  Session issuance requires the [`RedeemSuccess`] proof, which
17//! is only constructible when the claim returns `Won`.
18
19use std::future::Future;
20
21use crate::audit::{AuditSink, CodeAuthEvent};
22use crate::clock::Clock;
23use crate::code::{CodePolicy, validate_code_input};
24use crate::error::PublicRedemptionError;
25use crate::error::RedemptionFailReason;
26use crate::hashing::{KeyProvider, SecretDomain, SecretHasher};
27use crate::secret::{CodeId, SubjectId};
28use crate::store::code::{
29    ClaimRequest, CodeRecord, CodeStore, RedeemableCode, expires_at_from_ttl,
30};
31use crate::store::ratelimit::{RateLimitKey, RateLimitOutcome, RateLimitPolicy, RateLimitStore};
32
33use super::error::{ClaimProof, RedeemError, RedeemSuccess};
34
35/// Manages one-time code issuance, validation, and redemption (RFC-013 §3).
36///
37/// Generic over:
38/// - `CS` — the [`CodeStore`] backend;
39/// - `RL` — the [`RateLimitStore`] backend (use `()` to opt out);
40/// - `K` — the [`KeyProvider`];
41/// - `C` — the [`Clock`];
42/// - `A` — the [`AuditSink`].
43pub struct CodeAuth<CS, RL, K, C, A> {
44    store: CS,
45    rate_limit_store: RL,
46    hasher: SecretHasher<K>,
47    clock: C,
48    audit: A,
49    policy: CodePolicy,
50    rate_limit_policy: Option<RateLimitPolicy>,
51}
52
53impl<CS, RL, K, C, A> CodeAuth<CS, RL, K, C, A>
54where
55    CS: CodeStore,
56    RL: RateLimitStore,
57    K: KeyProvider,
58    C: Clock,
59    A: AuditSink,
60{
61    /// Construct a `CodeAuth` with a rate-limit store and policy.
62    #[must_use]
63    pub fn new(
64        store: CS,
65        rate_limit_store: RL,
66        hasher: SecretHasher<K>,
67        clock: C,
68        audit: A,
69        policy: CodePolicy,
70        rate_limit_policy: RateLimitPolicy,
71    ) -> Self {
72        Self {
73            store,
74            rate_limit_store,
75            hasher,
76            clock,
77            audit,
78            policy,
79            rate_limit_policy: Some(rate_limit_policy),
80        }
81    }
82
83    // ── Issue ────────────────────────────────────────────────────────────────
84
85    /// Issue a new one-time code and insert it into the store.
86    ///
87    /// Returns the [`CodeId`] (for audit/admin) and the plaintext code (for
88    /// delivery to the recipient). The plaintext must not be logged or stored.
89    ///
90    /// `rng` must be a fresh CSPRNG; `ttl` overrides the policy TTL if needed.
91    /// `scope` and `grant` are host-owned and not interpreted by codlet.
92    ///
93    /// # Errors
94    /// Returns [`RedeemError::Internal`] if the RNG or store fails.
95    pub async fn issue_code<R: crate::rng::RandomSource>(
96        &self,
97        rng: &mut R,
98        id: CodeId,
99        purpose: Option<String>,
100        scope: Option<String>,
101        grant: Option<String>,
102    ) -> Result<(CodeId, crate::secret::PlainCode), RedeemError> {
103        let plain =
104            crate::code::generate_code(&self.policy, rng).map_err(|e| RedeemError::Internal {
105                cause: format!("rng: {e}"),
106                public: PublicRedemptionError::TemporarilyUnavailable,
107            })?;
108
109        let normalized = plain.expose().to_string(); // already in canonical form
110        let (lookup_key, key_version) = self
111            .hasher
112            .lookup_key(SecretDomain::Code, &normalized)
113            .map_err(RedeemError::from_key)?;
114
115        let now = self.clock.unix_now();
116        let expires_at = expires_at_from_ttl(now, self.policy.ttl());
117
118        let record = CodeRecord {
119            id: id.clone(),
120            lookup_key,
121            key_version,
122            purpose,
123            scope,
124            grant,
125            expires_at,
126        };
127        self.store
128            .insert_code(record)
129            .await
130            .map_err(RedeemError::from_store)?;
131
132        self.audit.record(CodeAuthEvent::CodeIssued {
133            code_id: id.clone(),
134            purpose: None,
135        });
136
137        Ok((id, plain))
138    }
139
140    // ── Two-step redemption ──────────────────────────────────────────────────
141
142    /// Step 1: validate and look up a submitted code without claiming it.
143    ///
144    /// Returns a [`RedeemableCode`] that the caller can inspect (e.g. to
145    /// display a confirmation or collect additional user input) before
146    /// committing the claim in [`Self::claim`].
147    ///
148    /// Rate limiting is applied here if configured.
149    ///
150    /// # Errors
151    /// Returns [`RedeemError`] on validation failure, rate limit, or lookup miss.
152    pub async fn find(
153        &self,
154        raw_input: &str,
155        rate_key: Option<&RateLimitKey>,
156    ) -> Result<RedeemableCode, RedeemError> {
157        // Step 1: rate-limit check.
158        if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
159            match self.rate_limit_store.check(key, rl_policy).await {
160                Ok(RateLimitOutcome::Deny) => {
161                    self.audit.record(CodeAuthEvent::RateLimitHit {
162                        key_fingerprint: key.fingerprint().to_string(),
163                        purpose: None,
164                    });
165                    return Err(RedeemError::RateLimited {
166                        public: PublicRedemptionError::RateLimited,
167                    });
168                }
169                Ok(RateLimitOutcome::Allow) => {}
170                Err(_) => { /* fail-open per policy; store error logged internally */ }
171            }
172        }
173
174        // Step 2: input normalization + validation.
175        let normalized = validate_code_input(raw_input, &self.policy).map_err(|_| {
176            self.audit.record(CodeAuthEvent::RedemptionFailed {
177                reason: RedemptionFailReason::InvalidFormat,
178            });
179            RedeemError::InvalidInput {
180                reason: RedemptionFailReason::InvalidFormat,
181                public: PublicRedemptionError::from_reason(&RedemptionFailReason::InvalidFormat),
182            }
183        })?;
184
185        // Step 3: derive lookup key candidates and find the record.
186        let (lookup_key, _) = self
187            .hasher
188            .lookup_key(SecretDomain::Code, &normalized)
189            .map_err(RedeemError::from_key)?;
190
191        let now = self.clock.unix_now();
192        let record = self
193            .store
194            .find_redeemable(&[lookup_key], now, None)
195            .await
196            .map_err(RedeemError::from_store)?
197            .ok_or_else(|| {
198                self.audit.record(CodeAuthEvent::RedemptionFailed {
199                    reason: RedemptionFailReason::NotFound,
200                });
201                RedeemError::NotRedeemable {
202                    reason: RedemptionFailReason::NotFound,
203                    public: PublicRedemptionError::InvalidOrExpired,
204                }
205            })?;
206
207        Ok(record)
208    }
209
210    /// Step 2: atomically claim a [`RedeemableCode`] found by [`Self::find`].
211    ///
212    /// Returns a [`RedeemSuccess`] proof only if `claim_code` returns `Won`.
213    /// A `Lost` result means a concurrent caller already claimed the code.
214    ///
215    /// Rate-limit failures are recorded on a failed claim, and cleared on a
216    /// successful one, when a `rate_key` is provided.
217    ///
218    /// # Errors
219    /// Returns [`RedeemError::ClaimLost`] if the atomic claim was lost, or
220    /// [`RedeemError::Internal`] on store failure.
221    pub async fn claim(
222        &self,
223        record: &RedeemableCode,
224        subject: SubjectId,
225        rate_key: Option<&RateLimitKey>,
226    ) -> Result<RedeemSuccess, RedeemError> {
227        let now = self.clock.unix_now();
228        let outcome = self
229            .store
230            .claim_code(&ClaimRequest {
231                code_id: &record.id,
232                subject: &subject,
233                now,
234                purpose: None,
235                scope: None,
236            })
237            .await
238            .map_err(RedeemError::from_store)?;
239
240        match ClaimProof::new(outcome) {
241            Some(proof) => {
242                // Clear rate-limit counter on success.
243                if let Some(key) = rate_key {
244                    if self.rate_limit_policy.is_some() {
245                        let _ = self.rate_limit_store.clear_failures(key).await;
246                    }
247                }
248                self.audit.record(CodeAuthEvent::CodeRedeemed {
249                    code_id: record.id.clone(),
250                    subject_id: subject.clone(),
251                });
252                Ok(RedeemSuccess {
253                    subject,
254                    grant: record.grant.clone(),
255                    _claim_proof: proof,
256                })
257            }
258            None => {
259                // Record failure in rate limiter for a lost claim too.
260                if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
261                    let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
262                }
263                self.audit.record(CodeAuthEvent::RedemptionFailed {
264                    reason: RedemptionFailReason::AlreadyUsed,
265                });
266                Err(RedeemError::ClaimLost {
267                    public: PublicRedemptionError::InvalidOrExpired,
268                })
269            }
270        }
271    }
272
273    // ── Single-call callback flow (RFC-013 §4) ───────────────────────────────
274
275    /// Validate, look up, and claim a code in one call, invoking `on_won` as
276    /// the host callback that creates or resolves the subject.
277    ///
278    /// Enforces RFC-013 §10.3 step order. `on_won` is called only after a
279    /// confirmed won claim; its error aborts the flow without a session.
280    ///
281    /// # Errors
282    /// Returns [`RedeemError`] on any failure. If `on_won` fails, returns
283    /// [`RedeemError::Internal`] and the claim is already consumed (the host
284    /// must decide on compensation if needed — RFC-013 §5).
285    pub async fn redeem_with_callback<F, Fut, E>(
286        &self,
287        raw_input: &str,
288        rate_key: Option<&RateLimitKey>,
289        on_won: F,
290    ) -> Result<RedeemSuccess, RedeemError>
291    where
292        F: FnOnce(&RedeemableCode) -> Fut,
293        Fut: Future<Output = Result<SubjectId, E>>,
294        E: std::fmt::Display,
295    {
296        let record = self.find(raw_input, rate_key).await?;
297        let now = self.clock.unix_now();
298
299        // Attempt claim before invoking host callback (fail-fast on race).
300        let outcome = self
301            .store
302            .claim_code(&ClaimRequest {
303                code_id: &record.id,
304                subject: &SubjectId::new("__pending__".into()),
305                now,
306                purpose: None,
307                scope: None,
308            })
309            .await
310            .map_err(RedeemError::from_store)?;
311
312        let proof = ClaimProof::new(outcome).ok_or_else(|| {
313            self.audit.record(CodeAuthEvent::RedemptionFailed {
314                reason: RedemptionFailReason::AlreadyUsed,
315            });
316            RedeemError::ClaimLost {
317                public: PublicRedemptionError::InvalidOrExpired,
318            }
319        })?;
320
321        // Claim won — now invoke host callback.
322        let subject = on_won(&record).await.map_err(|e| RedeemError::Internal {
323            cause: format!("host callback failed: {e}"),
324            public: PublicRedemptionError::TemporarilyUnavailable,
325        })?;
326
327        if let Some(key) = rate_key {
328            if self.rate_limit_policy.is_some() {
329                let _ = self.rate_limit_store.clear_failures(key).await;
330            }
331        }
332        self.audit.record(CodeAuthEvent::CodeRedeemed {
333            code_id: record.id.clone(),
334            subject_id: subject.clone(),
335        });
336
337        Ok(RedeemSuccess {
338            subject,
339            grant: record.grant.clone(),
340            _claim_proof: proof,
341        })
342    }
343
344    /// Revoke a code by its record ID. Scoped to `scope` when provided.
345    ///
346    /// # Errors
347    /// Returns [`RedeemError::Internal`] on store failure.
348    pub async fn revoke_code(
349        &self,
350        code_id: &CodeId,
351        scope: Option<&str>,
352    ) -> Result<(), RedeemError> {
353        let now = self.clock.unix_now();
354        self.store
355            .revoke_code(code_id, scope, now)
356            .await
357            .map_err(RedeemError::from_store)?;
358        self.audit.record(CodeAuthEvent::CodeRevoked {
359            code_id: code_id.clone(),
360            scope: scope.map(str::to_string),
361        });
362        Ok(())
363    }
364}
365
366/// Convenience impl: construct a [`CodeAuth`] with no rate-limit store.
367///
368/// Uses `NoRateLimit` as the `RL` type parameter so callers don't need to
369/// spell out the full generic signature when rate limiting is handled elsewhere.
370impl<CS, K, C, A> CodeAuth<CS, super::norate::NoRateLimit, K, C, A>
371where
372    CS: CodeStore,
373    K: KeyProvider,
374    C: Clock,
375    A: AuditSink,
376{
377    /// Construct without a rate-limit store. Equivalent to passing
378    /// `NoRateLimit` explicitly.
379    #[must_use]
380    pub fn without_rate_limit(
381        store: CS,
382        hasher: SecretHasher<K>,
383        clock: C,
384        audit: A,
385        policy: CodePolicy,
386    ) -> Self {
387        Self {
388            store,
389            rate_limit_store: super::norate::NoRateLimit,
390            hasher,
391            clock,
392            audit,
393            policy,
394            rate_limit_policy: None,
395        }
396    }
397}