use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::{info, warn};
use uuid::Uuid;
use vti_common::audit::{AuditEvent, CrossCommunitySessionMintedData};
use crate::auth::session::{Session, SessionState, now_epoch, store_session};
use crate::credentials::exchange::verify_vp_token;
use crate::error::AppError;
use crate::policy::{
PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
get_policy,
};
use affinidi_data_integrity::VerificationMethodResolver;
use crate::credentials::vm_resolver::DidVmResolver;
use crate::recognition::{
HttpStatusListFetcher, RecognitionError, VerifiedForeignCredential, challenge,
verify_foreign_vec,
};
use crate::server::AppState;
use affinidi_vc::VerifiableCredential;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RecogniseRequest {
pub presentation: JsonValue,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RecogniseChallengeResponse {
pub nonce: String,
pub expires_at: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RecogniseResponse {
pub session_id: String,
pub data: RecogniseData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RecogniseData {
pub access_token: String,
pub access_expires_at: u64,
pub foreign_issuer_did: String,
pub mapped_role: String,
}
#[utoipa::path(
post, path = "/auth/recognise/challenge", tag = "recognise",
responses(
(status = 200, description = "Single-use recognition nonce", body = RecogniseChallengeResponse),
),
)]
pub async fn recognise_challenge(
State(state): State<AppState>,
) -> Result<Json<RecogniseChallengeResponse>, AppError> {
let vtc_did = vtc_did(&state).await?;
let now = Utc::now();
let nonce = challenge::issue(
&state.join_requests_ks,
&vtc_did,
challenge::DEFAULT_CHALLENGE_TTL,
now,
)
.await?;
let expires_at = (now + challenge::DEFAULT_CHALLENGE_TTL).timestamp() as u64;
Ok(Json(RecogniseChallengeResponse { nonce, expires_at }))
}
#[utoipa::path(
post, path = "/auth/recognise", tag = "recognise",
request_body = RecogniseRequest,
responses(
(status = 200, description = "Minted cross-community session", body = RecogniseResponse),
(status = 403, description = "Holder-binding, recognition gate, or role-mapping denied"),
),
)]
pub async fn recognise(
State(state): State<AppState>,
Json(req): Json<RecogniseRequest>,
) -> Result<Json<RecogniseResponse>, AppError> {
let resolver = state
.did_resolver
.as_ref()
.cloned()
.ok_or_else(|| AppError::Internal("DID resolver not configured".into()))?;
state
.jwt_keys
.as_ref()
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
let now = Utc::now();
let nonce = req
.presentation
.get("nonce")
.and_then(JsonValue::as_str)
.ok_or_else(|| {
AppError::Validation("presentation carries no top-level `nonce` to consume".into())
})?
.to_string();
let consumed = challenge::consume(&state.join_requests_ks, &nonce, now).await?;
let vp_token = serde_json::json!({ "recognise": req.presentation });
let verified_vp = verify_vp_token(
&vp_token,
&consumed.aud,
&nonce,
state.did_resolver.as_ref(),
now,
)
.await?;
let holder_did = verified_vp.holder;
let (vec, vmc) = extract_vec_vmc(&req.presentation)?;
let vec_subject = vc_subject_id(&vec)
.ok_or_else(|| AppError::Validation("foreign VEC has no credentialSubject.id".into()))?;
if holder_did != vec_subject {
let err = RecognitionError::Malformed(format!(
"VP holder `{holder_did}` is not the credential subject `{vec_subject}`"
));
emit_denied_audit(&state, &holder_did, None, "holder-binding", None, &err).await;
return Err(AppError::Forbidden(
"presentation holder is not the foreign credential subject".into(),
));
}
let registry = state.registry_client.as_ref().cloned().ok_or_else(|| {
AppError::Validation("trust-registry client not configured on this VTC".into())
})?;
let key_resolver: Arc<dyn VerificationMethodResolver> =
Arc::new(DidVmResolver::new(Some(resolver)));
let status_fetcher = HttpStatusListFetcher::with_issuer_verification(key_resolver.clone());
let verified = match verify_foreign_vec(
&vec,
&vmc,
key_resolver.as_ref(),
&status_fetcher,
Arc::clone(®istry),
now,
)
.await
{
Ok(v) => v,
Err(e) => {
emit_denied_audit(&state, &holder_did, None, e.reason_code(), None, &e).await;
return Err(map_recognition_error(e));
}
};
mint_recognised_session(&state, verified).await
}
async fn vtc_did(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::Validation("VTC DID not configured".into()))
}
fn extract_vec_vmc(
presentation: &JsonValue,
) -> Result<(VerifiableCredential, VerifiableCredential), AppError> {
let raw = presentation
.get("verifiableCredential")
.ok_or_else(|| AppError::Validation("presentation has no `verifiableCredential`".into()))?;
let entries: Vec<&JsonValue> = match raw {
JsonValue::Array(items) => items.iter().collect(),
other => vec![other],
};
let mut vec_cred: Option<VerifiableCredential> = None;
let mut vmc_cred: Option<VerifiableCredential> = None;
for entry in entries {
let cred: VerifiableCredential = serde_json::from_value(entry.clone()).map_err(|e| {
AppError::Validation(format!("embedded credential is not a valid VC: {e}"))
})?;
let (slot, label) = if cred
.types
.iter()
.any(|t| t == "VerifiableEndorsementCredential")
{
(&mut vec_cred, "VerifiableEndorsementCredential")
} else if cred
.types
.iter()
.any(|t| t == "VerifiableMembershipCredential")
{
(&mut vmc_cred, "VerifiableMembershipCredential")
} else {
continue;
};
if slot.replace(cred).is_some() {
return Err(AppError::Validation(format!(
"presentation carries more than one {label}"
)));
}
}
let vec = vec_cred.ok_or_else(|| {
AppError::Validation("presentation has no VerifiableEndorsementCredential".into())
})?;
let vmc = vmc_cred.ok_or_else(|| {
AppError::Validation("presentation has no VerifiableMembershipCredential".into())
})?;
Ok((vec, vmc))
}
fn vc_subject_id(vc: &VerifiableCredential) -> Option<String> {
use affinidi_vc::SubjectValue;
let subj = match &vc.credential_subject {
SubjectValue::Single(m) => Some(m),
SubjectValue::Multiple(v) => v.first(),
}?;
subj.get("id").and_then(|v| v.as_str()).map(str::to_string)
}
pub async fn mint_recognised_session(
state: &AppState,
verified: VerifiedForeignCredential,
) -> Result<Json<RecogniseResponse>, AppError> {
let jwt_keys = state
.jwt_keys
.as_ref()
.cloned()
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
let mapped_role = match map_foreign_role(state, &verified).await? {
Some(r) => r,
None => {
emit_denied_audit(
state,
&verified.subject_did,
Some(verified.foreign_issuer_did.as_str()),
"role-mapping-denied",
Some(verified.foreign_role.as_str()),
&RecognitionError::Malformed("policy denied role mapping".into()),
)
.await;
return Err(AppError::Forbidden(format!(
"cross_community_roles.rego denied mapping for foreign role '{}'",
verified.foreign_role
)));
}
};
let config = state.config.read().await;
let jwt_default = config.auth.access_token_expiry;
drop(config);
let now = Utc::now();
let creds_window_secs = (verified.earliest_valid_until - now).num_seconds().max(0) as u64;
let access_expiry = jwt_default.min(creds_window_secs);
if access_expiry == 0 {
emit_denied_audit(
state,
&verified.subject_did,
Some(verified.foreign_issuer_did.as_str()),
"validity-window",
Some(verified.foreign_role.as_str()),
&RecognitionError::ValidityWindow("credentials expire immediately".into()),
)
.await;
return Err(AppError::Forbidden(
"foreign credentials expire too soon to mint a session".into(),
));
}
let session_id = format!("xc-{}", Uuid::new_v4());
let session = Session {
session_id: session_id.clone(),
did: verified.subject_did.clone(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
token_id: None,
session_pubkey_b58btc: None,
};
store_session(&state.sessions_ks, &session).await?;
let claims = jwt_keys
.new_claims(
verified.subject_did.clone(),
session_id.clone(),
mapped_role.clone(),
Vec::new(),
access_expiry,
false,
)
.with_aal(vec!["did".to_string()], "aal1");
let access_expires_at = claims.exp;
let access_token = jwt_keys.encode(&claims)?;
if let Some(writer) = state.audit_writer.as_ref() {
let payload = CrossCommunitySessionMintedData {
outcome: "minted".into(),
foreign_issuer_did: verified.foreign_issuer_did.clone(),
foreign_role: Some(verified.foreign_role.clone()),
mapped_role: Some(mapped_role.clone()),
ttl_seconds: Some(access_expiry),
reason: None,
};
if let Err(e) = writer
.write(
&verified.subject_did,
Some(&verified.subject_did),
AuditEvent::CrossCommunitySessionMinted(payload),
)
.await
{
warn!(error = %e, "failed to emit CrossCommunitySessionMinted (minted) envelope");
}
}
info!(
subject = %verified.subject_did,
issuer = %verified.foreign_issuer_did,
mapped_role = %mapped_role,
ttl = access_expiry,
"cross-community session minted"
);
Ok(Json(RecogniseResponse {
session_id,
data: RecogniseData {
access_token,
access_expires_at,
foreign_issuer_did: verified.foreign_issuer_did,
mapped_role,
},
}))
}
async fn map_foreign_role(
state: &AppState,
verified: &VerifiedForeignCredential,
) -> Result<Option<String>, AppError> {
let active_id = get_active_policy_id(
&state.active_policies_ks,
PolicyPurpose::CrossCommunityRoles,
)
.await?
.ok_or_else(|| AppError::Internal("no active cross_community_roles policy".into()))?;
let policy = get_policy(&state.policies_ks, active_id)
.await?
.ok_or_else(|| {
AppError::Internal(format!(
"active cross_community_roles policy {active_id} not found"
))
})?;
let compiled = compile_policy(&policy.rego_source, policy.id)?;
let input = serde_json::json!({
"foreign_vec": {
"issuer": verified.foreign_issuer_did,
"role": verified.foreign_role,
"subject_did": verified.subject_did,
},
"action": "mint_session",
});
let allow = evaluate_policy(
&compiled,
"data.vtc.cross_community_roles.allow",
input.clone(),
)?;
let allow = allow
.pointer("/result/0/expressions/0/value")
.and_then(JsonValue::as_bool)
.unwrap_or(false);
if !allow {
return Ok(None);
}
let mapped = evaluate_policy(
&compiled,
"data.vtc.cross_community_roles.mapped_role",
input,
)?;
let mapped = mapped
.pointer("/result/0/expressions/0/value")
.and_then(JsonValue::as_str)
.map(str::to_string);
Ok(mapped)
}
fn map_recognition_error(e: RecognitionError) -> AppError {
use axum::http::StatusCode;
match e {
RecognitionError::RegistryUnreachable(msg) => AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: format!("trust registry unavailable: {msg}"),
},
RecognitionError::RegistryRejected(msg) => AppError::ServiceError {
status: StatusCode::BAD_GATEWAY,
message: format!("trust registry rejected the recognise query: {msg}"),
},
other => AppError::Forbidden(other.to_string()),
}
}
async fn emit_denied_audit(
state: &AppState,
actor_did: &str,
foreign_issuer_did: Option<&str>,
reason: &str,
foreign_role: Option<&str>,
_err: &RecognitionError,
) {
let Some(writer) = state.audit_writer.as_ref() else {
return;
};
let payload = CrossCommunitySessionMintedData {
outcome: "denied".into(),
foreign_issuer_did: foreign_issuer_did.unwrap_or("<unknown>").to_string(),
foreign_role: foreign_role.map(str::to_string),
mapped_role: None,
ttl_seconds: None,
reason: Some(reason.to_string()),
};
if let Err(e) = writer
.write(
actor_did,
None,
AuditEvent::CrossCommunitySessionMinted(payload),
)
.await
{
warn!(error = %e, "failed to emit CrossCommunitySessionMinted (denied) envelope");
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[test]
fn registry_failures_map_to_5xx_not_500_or_403() {
let status = |e: RecognitionError| match map_recognition_error(e) {
AppError::ServiceError { status, .. } => status,
other => panic!("expected ServiceError, got {other:?}"),
};
assert_eq!(
status(RecognitionError::RegistryUnreachable("dns".into())),
StatusCode::SERVICE_UNAVAILABLE,
);
assert_eq!(
status(RecognitionError::RegistryRejected("bad query".into())),
StatusCode::BAD_GATEWAY,
);
}
#[test]
fn caller_rejections_stay_403() {
assert!(matches!(
map_recognition_error(RecognitionError::IssuerNotRecognised("did:x".into())),
AppError::Forbidden(_)
));
assert!(matches!(
map_recognition_error(RecognitionError::ProofInvalid("bad sig".into())),
AppError::Forbidden(_)
));
}
}