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            created_at: now,
126            expires_at,
127        };
128        self.store
129            .insert_code(record)
130            .await
131            .map_err(RedeemError::from_store)?;
132
133        self.audit.record(CodeAuthEvent::CodeIssued {
134            code_id: id.clone(),
135            purpose: None,
136        });
137
138        Ok((id, plain))
139    }
140
141    // ── Two-step redemption ──────────────────────────────────────────────────
142
143    /// Step 1: validate and look up a submitted code without claiming it.
144    ///
145    /// Returns a [`RedeemableCode`] that the caller can inspect (e.g. to
146    /// display a confirmation or collect additional user input) before
147    /// committing the claim in [`Self::claim`].
148    ///
149    /// Rate limiting is applied here if configured.
150    ///
151    /// # Errors
152    /// Returns [`RedeemError`] on validation failure, rate limit, or lookup miss.
153    pub async fn find(
154        &self,
155        raw_input: &str,
156        rate_key: Option<&RateLimitKey>,
157    ) -> Result<RedeemableCode, RedeemError> {
158        // Step 1: rate-limit check. Honour unavailable policy on store error.
159        if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
160            match self.rate_limit_store.check(key, rl_policy).await {
161                Ok(RateLimitOutcome::Deny) => {
162                    self.audit.record(CodeAuthEvent::RateLimitHit {
163                        key_fingerprint: key.fingerprint().to_string(),
164                        purpose: None,
165                    });
166                    return Err(RedeemError::RateLimited {
167                        public: PublicRedemptionError::RateLimited,
168                    });
169                }
170                Ok(RateLimitOutcome::Allow) => {}
171                Err(_) => {
172                    // Rate-limit store unavailable: apply configured policy.
173                    match rl_policy.unavailable {
174                        crate::store::ratelimit::RateLimitUnavailable::FailClosed => {
175                            self.audit.record(CodeAuthEvent::RateLimitHit {
176                                key_fingerprint: key.fingerprint().to_string(),
177                                purpose: None,
178                            });
179                            return Err(RedeemError::RateLimited {
180                                public: PublicRedemptionError::RateLimited,
181                            });
182                        }
183                        crate::store::ratelimit::RateLimitUnavailable::FailOpen => {}
184                    }
185                }
186            }
187        }
188
189        // Step 2: input normalization + validation.
190        let normalized = match validate_code_input(raw_input, &self.policy) {
191            Ok(n) => n,
192            Err(_) => {
193                self.audit.record(CodeAuthEvent::RedemptionFailed {
194                    reason: RedemptionFailReason::InvalidFormat,
195                });
196                // Invalid-format guesses count toward the rate limit (RFC-B).
197                if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
198                    let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
199                }
200                return Err(RedeemError::InvalidInput {
201                    reason: RedemptionFailReason::InvalidFormat,
202                    public: PublicRedemptionError::from_reason(
203                        &RedemptionFailReason::InvalidFormat,
204                    ),
205                });
206            }
207        };
208
209        // Step 3: derive one candidate per held key (RFC-A) and find the record.
210        let candidates: Vec<_> = self
211            .hasher
212            .lookup_key_candidates(SecretDomain::Code, &normalized)
213            .map_err(RedeemError::from_key)?
214            .into_iter()
215            .map(|(lk, _)| lk)
216            .collect();
217
218        let now = self.clock.unix_now();
219        let record = self
220            .store
221            .find_redeemable(&candidates, now, None)
222            .await
223            .map_err(RedeemError::from_store)?
224            .ok_or_else(|| {
225                self.audit.record(CodeAuthEvent::RedemptionFailed {
226                    reason: RedemptionFailReason::NotFound,
227                });
228                RedeemError::NotRedeemable {
229                    reason: RedemptionFailReason::NotFound,
230                    public: PublicRedemptionError::InvalidOrExpired,
231                }
232            });
233
234        // Not-found guesses count toward the rate limit (RFC-B).
235        if record.is_err() {
236            if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
237                let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
238            }
239        }
240        let record = record?;
241
242        Ok(record)
243    }
244
245    /// Step 2: atomically claim a [`RedeemableCode`] found by [`Self::find`].
246    ///
247    /// Returns a [`RedeemSuccess`] proof only if `claim_code` returns `Won`.
248    /// A `Lost` result means a concurrent caller already claimed the code.
249    ///
250    /// Rate-limit failures are recorded on a failed claim, and cleared on a
251    /// successful one, when a `rate_key` is provided.
252    ///
253    /// # Errors
254    /// Returns [`RedeemError::ClaimLost`] if the atomic claim was lost, or
255    /// [`RedeemError::Internal`] on store failure.
256    pub async fn claim(
257        &self,
258        record: &RedeemableCode,
259        subject: SubjectId,
260        rate_key: Option<&RateLimitKey>,
261    ) -> Result<RedeemSuccess, RedeemError> {
262        let now = self.clock.unix_now();
263        let outcome = self
264            .store
265            .claim_code(&ClaimRequest {
266                code_id: &record.id,
267                subject: &subject,
268                now,
269                // Pass purpose/scope from the found record so adapters can
270                // enforce cross-flow isolation in the UPDATE WHERE (RFC-C).
271                purpose: record.purpose.as_deref(),
272                scope: record.scope.as_deref(),
273            })
274            .await
275            .map_err(RedeemError::from_store)?;
276
277        match ClaimProof::new(outcome) {
278            Some(proof) => {
279                // Clear rate-limit counter on success.
280                if let Some(key) = rate_key {
281                    if self.rate_limit_policy.is_some() {
282                        let _ = self.rate_limit_store.clear_failures(key).await;
283                    }
284                }
285                self.audit.record(CodeAuthEvent::CodeRedeemed {
286                    code_id: record.id.clone(),
287                    subject_id: subject.clone(),
288                });
289                Ok(RedeemSuccess {
290                    subject,
291                    grant: record.grant.clone(),
292                    _claim_proof: proof,
293                })
294            }
295            None => {
296                // Record failure in rate limiter for a lost claim too.
297                if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
298                    let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
299                }
300                self.audit.record(CodeAuthEvent::RedemptionFailed {
301                    reason: RedemptionFailReason::AlreadyUsed,
302                });
303                Err(RedeemError::ClaimLost {
304                    public: PublicRedemptionError::InvalidOrExpired,
305                })
306            }
307        }
308    }
309
310    // ── Single-call callback flow (RFC-013 §4) ───────────────────────────────
311
312    /// Validate, look up, and claim a code in one call, invoking `on_won` as
313    /// the host callback that creates or resolves the subject.
314    ///
315    /// Enforces RFC-013 §10.3 step order. `on_won` is called only after a
316    /// confirmed won claim; its error aborts the flow without a session.
317    ///
318    /// # Errors
319    /// Returns [`RedeemError`] on any failure. If `on_won` fails, returns
320    /// [`RedeemError::Internal`] and the claim is already consumed (the host
321    /// must decide on compensation if needed — RFC-013 §5).
322    ///
323    /// # Production warning
324    ///
325    /// **Experimental (RFC-D).** This method claims the code before the host
326    /// callback returns the real subject, leaving `used_by_subject = "__pending__"`
327    /// in the database until the callback completes. If the callback fails, the
328    /// code is permanently consumed with no subject recorded, and the audit event
329    /// and database state disagree on who claimed it.
330    ///
331    /// For production audit-sensitive deployments, use the explicit two-step
332    /// flow: [`Self::find`] → host creates/resolves subject → [`Self::claim`].
333    #[deprecated(
334        note = "experimental: DB and audit state diverge if callback fails.                 Use find() + host subject creation + claim() for production."
335    )]
336    pub async fn redeem_with_callback<F, Fut, E>(
337        &self,
338        raw_input: &str,
339        rate_key: Option<&RateLimitKey>,
340        on_won: F,
341    ) -> Result<RedeemSuccess, RedeemError>
342    where
343        F: FnOnce(&RedeemableCode) -> Fut,
344        Fut: Future<Output = Result<SubjectId, E>>,
345        E: std::fmt::Display,
346    {
347        let record = self.find(raw_input, rate_key).await?;
348        let now = self.clock.unix_now();
349
350        // Attempt claim before invoking host callback (fail-fast on race).
351        // WARNING: redeem_with_callback() is experimental (RFC-D). The DB record
352        // will store the real subject once the callback returns, but the interim
353        // state is a won claim with no subject yet. Use find()+claim() for
354        // production audit-sensitive deployments.
355        let outcome = self
356            .store
357            .claim_code(&ClaimRequest {
358                code_id: &record.id,
359                subject: &SubjectId::new("__pending__".into()),
360                now,
361                purpose: record.purpose.as_deref(),
362                scope: record.scope.as_deref(),
363            })
364            .await
365            .map_err(RedeemError::from_store)?;
366
367        let proof = ClaimProof::new(outcome).ok_or_else(|| {
368            self.audit.record(CodeAuthEvent::RedemptionFailed {
369                reason: RedemptionFailReason::AlreadyUsed,
370            });
371            RedeemError::ClaimLost {
372                public: PublicRedemptionError::InvalidOrExpired,
373            }
374        })?;
375
376        // Claim won — now invoke host callback.
377        let subject = on_won(&record).await.map_err(|e| RedeemError::Internal {
378            cause: format!("host callback failed: {e}"),
379            public: PublicRedemptionError::TemporarilyUnavailable,
380        })?;
381
382        if let Some(key) = rate_key {
383            if self.rate_limit_policy.is_some() {
384                let _ = self.rate_limit_store.clear_failures(key).await;
385            }
386        }
387        self.audit.record(CodeAuthEvent::CodeRedeemed {
388            code_id: record.id.clone(),
389            subject_id: subject.clone(),
390        });
391
392        Ok(RedeemSuccess {
393            subject,
394            grant: record.grant.clone(),
395            _claim_proof: proof,
396        })
397    }
398
399    /// Revoke a code by its record ID. Scoped to `scope` when provided.
400    ///
401    /// # Errors
402    /// Returns [`RedeemError::Internal`] on store failure.
403    pub async fn revoke_code(
404        &self,
405        code_id: &CodeId,
406        scope: Option<&str>,
407    ) -> Result<(), RedeemError> {
408        let now = self.clock.unix_now();
409        self.store
410            .revoke_code(code_id, scope, now)
411            .await
412            .map_err(RedeemError::from_store)?;
413        self.audit.record(CodeAuthEvent::CodeRevoked {
414            code_id: code_id.clone(),
415            scope: scope.map(str::to_string),
416        });
417        Ok(())
418    }
419}
420
421/// Convenience impl: construct a [`CodeAuth`] with no rate-limit store.
422///
423/// Uses `NoRateLimit` as the `RL` type parameter so callers don't need to
424/// spell out the full generic signature when rate limiting is handled elsewhere.
425impl<CS, K, C, A> CodeAuth<CS, super::norate::NoRateLimit, K, C, A>
426where
427    CS: CodeStore,
428    K: KeyProvider,
429    C: Clock,
430    A: AuditSink,
431{
432    /// Construct without a rate-limit store. Equivalent to passing
433    /// `NoRateLimit` explicitly.
434    #[must_use]
435    pub fn without_rate_limit(
436        store: CS,
437        hasher: SecretHasher<K>,
438        clock: C,
439        audit: A,
440        policy: CodePolicy,
441    ) -> Self {
442        Self {
443            store,
444            rate_limit_store: super::norate::NoRateLimit,
445            hasher,
446            clock,
447            audit,
448            policy,
449            rate_limit_policy: None,
450        }
451    }
452}