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::error::AppError;
use crate::policy::{
PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
get_policy,
};
use crate::recognition::{
DidResolverKeyResolver, HttpStatusListFetcher, RecognitionError, VerifiedForeignCredential,
verify_foreign_vec,
};
use crate::server::AppState;
use affinidi_vc::VerifiableCredential;
#[derive(Debug, Deserialize)]
pub struct RecogniseRequest {
pub vec: VerifiableCredential,
pub vmc: VerifiableCredential,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecogniseResponse {
pub session_id: String,
pub data: RecogniseData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecogniseData {
pub access_token: String,
pub access_expires_at: u64,
pub foreign_issuer_did: String,
pub mapped_role: String,
}
pub async fn recognise(
State(state): State<AppState>,
Json(req): Json<RecogniseRequest>,
) -> Result<Json<RecogniseResponse>, AppError> {
let registry = state.registry_client.as_ref().cloned().ok_or_else(|| {
AppError::Validation("trust-registry client not configured on this VTC".into())
})?;
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 key_resolver = DidResolverKeyResolver::new(resolver);
let status_fetcher = HttpStatusListFetcher::new(reqwest::Client::new());
let actor_did_for_audit = req.vec.credential_subject_id_for_audit();
let verified = match verify_foreign_vec(
&req.vec,
&req.vmc,
&key_resolver,
&status_fetcher,
Arc::clone(®istry),
Utc::now(),
)
.await
{
Ok(v) => v,
Err(e) => {
emit_denied_audit(
&state,
&actor_did_for_audit,
None,
e.reason_code(),
None,
&e,
)
.await;
return Err(map_recognition_error(e));
}
};
mint_recognised_session(&state, verified).await
}
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 {
match e {
RecognitionError::RegistryUnreachable(msg) => {
AppError::Internal(format!("trust registry unreachable: {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");
}
}
trait CredentialSubjectIdAccessor {
fn credential_subject_id_for_audit(&self) -> String;
}
impl CredentialSubjectIdAccessor for VerifiableCredential {
fn credential_subject_id_for_audit(&self) -> String {
use affinidi_vc::SubjectValue;
let subj_map = match &self.credential_subject {
SubjectValue::Single(m) => Some(m.clone()),
SubjectValue::Multiple(v) => v.first().cloned(),
};
subj_map
.and_then(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.unwrap_or_else(|| "<unknown-subject>".into())
}
}