1use 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
35pub 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 #[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 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(); 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 pub async fn find(
154 &self,
155 raw_input: &str,
156 rate_key: Option<&RateLimitKey>,
157 ) -> Result<RedeemableCode, RedeemError> {
158 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 }
173 }
174
175 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 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 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 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 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 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 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 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 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
367impl<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 #[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}