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.
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(_) => { /* fail-open per policy; store error logged internally */ }
172            }
173        }
174
175        // Step 2: input normalization + validation.
176        let normalized = validate_code_input(raw_input, &self.policy).map_err(|_| {
177            self.audit.record(CodeAuthEvent::RedemptionFailed {
178                reason: RedemptionFailReason::InvalidFormat,
179            });
180            RedeemError::InvalidInput {
181                reason: RedemptionFailReason::InvalidFormat,
182                public: PublicRedemptionError::from_reason(&RedemptionFailReason::InvalidFormat),
183            }
184        })?;
185
186        // Step 3: derive lookup key candidates and find the record.
187        let (lookup_key, _) = self
188            .hasher
189            .lookup_key(SecretDomain::Code, &normalized)
190            .map_err(RedeemError::from_key)?;
191
192        let now = self.clock.unix_now();
193        let record = self
194            .store
195            .find_redeemable(&[lookup_key], now, None)
196            .await
197            .map_err(RedeemError::from_store)?
198            .ok_or_else(|| {
199                self.audit.record(CodeAuthEvent::RedemptionFailed {
200                    reason: RedemptionFailReason::NotFound,
201                });
202                RedeemError::NotRedeemable {
203                    reason: RedemptionFailReason::NotFound,
204                    public: PublicRedemptionError::InvalidOrExpired,
205                }
206            })?;
207
208        Ok(record)
209    }
210
211    /// Step 2: atomically claim a [`RedeemableCode`] found by [`Self::find`].
212    ///
213    /// Returns a [`RedeemSuccess`] proof only if `claim_code` returns `Won`.
214    /// A `Lost` result means a concurrent caller already claimed the code.
215    ///
216    /// Rate-limit failures are recorded on a failed claim, and cleared on a
217    /// successful one, when a `rate_key` is provided.
218    ///
219    /// # Errors
220    /// Returns [`RedeemError::ClaimLost`] if the atomic claim was lost, or
221    /// [`RedeemError::Internal`] on store failure.
222    pub async fn claim(
223        &self,
224        record: &RedeemableCode,
225        subject: SubjectId,
226        rate_key: Option<&RateLimitKey>,
227    ) -> Result<RedeemSuccess, RedeemError> {
228        let now = self.clock.unix_now();
229        let outcome = self
230            .store
231            .claim_code(&ClaimRequest {
232                code_id: &record.id,
233                subject: &subject,
234                now,
235                purpose: None,
236                scope: None,
237            })
238            .await
239            .map_err(RedeemError::from_store)?;
240
241        match ClaimProof::new(outcome) {
242            Some(proof) => {
243                // Clear rate-limit counter on success.
244                if let Some(key) = rate_key {
245                    if self.rate_limit_policy.is_some() {
246                        let _ = self.rate_limit_store.clear_failures(key).await;
247                    }
248                }
249                self.audit.record(CodeAuthEvent::CodeRedeemed {
250                    code_id: record.id.clone(),
251                    subject_id: subject.clone(),
252                });
253                Ok(RedeemSuccess {
254                    subject,
255                    grant: record.grant.clone(),
256                    _claim_proof: proof,
257                })
258            }
259            None => {
260                // Record failure in rate limiter for a lost claim too.
261                if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
262                    let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
263                }
264                self.audit.record(CodeAuthEvent::RedemptionFailed {
265                    reason: RedemptionFailReason::AlreadyUsed,
266                });
267                Err(RedeemError::ClaimLost {
268                    public: PublicRedemptionError::InvalidOrExpired,
269                })
270            }
271        }
272    }
273
274    // ── Single-call callback flow (RFC-013 §4) ───────────────────────────────
275
276    /// Validate, look up, and claim a code in one call, invoking `on_won` as
277    /// the host callback that creates or resolves the subject.
278    ///
279    /// Enforces RFC-013 §10.3 step order. `on_won` is called only after a
280    /// confirmed won claim; its error aborts the flow without a session.
281    ///
282    /// # Errors
283    /// Returns [`RedeemError`] on any failure. If `on_won` fails, returns
284    /// [`RedeemError::Internal`] and the claim is already consumed (the host
285    /// must decide on compensation if needed — RFC-013 §5).
286    pub async fn redeem_with_callback<F, Fut, E>(
287        &self,
288        raw_input: &str,
289        rate_key: Option<&RateLimitKey>,
290        on_won: F,
291    ) -> Result<RedeemSuccess, RedeemError>
292    where
293        F: FnOnce(&RedeemableCode) -> Fut,
294        Fut: Future<Output = Result<SubjectId, E>>,
295        E: std::fmt::Display,
296    {
297        let record = self.find(raw_input, rate_key).await?;
298        let now = self.clock.unix_now();
299
300        // Attempt claim before invoking host callback (fail-fast on race).
301        let outcome = self
302            .store
303            .claim_code(&ClaimRequest {
304                code_id: &record.id,
305                subject: &SubjectId::new("__pending__".into()),
306                now,
307                purpose: None,
308                scope: None,
309            })
310            .await
311            .map_err(RedeemError::from_store)?;
312
313        let proof = ClaimProof::new(outcome).ok_or_else(|| {
314            self.audit.record(CodeAuthEvent::RedemptionFailed {
315                reason: RedemptionFailReason::AlreadyUsed,
316            });
317            RedeemError::ClaimLost {
318                public: PublicRedemptionError::InvalidOrExpired,
319            }
320        })?;
321
322        // Claim won — now invoke host callback.
323        let subject = on_won(&record).await.map_err(|e| RedeemError::Internal {
324            cause: format!("host callback failed: {e}"),
325            public: PublicRedemptionError::TemporarilyUnavailable,
326        })?;
327
328        if let Some(key) = rate_key {
329            if self.rate_limit_policy.is_some() {
330                let _ = self.rate_limit_store.clear_failures(key).await;
331            }
332        }
333        self.audit.record(CodeAuthEvent::CodeRedeemed {
334            code_id: record.id.clone(),
335            subject_id: subject.clone(),
336        });
337
338        Ok(RedeemSuccess {
339            subject,
340            grant: record.grant.clone(),
341            _claim_proof: proof,
342        })
343    }
344
345    /// Revoke a code by its record ID. Scoped to `scope` when provided.
346    ///
347    /// # Errors
348    /// Returns [`RedeemError::Internal`] on store failure.
349    pub async fn revoke_code(
350        &self,
351        code_id: &CodeId,
352        scope: Option<&str>,
353    ) -> Result<(), RedeemError> {
354        let now = self.clock.unix_now();
355        self.store
356            .revoke_code(code_id, scope, now)
357            .await
358            .map_err(RedeemError::from_store)?;
359        self.audit.record(CodeAuthEvent::CodeRevoked {
360            code_id: code_id.clone(),
361            scope: scope.map(str::to_string),
362        });
363        Ok(())
364    }
365}
366
367/// Convenience impl: construct a [`CodeAuth`] with no rate-limit store.
368///
369/// Uses `NoRateLimit` as the `RL` type parameter so callers don't need to
370/// spell out the full generic signature when rate limiting is handled elsewhere.
371impl<CS, K, C, A> CodeAuth<CS, super::norate::NoRateLimit, K, C, A>
372where
373    CS: CodeStore,
374    K: KeyProvider,
375    C: Clock,
376    A: AuditSink,
377{
378    /// Construct without a rate-limit store. Equivalent to passing
379    /// `NoRateLimit` explicitly.
380    #[must_use]
381    pub fn without_rate_limit(
382        store: CS,
383        hasher: SecretHasher<K>,
384        clock: C,
385        audit: A,
386        policy: CodePolicy,
387    ) -> Self {
388        Self {
389            store,
390            rate_limit_store: super::norate::NoRateLimit,
391            hasher,
392            clock,
393            audit,
394            policy,
395            rate_limit_policy: None,
396        }
397    }
398}