use super::issue::{credential_offer, issue_on_request};
use super::jwt::decode_segment;
use affinidi_openid4vci::{CredentialOffer, CredentialRequest, CredentialResponse};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const PENDING_PREFIX: &str = "credx-pending:";
pub const DEFAULT_OFFER_TTL: Duration = Duration::minutes(30);
fn pending_key(code: &str) -> String {
format!("{PENDING_PREFIX}{code}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingIssuance {
credential: Value,
expected_holder_did: String,
issuer_id: String,
expires_at: i64,
}
#[allow(clippy::too_many_arguments)]
pub async fn make_offer(
ks: &KeyspaceHandle,
issuer_id: &str,
config_ids: Vec<String>,
credential: Value,
expected_holder_did: &str,
ttl: Duration,
now: DateTime<Utc>,
) -> Result<(CredentialOffer, String), AppError> {
let code = format!("pac_{}", Uuid::new_v4().simple());
let pending = PendingIssuance {
credential,
expected_holder_did: expected_holder_did.to_string(),
issuer_id: issuer_id.to_string(),
expires_at: (now + ttl).timestamp(),
};
ks.insert(pending_key(&code), &pending).await?;
Ok((credential_offer(issuer_id, config_ids, code.clone()), code))
}
pub async fn sweep_expired_pending(
ks: &KeyspaceHandle,
now: DateTime<Utc>,
) -> Result<usize, AppError> {
let now_ts = now.timestamp();
let mut purged = 0usize;
for (k, raw) in ks
.prefix_iter_raw(PENDING_PREFIX.as_bytes().to_vec())
.await?
{
if let Ok(rec) = serde_json::from_slice::<PendingIssuance>(&raw)
&& now_ts >= rec.expires_at
{
ks.remove(k).await?;
purged += 1;
}
}
Ok(purged)
}
pub async fn redeem(
ks: &KeyspaceHandle,
request: &CredentialRequest,
now: DateTime<Utc>,
) -> Result<CredentialResponse, AppError> {
let code = proof_nonce(request)?.ok_or_else(|| {
AppError::Validation(
"credential request proof carries no nonce (the pre-authorized code)".into(),
)
})?;
let pending = get_pending(ks, &code).await?.ok_or_else(|| {
AppError::NotFound(
"no pending issuance for this code (unknown, already redeemed, or expired)".into(),
)
})?;
if now.timestamp() > pending.expires_at {
let _ = ks.remove(pending_key(&code)).await;
return Err(AppError::Validation("pending issuance has expired".into()));
}
let response = issue_on_request(
request,
pending.credential.clone(),
&pending.expected_holder_did,
&pending.issuer_id,
now,
)?;
ks.remove(pending_key(&code)).await?;
Ok(response)
}
async fn get_pending(ks: &KeyspaceHandle, code: &str) -> Result<Option<PendingIssuance>, AppError> {
match ks.get_raw(pending_key(code)).await? {
Some(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| AppError::Internal(format!("PendingIssuance decode: {e}"))),
None => Ok(None),
}
}
fn proof_nonce(request: &CredentialRequest) -> Result<Option<String>, AppError> {
let Some(proof) = request.proof.as_ref() else {
return Ok(None);
};
let payload_b64 = proof.jwt.split('.').nth(1).ok_or_else(|| {
AppError::Validation("credential request proof is not a compact JWT".into())
})?;
let payload = decode_segment(payload_b64, "proof payload")?;
Ok(payload
.get("nonce")
.and_then(Value::as_str)
.map(str::to_string))
}