use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use uuid::Uuid;
use webauthn_rs::prelude::*;
use super::{PasskeyState, store};
use crate::server::acl::{self, AclEntry, Role, check_acl};
use crate::server::auth::extractor::{AdminAuth, AuthClaims, StepUpAuth};
use crate::server::auth::session::{
TokenResponse, create_authenticated_session, elevate_session, now_epoch,
};
use crate::server::error::AppError;
fn token_prefix(token: &str) -> &str {
&token[..token.len().min(8)]
}
fn require_webauthn<S: PasskeyState>(state: &S) -> Result<&Webauthn, AppError> {
state.webauthn().map(|w| w.as_ref()).ok_or_else(|| {
warn!("passkey request rejected: WebAuthn not configured (set public_url)");
AppError::Authentication("passkey auth not configured (set public_url)".into())
})
}
fn require_jwt_keys<S: PasskeyState>(
state: &S,
) -> Result<&crate::server::auth::jwt::JwtKeys, AppError> {
state
.jwt_keys()
.map(|k| k.as_ref())
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))
}
#[derive(Deserialize)]
pub struct EnrollStartRequest {
pub token: String,
}
impl std::fmt::Debug for EnrollStartRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnrollStartRequest")
.field("token", &"<redacted>")
.finish()
}
}
#[derive(Debug, Serialize)]
pub struct EnrollStartResponse {
pub registration_id: String,
pub options: CreationChallengeResponse,
}
pub async fn enroll_start<S: PasskeyState>(
State(state): State<S>,
Json(req): Json<EnrollStartRequest>,
) -> Result<Json<EnrollStartResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let sessions_ks = state.sessions_ks();
let acl_ks = state.acl_ks();
let mut enrollment = store::get_enrollment(sessions_ks, &req.token)
.await?
.ok_or_else(|| {
warn!("passkey enrollment rejected: token not found or already used");
AppError::Authentication("enrollment not found or already used".into())
})?;
let now = now_epoch();
if now > enrollment.expires_at {
warn!(did = %enrollment.did, "passkey enrollment rejected: link expired");
return Err(AppError::Authentication(
"enrollment link has expired".into(),
));
}
if let Some(claimed_at) = enrollment.claimed_at
&& now.saturating_sub(claimed_at) < store::ENROLLMENT_CLAIM_WINDOW_SECS
{
warn!(did = %enrollment.did, "passkey enrollment rejected: ceremony already in progress");
return Err(AppError::Authentication(
"enrollment is already in progress; retry after the current ceremony completes or expires".into(),
));
}
enrollment.claimed_at = Some(now);
store::store_enrollment(sessions_ks, &enrollment).await?;
let role = enrollment.role.parse::<Role>()?;
if acl::get_acl_entry(acl_ks, &enrollment.did).await?.is_none() {
let entry = AclEntry {
did: enrollment.did.clone(),
role: role.clone(),
label: Some("enrolled via passkey invite".into()),
created_at: now_epoch(),
max_total_size: None,
max_did_count: None,
domains: crate::server::domain::DomainScope::All,
};
acl::store_acl_entry(acl_ks, &entry).await?;
info!(did = %enrollment.did, role = %role, "ACL entry created from enrollment");
}
let user = match store::get_passkey_user_by_did(sessions_ks, &enrollment.did).await? {
Some(u) => u,
None => store::PasskeyUser {
user_uuid: Uuid::new_v4(),
did: enrollment.did.clone(),
display_name: enrollment.did.clone(),
credentials: Vec::new(),
},
};
let exclude: Option<Vec<CredentialID>> = if user.credentials.is_empty() {
None
} else {
Some(
user.credentials
.iter()
.map(|c| c.cred_id().clone())
.collect(),
)
};
let (ccr, reg_state) = webauthn
.start_passkey_registration(user.user_uuid, &user.did, &user.display_name, exclude)
.map_err(|e| AppError::Internal(format!("webauthn registration start failed: {e}")))?;
store::store_passkey_user(sessions_ks, &user).await?;
let reg_id = Uuid::new_v4().to_string();
store::store_registration_state(sessions_ks, ®_id, ®_state).await?;
store::store_registration_user(sessions_ks, ®_id, &user.user_uuid).await?;
store::store_registration_enrollment(sessions_ks, ®_id, &req.token).await?;
info!(did = %user.did, reg_id = %reg_id, "passkey enrollment started");
Ok(Json(EnrollStartResponse {
registration_id: reg_id,
options: ccr,
}))
}
#[derive(Debug, Deserialize)]
pub struct EnrollFinishRequest {
pub registration_id: String,
pub credential: RegisterPublicKeyCredential,
}
pub async fn enroll_finish<S: PasskeyState>(
State(state): State<S>,
Json(req): Json<EnrollFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let jwt_keys = require_jwt_keys(&state)?;
let sessions_ks = state.sessions_ks();
let acl_ks = state.acl_ks();
let reg_state = store::take_registration_state(sessions_ks, &req.registration_id)
.await?
.ok_or_else(|| {
AppError::Authentication("registration state not found or expired".into())
})?;
let passkey = webauthn
.finish_passkey_registration(&req.credential, ®_state)
.map_err(|e| {
warn!(reg_id = %req.registration_id, error = %e, "passkey registration ceremony failed");
AppError::Authentication(format!("passkey registration failed: {e}"))
})?;
if let Some(token) =
store::take_registration_enrollment(sessions_ks, &req.registration_id).await?
{
let _ = store::take_enrollment(sessions_ks, &token).await?;
}
let user_uuid = store::get_registration_user(sessions_ks, &req.registration_id)
.await?
.ok_or_else(|| AppError::Internal("registration user mapping not found".into()))?;
store::delete_registration_user(sessions_ks, &req.registration_id).await?;
let mut user = store::get_passkey_user(sessions_ks, &user_uuid)
.await?
.ok_or_else(|| AppError::Internal("passkey user not found".into()))?;
let cred_id_hex = hex::encode(passkey.cred_id());
store::store_credential_mapping(sessions_ks, &cred_id_hex, user.user_uuid).await?;
user.credentials.push(passkey);
store::store_passkey_user(sessions_ks, &user).await?;
let role = check_acl(acl_ks, &user.did).await?;
let token_resp = create_authenticated_session(
sessions_ks,
jwt_keys,
&user.did,
&role,
state.access_token_expiry(),
state.refresh_token_expiry(),
None,
Some((vec!["passkey".to_string()], "aal2".to_string())),
)
.await?;
info!(did = %user.did, "passkey enrollment completed");
Ok(Json(token_resp))
}
#[derive(Debug, Serialize)]
pub struct LoginStartResponse {
pub auth_id: String,
pub options: RequestChallengeResponse,
}
pub async fn login_start<S: PasskeyState>(
State(state): State<S>,
) -> Result<Json<LoginStartResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let sessions_ks = state.sessions_ks();
let all_passkeys = store::get_all_passkeys(sessions_ks).await?;
if all_passkeys.is_empty() {
warn!("passkey login failed: no passkeys registered on this server");
return Err(AppError::Authentication(
"no passkeys registered on this server".into(),
));
}
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&all_passkeys)
.map_err(|e| AppError::Internal(format!("webauthn auth start failed: {e}")))?;
let auth_id = Uuid::new_v4().to_string();
store::store_auth_state(sessions_ks, &auth_id, &auth_state).await?;
info!(auth_id = %auth_id, passkey_count = all_passkeys.len(), "passkey login challenge issued");
Ok(Json(LoginStartResponse {
auth_id,
options: rcr,
}))
}
#[derive(Debug, Deserialize)]
pub struct LoginFinishRequest {
pub auth_id: String,
pub credential: PublicKeyCredential,
#[serde(default)]
pub session_pubkey_b58btc: Option<String>,
}
pub async fn login_finish<S: PasskeyState>(
State(state): State<S>,
Json(req): Json<LoginFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let jwt_keys = require_jwt_keys(&state)?;
let sessions_ks = state.sessions_ks();
let acl_ks = state.acl_ks();
let auth_state = store::take_auth_state(sessions_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 ceremony failed");
AppError::Authentication(format!("passkey authentication failed: {e}"))
})?;
let cred_id_hex = hex::encode(auth_result.cred_id());
let mut user = store::get_passkey_user_by_cred(sessions_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::store_passkey_user(sessions_ks, &user).await?;
let role = check_acl(acl_ks, &user.did).await?;
let session_pubkey = match req.session_pubkey_b58btc.as_deref() {
Some(pk) if pk.starts_with("z6Mk") => Some(pk.to_string()),
Some(pk) => {
warn!(
prefix = %&pk[..pk.len().min(8)],
"rejected unsupported session-key shape on passkey login"
);
return Err(AppError::Authentication(
"session_pubkey_b58btc must be an Ed25519 multikey (z6Mk… prefix)".into(),
));
}
None => None,
};
let token_resp = create_authenticated_session(
sessions_ks,
jwt_keys,
&user.did,
&role,
state.access_token_expiry(),
state.refresh_token_expiry(),
session_pubkey,
Some((vec!["passkey".to_string()], "aal2".to_string())),
)
.await?;
info!(did = %user.did, "passkey login successful");
Ok(Json(token_resp))
}
#[derive(Debug, Serialize)]
pub struct StepUpStartResponse {
pub auth_id: String,
pub options: RequestChallengeResponse,
}
pub async fn step_up_start<S: PasskeyState>(
auth: AuthClaims,
State(state): State<S>,
) -> Result<Json<StepUpStartResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let sessions_ks = state.sessions_ks();
let user = store::get_passkey_user_by_did(sessions_ks, &auth.did)
.await?
.filter(|u| !u.credentials.is_empty())
.ok_or_else(|| {
warn!(did = %auth.did, "step-up start failed: no passkey enrolled for this DID");
AppError::Authentication(
"no passkey enrolled for this DID; enroll one before stepping up".into(),
)
})?;
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&user.credentials)
.map_err(|e| AppError::Internal(format!("webauthn auth start failed: {e}")))?;
let auth_id = Uuid::new_v4().to_string();
store::store_auth_state(sessions_ks, &auth_id, &auth_state).await?;
info!(did = %auth.did, auth_id = %auth_id, "step-up challenge issued");
Ok(Json(StepUpStartResponse {
auth_id,
options: rcr,
}))
}
#[derive(Debug, Deserialize)]
pub struct StepUpFinishRequest {
pub auth_id: String,
pub credential: PublicKeyCredential,
}
pub async fn step_up_finish<S: PasskeyState>(
auth: AuthClaims,
State(state): State<S>,
Json(req): Json<StepUpFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let webauthn = require_webauthn(&state)?;
let jwt_keys = require_jwt_keys(&state)?;
let sessions_ks = state.sessions_ks();
let acl_ks = state.acl_ks();
let auth_state = store::take_auth_state(sessions_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, "step-up ceremony failed");
AppError::Authentication(format!("passkey authentication failed: {e}"))
})?;
let cred_id_hex = hex::encode(auth_result.cred_id());
let mut user = store::get_passkey_user_by_cred(sessions_ks, &cred_id_hex)
.await?
.ok_or_else(|| AppError::Authentication("credential not registered".into()))?;
if user.did != auth.did {
warn!(authed = %auth.did, cred_did = %user.did, "step-up rejected: passkey belongs to a different DID");
return Err(AppError::Forbidden(
"passkey does not belong to the authenticated DID".into(),
));
}
for cred in &mut user.credentials {
cred.update_credential(&auth_result);
}
store::store_passkey_user(sessions_ks, &user).await?;
let role = check_acl(acl_ks, &auth.did).await?;
let token_resp = elevate_session(
sessions_ks,
jwt_keys,
&auth.session_id,
&role,
vec!["did".to_string(), "webauthn".to_string()],
"aal2",
state.access_token_expiry(),
state.refresh_token_expiry(),
)
.await?;
info!(did = %auth.did, "step-up complete: session elevated to aal2");
Ok(Json(token_resp))
}
#[derive(Debug, Serialize)]
pub struct StepUpCheckResponse {
pub ok: bool,
pub did: String,
pub acr: String,
pub amr: Vec<String>,
}
pub async fn step_up_check(StepUpAuth(claims): StepUpAuth) -> Json<StepUpCheckResponse> {
Json(StepUpCheckResponse {
ok: true,
did: claims.did,
acr: claims.acr,
amr: claims.amr,
})
}
#[derive(Debug, Deserialize)]
pub struct CreateInviteRequest {
pub did: String,
#[serde(default = "default_invite_role")]
pub role: String,
}
fn default_invite_role() -> String {
"owner".into()
}
#[derive(Serialize)]
pub struct CreateInviteResponse {
pub token: String,
pub enrollment_url: String,
pub expires_at: u64,
}
impl std::fmt::Debug for CreateInviteResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CreateInviteResponse")
.field("token", &"<redacted>")
.field("enrollment_url", &"<redacted>")
.field("expires_at", &self.expires_at)
.finish()
}
}
pub async fn create_enrollment_invite(
sessions_ks: &crate::server::store::KeyspaceHandle,
base_url: &str,
enrollment_ttl: u64,
did: &str,
role: &str,
) -> Result<CreateInviteResponse, AppError> {
let _ = role.parse::<Role>()?;
let token = {
use rand::RngExt;
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
hex::encode(bytes)
};
let now = now_epoch();
let enrollment = store::Enrollment {
token: token.clone(),
did: did.to_string(),
role: role.to_string(),
created_at: now,
expires_at: now + enrollment_ttl,
claimed_at: None,
};
store::store_enrollment(sessions_ks, &enrollment).await?;
let enrollment_url = format!("{base_url}/enroll?token={token}");
info!(did = %did, role = %role, "enrollment invite created");
Ok(CreateInviteResponse {
token,
enrollment_url,
expires_at: enrollment.expires_at,
})
}
pub async fn create_invite<S: PasskeyState>(
_auth: AdminAuth,
State(state): State<S>,
Json(req): Json<CreateInviteRequest>,
) -> Result<Json<CreateInviteResponse>, AppError> {
let base_url = state
.public_url()
.ok_or_else(|| AppError::Config("public_url is required for enrollment invites".into()))?;
let resp = create_enrollment_invite(
state.sessions_ks(),
base_url,
state.enrollment_ttl(),
&req.did,
&req.role,
)
.await?;
Ok(Json(resp))
}
#[derive(Serialize)]
pub struct InviteListItem {
pub token: String,
pub did: String,
pub role: String,
pub created_at: u64,
pub expires_at: u64,
pub enrollment_url: String,
pub expired: bool,
}
impl std::fmt::Debug for InviteListItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InviteListItem")
.field("token", &"<redacted>")
.field("did", &self.did)
.field("role", &self.role)
.field("created_at", &self.created_at)
.field("expires_at", &self.expires_at)
.field("enrollment_url", &"<redacted>")
.field("expired", &self.expired)
.finish()
}
}
#[derive(Debug, Serialize)]
pub struct InviteListResponse {
pub invites: Vec<InviteListItem>,
}
pub async fn list_invites<S: PasskeyState>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<Json<InviteListResponse>, AppError> {
let base_url = state.public_url().unwrap_or("").to_string();
let now = now_epoch();
let pairs = store::list_enrollments(state.sessions_ks()).await?;
let mut invites: Vec<InviteListItem> = pairs
.into_iter()
.map(|e| InviteListItem {
enrollment_url: if base_url.is_empty() {
format!("/enroll?token={}", e.token)
} else {
format!("{base_url}/enroll?token={}", e.token)
},
expired: e.expires_at < now,
token: e.token,
did: e.did,
role: e.role,
created_at: e.created_at,
expires_at: e.expires_at,
})
.collect();
invites.sort_by_key(|b| std::cmp::Reverse(b.created_at));
Ok(Json(InviteListResponse { invites }))
}
#[derive(Debug, Deserialize)]
pub struct UpdateInviteRequest {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub expires_at: Option<u64>,
#[serde(default)]
pub extend_ttl: Option<u64>,
}
pub async fn update_invite<S: PasskeyState>(
_auth: AdminAuth,
State(state): State<S>,
Path(token): Path<String>,
Json(req): Json<UpdateInviteRequest>,
) -> Result<Json<InviteListItem>, AppError> {
if req.expires_at.is_some() && req.extend_ttl.is_some() {
return Err(AppError::Validation(
"`expires_at` and `extend_ttl` are mutually exclusive".into(),
));
}
if let Some(ref role) = req.role {
role.parse::<Role>()?;
}
let sessions_ks = state.sessions_ks();
let existing = store::get_enrollment(sessions_ks, &token)
.await?
.ok_or_else(|| AppError::NotFound(format!("invite not found: {token}")))?;
let now = now_epoch();
let new_expires = match (req.expires_at, req.extend_ttl) {
(Some(ts), None) => ts,
(None, Some(seconds)) => now + seconds,
(None, None) => existing.expires_at,
(Some(_), Some(_)) => unreachable!(),
};
let updated = store::Enrollment {
token: existing.token.clone(),
did: existing.did.clone(),
role: req.role.unwrap_or(existing.role),
created_at: existing.created_at,
expires_at: new_expires,
claimed_at: existing.claimed_at,
};
store::store_enrollment(sessions_ks, &updated).await?;
let base_url = state.public_url().unwrap_or("").to_string();
info!(
did = %updated.did,
role = %updated.role,
token_prefix = %token_prefix(&token),
"invite updated",
);
Ok(Json(InviteListItem {
enrollment_url: if base_url.is_empty() {
format!("/enroll?token={}", updated.token)
} else {
format!("{base_url}/enroll?token={}", updated.token)
},
expired: updated.expires_at < now,
token: updated.token,
did: updated.did,
role: updated.role,
created_at: updated.created_at,
expires_at: updated.expires_at,
}))
}
pub async fn revoke_invite<S: PasskeyState>(
_auth: AdminAuth,
State(state): State<S>,
Path(token): Path<String>,
) -> Result<StatusCode, AppError> {
let removed = store::take_enrollment(state.sessions_ks(), &token).await?;
if removed.is_none() {
return Err(AppError::NotFound(format!("invite not found: {token}")));
}
info!(token_prefix = %token_prefix(&token), "invite revoked");
Ok(StatusCode::NO_CONTENT)
}