use axum::Json;
use axum::extract::State;
use axum::http::header::SET_COOKIE;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use trust_tasks_rs::TrustTask;
use trust_tasks_rs::specs::auth::passkey::login::start::v0_2 as start_spec;
use trust_tasks_rs::specs::auth::refresh::v0_1 as refresh;
use uuid::Uuid;
use vti_common::auth::extractor::ADMIN_REFRESH_COOKIE;
use vta_sdk::protocols::auth::{
AuthenticateResponse, ChallengeRequest, ChallengeResponse, Session as WireSession, TokenBundle,
epoch_to_rfc3339,
};
use crate::acl::{get_acl_entry, resolve_auth_role};
use crate::auth::AuthClaims;
use crate::auth::session::{
Session, SessionState, delete_session, get_session, list_sessions, now_epoch,
store_refresh_index, store_session,
};
use crate::error::{AppError, TaskError};
use crate::server::AppState;
use tracing::{info, warn};
use vti_common::audit::{
AuditEvent, AuthSteppedUpData, SessionRevocationRefusedData, SessionRevokedData, SignedOutData,
};
use vti_common::store::KeyspaceHandle;
#[utoipa::path(
post, path = "/auth/challenge",
operation_id = "authChallenge", tag = "auth",
request_body = ChallengeRequest,
responses(
(status = 200, description = "DID-auth challenge nonce", body = ChallengeResponse),
(status = 401, description = "ACL gate rejected the subject DID"),
),
)]
pub async fn challenge(
State(state): State<AppState>,
Json(req): Json<ChallengeRequest>,
) -> Result<Json<ChallengeResponse>, AppError> {
let backend = crate::auth::VtcAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_challenge(
&backend,
vti_common::auth::ChallengeInput {
did: req.did,
session_pubkey_b58btc: None,
},
)
.await?;
Ok(Json(resp))
}
#[utoipa::path(
post, path = "/auth/", tag = "auth",
request_body(content = String, description = "DIDComm envelope, SIOP id_token envelope, or Trust-Task auth document"),
responses(
(status = 200, description = "Access + refresh tokens", body = AuthenticateResponse),
(status = 401, description = "Authentication failed (bad proof, challenge mismatch, or replay)"),
),
)]
pub async fn authenticate(
State(state): State<AppState>,
body: String,
) -> Result<Json<AuthenticateResponse>, AppError> {
Ok(Json(authenticate_and_mint(&state, &body).await?))
}
const SIOP_CLOCK_SKEW_SECS: u64 = 60;
#[derive(Debug, Deserialize)]
struct SiopAuthEnvelope {
#[serde(rename = "type")]
typ: String,
payload: SiopAuthPayload,
}
#[derive(Deserialize)]
struct SiopAuthPayload {
id_token: String,
session_id: String,
#[serde(default)]
session_pubkey_b58btc: Option<String>,
}
impl std::fmt::Debug for SiopAuthPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SiopAuthPayload")
.field("id_token", &"<redacted>")
.field("session_id", &self.session_id)
.field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
.finish()
}
}
async fn authenticate_siop(
state: &AppState,
body: &str,
) -> Result<Option<AuthenticateResponse>, AppError> {
let backend = crate::auth::VtcAuthBackend::from_state(state).await?;
authenticate_siop_with(state, body, &state.sessions_ks, &backend).await
}
pub(crate) async fn authenticate_siop_with<B>(
state: &AppState,
body: &str,
sessions_ks: &vti_common::store::KeyspaceHandle,
backend: &B,
) -> Result<Option<AuthenticateResponse>, AppError>
where
B: vti_common::auth::AuthBackend<Error = AppError>,
{
let Ok(env) = serde_json::from_str::<SiopAuthEnvelope>(body) else {
return Ok(None);
};
if env.typ.as_str() != AUTHENTICATE_TASK_URI || env.payload.id_token.is_empty() {
return Ok(None);
}
let unverified_iss = vti_common::auth::parse_unverified_iss(&env.payload.id_token)
.map_err(|e| AppError::Authentication(format!("id_token: {e}")))?;
let session = crate::auth::session::get_session(sessions_ks, &env.payload.session_id)
.await?
.ok_or_else(|| AppError::Authentication("session not found".into()))?;
if unverified_iss != session.did {
return Err(AppError::Authentication(
"id_token `iss` does not match the challenge session's DID".into(),
));
}
let resolver = state.did_resolver.as_ref().ok_or_else(|| {
AppError::Authentication("DID resolver not configured; cannot verify id_token".into())
})?;
let verified = vti_common::auth::verify_siop_id_token(&env.payload.id_token, resolver)
.await
.map_err(|e| AppError::Authentication(format!("id_token verification failed: {e}")))?;
let vtc_did = {
let cfg = state.config.read().await;
cfg.vtc_did.clone()
}
.ok_or_else(|| AppError::Authentication("VTC DID not configured".into()))?;
if verified.audience != vtc_did {
return Err(AppError::Authentication(
"id_token `aud` does not match this service".into(),
));
}
let now = now_epoch();
if verified.expires_at <= now {
return Err(AppError::Authentication("id_token has expired".into()));
}
if verified.issued_at > now.saturating_add(SIOP_CLOCK_SKEW_SECS) {
return Err(AppError::Authentication(
"id_token `iat` is in the future".into(),
));
}
if verified.issued_at > verified.expires_at {
return Err(AppError::Authentication(
"id_token `iat` is after `exp`".into(),
));
}
if let Some(pk) = env.payload.session_pubkey_b58btc.as_deref()
&& !pk.starts_with("z6Mk")
{
return Err(AppError::Authentication(
"session_pubkey_b58btc must be an Ed25519 multikey (z6Mk… prefix)".into(),
));
}
let resp = vti_common::auth::handlers::handle_authenticate(
backend,
vti_common::auth::AuthenticateInput {
session_id: env.payload.session_id,
challenge: verified.nonce,
signer_did: verified.issuer,
created_time: None,
session_pubkey_b58btc: env.payload.session_pubkey_b58btc,
audience: vti_common::auth::AudienceBinding::Transport,
},
)
.await?;
Ok(Some(resp))
}
async fn authenticate_and_mint(
state: &AppState,
body: &str,
) -> Result<AuthenticateResponse, AppError> {
if let Some(resp) = authenticate_siop(state, body).await? {
return Ok(resp);
}
if let Some(resp) = authenticate_trust_task(state, body).await? {
return Ok(resp);
}
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, metadata) =
vta_sdk::did_refresh::unpack_refreshing_sender(atm, state.did_resolver.as_ref(), body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
let sender_base = vti_common::auth::bind_authcrypt_sender(body, &msg, &metadata)
.map_err(|e| AppError::Authentication(e.message("authenticate message")))?;
if msg.typ.as_str() != AUTHENTICATE_TASK_URI {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let challenge = msg.body["challenge"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing challenge in message body".into()))?
.to_string();
let session_id = msg.body["session_id"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing session_id in message body".into()))?
.to_string();
let backend = crate::auth::VtcAuthBackend::from_state(state).await?;
vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id,
challenge,
signer_did: sender_base,
created_time: msg.created_time,
session_pubkey_b58btc: None,
audience: vti_common::auth::AudienceBinding::Transport,
},
)
.await
}
const AUTHENTICATE_TASK_URI: &str = "https://trusttasks.org/spec/auth/authenticate/0.1";
async fn authenticate_trust_task(
state: &AppState,
body: &str,
) -> Result<Option<AuthenticateResponse>, AppError> {
use trust_tasks_rs::specs::auth::authenticate::v0_1 as authenticate;
let doc: TrustTask<serde_json::Value> = match serde_json::from_str(body) {
Ok(doc) => doc,
Err(_) => return Ok(None), };
if doc.type_uri.to_string() != AUTHENTICATE_TASK_URI || doc.proof.is_none() {
return Ok(None);
}
let received: serde_json::Value = serde_json::from_str(body)
.map_err(|e| AppError::Authentication(format!("invalid authenticate document: {e}")))?;
let signer_did =
vti_common::auth::verify_trust_task_proof_value(&received, &state.trust_task_vm_resolver())
.await
.map_err(|e| AppError::Authentication(e.to_string()))?;
let payload: authenticate::Payload = serde_json::from_value(doc.payload.clone())
.map_err(|e| AppError::Authentication(format!("invalid authenticate payload: {e}")))?;
let backend = crate::auth::VtcAuthBackend::from_state(state).await?;
let resp = vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id: payload.session_id.to_string(),
challenge: payload.challenge.to_string(),
signer_did,
created_time: None,
session_pubkey_b58btc: None,
audience: vti_common::auth::AudienceBinding::Recipient {
recipient: doc.recipient.clone(),
own_did: state.config.read().await.vtc_did.clone(),
},
},
)
.await?;
Ok(Some(resp))
}
const EXT_NAMESPACE: &str = "org.openvtc";
pub(crate) fn refresh_token_from_ext(ext: Option<&JsonValue>) -> Option<String> {
ext?.get(EXT_NAMESPACE)?
.get("refreshToken")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionResponse {
pub session_id: String,
pub expires_at: u64,
}
pub const ADMIN_SESSION_ERR_INVALID_TOKEN: &str =
trust_tasks_rs::specs::vtc::auth::admin_session::v0_1::error_codes::INVALID_TOKEN.code;
pub(crate) fn decode_admin_session_token(
state: &AppState,
access_token: &str,
) -> Result<vti_common::auth::jwt::Claims, TaskError> {
let jwt_keys = state
.jwt_keys
.as_ref()
.ok_or_else(|| AppError::Internal("JWT keys not configured".into()))?;
jwt_keys.decode(access_token).map_err(|_| {
TaskError::declared(
ADMIN_SESSION_ERR_INVALID_TOKEN,
AppError::Authentication("invalid or expired access token".into()),
)
})
}
pub(crate) async fn admin_session_cookies(
state: &AppState,
access_token: &str,
claims: &vti_common::auth::jwt::Claims,
refresh_token: Option<String>,
) -> (AdminSessionResponse, Vec<String>) {
use rand::RngExt;
let max_age = claims.exp.saturating_sub(now_epoch()).max(1);
let refresh_max_age = {
let cfg = state.config.read().await;
cfg.auth.refresh_token_expiry
};
let cookie_window = if refresh_token.is_some() {
refresh_max_age.max(max_age)
} else {
max_age
};
let mut csrf_bytes = [0u8; 32];
rand::rng().fill(&mut csrf_bytes);
let csrf = hex::encode(csrf_bytes);
let mut cookies = vec![
build_session_cookie(access_token, max_age),
build_csrf_cookie(&csrf, cookie_window),
];
if let Some(token) = refresh_token {
cookies.push(build_refresh_cookie(&token, refresh_max_age));
}
(
AdminSessionResponse {
session_id: claims.session_id.clone(),
expires_at: claims.exp,
},
cookies,
)
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
#[schema(as = AdminPasskeyLoginStartResponse)]
pub struct PasskeyLoginStartResponse {
pub auth_id: String,
#[schema(value_type = Object)]
pub options: webauthn_rs_proto::PublicKeyCredentialRequestOptions,
}
#[derive(Debug, Default, Deserialize, utoipa::ToSchema)]
#[schema(as = AdminPasskeyLoginStartRequest)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PasskeyLoginStartRequest {
#[serde(default)]
#[schema(value_type = String)]
pub purpose: Option<start_spec::PayloadPurpose>,
#[serde(default)]
pub subject: Option<String>,
#[serde(default)]
#[schema(value_type = Object)]
#[allow(dead_code)]
pub ext: Option<serde_json::Value>,
}
#[utoipa::path(
post, path = "/auth/passkey-login/start", tag = "auth",
request_body = Option<PasskeyLoginStartRequest>,
responses(
(status = 200, description = "WebAuthn assertion challenge", body = PasskeyLoginStartResponse),
(status = 401, description = "WebAuthn not configured, no passkeys registered, or step-up requested without a session"),
),
)]
pub async fn passkey_login_start(
auth: Option<AuthClaims>,
State(state): State<AppState>,
body: Option<Json<PasskeyLoginStartRequest>>,
) -> Result<Json<PasskeyLoginStartResponse>, AppError> {
use vti_common::auth::passkey::store::{
StepUpBinding, get_all_passkeys, store_auth_state, store_auth_step_up,
};
let req = body.map(|Json(b)| b).unwrap_or_default();
let stepping_up = matches!(req.purpose, Some(start_spec::PayloadPurpose::StepUp));
let webauthn = state
.webauthn
.as_ref()
.ok_or_else(|| AppError::Authentication("WebAuthn not configured".into()))?;
let (passkeys, binding) = if stepping_up {
let claims = auth.ok_or_else(|| {
AppError::Unauthorized(
"step-up requires an authenticated session; sign in first".into(),
)
})?;
if let Some(subject) = &req.subject
&& subject != &claims.did
{
return Err(AppError::Validation(format!(
"step-up subject {subject} does not match the authenticated session ({})",
claims.did
)));
}
let user = credentials_for(&state, &claims.did).await?;
let binding = StepUpBinding {
session_id: claims.session_id.clone(),
did: claims.did.clone(),
};
(user, Some(binding))
} else if let Some(subject) = &req.subject {
(credentials_for(&state, subject).await?, None)
} else {
(get_all_passkeys(&state.passkey_ks).await?, None)
};
if passkeys.is_empty() {
warn!("passkey login refused: no passkeys registered");
return Err(AppError::Authentication(
"no passkeys registered on this server".into(),
));
}
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|e| AppError::Internal(format!("webauthn auth start failed: {e}")))?;
let auth_id = Uuid::new_v4().to_string();
if let Some(binding) = &binding {
store_auth_step_up(&state.passkey_ks, &auth_id, binding).await?;
}
store_auth_state(&state.passkey_ks, &auth_id, &auth_state).await?;
info!(
auth_id = %auth_id,
passkey_count = passkeys.len(),
purpose = if stepping_up { "stepUp" } else { "login" },
"passkey login challenge issued"
);
Ok(Json(PasskeyLoginStartResponse {
auth_id,
options: rcr.public_key,
}))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[schema(as = AdminPasskeyLoginFinishRequest)]
pub struct PasskeyLoginFinishRequest {
pub auth_id: String,
#[schema(value_type = Object)]
pub credential: webauthn_rs::prelude::PublicKeyCredential,
#[serde(default)]
#[schema(value_type = Option<Object>)]
pub ext: Option<serde_json::Value>,
}
#[utoipa::path(
post, path = "/auth/passkey-login/finish", tag = "auth",
request_body = PasskeyLoginFinishRequest,
responses(
(status = 200, description = "Access + refresh tokens (sets admin session + CSRF cookies), or the elevated session for a step-up"),
(status = 401, description = "Passkey assertion verification failed or credential not registered"),
),
)]
pub async fn passkey_login_finish(
auth: Option<AuthClaims>,
State(state): State<AppState>,
Json(req): Json<PasskeyLoginFinishRequest>,
) -> Result<axum::response::Response, AppError> {
use axum::http::HeaderValue;
use axum::http::header::SET_COOKIE;
use vti_common::auth::passkey::store::{
get_passkey_user_by_cred, store_passkey_user, take_auth_state, take_auth_step_up,
};
let webauthn = state
.webauthn
.as_ref()
.ok_or_else(|| AppError::Authentication("WebAuthn not configured".into()))?;
let session_key = session_key_from_ext(req.ext.as_ref())?;
let auth_state = take_auth_state(&state.passkey_ks, &req.auth_id)
.await?
.ok_or_else(|| AppError::Authentication("auth state not found or expired".into()))?;
let step_up = take_auth_step_up(&state.passkey_ks, &req.auth_id).await?;
let auth_result = webauthn
.finish_passkey_authentication(&req.credential, &auth_state)
.map_err(|e| {
warn!(auth_id = %req.auth_id, error = %e, "passkey authentication failed");
AppError::Authentication(format!("passkey authentication failed: {e}"))
})?;
let cred_id_hex = hex::encode(auth_result.cred_id());
let mut user = get_passkey_user_by_cred(&state.passkey_ks, &cred_id_hex)
.await?
.ok_or_else(|| AppError::Authentication("credential not registered".into()))?;
for cred in &mut user.credentials {
cred.update_credential(&auth_result);
}
store_passkey_user(&state.passkey_ks, &user).await?;
if let Some(binding) = step_up {
return step_up_finish(&state, &binding, auth.as_ref(), &user.did, &auth_result).await;
}
let AdminCookieSession {
session_id,
minted,
cookies,
} = mint_admin_cookie_session(&state, &user.did, &["passkey"], session_key, None).await?;
let amr = vec!["passkey".to_string()];
let acr = "aal2".to_string();
info!(did = %user.did, %session_id, "passkey login successful");
let resp = PasskeyLoginResponse {
purpose: "login".to_string(),
session: WireSession {
id: session_id.clone(),
subject: user.did.clone(),
issued_at: epoch_to_rfc3339(minted.issued_at),
expires_at: epoch_to_rfc3339(minted.access_expires_at),
amr: amr.clone(),
acr: acr.clone(),
},
tokens: TokenBundle {
access_token: minted.access_token.clone(),
refresh_token: Some(minted.refresh_token),
token_type: "Bearer".to_string(),
expires_in: minted.access_ttl,
refresh_expires_in: Some(minted.refresh_expires_at.saturating_sub(minted.issued_at)),
scope: Vec::new(),
},
};
let mut response = Json(resp).into_response();
let headers = response.headers_mut();
for cookie in cookies {
headers.append(
SET_COOKIE,
HeaderValue::try_from(cookie)
.map_err(|e| AppError::Internal(format!("invalid session cookie: {e}")))?,
);
}
Ok(response)
}
pub(crate) struct AdminCookieSession {
pub session_id: String,
pub minted: vti_common::auth::handlers::MintedTokens,
pub cookies: Vec<String>,
}
pub(crate) async fn mint_admin_cookie_session(
state: &AppState,
did: &str,
amr: &[&str],
session_pubkey_b58btc: Option<String>,
not_after: Option<u64>,
) -> Result<AdminCookieSession, AppError> {
let (role, allowed_contexts) = resolve_auth_role(&state.acl_ks, did).await?;
let backend = crate::auth::VtcAuthBackend::from_state(state).await?;
let session_id = Uuid::new_v4().to_string();
let amr: Vec<String> = amr.iter().map(|s| s.to_string()).collect();
let acr = "aal2".to_string();
let minted = vti_common::auth::handlers::mint_session_tokens_until(
&backend,
did,
&session_id,
&role,
&allowed_contexts,
&amr,
&acr,
false,
not_after,
)
.await?;
let session = Session {
session_id: session_id.clone(),
did: did.to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: minted.issued_at,
last_seen: minted.issued_at,
refresh_token: Some(minted.refresh_token.clone()),
refresh_expires_at: Some(minted.refresh_expires_at),
tee_attested: false,
amr,
acr,
acr_expires_at: None,
token_id: Some(minted.token_id.clone()),
session_pubkey_b58btc,
not_after,
};
store_session(&state.sessions_ks, &session).await?;
store_refresh_index(&state.sessions_ks, &minted.refresh_token, &session_id).await?;
let max_age = minted.access_expires_at.saturating_sub(now_epoch()).max(1);
let session_cookie = build_session_cookie(&minted.access_token, max_age);
let refresh_max_age = minted.refresh_expires_at.saturating_sub(now_epoch()).max(1);
let refresh_cookie = build_refresh_cookie(&minted.refresh_token, refresh_max_age);
use rand::RngExt;
let mut csrf_bytes = [0u8; 32];
rand::rng().fill(&mut csrf_bytes);
let csrf = hex::encode(csrf_bytes);
let csrf_cookie = build_csrf_cookie(&csrf, refresh_max_age);
Ok(AdminCookieSession {
session_id,
minted,
cookies: vec![session_cookie, csrf_cookie, refresh_cookie],
})
}
async fn credentials_for(
state: &AppState,
did: &str,
) -> Result<Vec<webauthn_rs::prelude::Passkey>, AppError> {
vti_common::identifier::validate_did("subject", did)?;
vti_common::auth::passkey::store::get_passkey_user_by_did(&state.passkey_ks, did)
.await?
.map(|user| user.credentials)
.ok_or_else(|| AppError::Forbidden("no passkeys registered for that subject".into()))
}
const STEP_UP_ELEVATION_TTL_SECS: u64 = 900;
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginResponse {
pub purpose: String,
pub session: WireSession,
pub tokens: TokenBundle,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyStepUpResponse {
pub purpose: String,
pub session: WireSession,
#[schema(value_type = Object)]
pub ext: serde_json::Value,
}
async fn step_up_finish(
state: &AppState,
binding: &vti_common::auth::passkey::store::StepUpBinding,
auth: Option<&AuthClaims>,
asserted_did: &str,
auth_result: &webauthn_rs::prelude::AuthenticationResult,
) -> Result<axum::response::Response, AppError> {
let claims = auth.ok_or_else(|| {
AppError::Unauthorized("step-up requires an authenticated session".into())
})?;
if claims.session_id != binding.session_id || claims.did != binding.did {
warn!(
presented_session = %claims.session_id,
bound_session = %binding.session_id,
"step-up rejected: ceremony belongs to a different session",
);
return Err(AppError::Unauthorized(
"step-up challenge was issued for a different session".into(),
));
}
if asserted_did != binding.did {
warn!(
asserted = %asserted_did,
bound = %binding.did,
"step-up rejected: assertion came from another subject's passkey",
);
return Err(AppError::Unauthorized(
"the presented passkey does not belong to this session".into(),
));
}
if !auth_result.user_verified() {
return Err(AppError::Unauthorized(
"passkey did not assert user verification (UV); cannot step up".into(),
));
}
let now = now_epoch();
let expires_at = now.saturating_add(STEP_UP_ELEVATION_TTL_SECS);
let session = crate::auth::session::update_session_with(
&state.sessions_ks,
&binding.session_id,
|session| {
if !session.amr.iter().any(|m| m == "passkey") {
session.amr.push("passkey".to_string());
}
session.acr = "aal2".to_string();
session.acr_expires_at = Some(expires_at);
session.last_seen = now;
true
},
)
.await?
.ok_or_else(|| AppError::Unauthorized("session not found".into()))?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&binding.did,
Some(&binding.session_id),
AuditEvent::AuthSteppedUp(AuthSteppedUpData {
session_id: binding.session_id.clone(),
credential_id: hex::encode(<_ as AsRef<[u8]>>::as_ref(auth_result.cred_id())),
acr: session.acr.clone(),
expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0)
.unwrap_or_default(),
}),
)
.await?;
}
info!(
did = %binding.did,
session_id = %binding.session_id,
"passkey step-up: session elevated"
);
Ok(Json(PasskeyStepUpResponse {
purpose: "stepUp".to_string(),
session: WireSession {
id: session.session_id.clone(),
subject: session.did.clone(),
issued_at: epoch_to_rfc3339(session.created_at),
expires_at: epoch_to_rfc3339(claims.access_expires_at),
amr: session.amr.clone(),
acr: session.acr.clone(),
},
ext: serde_json::json!({
"org.openvtc.step-up": { "expiresAt": epoch_to_rfc3339(expires_at) }
}),
})
.into_response())
}
fn build_session_cookie(access_token: &str, max_age: u64) -> String {
format!(
"{name}={access_token}; Path=/; Max-Age={max_age}; SameSite=Strict; Secure; HttpOnly",
name = vti_common::auth::extractor::ADMIN_SESSION_COOKIE,
)
}
fn build_csrf_cookie(csrf: &str, max_age: u64) -> String {
format!("csrf={csrf}; Path=/; Max-Age={max_age}; SameSite=Strict; Secure")
}
fn build_refresh_cookie(refresh_token: &str, max_age: u64) -> String {
format!(
"{name}={refresh_token}; Path=/; Max-Age={max_age}; SameSite=Strict; Secure; HttpOnly",
name = vti_common::auth::extractor::ADMIN_REFRESH_COOKIE,
)
}
#[cfg(test)]
mod cookie_format_tests {
use super::*;
#[test]
fn session_cookie_path_is_root() {
let c = build_session_cookie("jwt.token.value", 900);
assert!(c.contains("Path=/;"), "got {c}");
}
#[test]
fn session_cookie_has_security_flags() {
let c = build_session_cookie("jwt.token.value", 900);
assert!(c.contains("HttpOnly"), "got {c}");
assert!(c.contains("Secure"), "got {c}");
assert!(c.contains("SameSite=Strict"), "got {c}");
}
#[test]
fn csrf_cookie_is_root_scoped_but_not_httponly() {
let c = build_csrf_cookie("abc123", 900);
assert!(c.contains("Path=/"), "got {c}");
assert!(
!c.contains("HttpOnly"),
"CSRF cookie must be JS-readable: {c}"
);
assert!(c.contains("Secure"), "got {c}");
assert!(c.contains("SameSite=Strict"), "got {c}");
}
#[test]
fn session_cookie_uses_canonical_name() {
let c = build_session_cookie("t", 1);
assert!(
c.starts_with(&format!(
"{}=",
vti_common::auth::extractor::ADMIN_SESSION_COOKIE
)),
"got {c}"
);
}
}
const REFRESH_TASK_URI: &str = <refresh::Payload as trust_tasks_rs::Payload>::TYPE_URI;
#[utoipa::path(
post, path = "/auth/refresh", tag = "auth",
request_body(content = String, description = "DIDComm envelope or Trust-Task refresh document"),
responses(
(status = 200, description = "Rotated access + refresh tokens", body = AuthenticateResponse),
(status = 401, description = "Refresh token not found, revoked, or already used"),
),
)]
pub async fn refresh(
State(state): State<AppState>,
headers: HeaderMap,
body: String,
) -> Result<Response, AppError> {
if let Some(token) = cookie_value(&headers, ADMIN_REFRESH_COOKIE) {
let backend = crate::auth::VtcAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token: token,
signer_did: None,
},
)
.await?;
return reissue_session_cookies(&state, resp, &headers).await;
}
if let Some(resp) = try_refresh_trust_task(&state, &body).await? {
return Ok(Json(resp).into_response());
}
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, metadata) =
vta_sdk::did_refresh::unpack_refreshing_sender(atm, state.did_resolver.as_ref(), &body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
let sender_base = vti_common::auth::bind_authcrypt_sender(&body, &msg, &metadata)
.map_err(|e| AppError::Authentication(e.message("refresh message")))?;
if msg.typ.as_str() != REFRESH_TASK_URI {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let refresh_token = msg.body["refresh_token"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing refresh_token in message body".into()))?
.to_string();
let backend = crate::auth::VtcAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token,
signer_did: Some(sender_base),
},
)
.await?;
Ok(Json(resp).into_response())
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(';'))
.map(str::trim)
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
.filter(|v| !v.is_empty())
}
async fn reissue_session_cookies(
state: &AppState,
resp: AuthenticateResponse,
request_headers: &HeaderMap,
) -> Result<Response, AppError> {
let refresh_ttl = match resp.tokens.refresh_expires_in {
Some(left) => left.max(1),
None => state.config.read().await.auth.refresh_token_expiry,
};
let access_max_age = resp.tokens.expires_in.max(1);
let session_cookie = build_session_cookie(&resp.tokens.access_token, access_max_age);
let refresh_cookie = resp
.tokens
.refresh_token
.as_ref()
.map(|t| build_refresh_cookie(t, refresh_ttl));
let csrf_cookie =
cookie_value(request_headers, "csrf").map(|v| build_csrf_cookie(&v, refresh_ttl));
let mut response = Json(resp).into_response();
let headers = response.headers_mut();
for cookie in [Some(session_cookie), refresh_cookie, csrf_cookie]
.into_iter()
.flatten()
{
headers.append(
SET_COOKIE,
HeaderValue::try_from(cookie)
.map_err(|e| AppError::Internal(format!("invalid cookie on refresh: {e}")))?,
);
}
Ok(response)
}
async fn try_refresh_trust_task(
state: &AppState,
body: &str,
) -> Result<Option<AuthenticateResponse>, AppError> {
let doc: TrustTask<serde_json::Value> = match serde_json::from_str(body) {
Ok(doc) => doc,
Err(_) => return Ok(None), };
if doc.type_uri.to_string() != REFRESH_TASK_URI {
return Ok(None);
}
let payload: refresh::Payload = serde_json::from_value(doc.payload)
.map_err(|e| AppError::Authentication(format!("invalid refresh payload: {e}")))?;
let backend = crate::auth::VtcAuthBackend::from_state(state).await?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token: payload.refresh_token.to_string(),
signer_did: None,
},
)
.await?;
Ok(Some(resp))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct WhoamiResponse {
pub session: SessionView,
pub roles: Vec<String>,
pub scopes: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>)]
pub ext: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionView {
pub id: String,
pub subject: String,
pub issued_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub amr: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acr: Option<String>,
}
pub(crate) async fn whoami_for(
state: &AppState,
session: &Session,
role: vti_common::acl::Role,
) -> WhoamiResponse {
let did = session.did.as_str();
let entry = crate::acl::get_acl_entry(&state.acl_ks, did)
.await
.ok()
.flatten()
.filter(|e| !e.is_expired(now_epoch()));
let capabilities = entry
.as_ref()
.map(crate::acl::VtcAclEntry::capability_list)
.unwrap_or_default();
let ext = entry.as_ref().map(|e| {
let approves: Vec<String> = if e.admin.approve.is_all() {
e.admin
.effective_approvable()
.iter()
.map(crate::acl::CapRef::display)
.collect()
} else {
Vec::new()
};
serde_json::json!({ "org.openvtc": {
"adminRole": e.admin.admin_role.as_ref().map(|r| r.to_string()),
"approves": approves,
}})
});
let ends_at = match (session.refresh_expires_at, session.not_after) {
(Some(r), Some(n)) => r.min(n),
(Some(r), None) => r,
(None, Some(n)) => n,
(None, None) => now_epoch(),
};
WhoamiResponse {
capabilities,
ext,
session: SessionView {
id: session.session_id.clone(),
subject: did.to_string(),
issued_at: epoch_to_datetime(session.created_at),
expires_at: epoch_to_datetime(ends_at),
amr: session.amr.clone(),
acr: Some(session.acr.clone()).filter(|a| !a.is_empty()),
},
roles: vec![role.to_string()],
scopes: Vec::new(),
}
}
pub(crate) fn session_key_from_ext(ext: Option<&JsonValue>) -> Result<Option<String>, AppError> {
let Some(value) = ext
.and_then(|e| e.get(EXT_NAMESPACE))
.and_then(|o| o.get("sessionKey"))
else {
return Ok(None);
};
value
.as_str()
.and_then(crate::member_portal::oob::did_key_multikey)
.map(|mk| Some(mk.to_string()))
.ok_or_else(|| {
AppError::Validation(
"ext[\"org.openvtc\"].sessionKey must be an Ed25519 did:key (did:key:z6Mk…)".into(),
)
})
}
fn epoch_to_datetime(secs: u64) -> DateTime<Utc> {
DateTime::from_timestamp(secs as i64, 0).unwrap_or(DateTime::UNIX_EPOCH)
}
#[utoipa::path(
post, path = "/auth/sign-out", tag = "auth",
security((), ("bearer_jwt" = [])),
responses(
(status = 204, description = "Any session the request names is revoked; the session, refresh and CSRF cookies are cleared"),
),
)]
pub async fn sign_out(
auth: Result<AuthClaims, AppError>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
use axum::http::HeaderValue;
use axum::http::header::SET_COOKIE;
let sessions = state.sessions_ks.clone();
let ending = match auth {
Ok(auth) => Some((auth.did, auth.session_id)),
Err(_) => match cookie_value(&headers, ADMIN_REFRESH_COOKIE) {
Some(token) => session_by_current_refresh(&sessions, &token)
.await
.unwrap_or_else(|e| {
warn!(error = %e, "sign-out: refresh-cookie session lookup failed");
None
}),
None => None,
},
};
if let Some((did, session_id)) = ending {
let _ = delete_session(&sessions, &session_id).await;
if let Some(writer) = state.audit_writer.as_ref()
&& let Err(e) = writer
.write(
&did,
Some(&did),
AuditEvent::SignedOut(SignedOutData {
session_id: session_id.clone(),
}),
)
.await
{
tracing::warn!(error = %e, did = %did, "sign-out audit write failed");
}
info!(did = %did, session_id = %session_id, "sign-out");
} else {
tracing::debug!("sign-out with no live session; clearing cookies only");
}
let mut response = StatusCode::NO_CONTENT.into_response();
let headers = response.headers_mut();
let session_clear = format!(
"{name}=; Path=/; Max-Age=0; SameSite=Strict; Secure; HttpOnly",
name = vti_common::auth::extractor::ADMIN_SESSION_COOKIE,
);
let csrf_clear = "csrf=; Path=/; Max-Age=0; SameSite=Strict; Secure".to_string();
let refresh_clear = format!(
"{name}=; Path=/; Max-Age=0; SameSite=Strict; Secure; HttpOnly",
name = ADMIN_REFRESH_COOKIE,
);
headers.append(
SET_COOKIE,
HeaderValue::try_from(session_clear)
.map_err(|e| AppError::Internal(format!("invalid session cookie: {e}")))?,
);
headers.append(
SET_COOKIE,
HeaderValue::try_from(refresh_clear)
.map_err(|e| AppError::Internal(format!("invalid refresh cookie: {e}")))?,
);
headers.append(
SET_COOKIE,
HeaderValue::try_from(csrf_clear)
.map_err(|e| AppError::Internal(format!("invalid csrf cookie: {e}")))?,
);
Ok(response)
}
async fn session_by_current_refresh(
sessions: &KeyspaceHandle,
token: &str,
) -> Result<Option<(String, String)>, AppError> {
use crate::auth::session::{current_refresh_hash, get_session_by_refresh, refresh_token_hash};
let Some(session_id) = get_session_by_refresh(sessions, token).await? else {
return Ok(None);
};
let Some(session) = get_session(sessions, &session_id).await? else {
return Ok(None);
};
let presented = refresh_token_hash(token);
let current = match current_refresh_hash(sessions, &session_id).await? {
Some(hash) => hash == presented,
None => session.refresh_token.as_deref().map(refresh_token_hash) == Some(presented),
};
Ok(current.then_some((session.did, session.session_id)))
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionListResponse {
pub sessions: Vec<SessionView>,
}
fn session_expiry(session: &Session) -> u64 {
if session.refresh_token.is_none() && session.session_id == session.did {
session.last_seen.max(session.created_at)
+ vti_common::auth::session::INTRINSIC_SESSION_IDLE_TTL_SECS
} else {
session.refresh_expires_at.unwrap_or(session.created_at)
}
}
pub(crate) async fn list_sessions_for(
state: &AppState,
actor: &AuthClaims,
) -> Result<SessionListResponse, AppError> {
let now = now_epoch();
let mut visible = std::collections::HashMap::<String, bool>::new();
let mut sessions = Vec::new();
for session in list_sessions(&state.sessions_ks).await? {
if session.state != SessionState::Authenticated {
continue;
}
let expires = session_expiry(&session);
if expires < now {
continue;
}
let may = match visible.get(&session.did) {
Some(may) => *may,
None => {
let may = may_end_sessions_of(state, actor, &session.did).await?;
visible.insert(session.did.clone(), may);
may
}
};
if !may {
continue;
}
sessions.push(SessionView {
id: session.session_id.clone(),
subject: session.did.clone(),
issued_at: epoch_to_datetime(session.created_at),
expires_at: epoch_to_datetime(expires),
amr: session.amr.clone(),
acr: Some(session.acr.clone()).filter(|a| !a.is_empty()),
});
}
sessions.sort_by(|a, b| b.issued_at.cmp(&a.issued_at).then(a.id.cmp(&b.id)));
info!(caller = %actor.did, count = sessions.len(), "sessions listed");
Ok(SessionListResponse { sessions })
}
pub(crate) enum RevokeTarget {
Session(String),
Subject(String),
}
pub(crate) async fn revoke_sessions_task(
state: &AppState,
actor: &AuthClaims,
target: RevokeTarget,
reason: Option<String>,
) -> Result<RevokeSessionResponse, AppError> {
let sessions = &state.sessions_ks;
match target {
RevokeTarget::Session(session_id) => {
let none = || RevokeSessionResponse { revoked_count: 0 };
let Some(session) = get_session(sessions, &session_id).await? else {
return Ok(none());
};
if !may_end_sessions_of(state, actor, &session.did).await? {
info!(
caller = %actor.did,
target_did = %session.did,
"session revocation outside the caller's authority, answered as absent"
);
return Ok(none());
}
delete_session(sessions, &session_id).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&actor.did,
Some(&session.did),
AuditEvent::SessionRevoked(SessionRevokedData {
session_id: Some(session_id.clone()),
revoked_count: 1,
reason,
}),
)
.await?;
}
info!(caller = %actor.did, session_id = %session_id, "session revoked");
Ok(RevokeSessionResponse { revoked_count: 1 })
}
RevokeTarget::Subject(subject) => {
if !may_end_sessions_of(state, actor, &subject).await? {
info!(
caller = %actor.did,
target_did = %subject,
"session revocation by subject refused: outside the caller's authority"
);
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&actor.did,
Some(&subject),
AuditEvent::SessionRevocationRefused(SessionRevocationRefusedData {
reason,
}),
)
.await?;
}
return Err(AppError::Forbidden(
"cannot revoke sessions for a DID outside your authority".into(),
));
}
let revoked = revoke_sessions_for_did(sessions, &subject).await?;
if revoked > 0
&& let Some(writer) = state.audit_writer.as_ref()
{
writer
.write(
&actor.did,
Some(&subject),
AuditEvent::SessionRevoked(SessionRevokedData {
session_id: None,
revoked_count: revoked as u32,
reason,
}),
)
.await?;
}
info!(caller = %actor.did, target_did = %subject, revoked, "sessions revoked by DID");
Ok(RevokeSessionResponse {
revoked_count: revoked,
})
}
}
}
async fn may_end_sessions_of(
state: &AppState,
actor: &AuthClaims,
subject: &str,
) -> Result<bool, AppError> {
if actor.did == subject {
return Ok(true);
}
let Some(actor_entry) = get_acl_entry(&state.acl_ks, &actor.did).await? else {
return Ok(false);
};
if !actor_entry.can(crate::acl::Capability::SessionsRevoke, None) {
return Ok(false);
}
Ok(match get_acl_entry(&state.acl_ks, subject).await? {
Some(entry) if entry.is_administrator() => crate::acl::granting::covers_entry(
&actor_entry,
&entry,
crate::auth::session::now_epoch(),
),
_ => true,
})
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RevokeSessionResponse {
pub revoked_count: u64,
}
pub(crate) async fn revoke_sessions_for_did(
sessions: &KeyspaceHandle,
did: &str,
) -> Result<u64, AppError> {
let all = list_sessions(sessions).await?;
let mut revoked = 0u64;
for session in all {
if session.did == did {
delete_session(sessions, &session.session_id).await?;
revoked += 1;
}
}
Ok(revoked)
}