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 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 pub async fn find(
153 &self,
154 raw_input: &str,
155 rate_key: Option<&RateLimitKey>,
156 ) -> Result<RedeemableCode, RedeemError> {
157 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(_) => { }
171 }
172 }
173
174 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 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 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 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 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 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 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 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 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
366impl<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 #[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}