use std::future::Future;
use crate::audit::{AuditSink, CodeAuthEvent};
use crate::clock::Clock;
use crate::code::{CodePolicy, validate_code_input};
use crate::error::PublicRedemptionError;
use crate::error::RedemptionFailReason;
use crate::hashing::{KeyProvider, SecretDomain, SecretHasher};
use crate::secret::{CodeId, SubjectId};
use crate::store::code::{
ClaimRequest, CodeRecord, CodeStore, RedeemableCode, expires_at_from_ttl,
};
use crate::store::ratelimit::{RateLimitKey, RateLimitOutcome, RateLimitPolicy, RateLimitStore};
use super::error::{ClaimProof, RedeemError, RedeemSuccess};
pub struct CodeAuth<CS, RL, K, C, A> {
store: CS,
rate_limit_store: RL,
hasher: SecretHasher<K>,
clock: C,
audit: A,
policy: CodePolicy,
rate_limit_policy: Option<RateLimitPolicy>,
}
impl<CS, RL, K, C, A> CodeAuth<CS, RL, K, C, A>
where
CS: CodeStore,
RL: RateLimitStore,
K: KeyProvider,
C: Clock,
A: AuditSink,
{
#[must_use]
pub fn new(
store: CS,
rate_limit_store: RL,
hasher: SecretHasher<K>,
clock: C,
audit: A,
policy: CodePolicy,
rate_limit_policy: RateLimitPolicy,
) -> Self {
Self {
store,
rate_limit_store,
hasher,
clock,
audit,
policy,
rate_limit_policy: Some(rate_limit_policy),
}
}
pub async fn issue_code<R: crate::rng::RandomSource>(
&self,
rng: &mut R,
id: CodeId,
purpose: Option<String>,
scope: Option<String>,
grant: Option<String>,
) -> Result<(CodeId, crate::secret::PlainCode), RedeemError> {
let plain =
crate::code::generate_code(&self.policy, rng).map_err(|e| RedeemError::Internal {
cause: format!("rng: {e}"),
public: PublicRedemptionError::TemporarilyUnavailable,
})?;
let normalized = plain.expose().to_string(); let (lookup_key, key_version) = self
.hasher
.lookup_key(SecretDomain::Code, &normalized)
.map_err(RedeemError::from_key)?;
let now = self.clock.unix_now();
let expires_at = expires_at_from_ttl(now, self.policy.ttl());
let record = CodeRecord {
id: id.clone(),
lookup_key,
key_version,
purpose,
scope,
grant,
expires_at,
};
self.store
.insert_code(record)
.await
.map_err(RedeemError::from_store)?;
self.audit.record(CodeAuthEvent::CodeIssued {
code_id: id.clone(),
purpose: None,
});
Ok((id, plain))
}
pub async fn find(
&self,
raw_input: &str,
rate_key: Option<&RateLimitKey>,
) -> Result<RedeemableCode, RedeemError> {
if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
match self.rate_limit_store.check(key, rl_policy).await {
Ok(RateLimitOutcome::Deny) => {
self.audit.record(CodeAuthEvent::RateLimitHit {
key_fingerprint: key.fingerprint().to_string(),
purpose: None,
});
return Err(RedeemError::RateLimited {
public: PublicRedemptionError::RateLimited,
});
}
Ok(RateLimitOutcome::Allow) => {}
Err(_) => { }
}
}
let normalized = validate_code_input(raw_input, &self.policy).map_err(|_| {
self.audit.record(CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::InvalidFormat,
});
RedeemError::InvalidInput {
reason: RedemptionFailReason::InvalidFormat,
public: PublicRedemptionError::from_reason(&RedemptionFailReason::InvalidFormat),
}
})?;
let (lookup_key, _) = self
.hasher
.lookup_key(SecretDomain::Code, &normalized)
.map_err(RedeemError::from_key)?;
let now = self.clock.unix_now();
let record = self
.store
.find_redeemable(&[lookup_key], now, None)
.await
.map_err(RedeemError::from_store)?
.ok_or_else(|| {
self.audit.record(CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::NotFound,
});
RedeemError::NotRedeemable {
reason: RedemptionFailReason::NotFound,
public: PublicRedemptionError::InvalidOrExpired,
}
})?;
Ok(record)
}
pub async fn claim(
&self,
record: &RedeemableCode,
subject: SubjectId,
rate_key: Option<&RateLimitKey>,
) -> Result<RedeemSuccess, RedeemError> {
let now = self.clock.unix_now();
let outcome = self
.store
.claim_code(&ClaimRequest {
code_id: &record.id,
subject: &subject,
now,
purpose: None,
scope: None,
})
.await
.map_err(RedeemError::from_store)?;
match ClaimProof::new(outcome) {
Some(proof) => {
if let Some(key) = rate_key {
if self.rate_limit_policy.is_some() {
let _ = self.rate_limit_store.clear_failures(key).await;
}
}
self.audit.record(CodeAuthEvent::CodeRedeemed {
code_id: record.id.clone(),
subject_id: subject.clone(),
});
Ok(RedeemSuccess {
subject,
grant: record.grant.clone(),
_claim_proof: proof,
})
}
None => {
if let (Some(key), Some(rl_policy)) = (rate_key, &self.rate_limit_policy) {
let _ = self.rate_limit_store.record_failure(key, rl_policy).await;
}
self.audit.record(CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::AlreadyUsed,
});
Err(RedeemError::ClaimLost {
public: PublicRedemptionError::InvalidOrExpired,
})
}
}
}
pub async fn redeem_with_callback<F, Fut, E>(
&self,
raw_input: &str,
rate_key: Option<&RateLimitKey>,
on_won: F,
) -> Result<RedeemSuccess, RedeemError>
where
F: FnOnce(&RedeemableCode) -> Fut,
Fut: Future<Output = Result<SubjectId, E>>,
E: std::fmt::Display,
{
let record = self.find(raw_input, rate_key).await?;
let now = self.clock.unix_now();
let outcome = self
.store
.claim_code(&ClaimRequest {
code_id: &record.id,
subject: &SubjectId::new("__pending__".into()),
now,
purpose: None,
scope: None,
})
.await
.map_err(RedeemError::from_store)?;
let proof = ClaimProof::new(outcome).ok_or_else(|| {
self.audit.record(CodeAuthEvent::RedemptionFailed {
reason: RedemptionFailReason::AlreadyUsed,
});
RedeemError::ClaimLost {
public: PublicRedemptionError::InvalidOrExpired,
}
})?;
let subject = on_won(&record).await.map_err(|e| RedeemError::Internal {
cause: format!("host callback failed: {e}"),
public: PublicRedemptionError::TemporarilyUnavailable,
})?;
if let Some(key) = rate_key {
if self.rate_limit_policy.is_some() {
let _ = self.rate_limit_store.clear_failures(key).await;
}
}
self.audit.record(CodeAuthEvent::CodeRedeemed {
code_id: record.id.clone(),
subject_id: subject.clone(),
});
Ok(RedeemSuccess {
subject,
grant: record.grant.clone(),
_claim_proof: proof,
})
}
pub async fn revoke_code(
&self,
code_id: &CodeId,
scope: Option<&str>,
) -> Result<(), RedeemError> {
let now = self.clock.unix_now();
self.store
.revoke_code(code_id, scope, now)
.await
.map_err(RedeemError::from_store)?;
self.audit.record(CodeAuthEvent::CodeRevoked {
code_id: code_id.clone(),
scope: scope.map(str::to_string),
});
Ok(())
}
}
impl<CS, K, C, A> CodeAuth<CS, super::norate::NoRateLimit, K, C, A>
where
CS: CodeStore,
K: KeyProvider,
C: Clock,
A: AuditSink,
{
#[must_use]
pub fn without_rate_limit(
store: CS,
hasher: SecretHasher<K>,
clock: C,
audit: A,
policy: CodePolicy,
) -> Self {
Self {
store,
rate_limit_store: super::norate::NoRateLimit,
hasher,
clock,
audit,
policy,
rate_limit_policy: None,
}
}
}