use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vta_sdk::protocols::auth::{
AuthenticateResponse, ChallengeRequest, ChallengeResponse, Session as WireSession, TokenBundle,
epoch_to_rfc3339,
};
use crate::acl::{Role, check_acl_full};
use crate::auth::session::{
Session, SessionState, delete_session, get_session, list_sessions, now_epoch,
store_refresh_index, store_session,
};
use crate::auth::{AdminAuth, AuthClaims, ManageAuth};
use crate::error::AppError;
use crate::server::AppState;
use tracing::{info, warn};
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))
}
pub async fn authenticate(
State(state): State<AppState>,
body: String,
) -> Result<Json<AuthenticateResponse>, AppError> {
Ok(Json(authenticate_and_mint(&state, &body).await?))
}
async fn authenticate_and_mint(
state: &AppState,
body: &str,
) -> Result<AuthenticateResponse, AppError> {
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, _metadata) = atm
.unpack(body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
if !matches!(
msg.typ.as_str(),
"https://affinidi.com/atm/1.0/authenticate"
| "https://trusttasks.org/spec/auth/authenticate/0.1"
) {
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 sender_did = msg
.from
.as_deref()
.ok_or_else(|| AppError::Authentication("message has no sender (from)".into()))?;
let sender_base = sender_did
.split('#')
.next()
.unwrap_or(sender_did)
.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,
},
)
.await
}
pub async fn admin_login(
State(state): State<AppState>,
body: String,
) -> Result<axum::response::Response, AppError> {
use axum::http::HeaderValue;
use axum::http::header::SET_COOKIE;
use axum::response::IntoResponse;
let resp = authenticate_and_mint(&state, &body).await?;
let access_expires_at_epoch = resp
.access_expires_at_epoch()
.unwrap_or_else(|| now_epoch().saturating_add(resp.tokens.expires_in));
let max_age = access_expires_at_epoch.saturating_sub(now_epoch()).max(1);
use rand::RngExt;
let mut csrf_bytes = [0u8; 32];
rand::rng().fill(&mut csrf_bytes);
let csrf = hex::encode(csrf_bytes);
let session_cookie = build_session_cookie(&resp.tokens.access_token, max_age);
let csrf_cookie = build_csrf_cookie(&csrf, max_age);
let session_cookie_hv = HeaderValue::try_from(session_cookie)
.map_err(|e| AppError::Internal(format!("invalid session cookie value: {e}")))?;
let csrf_cookie_hv = HeaderValue::try_from(csrf_cookie)
.map_err(|e| AppError::Internal(format!("invalid csrf cookie value: {e}")))?;
let mut response = Json(resp).into_response();
let headers = response.headers_mut();
headers.append(SET_COOKIE, session_cookie_hv);
headers.append(SET_COOKIE, csrf_cookie_hv);
Ok(response)
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginStartResponse {
pub auth_id: String,
pub options: webauthn_rs::prelude::RequestChallengeResponse,
}
pub async fn passkey_login_start(
State(state): State<AppState>,
) -> Result<Json<PasskeyLoginStartResponse>, AppError> {
use vti_common::auth::passkey::store::{get_all_passkeys, store_auth_state};
let webauthn = state
.webauthn
.as_ref()
.ok_or_else(|| AppError::Authentication("WebAuthn not configured".into()))?;
let passkeys = get_all_passkeys(&state.passkey_ks).await?;
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();
store_auth_state(&state.passkey_ks, &auth_id, &auth_state).await?;
info!(
auth_id = %auth_id,
passkey_count = passkeys.len(),
"passkey login challenge issued"
);
Ok(Json(PasskeyLoginStartResponse {
auth_id,
options: rcr,
}))
}
#[derive(Debug, Deserialize)]
pub struct PasskeyLoginFinishRequest {
pub auth_id: String,
pub credential: webauthn_rs::prelude::PublicKeyCredential,
}
pub async fn passkey_login_finish(
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,
};
let webauthn = state
.webauthn
.as_ref()
.ok_or_else(|| AppError::Authentication("WebAuthn not configured".into()))?;
let jwt_keys = state
.jwt_keys
.as_ref()
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
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 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?;
let (role, allowed_contexts) = check_acl_full(&state.acl_ks, &user.did).await?;
let config = state.config.read().await;
let access_expiry = config.auth.access_token_expiry;
let refresh_expiry = config.auth.refresh_token_expiry;
drop(config);
let session_id = Uuid::new_v4().to_string();
let claims = jwt_keys
.new_claims(
user.did.clone(),
session_id.clone(),
role.to_string(),
allowed_contexts,
access_expiry,
false,
)
.with_aal(vec!["passkey".to_string()], "aal2");
let access_expires_at = claims.exp;
let access_token = jwt_keys.encode(&claims)?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires_at = now_epoch() + refresh_expiry;
let session = Session {
session_id: session_id.clone(),
did: user.did.clone(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now_epoch(),
refresh_token: Some(refresh_token.clone()),
refresh_expires_at: Some(refresh_expires_at),
tee_attested: false,
amr: claims.amr.clone(),
acr: claims.acr.clone(),
token_id: None,
session_pubkey_b58btc: None,
};
store_session(&state.sessions_ks, &session).await?;
store_refresh_index(&state.sessions_ks, &refresh_token, &session_id).await?;
info!(did = %user.did, %session_id, "passkey login successful");
let max_age = access_expires_at.saturating_sub(now_epoch()).max(1);
let session_cookie = build_session_cookie(&access_token, 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, max_age);
let issued_at_epoch = now_epoch();
let resp = AuthenticateResponse {
session: WireSession {
id: session_id.clone(),
subject: user.did.clone(),
issued_at: epoch_to_rfc3339(issued_at_epoch),
expires_at: epoch_to_rfc3339(access_expires_at),
amr: claims.amr.clone(),
acr: claims.acr.clone(),
},
tokens: TokenBundle {
access_token: access_token.clone(),
refresh_token: Some(refresh_token),
token_type: "Bearer".to_string(),
expires_in: access_expires_at.saturating_sub(issued_at_epoch),
refresh_expires_in: Some(refresh_expires_at.saturating_sub(issued_at_epoch)),
scope: Vec::new(),
},
};
let mut response = Json(resp).into_response();
let headers = response.headers_mut();
headers.append(
SET_COOKIE,
HeaderValue::try_from(session_cookie)
.map_err(|e| AppError::Internal(format!("invalid session cookie: {e}")))?,
);
headers.append(
SET_COOKIE,
HeaderValue::try_from(csrf_cookie)
.map_err(|e| AppError::Internal(format!("invalid csrf cookie: {e}")))?,
);
Ok(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")
}
#[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}"
);
}
}
pub async fn refresh(
State(state): State<AppState>,
body: String,
) -> Result<Json<AuthenticateResponse>, AppError> {
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, _metadata) = atm
.unpack(&body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
if !matches!(
msg.typ.as_str(),
"https://affinidi.com/atm/1.0/authenticate/refresh"
| "https://trusttasks.org/spec/auth/refresh/0.1"
) {
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 sender_base = msg
.from
.as_deref()
.map(|s| s.split('#').next().unwrap_or(s).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: sender_base,
},
)
.await?;
Ok(Json(resp))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub session_id: String,
pub did: String,
pub state: SessionState,
pub created_at: u64,
pub refresh_expires_at: Option<u64>,
}
impl From<Session> for SessionSummary {
fn from(s: Session) -> Self {
Self {
session_id: s.session_id,
did: s.did,
state: s.state,
created_at: s.created_at,
refresh_expires_at: s.refresh_expires_at,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WhoamiResponse {
pub did: String,
pub role: String,
pub session_id: String,
pub access_expires_at: u64,
pub allowed_contexts: Vec<String>,
}
pub async fn whoami(auth: AuthClaims) -> Json<WhoamiResponse> {
Json(WhoamiResponse {
did: auth.did,
role: auth.role.to_string(),
session_id: auth.session_id,
access_expires_at: auth.access_expires_at,
allowed_contexts: auth.allowed_contexts,
})
}
pub async fn sign_out(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<axum::response::Response, AppError> {
use axum::http::HeaderValue;
use axum::http::header::SET_COOKIE;
let sessions = state.sessions_ks.clone();
let _ = delete_session(&sessions, &auth.session_id).await;
info!(did = %auth.did, session_id = %auth.session_id, "sign-out");
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();
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(csrf_clear)
.map_err(|e| AppError::Internal(format!("invalid csrf cookie: {e}")))?,
);
Ok(response)
}
pub async fn session_list(
_auth: ManageAuth,
State(state): State<AppState>,
) -> Result<Json<Vec<SessionSummary>>, AppError> {
let sessions = state.sessions_ks.clone();
let all = list_sessions(&sessions).await?;
let summaries: Vec<SessionSummary> = all.into_iter().map(SessionSummary::from).collect();
info!(caller = %_auth.0.did, count = summaries.len(), "sessions listed");
Ok(Json(summaries))
}
pub async fn revoke_session(
auth: AuthClaims,
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let sessions = state.sessions_ks.clone();
let session = get_session(&sessions, &session_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("session not found: {session_id}")))?;
if session.did != auth.did && auth.role != Role::Admin {
return Err(AppError::Forbidden(
"cannot revoke another user's session".into(),
));
}
delete_session(&sessions, &session_id).await?;
info!(caller = %auth.did, session_id = %session_id, "session revoked");
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct RevokeByDidQuery {
pub did: String,
}
#[derive(Debug, Serialize)]
pub struct RevokeByDidResponse {
pub revoked: u64,
}
pub async fn revoke_sessions_by_did(
_auth: AdminAuth,
State(state): State<AppState>,
Query(query): Query<RevokeByDidQuery>,
) -> Result<Json<RevokeByDidResponse>, AppError> {
let sessions = state.sessions_ks.clone();
let all = list_sessions(&sessions).await?;
let mut revoked = 0u64;
for session in all {
if session.did == query.did {
delete_session(&sessions, &session.session_id).await?;
revoked += 1;
}
}
info!(caller = %_auth.0.did, target_did = %query.did, revoked, "sessions revoked by DID");
Ok(Json(RevokeByDidResponse { revoked }))
}