use axum::http::StatusCode; use chrono::{DateTime, NaiveDateTime}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::auth::{ api_tokens::{ create_api_token as issue_token, list_api_tokens as load_tokens, regenerate_api_token as rotate_token, revoke_api_token as revoke_token, }, capability_sets::load_capability_set, passkeys::PasskeySummary, }; use crate::error::{AppError, AppResult}; use crate::http::responders::{created_json, no_content, ok_json, JsonResponse}; use crate::models::ApiToken; use crate::state::{AppState, PgPooledConnection}; use crate::utils::time::to_iso; #[derive(Debug, Serialize, ToSchema)] pub struct ApiTokenResponse { pub id: Uuid, pub tenant_id: Uuid, #[schema(nullable)] pub label: Option, pub capability_set_id: Uuid, pub created_at: String, #[schema(nullable)] pub last_used_at: Option, #[schema(nullable)] pub expires_at: Option, #[schema(nullable)] pub revoked_at: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct ApiTokenCreatedResponse { pub token: String, pub token_info: ApiTokenResponse, } #[derive(Debug, Deserialize, ToSchema)] pub struct CreateApiTokenRequest { #[schema(nullable)] pub label: Option, #[schema(nullable)] pub expires_at: Option, pub capability_set_id: Uuid, } #[derive(Debug, Deserialize, ToSchema)] pub struct RevokePasskeyQuery { #[serde(default)] #[schema(nullable)] pub reason: Option, } pub struct ProfileService<'a> { state: &'a AppState, } impl<'a> ProfileService<'a> { pub fn new(state: &'a AppState) -> Self { Self { state } } pub fn list_passkeys( &self, conn: &mut PgPooledConnection, user_id: Uuid, ) -> AppResult>> { let service = self .state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let passkeys = service.list_for_user(conn, user_id)?; ok_json(passkeys) } pub fn list_api_tokens( &self, conn: &mut PgPooledConnection, tenant_id: Uuid, user_id: Uuid, ) -> AppResult>> { let tokens = load_tokens(conn, user_id, Some(tenant_id))?; let responses = tokens.into_iter().map(api_token_to_response).collect(); ok_json(responses) } pub fn create_api_token( &self, conn: &mut PgPooledConnection, tenant_id: Uuid, user_id: Uuid, payload: CreateApiTokenRequest, ) -> AppResult> { let expires_at = payload .expires_at .as_ref() .map(|value| parse_timestamp(value)) .transpose()?; let capability_set_id = validate_capability_set(conn, tenant_id, payload.capability_set_id)?; let issued = issue_token( conn, user_id, tenant_id, payload.label.clone(), expires_at, capability_set_id, )?; let token_info = api_token_to_response(issued.record); let response = ApiTokenCreatedResponse { token: issued.token, token_info, }; created_json(response) } pub fn regenerate_api_token( &self, conn: &mut PgPooledConnection, tenant_id: Uuid, user_id: Uuid, token_id: Uuid, ) -> AppResult> { let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?; let token_info = api_token_to_response(issued.record); ok_json(ApiTokenCreatedResponse { token: issued.token, token_info, }) } pub fn delete_api_token( &self, conn: &mut PgPooledConnection, user_id: Uuid, token_id: Uuid, ) -> AppResult { revoke_token(conn, token_id, user_id)?; no_content() } pub fn delete_passkey( &self, conn: &mut PgPooledConnection, user_id: Uuid, passkey_id: Uuid, reason: Option, ) -> AppResult { let service = self .state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let active_count = service.active_passkey_count(conn, user_id)?; if active_count <= 1 { return Err(AppError::bad_request( "cannot revoke the last remaining passkey", )); } service.revoke_passkey(conn, user_id, passkey_id, reason)?; no_content() } } fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { let ApiToken { id, tenant_id, label, created_at, last_used_at, expires_at, revoked_at, capability_set_id, .. } = token; ApiTokenResponse { id, tenant_id, label, capability_set_id, created_at: to_iso(created_at), last_used_at: last_used_at.map(to_iso), expires_at: expires_at.map(to_iso), revoked_at: revoked_at.map(to_iso), } } fn parse_timestamp(value: &str) -> AppResult { let dt = DateTime::parse_from_rfc3339(value) .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; Ok(dt.naive_utc()) } fn validate_capability_set( conn: &mut PgPooledConnection, tenant_id: Uuid, capability_set_id: Uuid, ) -> AppResult { let set = load_capability_set(conn, capability_set_id)?; if set.tenant_id != tenant_id { return Err(AppError::bad_request( "capability set does not belong to the tenant", )); } Ok(set.id) }