use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use issuerd_auth_flow::{
built_in::{federated_password_provider, set_user_password, verify_password_hash},
totp,
};
use issuerd_core::{
typestate::{AccountSessionGuard, RealmBound},
BrokerIdpSettings, ClientIdentifier, Credential, CredentialId, CredentialType, DisplayName,
Email, IssuerdError, Pagination, PasswordPolicyError, Realm, RealmId, SessionId, User, UserId,
Username, ACTION_TOKEN_PURPOSE_BROKER_LINK,
};
use issuerd_token::action_tokens::{action_token_claims, issue_action_token};
use tracing::{debug, error, instrument, warn};
use super::required_actions::send_verification_email;
use crate::{
middleware::{proxy_ip::ClientIp, realm::ResolvedRealm},
state::ServerState,
};
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AccountMeResponse {
pub id: String,
pub username: String,
pub email: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email_verified: bool,
pub enabled: bool,
pub roles: Vec<String>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AccountSession {
pub id: String,
pub ip_address: String,
pub started: String,
pub last_session_refresh: String,
pub clients: Vec<String>,
}
mod double_option {
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Some(Option::<T>::deserialize(deserializer)?))
}
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateMeRequest {
#[serde(default, deserialize_with = "double_option::deserialize")]
pub first_name: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option::deserialize")]
pub last_name: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option::deserialize")]
pub email: Option<Option<String>>,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct TotpStartResponse {
pub secret: String,
#[serde(rename = "otpauthUrl")]
pub otpauth_url: String,
#[serde(rename = "qrSvg")]
pub qr_svg: String,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct TotpVerifyRequest {
pub code: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AccountCredentialsResponse {
pub password: bool,
pub totp: bool,
pub webauthn: bool,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AccountConsentResponse {
pub client_id: String,
pub granted_scopes: Vec<String>,
pub created_at: String,
pub last_updated_at: String,
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/me",
tag = "Account",
summary = "Get the authenticated user's profile",
operation_id = "account_get_me",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Account profile", body = AccountMeResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_me_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let user = match load_user(&state, &guard).await {
Ok(u) => u,
Err(resp) => return resp,
};
match build_me_response(&state, &guard.realm_id, &user).await {
Ok(me) => Json(me).into_response(),
Err(resp) => resp,
}
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/sessions",
tag = "Account",
summary = "List the authenticated user's sessions",
operation_id = "account_list_sessions",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Active sessions", body = Vec<AccountSession>),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_sessions_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let sessions = match state
.storage
.list_sessions(&guard.realm_id, Some(guard.user_id), &Pagination::default())
.await
{
Ok(s) => s,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "failed to list sessions"})),
)
.into_response()
}
};
let result: Vec<AccountSession> = sessions
.into_iter()
.map(|s| AccountSession {
id: s.id.0,
ip_address: s.ip_address.to_string(),
started: s.started.to_rfc3339(),
last_session_refresh: s.last_session_refresh.to_rfc3339(),
clients: s.clients.into_iter().map(|c| c.client_id.0).collect(),
})
.collect();
Json(result).into_response()
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/sessions/{id}/logout",
tag = "Account",
summary = "Log out (delete) one of the user's own sessions",
operation_id = "account_logout_session",
params(
("realm" = String, Path, description = "Realm name"),
("id" = String, Path, description = "Session or credential id"),
),
responses(
(status = 204, description = "Session deleted (idempotent)"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, _realm, session_id))]
pub async fn account_logout_session_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Path((_realm, session_id)): Path<(String, String)>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let sid = match SessionId::new(session_id.clone()) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response()
}
};
let session = match state.storage.get_user_session(&guard.realm_id, &sid).await {
Ok(Some(session)) => {
if session.user_id != guard.user_id {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "cannot logout another user's session"})),
)
.into_response();
}
session
}
Ok(None) => {
return StatusCode::NO_CONTENT.into_response();
}
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account logout: failed to load session");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "failed to load session"})),
)
.into_response();
}
};
if let Err(e) = state.storage.delete_user_session(&guard.realm_id, &sid).await {
error!(realm = %guard.realm_id, error = %e, "account logout: failed to delete session");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "failed to delete session"})),
)
.into_response();
}
crate::session_cache::invalidate_session(&state, &guard.realm_id, &sid).await;
if let Ok(Some(realm)) = state.storage.get_realm(&guard.realm_id).await {
state.logout_notifier.notify_session_destroyed(&realm, &session).await;
}
StatusCode::NO_CONTENT.into_response()
}
#[utoipa::path(
put,
path = "/realms/{realm}/account/api/me",
tag = "Account",
summary = "Update the authenticated user's profile",
operation_id = "account_update_me",
params(
("realm" = String, Path, description = "Realm name"),
),
request_body = UpdateMeRequest,
responses(
(status = 200, description = "Updated account profile", body = AccountMeResponse),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, body))]
pub async fn account_update_me_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Json(body): Json<UpdateMeRequest>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let mut user = match load_user(&state, &guard).await {
Ok(u) => u,
Err(resp) => return resp,
};
if let Some(new_username) = body.username {
if new_username != user.username.as_str() {
if !realm_model.edit_username_allowed {
return bad_request("username changes are not allowed in this realm");
}
match Username::new(new_username) {
Ok(username) => user.username = username,
Err(e) => return bad_request(&e.to_string()),
}
}
}
if let Some(first_name) = body.first_name {
match parse_optional_name(first_name) {
Ok(name) => user.first_name = name,
Err(msg) => return bad_request(&msg),
}
}
if let Some(last_name) = body.last_name {
match parse_optional_name(last_name) {
Ok(name) => user.last_name = name,
Err(msg) => return bad_request(&msg),
}
}
let mut send_verification = false;
if let Some(email) = body.email {
match apply_email_change(&state, &guard, &realm_model, &mut user, email).await {
Ok(v) => send_verification = v,
Err(resp) => return resp,
}
}
if let Err(e) = state.storage.update_user(&guard.realm_id, &user).await {
if matches!(e, IssuerdError::Conflict)
|| matches!(&e, IssuerdError::InvalidRequest(m) if m.contains("username already exists"))
{
return bad_request("username already in use");
}
error!(realm = %guard.realm_id, error = %e, "account update me: failed to update user");
return internal_error("failed to update user");
}
issuerd_cluster::invalidate::invalidate_user_claims(
state.cache.as_ref(),
&guard.realm_id,
&user.id,
)
.await;
if send_verification {
if let Err(e) = send_verification_email(
&state,
&realm_model,
realm_model.name.as_ref(),
&user,
None,
None,
)
.await
{
error!(realm = %guard.realm_id, error = %e, "account update me: verification email failed");
}
}
match build_me_response(&state, &guard.realm_id, &user).await {
Ok(me) => Json(me).into_response(),
Err(resp) => resp,
}
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/credentials/password",
tag = "Account",
summary = "Change the account password (verifies the current one, enforces the realm policy)",
operation_id = "account_change_password",
params(
("realm" = String, Path, description = "Realm name"),
),
request_body = ChangePasswordRequest,
responses(
(status = 204, description = "Password changed"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, body, ip))]
pub async fn account_change_password_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
axum::extract::Extension(ClientIp(ip)): axum::extract::Extension<ClientIp>,
headers: axum::http::HeaderMap,
Json(body): Json<ChangePasswordRequest>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let user = match load_user(&state, &guard).await {
Ok(u) => u,
Err(resp) => return resp,
};
let federation = match federated_password_provider(
state.storage.as_ref(),
state.federation_manager.as_ref(),
&guard.realm_id,
&guard.user_id,
)
.await
{
Ok(v) => v,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account change password: federation lookup failed");
return internal_error("failed to resolve federation provider");
}
};
if let Some((provider, username)) = federation {
let current_ok = match provider.validate_password(&username, &body.current_password).await {
Ok(v) => v,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account change password: federation validation failed");
return internal_error("failed to verify current password");
}
};
if !current_ok {
warn!(realm = %guard.realm_id, username = %issuerd_core::utils::sanitize_log_str(user.username.as_str()), ip = %ip, "account change password: wrong current password");
return bad_request("current password is incorrect");
}
if let Err(policy_err) = realm_model.password_policy.validate(&body.new_password, &user) {
return policy_error_response(policy_err);
}
return match provider.update_password(&username, &body.new_password).await {
Ok(()) => {
match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::Password)
.await
{
Ok(stale) => {
for cred in stale {
if let Err(e) = state
.storage
.delete_credential(&guard.realm_id, &guard.user_id, &cred.id)
.await
{
warn!(realm = %guard.realm_id, error = %e, "account change password: stale credential cleanup failed");
}
}
}
Err(e) => {
warn!(realm = %guard.realm_id, error = %e, "account change password: stale credential cleanup failed");
}
}
StatusCode::NO_CONTENT.into_response()
}
Err(issuerd_core::FederationError::NotSupported) => bad_request(
"the external directory for this account does not accept password changes",
),
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account change password: directory write failed");
internal_error("failed to update password")
}
};
}
let credentials = match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::Password)
.await
{
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account change password: load failed");
return internal_error("failed to load credentials");
}
};
let current_ok = credentials.iter().any(|c| verify_password_hash(&body.current_password, c));
if !current_ok {
warn!(realm = %guard.realm_id, username = %issuerd_core::utils::sanitize_log_str(user.username.as_str()), ip = %ip, "account change password: wrong current password");
return bad_request("current password is incorrect");
}
if let Err(policy_err) = realm_model.password_policy.validate(&body.new_password, &user) {
return policy_error_response(policy_err);
}
match set_user_password(
state.storage.as_ref(),
&guard.realm_id,
&guard.user_id,
&body.new_password,
realm_model.password_policy.history_size,
false,
)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let msg = e.to_string();
if msg.contains("password_history") {
let violations = vec![issuerd_core::PasswordPolicyViolation {
code: "password_history".to_string(),
message: msg.clone(),
}];
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": msg, "policyViolations": violations})),
)
.into_response();
}
error!(realm = %guard.realm_id, error = %e, "account change password: failed");
internal_error("failed to update password")
}
}
}
const TOTP_ENROLL_TTL_SECS: u64 = 600;
fn totp_enrollment_key(realm_id: &RealmId, user_id: &UserId) -> String {
format!("totp-enroll:{realm_id}:{user_id}")
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/credentials/totp/start",
tag = "Account",
summary = "Start TOTP enrollment (returns secret + otpauth URL + QR SVG)",
operation_id = "account_totp_start",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Pending enrollment", body = TotpStartResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_totp_start_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let user = match load_user(&state, &guard).await {
Ok(u) => u,
Err(resp) => return resp,
};
let secret = totp::generate_secret();
let key = totp_enrollment_key(&guard.realm_id, &guard.user_id);
if let Err(e) = state
.cache
.set(
&key,
secret.clone().into_bytes(),
Some(Duration::from_secs(TOTP_ENROLL_TTL_SECS)),
)
.await
{
error!(realm = %guard.realm_id, error = %e, "account TOTP start: cache write failed");
return internal_error("failed to start authenticator enrollment");
}
let issuer = realm_model
.display_name
.as_ref()
.map(|d| d.as_str())
.unwrap_or(realm_model.name.as_str());
let otpauth_url =
totp::otpauth_url(issuer, user.username.as_str(), &secret, &realm_model.otp_policy);
let qr_svg = match crate::qr::qr_svg(&otpauth_url) {
Ok(svg) => svg,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account TOTP start: QR render failed");
return internal_error("failed to render QR code");
}
};
Json(TotpStartResponse {
secret,
otpauth_url,
qr_svg,
})
.into_response()
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/credentials/totp/verify",
tag = "Account",
summary = "Verify a TOTP code and persist the authenticator credential",
operation_id = "account_totp_verify",
params(
("realm" = String, Path, description = "Realm name"),
),
request_body = TotpVerifyRequest,
responses(
(status = 204, description = "TOTP credential created"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, body))]
pub async fn account_totp_verify_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Json(body): Json<TotpVerifyRequest>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let key = totp_enrollment_key(&guard.realm_id, &guard.user_id);
let secret = match state.cache.get(&key).await {
Ok(Some(bytes)) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return internal_error("corrupt enrollment state"),
},
Ok(None) => return bad_request("enrollment not started or expired"),
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account TOTP verify: cache read failed");
return internal_error("failed to load enrollment state");
}
};
let now = issuerd_core::utils::now_secs();
let Some(matched_step) = totp::verify(&secret, &body.code, now, &realm_model.otp_policy, None)
else {
return bad_request("invalid code");
};
let cred = Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::Totp,
user_label: None,
created_date: chrono::Utc::now(),
secret_data: secret.into_bytes(),
credential_data: serde_json::json!({
"algorithm": realm_model.otp_policy.algorithm,
"digits": realm_model.otp_policy.digits,
"period": realm_model.otp_policy.period_secs,
"last_used_step": matched_step,
}),
priority: 0,
};
if let Err(e) = state.storage.create_credential(&guard.realm_id, &guard.user_id, &cred).await {
error!(realm = %guard.realm_id, error = %e, "account TOTP verify: store failed");
return internal_error("failed to store authenticator");
}
if let Err(e) = state.cache.delete(&key).await {
warn!(realm = %guard.realm_id, error = %e, "account TOTP verify: cleanup failed");
}
StatusCode::NO_CONTENT.into_response()
}
#[utoipa::path(
delete,
path = "/realms/{realm}/account/api/credentials/totp",
tag = "Account",
summary = "Delete the account's TOTP credential",
operation_id = "account_totp_delete",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 204, description = "TOTP credential deleted (idempotent)"),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_totp_delete_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let credentials = match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::Totp)
.await
{
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account TOTP delete: load failed");
return internal_error("failed to load credentials");
}
};
for cred in credentials {
if let Err(e) =
state.storage.delete_credential(&guard.realm_id, &guard.user_id, &cred.id).await
{
error!(realm = %guard.realm_id, error = %e, "account TOTP delete: failed");
return internal_error("failed to delete authenticator");
}
}
StatusCode::NO_CONTENT.into_response()
}
const WEBAUTHN_REG_TTL_SECS: u64 = 600;
fn webauthn_registration_key(realm_id: &RealmId, user_id: &UserId) -> String {
format!("webauthn-reg:{realm_id}:{user_id}")
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct WebAuthnRegisterFinishRequest {
#[serde(default)]
pub label: Option<String>,
pub credential: serde_json::Value,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AccountPasskeyResponse {
pub id: String,
pub label: String,
#[serde(rename = "createdAt")]
pub created_at: String,
}
fn webauthn_rp(state: &ServerState, realm: &Realm) -> Option<webauthn_rs::prelude::Webauthn> {
let (rp_id, rp_origin) =
match issuerd_auth_flow::webauthn::relying_party_from_issuer(&state.config.issuer_url) {
Ok(v) => v,
Err(e) => {
error!(error = %e, "account WebAuthn: invalid relying-party configuration");
return None;
}
};
let rp_name = realm.display_name.as_ref().map(|d| d.as_str()).unwrap_or(realm.name.as_str());
match issuerd_auth_flow::webauthn::build_webauthn(&rp_id, &rp_origin, rp_name) {
Ok(w) => Some(w),
Err(e) => {
error!(error = %e, "account WebAuthn: failed to build relying party");
None
}
}
}
fn passkey_display_name(user: &User) -> String {
match (&user.first_name, &user.last_name) {
(Some(first), Some(last)) => format!("{first} {last}"),
(Some(first), None) => first.to_string(),
(None, Some(last)) => last.to_string(),
(None, None) => user.username.to_string(),
}
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/webauthn/register/start",
tag = "Account",
summary = "Start a passkey registration ceremony",
operation_id = "account_webauthn_register_start",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "WebAuthn creation options (PublicKeyCredentialCreationOptions JSON)", body = serde_json::Value),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_webauthn_register_start_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let user = match load_user(&state, &guard).await {
Ok(u) => u,
Err(resp) => return resp,
};
let Some(webauthn) = webauthn_rp(&state, &realm_model) else {
return internal_error("invalid relying-party configuration");
};
let existing = match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::WebAuthn)
.await
{
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn start: load failed");
return internal_error("failed to load credentials");
}
};
let mut exclude = Vec::with_capacity(existing.len());
for cred in &existing {
match issuerd_auth_flow::webauthn::passkey_from_bytes(&cred.secret_data) {
Ok(passkey) => exclude.push(passkey.cred_id().clone()),
Err(e) => {
warn!(realm = %guard.realm_id, error = %e, "account WebAuthn start: skipping undecodable passkey");
}
}
}
let display_name = passkey_display_name(&user);
let (ccr, reg_state) = match webauthn.start_passkey_registration(
issuerd_auth_flow::webauthn::user_handle(&guard.user_id),
user.username.as_str(),
&display_name,
Some(exclude),
) {
Ok(v) => v,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn start: ceremony init failed");
return internal_error("failed to start passkey registration");
}
};
let state_bytes = match serde_json::to_vec(®_state) {
Ok(b) => b,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn start: state serialization failed");
return internal_error("failed to start passkey registration");
}
};
let key = webauthn_registration_key(&guard.realm_id, &guard.user_id);
if let Err(e) = state
.cache
.set(&key, state_bytes, Some(Duration::from_secs(WEBAUTHN_REG_TTL_SECS)))
.await
{
error!(realm = %guard.realm_id, error = %e, "account WebAuthn start: cache write failed");
return internal_error("failed to start passkey registration");
}
Json(ccr.public_key).into_response()
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/webauthn/register/finish",
tag = "Account",
summary = "Finish a passkey registration ceremony and store the credential",
operation_id = "account_webauthn_register_finish",
params(
("realm" = String, Path, description = "Realm name"),
),
request_body = WebAuthnRegisterFinishRequest,
responses(
(status = 204, description = "Passkey registered"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, body))]
pub async fn account_webauthn_register_finish_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Json(body): Json<WebAuthnRegisterFinishRequest>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_model = match load_realm(&state, &guard).await {
Ok(r) => r,
Err(resp) => return resp,
};
let Some(webauthn) = webauthn_rp(&state, &realm_model) else {
return internal_error("invalid relying-party configuration");
};
let key = webauthn_registration_key(&guard.realm_id, &guard.user_id);
let state_bytes = match state.cache.get(&key).await {
Ok(Some(b)) => b,
Ok(None) => return bad_request("registration not started or expired"),
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: cache read failed");
return internal_error("failed to load registration state");
}
};
let reg_state: webauthn_rs::prelude::PasskeyRegistration = match serde_json::from_slice(
&state_bytes,
) {
Ok(s) => s,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: corrupt state");
return internal_error("corrupt registration state");
}
};
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
match serde_json::from_value(body.credential) {
Ok(c) => c,
Err(_) => return bad_request("malformed credential"),
};
let passkey = match webauthn.finish_passkey_registration(&credential, ®_state) {
Ok(p) => p,
Err(e) => {
warn!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: passkey verification failed");
return bad_request("passkey verification failed");
}
};
let label = body
.label
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let secret_data = match issuerd_auth_flow::webauthn::passkey_to_bytes(&passkey) {
Ok(b) => b,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: serialization failed");
return internal_error("failed to store passkey");
}
};
let cred = Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::WebAuthn,
user_label: label,
created_date: chrono::Utc::now(),
secret_data,
credential_data: serde_json::json!({
"cred_id": base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&**passkey.cred_id(),
),
}),
priority: 0,
};
if let Err(e) = state.storage.create_credential(&guard.realm_id, &guard.user_id, &cred).await {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: store failed");
return internal_error("failed to store passkey");
}
if let Err(e) = state.cache.delete(&key).await {
warn!(realm = %guard.realm_id, error = %e, "account WebAuthn finish: cleanup failed");
}
StatusCode::NO_CONTENT.into_response()
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/webauthn/credentials",
tag = "Account",
summary = "List the account's registered passkeys",
operation_id = "account_webauthn_list_credentials",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Registered passkeys", body = Vec<AccountPasskeyResponse>),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_webauthn_credentials_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let credentials = match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::WebAuthn)
.await
{
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn list: load failed");
return internal_error("failed to load credentials");
}
};
let result: Vec<AccountPasskeyResponse> = credentials
.iter()
.map(|cred| AccountPasskeyResponse {
id: cred.id.0.clone(),
label: cred.user_label.clone().unwrap_or_default(),
created_at: cred.created_date.to_rfc3339(),
})
.collect();
Json(result).into_response()
}
#[utoipa::path(
delete,
path = "/realms/{realm}/account/api/webauthn/credentials/{id}",
tag = "Account",
summary = "Delete one of the account's passkeys",
operation_id = "account_webauthn_delete_credential",
params(
("realm" = String, Path, description = "Realm name"),
("id" = String, Path, description = "Session or credential id"),
),
responses(
(status = 204, description = "Passkey deleted (idempotent; only WebAuthn credentials are deletable)"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers, _realm, credential_id))]
pub async fn account_webauthn_delete_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Path((_realm, credential_id)): Path<(String, String)>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let cred_id = match CredentialId::new(credential_id) {
Ok(id) => id,
Err(e) => return bad_request(&e.to_string()),
};
let credentials = match state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, CredentialType::WebAuthn)
.await
{
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account WebAuthn delete: load failed");
return internal_error("failed to load credentials");
}
};
if let Some(cred) = credentials.iter().find(|c| c.id == cred_id) {
if let Err(e) =
state.storage.delete_credential(&guard.realm_id, &guard.user_id, &cred.id).await
{
error!(realm = %guard.realm_id, error = %e, "account WebAuthn delete: failed");
return internal_error("failed to delete passkey");
}
}
StatusCode::NO_CONTENT.into_response()
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/credentials",
tag = "Account",
summary = "Presence flags for the account's enrolled credentials",
operation_id = "account_get_credentials",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Credential presence flags", body = AccountCredentialsResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_credentials_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let password = match has_credential(&state, &guard, CredentialType::Password).await {
Ok(v) => v,
Err(resp) => return resp,
};
let password = if password {
true
} else {
matches!(
federated_password_provider(
state.storage.as_ref(),
state.federation_manager.as_ref(),
&guard.realm_id,
&guard.user_id,
)
.await,
Ok(Some((provider, _))) if provider.supports_password_update()
)
};
let totp = match has_credential(&state, &guard, CredentialType::Totp).await {
Ok(v) => v,
Err(resp) => return resp,
};
let webauthn = match has_credential(&state, &guard, CredentialType::WebAuthn).await {
Ok(v) => v,
Err(resp) => return resp,
};
let webauthn_passwordless =
match has_credential(&state, &guard, CredentialType::WebAuthnPasswordless).await {
Ok(v) => v,
Err(resp) => return resp,
};
Json(AccountCredentialsResponse {
password,
totp,
webauthn: webauthn || webauthn_passwordless,
})
.into_response()
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/consents",
tag = "Account",
summary = "List the account's granted consents",
operation_id = "account_list_consents",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Granted consents", body = Vec<AccountConsentResponse>),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_consents_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let consents = match state.storage.get_consents(&guard.realm_id, &guard.user_id).await {
Ok(c) => c,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account consents: failed to list");
return internal_error("failed to list consents");
}
};
let mut result = Vec::new();
for consent in consents {
let client = match state.storage.get_client(&guard.realm_id, &consent.client_id).await {
Ok(Some(c)) => c,
Ok(None) => continue,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account consents: resolve failed");
return internal_error("failed to resolve consent client");
}
};
result.push(AccountConsentResponse {
client_id: client.client_id.to_string(),
granted_scopes: consent.granted_scopes.to_vec(),
created_at: consent.created_at.to_rfc3339(),
last_updated_at: consent.last_updated_at.to_rfc3339(),
});
}
Json(result).into_response()
}
#[utoipa::path(
delete,
path = "/realms/{realm}/account/api/consents/{client_id}",
tag = "Account",
summary = "Revoke the account's consent for a client",
operation_id = "account_delete_consent",
params(
("realm" = String, Path, description = "Realm name"),
("client_id" = String, Path, description = "Human client_id string"),
),
responses(
(status = 204, description = "Consent revoked (idempotent)"),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
(status = 404, description = "Not found", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_delete_consent_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Path((_realm, client_id)): Path<(String, String)>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let identifier = match ClientIdentifier::new(client_id) {
Ok(id) => id,
Err(e) => return bad_request(&e.to_string()),
};
let client = match state.storage.get_client_by_client_id(&guard.realm_id, &identifier).await {
Ok(Some(c)) => c,
Ok(None) => return error_response(StatusCode::NOT_FOUND, "client not found"),
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "account delete consent: load failed");
return internal_error("failed to load client");
}
};
if let Err(e) = state.storage.delete_consent(&guard.realm_id, &guard.user_id, &client.id).await
{
error!(realm = %guard.realm_id, error = %e, "account delete consent: failed");
return internal_error("failed to delete consent");
}
StatusCode::NO_CONTENT.into_response()
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct LinkedAccountResponse {
pub alias: String,
pub provider_id: String,
pub display_name: String,
pub external_username: Option<String>,
pub created_at: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct LinkAccountResponse {
pub redirect_url: String,
}
#[utoipa::path(
get,
path = "/realms/{realm}/account/api/linked-accounts",
tag = "Account",
summary = "List the account's linked external identities",
operation_id = "account_list_linked_accounts",
params(
("realm" = String, Path, description = "Realm name"),
),
responses(
(status = 200, description = "Linked accounts", body = Vec<LinkedAccountResponse>),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_linked_accounts_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let links = match state
.storage
.list_identity_provider_links(&guard.realm_id, &guard.user_id)
.await
{
Ok(l) => l,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "linked accounts: failed to list");
return internal_error("failed to list linked accounts");
}
};
let idps = match state.storage.list_identity_providers(&guard.realm_id).await {
Ok(l) => l,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "linked accounts: failed to list IdPs");
return internal_error("failed to list identity providers");
}
};
let result = links
.into_iter()
.map(|link| {
let (provider_id, display_name) =
match idps.iter().find(|i| i.alias.as_ref() == link.provider_alias) {
Some(cfg) => (
cfg.provider_id.as_str().to_string(),
BrokerIdpSettings::new(cfg).display_name(),
),
None => (String::new(), link.provider_alias.clone()),
};
LinkedAccountResponse {
alias: link.provider_alias.clone(),
provider_id,
display_name,
external_username: link.external_username.clone(),
created_at: link.created_at.to_rfc3339(),
}
})
.collect::<Vec<_>>();
Json(result).into_response()
}
#[utoipa::path(
post,
path = "/realms/{realm}/account/api/linked-accounts/{alias}",
tag = "Account",
summary = "Start the IdP account-linking ceremony (returns the broker redirect URL)",
operation_id = "account_link_identity",
params(
("realm" = String, Path, description = "Realm name"),
("alias" = String, Path, description = "Identity provider alias"),
),
responses(
(status = 200, description = "Link ceremony start", body = LinkAccountResponse),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
(status = 404, description = "Not found", body = crate::openapi::AccountErrorResponse),
(status = 409, description = "Conflict", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_link_identity_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Path((_realm, alias)): Path<(String, String)>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let realm_name = realm.as_deref().unwrap_or_default();
if let Err(e) = issuerd_core::Alias::new(alias.clone()) {
return bad_request(&e.to_string());
}
let idp = match state.storage.get_identity_provider_by_alias(&guard.realm_id, &alias).await {
Ok(Some(i)) => i,
Ok(None) => return error_response(StatusCode::NOT_FOUND, "identity provider not found"),
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "link account: IdP load failed");
return internal_error("failed to load identity provider");
}
};
if !idp.enabled || !BrokerIdpSettings::new(&idp).is_broker_provider() {
return bad_request("identity provider is not available for linking");
}
match state
.storage
.get_identity_provider_link_for_user(&guard.realm_id, &guard.user_id, &alias)
.await
{
Ok(Some(_)) => {
return error_response(StatusCode::CONFLICT, "identity provider already linked")
}
Ok(None) => {}
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "link account: link check failed");
return internal_error("failed to check existing link");
}
}
let mut claims = action_token_claims(
&guard.user_id,
&guard.realm_id,
ACTION_TOKEN_PURPOSE_BROKER_LINK,
super::broker::LINK_TOKEN_TTL_SECS,
);
claims.idp_alias = Some(alias.clone());
let token = match issue_action_token(state.crypto.as_ref(), &claims).await {
Ok(t) => t,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "link account: token issue failed");
return internal_error("failed to issue link token");
}
};
Json(LinkAccountResponse {
redirect_url: format!("/realms/{realm_name}/broker/{alias}/login?link={token}"),
})
.into_response()
}
#[utoipa::path(
delete,
path = "/realms/{realm}/account/api/linked-accounts/{alias}",
tag = "Account",
summary = "Unlink an external identity (refuses to remove the last sign-in method)",
operation_id = "account_unlink_identity",
params(
("realm" = String, Path, description = "Realm name"),
("alias" = String, Path, description = "Identity provider alias"),
),
responses(
(status = 204, description = "Unlinked (idempotent)"),
(status = 400, description = "Bad request", body = crate::openapi::AccountErrorResponse),
(status = 401, description = "Missing or invalid access token", body = crate::openapi::AccountErrorResponse),
),
security(("bearer_auth" = [])),
)]
#[instrument(skip(state, headers))]
pub async fn account_unlink_identity_handler(
State(state): State<Arc<ServerState>>,
axum::extract::Extension(ResolvedRealm(realm)): axum::extract::Extension<ResolvedRealm>,
headers: axum::http::HeaderMap,
Path((_realm, alias)): Path<(String, String)>,
) -> Response {
let guard = match extract_auth(&state, &realm, &headers).await {
Ok(v) => v,
Err(resp) => return resp,
};
let links = match state
.storage
.list_identity_provider_links(&guard.realm_id, &guard.user_id)
.await
{
Ok(l) => l,
Err(e) => {
error!(realm = %guard.realm_id, error = %e, "unlink account: failed to list");
return internal_error("failed to list linked accounts");
}
};
if !links.iter().any(|l| l.provider_alias == alias) {
return StatusCode::NO_CONTENT.into_response();
}
if links.len() == 1 {
match has_credential(&state, &guard, CredentialType::Password).await {
Ok(true) => {}
Ok(false) => {
return bad_request("cannot unlink the only sign-in method; set a password first")
}
Err(resp) => return resp,
}
}
if let Err(e) = state
.storage
.delete_identity_provider_link(&guard.realm_id, &guard.user_id, &alias)
.await
{
error!(realm = %guard.realm_id, error = %e, "unlink account: delete failed");
return internal_error("failed to unlink identity provider");
}
StatusCode::NO_CONTENT.into_response()
}
fn error_response(status: StatusCode, message: &str) -> Response {
(status, Json(serde_json::json!({"error": message}))).into_response()
}
fn bad_request(message: &str) -> Response {
error_response(StatusCode::BAD_REQUEST, message)
}
fn internal_error(message: &str) -> Response {
error_response(StatusCode::INTERNAL_SERVER_ERROR, message)
}
fn policy_error_response(err: PasswordPolicyError) -> Response {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": err.to_string(),
"policyViolations": err.violations,
})),
)
.into_response()
}
#[allow(clippy::result_large_err)]
async fn load_user(
state: &Arc<ServerState>,
guard: &AccountSessionGuard,
) -> Result<User, Response> {
state
.storage
.get_user(&guard.realm_id, &guard.user_id)
.await
.map_err(|e| {
error!(realm = %guard.realm_id, error = %e, "account API: failed to load user");
internal_error("failed to load user")
})?
.ok_or_else(|| error_response(StatusCode::NOT_FOUND, "user not found"))
}
#[allow(clippy::result_large_err)]
async fn load_realm(
state: &Arc<ServerState>,
guard: &AccountSessionGuard,
) -> Result<Realm, Response> {
state
.storage
.get_realm(&guard.realm_id)
.await
.map_err(|e| {
error!(realm = %guard.realm_id, error = %e, "account API: failed to load realm");
internal_error("failed to load realm")
})?
.ok_or_else(|| error_response(StatusCode::NOT_FOUND, "realm not found"))
}
#[allow(clippy::result_large_err)]
async fn build_me_response(
state: &Arc<ServerState>,
realm_id: &RealmId,
user: &User,
) -> Result<AccountMeResponse, Response> {
let role_ids = state.storage.list_user_realm_roles(realm_id, &user.id).await.map_err(|e| {
error!(realm = %realm_id, error = %e, "account me: failed to list roles");
internal_error("failed to list roles")
})?;
let mut roles = vec![];
for role_id in &role_ids {
if let Ok(Some(role)) = state.storage.get_role(realm_id, role_id).await {
roles.push(role.name.to_string());
}
}
Ok(AccountMeResponse {
id: user.id.0.clone(),
username: user.username.to_string(),
email: user.email.as_ref().map(|e| e.to_string()),
first_name: user.first_name.as_ref().map(|n| n.to_string()),
last_name: user.last_name.as_ref().map(|n| n.to_string()),
email_verified: user.email_verified,
enabled: user.enabled,
roles,
})
}
#[allow(clippy::result_large_err)]
async fn has_credential(
state: &Arc<ServerState>,
guard: &AccountSessionGuard,
cred_type: CredentialType,
) -> Result<bool, Response> {
state
.storage
.get_credentials(&guard.realm_id, &guard.user_id, cred_type)
.await
.map(|creds| !creds.is_empty())
.map_err(|e| {
error!(realm = %guard.realm_id, error = %e, "account credentials: load failed");
internal_error("failed to load credentials")
})
}
fn parse_optional_name(value: Option<String>) -> Result<Option<DisplayName>, String> {
value.map(|v| DisplayName::new(v).map_err(|e| e.to_string())).transpose()
}
#[allow(clippy::result_large_err)]
async fn apply_email_change(
state: &Arc<ServerState>,
guard: &AccountSessionGuard,
realm: &Realm,
user: &mut User,
new_email: Option<String>,
) -> Result<bool, Response> {
let new_email: Option<Email> = match new_email {
Some(v) => Some(Email::new(v).map_err(|e| bad_request(&e.to_string()))?),
None => None,
};
let changed = new_email.as_ref().map(|e| e.as_str()) != user.email.as_ref().map(|e| e.as_str());
if !changed {
return Ok(false);
}
if let Some(email) = &new_email {
if !realm.duplicate_emails_allowed {
let existing = state
.storage
.get_user_by_email(&guard.realm_id, email.as_str())
.await
.map_err(|e| {
error!(realm = %guard.realm_id, error = %e, "account update me: email lookup failed");
internal_error("failed to update user")
})?;
if existing.is_some_and(|other| other.id != user.id) {
return Err(bad_request("email already in use"));
}
}
}
user.email = new_email;
user.email_verified = false;
if user.email.is_none() || !realm.verify_email_enabled {
return Ok(false);
}
if !user.required_actions.iter().any(|a| a == "VERIFY_EMAIL") {
user.required_actions.push("VERIFY_EMAIL".to_string());
}
Ok(true)
}
fn token_is_expired(token: &str) -> bool {
let Some(payload) = token.split('.').nth(1) else {
return false;
};
let Ok(bytes) =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload)
else {
return false;
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return false;
};
claims
.get("exp")
.and_then(serde_json::Value::as_i64)
.is_some_and(|exp| exp < chrono::Utc::now().timestamp())
}
#[allow(clippy::result_large_err)]
async fn extract_auth(
state: &Arc<ServerState>,
realm: &Option<String>,
headers: &axum::http::HeaderMap,
) -> Result<AccountSessionGuard, Response> {
let realm_name = match realm.as_deref() {
Some(r) => r,
None => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "missing realm"})),
)
.into_response())
}
};
let realm = match state.resolve_realm(realm_name).await {
Ok(Some(realm)) => realm,
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid realm"})),
)
.into_response())
}
};
let realm_id = realm.id.clone();
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_token"})))
.into_response()
})?;
let validated = state.token_service.validate_access_token(token).map_err(|e| {
if token_is_expired(token) {
debug!(realm = %realm_id, error = %e, "account API authentication failed: token expired");
} else {
warn!(realm = %realm_id, error = %e, "account API authentication failed");
}
(StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_token"})))
.into_response()
})?;
let token_realm =
issuerd_core::typestate::extract_realm_from_issuer(validated.claims.iss.as_str());
if token_realm != Some(realm.name.as_str()) {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "invalid_token"})),
)
.into_response());
}
let (_issuer_realm, session) =
match super::oidc::enforce_token_validity(state, token, &validated.claims).await {
Ok(ctx) => ctx,
Err(resp) => return Err(resp),
};
if validated.claims.cnf.is_some() {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "invalid_token"})),
)
.into_response());
}
let user_id =
match super::oidc::resolve_token_user(state, &realm_id, &validated.claims, session).await {
Some(user) => user.id,
None => validated.claims.sub,
};
let bound = RealmBound::new(realm_id, user_id);
Ok(AccountSessionGuard::bind(bound))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::ServerConfig, state::ServerState};
async fn test_state() -> Arc<ServerState> {
let cfg = ServerConfig::default();
Arc::new(ServerState::from_config(&cfg).await.unwrap())
}
fn master_realm() -> axum::extract::Extension<ResolvedRealm> {
axum::extract::Extension(ResolvedRealm(Some("master".to_string())))
}
#[tokio::test]
async fn account_me_requires_auth() {
let response = account_me_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_update_me_requires_auth() {
let response = account_update_me_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
Json(UpdateMeRequest {
first_name: None,
last_name: None,
email: None,
username: None,
}),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_change_password_requires_auth() {
let response = account_change_password_handler(
State(test_state().await),
master_realm(),
axum::extract::Extension(ClientIp("127.0.0.1".parse().unwrap())),
axum::http::HeaderMap::new(),
Json(ChangePasswordRequest {
current_password: "old".to_string(),
new_password: "new".to_string(),
}),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_credentials_requires_auth() {
let response = account_credentials_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_consents_requires_auth() {
let response = account_consents_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_delete_consent_requires_auth() {
let response = account_delete_consent_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
Path(("master".to_string(), "some-client".to_string())),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_webauthn_register_start_requires_auth() {
let response = account_webauthn_register_start_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_webauthn_register_finish_requires_auth() {
let response = account_webauthn_register_finish_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
Json(WebAuthnRegisterFinishRequest {
label: None,
credential: serde_json::json!({}),
}),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_webauthn_credentials_requires_auth() {
let response = account_webauthn_credentials_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_webauthn_delete_requires_auth() {
let response = account_webauthn_delete_handler(
State(test_state().await),
master_realm(),
axum::http::HeaderMap::new(),
Path(("master".to_string(), "some-credential".to_string())),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn update_me_request_distinguishes_absent_from_explicit_null() {
let parsed: UpdateMeRequest = serde_json::from_str("{}").unwrap();
assert!(parsed.first_name.is_none());
assert!(parsed.last_name.is_none());
assert!(parsed.email.is_none());
assert!(parsed.username.is_none());
let parsed: UpdateMeRequest = serde_json::from_str(
r#"{"first_name": null, "email": "a@example.com", "username": "newname"}"#,
)
.unwrap();
assert_eq!(parsed.first_name, Some(None));
assert!(parsed.last_name.is_none());
assert_eq!(parsed.email, Some(Some("a@example.com".to_string())));
assert_eq!(parsed.username, Some("newname".to_string()));
}
#[test]
fn parse_optional_name_maps_clear_value_and_validation() {
assert_eq!(parse_optional_name(None).unwrap(), None);
assert_eq!(
parse_optional_name(Some("Ada".to_string())).unwrap(),
Some(DisplayName::new("Ada").unwrap())
);
assert!(!parse_optional_name(Some(String::new())).unwrap_err().is_empty());
}
#[test]
fn account_credentials_response_serializes_contract_shape() {
let json = serde_json::to_value(AccountCredentialsResponse {
password: true,
totp: false,
webauthn: true,
})
.unwrap();
assert_eq!(json, serde_json::json!({"password": true, "totp": false, "webauthn": true}));
}
#[test]
fn account_consent_response_serializes_contract_shape() {
let json = serde_json::to_value(AccountConsentResponse {
client_id: "my-client".to_string(),
granted_scopes: vec!["openid".to_string(), "profile".to_string()],
created_at: "2026-01-01T00:00:00+00:00".to_string(),
last_updated_at: "2026-01-02T00:00:00+00:00".to_string(),
})
.unwrap();
assert_eq!(
json,
serde_json::json!({
"client_id": "my-client",
"granted_scopes": ["openid", "profile"],
"created_at": "2026-01-01T00:00:00+00:00",
"last_updated_at": "2026-01-02T00:00:00+00:00",
})
);
}
async fn password_grant_token(state: &Arc<ServerState>) -> String {
let resp = crate::routes::oidc::token_handler(
State(state.clone()),
axum::extract::Extension(ResolvedRealm(Some("master".to_string()))),
axum::extract::Extension(ClientIp("127.0.0.1".parse().unwrap())),
axum::http::HeaderMap::new(),
"grant_type=password&username=admin&password=admin&client_id=admin-cli&scope=openid"
.to_string(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
json["access_token"].as_str().unwrap().to_string()
}
async fn account_me_with_token(state: &Arc<ServerState>, access_token: &str) -> Response {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", format!("Bearer {access_token}").parse().unwrap());
account_me_handler(
State(state.clone()),
axum::extract::Extension(ResolvedRealm(Some("master".to_string()))),
headers,
)
.await
}
#[tokio::test]
async fn account_me_accepts_token_with_live_session() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
let resp = account_me_with_token(&state, &access_token).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn account_me_rejects_token_of_deleted_session() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
let validated = state.token_service.validate_access_token(&access_token).unwrap();
let sid = validated.claims.sid.clone().unwrap();
state
.storage
.delete_user_session(&RealmId::new("master").unwrap(), &sid)
.await
.unwrap();
let resp = account_me_with_token(&state, &access_token).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_me_rejects_explicitly_revoked_token() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
state
.cache
.set(
&format!("revoked:{access_token}"),
b"1".to_vec(),
Some(std::time::Duration::from_secs(300)),
)
.await
.unwrap();
let resp = account_me_with_token(&state, &access_token).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_me_rejects_token_issued_before_realm_not_before() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
let mut realm = state
.storage
.get_realm(&RealmId::new("master").unwrap())
.await
.unwrap()
.unwrap();
realm.not_before = chrono::Utc::now().timestamp() + 60;
state.storage.update_realm(&realm).await.unwrap();
state
.cache
.delete(&issuerd_cluster::cache_keys::realm_by_name("master"))
.await
.unwrap();
let resp = account_me_with_token(&state, &access_token).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn account_me_rejects_dpop_bound_token_presented_as_bearer() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
let user = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
let client = state
.storage
.get_client_by_client_id(&realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap();
let session_id = SessionId::new(issuerd_core::utils::generate_id()).unwrap();
let session = issuerd_core::UserSession {
id: session_id.clone(),
realm_id: realm_id.clone(),
user_id: user.id.clone(),
login_username: user.username.clone(),
auth_method: issuerd_core::AuthMethod::Password,
remember_me: false,
offline: false,
ip_address: "127.0.0.1".parse().unwrap(),
started: chrono::Utc::now(),
last_session_refresh: chrono::Utc::now(),
auth_time: chrono::Utc::now(),
impersonator: None,
clients: vec![],
};
state.storage.create_user_session(&realm_id, &session).await.unwrap();
let overlay = crate::dpop::bind_cnf_overlay(None, Some("test-jkt"));
let token = state
.token_manager
.issue_access_token_with_roles(
&user,
&client,
&realm,
&["openid".to_string()],
&session_id,
None,
None,
overlay,
)
.await
.unwrap();
let resp = account_me_with_token(&state, &token.token).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
fn bearer_headers(access_token: &str) -> axum::http::HeaderMap {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", format!("Bearer {access_token}").parse().unwrap());
headers
}
async fn body_json(response: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
async fn admin_user_id(state: &Arc<ServerState>) -> UserId {
state
.storage
.get_user_by_username(&RealmId::new("master").unwrap(), "admin")
.await
.unwrap()
.unwrap()
.id
}
fn make_user(realm_id: &RealmId, username: &str) -> User {
User {
id: UserId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm_id.clone(),
username: Username::new(username).unwrap(),
email: Some(Email::new(format!("{username}@example.com")).unwrap()),
email_verified: true,
first_name: None,
last_name: None,
enabled: true,
federation_link: None,
attributes: std::collections::HashMap::new(),
required_actions: vec![],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
fn make_session(realm_id: &RealmId, user: &User) -> issuerd_core::UserSession {
issuerd_core::UserSession {
id: SessionId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm_id.clone(),
user_id: user.id.clone(),
login_username: user.username.clone(),
auth_method: issuerd_core::AuthMethod::Password,
remember_me: false,
offline: false,
ip_address: "127.0.0.1".parse().unwrap(),
started: chrono::Utc::now(),
last_session_refresh: chrono::Utc::now(),
auth_time: chrono::Utc::now(),
impersonator: None,
clients: vec![],
}
}
async fn issue_token_for_user(state: &Arc<ServerState>, user: &User) -> String {
let realm_id = RealmId::new("master").unwrap();
let realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
let client = state
.storage
.get_client_by_client_id(&realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap();
let session = make_session(&realm_id, user);
let session_id = session.id.clone();
state.storage.create_user_session(&realm_id, &session).await.unwrap();
state
.token_manager
.issue_access_token_with_roles(
user,
&client,
&realm,
&["openid".to_string()],
&session_id,
None,
None,
None,
)
.await
.unwrap()
.token
}
async fn set_edit_username_allowed(state: &Arc<ServerState>, allowed: bool) {
let realm_id = RealmId::new("master").unwrap();
let mut realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
realm.edit_username_allowed = allowed;
state.storage.update_realm(&realm).await.unwrap();
state
.cache
.delete(&issuerd_cluster::cache_keys::realm_by_name("master"))
.await
.unwrap();
}
async fn create_idp(
state: &Arc<ServerState>,
alias: &str,
provider_id: issuerd_core::ProviderId,
enabled: bool,
) {
let idp = issuerd_core::IdentityProviderConfig {
id: issuerd_core::IdentityProviderId::new(issuerd_core::utils::generate_id()).unwrap(),
alias: issuerd_core::Alias::new(alias).unwrap(),
provider_id,
enabled,
config: std::collections::HashMap::new(),
};
state
.storage
.create_identity_provider(&RealmId::new("master").unwrap(), &idp)
.await
.unwrap();
}
fn make_link(user_id: &UserId, alias: &str) -> issuerd_core::IdentityProviderLink {
issuerd_core::IdentityProviderLink {
user_id: user_id.clone(),
provider_alias: alias.to_string(),
external_subject: format!("ext-{alias}"),
external_username: Some(format!("{alias}-user")),
stored_refresh_token: None,
created_at: chrono::Utc::now(),
}
}
async fn call_link_identity(state: &Arc<ServerState>, token: &str, alias: &str) -> Response {
account_link_identity_handler(
State(state.clone()),
master_realm(),
bearer_headers(token),
Path(("master".to_string(), alias.to_string())),
)
.await
}
async fn call_unlink_identity(state: &Arc<ServerState>, token: &str, alias: &str) -> Response {
account_unlink_identity_handler(
State(state.clone()),
master_realm(),
bearer_headers(token),
Path(("master".to_string(), alias.to_string())),
)
.await
}
#[tokio::test]
async fn account_me_returns_profile_body() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
let resp = account_me_with_token(&state, &access_token).await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["username"], "admin");
assert_eq!(json["enabled"], true);
assert!(json["id"].as_str().is_some_and(|id| !id.is_empty()));
assert!(json["roles"].is_array());
}
#[tokio::test]
async fn account_logout_session_deletes_own_session() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let validated = state.token_service.validate_access_token(&access_token).unwrap();
let sid = validated.claims.sid.clone().unwrap();
let resp = account_logout_session_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Path(("master".to_string(), sid.0.clone())),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert!(state.storage.get_user_session(&realm_id, &sid).await.unwrap().is_none());
}
#[tokio::test]
async fn account_logout_session_rejects_other_users_session() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let bob = make_user(&realm_id, "bob");
state.storage.create_user(&realm_id, &bob).await.unwrap();
let bobs_session = make_session(&realm_id, &bob);
let bobs_sid = bobs_session.id.clone();
state.storage.create_user_session(&realm_id, &bobs_session).await.unwrap();
let resp = account_logout_session_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Path(("master".to_string(), bobs_sid.0.clone())),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(state.storage.get_user_session(&realm_id, &bobs_sid).await.unwrap().is_some());
}
#[tokio::test]
async fn account_update_me_same_username_is_noop_when_edits_disallowed() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
set_edit_username_allowed(&state, false).await;
let resp = account_update_me_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Json(UpdateMeRequest {
first_name: None,
last_name: None,
email: None,
username: Some("admin".to_string()),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["username"], "admin");
}
#[tokio::test]
async fn account_update_me_rejects_username_change_when_disallowed() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
set_edit_username_allowed(&state, false).await;
let resp = account_update_me_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Json(UpdateMeRequest {
first_name: None,
last_name: None,
email: None,
username: Some("renamed-admin".to_string()),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "username changes are not allowed in this realm");
assert!(state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().is_some());
}
#[tokio::test]
async fn account_change_password_rejects_wrong_current_password() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let resp = account_change_password_handler(
State(state.clone()),
master_realm(),
axum::extract::Extension(ClientIp("127.0.0.1".parse().unwrap())),
bearer_headers(&access_token),
Json(ChangePasswordRequest {
current_password: "not-the-password".to_string(),
new_password: "N0wPassword!".to_string(),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "current password is incorrect");
let user_id = admin_user_id(&state).await;
let creds = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Password)
.await
.unwrap();
assert!(creds.iter().any(|c| verify_password_hash("admin", c)));
}
#[tokio::test]
async fn account_change_password_replaces_credential_on_success() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let resp = account_change_password_handler(
State(state.clone()),
master_realm(),
axum::extract::Extension(ClientIp("127.0.0.1".parse().unwrap())),
bearer_headers(&access_token),
Json(ChangePasswordRequest {
current_password: "admin".to_string(),
new_password: "N0wPassword!".to_string(),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let user_id = admin_user_id(&state).await;
let creds = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Password)
.await
.unwrap();
assert_eq!(creds.len(), 1);
assert!(verify_password_hash("N0wPassword!", &creds[0]));
assert!(!verify_password_hash("admin", &creds[0]));
}
#[test]
fn totp_enrollment_key_scopes_realm_and_user() {
let realm = RealmId::new("master").unwrap();
let user = UserId::new("user-1").unwrap();
assert_eq!(totp_enrollment_key(&realm, &user), "totp-enroll:master:user-1");
assert_ne!(
totp_enrollment_key(&realm, &user),
totp_enrollment_key(&RealmId::new("other").unwrap(), &user)
);
assert_ne!(
totp_enrollment_key(&realm, &user),
totp_enrollment_key(&realm, &UserId::new("user-2").unwrap())
);
}
#[test]
fn webauthn_registration_key_scopes_realm_and_user() {
let realm = RealmId::new("master").unwrap();
let user = UserId::new("user-1").unwrap();
assert_eq!(webauthn_registration_key(&realm, &user), "webauthn-reg:master:user-1");
assert_ne!(
webauthn_registration_key(&realm, &user),
webauthn_registration_key(&RealmId::new("other").unwrap(), &user)
);
assert_ne!(
webauthn_registration_key(&realm, &user),
webauthn_registration_key(&realm, &UserId::new("user-2").unwrap())
);
}
#[tokio::test]
async fn account_totp_start_returns_secret_and_stores_pending() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let resp = account_totp_start_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let secret = json["secret"].as_str().expect("secret");
assert!(!secret.is_empty());
assert!(json["otpauthUrl"].as_str().unwrap().starts_with("otpauth://totp/"));
assert!(json["qrSvg"].as_str().unwrap().contains("<svg"));
let user_id = admin_user_id(&state).await;
let cached = state
.cache
.get(&totp_enrollment_key(&realm_id, &user_id))
.await
.unwrap()
.expect("pending enrollment secret");
assert_eq!(String::from_utf8(cached).unwrap(), secret);
}
#[tokio::test]
async fn account_totp_delete_removes_credential() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let cred = Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::Totp,
user_label: None,
created_date: chrono::Utc::now(),
secret_data: b"JBSWY3DPEHPK3PXP".to_vec(),
credential_data: serde_json::json!({}),
priority: 0,
};
state.storage.create_credential(&realm_id, &user_id, &cred).await.unwrap();
let resp = account_totp_delete_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert!(state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Totp)
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn account_webauthn_register_start_returns_options_and_stores_state() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let resp = account_webauthn_register_start_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["rp"]["id"], "localhost");
assert!(json["challenge"].as_str().is_some_and(|c| !c.is_empty()));
assert_eq!(json["user"]["name"], "admin");
let user_id = admin_user_id(&state).await;
assert!(state
.cache
.get(&webauthn_registration_key(&realm_id, &user_id))
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn account_webauthn_delete_removes_only_the_target_credential() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let make_cred = || Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::WebAuthn,
user_label: None,
created_date: chrono::Utc::now(),
secret_data: b"passkey-bytes".to_vec(),
credential_data: serde_json::json!({}),
priority: 0,
};
let cred_a = make_cred();
let cred_b = make_cred();
state.storage.create_credential(&realm_id, &user_id, &cred_a).await.unwrap();
state.storage.create_credential(&realm_id, &user_id, &cred_b).await.unwrap();
let resp = account_webauthn_delete_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Path(("master".to_string(), cred_a.id.0.clone())),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let remaining = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::WebAuthn)
.await
.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, cred_b.id);
let resp = account_webauthn_delete_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Path(("master".to_string(), "no-such-credential".to_string())),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::WebAuthn)
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn account_consents_returns_granted_consents() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let client = state
.storage
.get_client_by_client_id(&realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap();
let consent = issuerd_core::Consent {
client_id: client.id.clone(),
user_id: user_id.clone(),
granted_scopes: issuerd_core::Scope::parse("openid profile"),
granted_realm_roles: vec![],
granted_client_roles: std::collections::HashMap::new(),
created_at: chrono::Utc::now(),
last_updated_at: chrono::Utc::now(),
};
state.storage.create_consent(&realm_id, &consent).await.unwrap();
let resp = account_consents_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let arr = json.as_array().expect("consent list");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["client_id"], "admin-cli");
assert_eq!(arr[0]["granted_scopes"], serde_json::json!(["openid", "profile"]));
}
#[tokio::test]
async fn account_linked_accounts_resolves_idp_display_fields() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let idp = issuerd_core::IdentityProviderConfig {
id: issuerd_core::IdentityProviderId::new(issuerd_core::utils::generate_id()).unwrap(),
alias: issuerd_core::Alias::new("github").unwrap(),
provider_id: issuerd_core::ProviderId::Oidc,
enabled: true,
config: std::collections::HashMap::from([(
"displayName".to_string(),
"GitHub".to_string(),
)]),
};
state.storage.create_identity_provider(&realm_id, &idp).await.unwrap();
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "github"))
.await
.unwrap();
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "vanished"))
.await
.unwrap();
let resp = account_linked_accounts_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let arr = json.as_array().expect("linked accounts");
assert_eq!(arr.len(), 2);
let github = arr.iter().find(|e| e["alias"] == "github").expect("github entry");
assert_eq!(github["provider_id"], "oidc");
assert_eq!(github["display_name"], "GitHub");
let vanished = arr.iter().find(|e| e["alias"] == "vanished").expect("vanished entry");
assert_eq!(vanished["provider_id"], "");
assert_eq!(vanished["display_name"], "vanished");
}
#[tokio::test]
async fn account_link_identity_rejects_disabled_idp() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
create_idp(&state, "disabled-idp", issuerd_core::ProviderId::Oidc, false).await;
let resp = call_link_identity(&state, &access_token, "disabled-idp").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "identity provider is not available for linking");
}
#[tokio::test]
async fn account_link_identity_rejects_non_broker_provider() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
create_idp(&state, "corp-ldap", issuerd_core::ProviderId::Ldap, true).await;
let resp = call_link_identity(&state, &access_token, "corp-ldap").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "identity provider is not available for linking");
}
#[tokio::test]
async fn account_link_identity_returns_broker_url_for_enabled_broker() {
let state = test_state().await;
let access_token = password_grant_token(&state).await;
create_idp(&state, "github", issuerd_core::ProviderId::Oidc, true).await;
let resp = call_link_identity(&state, &access_token, "github").await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let url = json["redirect_url"].as_str().unwrap();
assert!(
url.starts_with("/realms/master/broker/github/login?link="),
"unexpected url: {url}"
);
let token = url.rsplit("?link=").next().unwrap();
let claims = issuerd_token::action_tokens::verify_action_token(
state.crypto.as_ref(),
token,
ACTION_TOKEN_PURPOSE_BROKER_LINK,
&RealmId::new("master").unwrap(),
)
.await
.expect("link token must verify");
assert_eq!(claims.idp_alias.as_deref(), Some("github"));
}
#[tokio::test]
async fn account_link_identity_conflicts_when_already_linked() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
create_idp(&state, "github", issuerd_core::ProviderId::Oidc, true).await;
let user_id = admin_user_id(&state).await;
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "github"))
.await
.unwrap();
let resp = call_link_identity(&state, &access_token, "github").await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn account_unlink_identity_removes_the_link() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "github"))
.await
.unwrap();
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "google"))
.await
.unwrap();
let resp = call_unlink_identity(&state, &access_token, "github").await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let links = state.storage.list_identity_provider_links(&realm_id, &user_id).await.unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].provider_alias, "google");
}
#[tokio::test]
async fn account_unlink_identity_deletes_single_link_when_password_set() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "github"))
.await
.unwrap();
let resp = call_unlink_identity(&state, &access_token, "github").await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert!(state
.storage
.list_identity_provider_links(&realm_id, &user_id)
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn account_unlink_identity_unknown_alias_is_noop() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&user_id, "github"))
.await
.unwrap();
let resp = call_unlink_identity(&state, &access_token, "unknown").await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let links = state.storage.list_identity_provider_links(&realm_id, &user_id).await.unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].provider_alias, "github");
}
#[tokio::test]
async fn account_unlink_identity_refuses_to_remove_last_signin_method() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let carol = make_user(&realm_id, "carol");
state.storage.create_user(&realm_id, &carol).await.unwrap();
state
.storage
.create_identity_provider_link(&realm_id, &make_link(&carol.id, "github"))
.await
.unwrap();
let token = issue_token_for_user(&state, &carol).await;
let resp = call_unlink_identity(&state, &token, "github").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "cannot unlink the only sign-in method; set a password first");
assert_eq!(
state
.storage
.list_identity_provider_links(&realm_id, &carol.id)
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn account_sessions_returns_live_session_rows() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let validated = state.token_service.validate_access_token(&access_token).unwrap();
let sid = validated.claims.sid.clone().unwrap();
let admin_cli = state
.storage
.get_client_by_client_id(&realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap();
let resp = account_sessions_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let arr = json.as_array().expect("session list");
let entry = arr.iter().find(|s| s["id"] == sid.0).expect("the grant's session is listed");
assert_eq!(entry["ip_address"], "127.0.0.1");
assert!(entry["started"].as_str().is_some_and(|s| !s.is_empty()));
assert!(entry["last_session_refresh"].as_str().is_some_and(|s| !s.is_empty()));
assert_eq!(entry["clients"], serde_json::json!([admin_cli.id.0]));
}
async fn update_me_failing_state(update_error: IssuerdError) -> (Arc<ServerState>, String) {
let mut state = ServerState::from_config(&ServerConfig::default()).await.unwrap();
let realm_id = RealmId::new("master").unwrap();
let realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
let user = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
let client = state
.storage
.get_client_by_client_id(&realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap();
let session = make_session(&realm_id, &user);
let session_id = session.id.clone();
let mut storage = issuerd_core::MockStorage::new();
{
let realm = realm.clone();
storage
.expect_get_realm_by_name()
.returning(move |name| Ok((name == "master").then(|| realm.clone())));
}
{
let user = user.clone();
storage.expect_get_user().returning(move |_, _| Ok(Some(user.clone())));
}
storage
.expect_get_user_session()
.returning(move |_, _| Ok(Some(session.clone())));
storage.expect_list_user_groups().returning(|_, _| Ok(vec![]));
storage.expect_get_groups_batch().returning(|_, _| Ok(vec![]));
storage.expect_list_user_realm_roles().returning(|_, _| Ok(vec![]));
storage.expect_list_user_client_roles().returning(|_, _| Ok(vec![]));
{
let realm = realm.clone();
storage.expect_get_realm().returning(move |_| Ok(Some(realm.clone())));
}
storage.expect_update_user().returning(move |_, _| Err(update_error.clone()));
state.storage = Arc::new(storage);
let state = Arc::new(state);
let token = state
.token_manager
.issue_access_token_with_roles(
&user,
&client,
&realm,
&["openid".to_string()],
&session_id,
None,
None,
None,
)
.await
.unwrap()
.token;
(state, token)
}
async fn update_me(state: &Arc<ServerState>, token: &str, body: UpdateMeRequest) -> Response {
account_update_me_handler(
State(state.clone()),
master_realm(),
bearer_headers(token),
Json(body),
)
.await
}
#[tokio::test]
async fn account_update_me_maps_storage_conflict_to_bad_request() {
let (state, token) = update_me_failing_state(IssuerdError::Conflict).await;
let resp = update_me(
&state,
&token,
UpdateMeRequest {
first_name: Some(Some("New".to_string())),
last_name: None,
email: None,
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "username already in use");
}
#[tokio::test]
async fn account_update_me_maps_duplicate_username_error_to_bad_request() {
let (state, token) = update_me_failing_state(IssuerdError::InvalidRequest(
"username already exists in realm".to_string(),
))
.await;
let resp = update_me(
&state,
&token,
UpdateMeRequest {
first_name: Some(Some("New".to_string())),
last_name: None,
email: None,
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "username already in use");
}
struct StubFederationProvider {
id: String,
updates: std::sync::Mutex<Vec<(String, String)>>,
}
#[async_trait::async_trait]
impl issuerd_core::FederationProvider for StubFederationProvider {
fn id(&self) -> &str {
&self.id
}
fn provider_type(&self) -> issuerd_core::FederationProviderType {
issuerd_core::FederationProviderType::Ldap
}
async fn find_user(
&self,
_username: &str,
) -> Result<Option<issuerd_core::FederatedUser>, issuerd_core::FederationError> {
Ok(None)
}
async fn find_user_by_email(
&self,
_email: &str,
) -> Result<Option<issuerd_core::FederatedUser>, issuerd_core::FederationError> {
Ok(None)
}
async fn validate_password(
&self,
_username: &str,
password: &str,
) -> Result<bool, issuerd_core::FederationError> {
Ok(password == "correct-horse")
}
async fn update_password(
&self,
username: &str,
password: &str,
) -> Result<(), issuerd_core::FederationError> {
self.updates.lock().unwrap().push((username.to_string(), password.to_string()));
Ok(())
}
fn supports_password_update(&self) -> bool {
true
}
}
struct StubFederationManager {
provider: Arc<StubFederationProvider>,
}
#[async_trait::async_trait]
impl issuerd_core::FederationManager for StubFederationManager {
async fn providers_for_realm(
&self,
_realm_id: &RealmId,
) -> Result<Vec<Arc<dyn issuerd_core::FederationProvider>>, IssuerdError> {
Ok(vec![self.provider.clone()])
}
async fn find_user(
&self,
_realm_id: &RealmId,
_username: &str,
) -> Result<
Option<(Arc<dyn issuerd_core::FederationProvider>, issuerd_core::FederatedUser)>,
IssuerdError,
> {
Ok(None)
}
async fn find_user_by_email(
&self,
_realm_id: &RealmId,
_email: &str,
) -> Result<
Option<(Arc<dyn issuerd_core::FederationProvider>, issuerd_core::FederatedUser)>,
IssuerdError,
> {
Ok(None)
}
}
async fn federated_state() -> (Arc<ServerState>, Arc<StubFederationProvider>, UserId) {
let provider = Arc::new(StubFederationProvider {
id: "ldap-1".to_string(),
updates: std::sync::Mutex::new(vec![]),
});
let mut state = ServerState::from_config(&ServerConfig::default()).await.unwrap();
state.federation_manager = Arc::new(StubFederationManager {
provider: provider.clone(),
});
let state = Arc::new(state);
let realm_id = RealmId::new("master").unwrap();
let mut user = make_user(&realm_id, "feduser");
user.federation_link = Some("ldap-1".to_string());
state.storage.create_user(&realm_id, &user).await.unwrap();
let cred = Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::Password,
user_label: None,
created_date: chrono::Utc::now(),
secret_data: b"stale-local-hash".to_vec(),
credential_data: serde_json::json!({}),
priority: 1,
};
state.storage.create_credential(&realm_id, &user.id, &cred).await.unwrap();
(state, provider, user.id)
}
async fn change_password(
state: &Arc<ServerState>,
token: &str,
current: &str,
new: &str,
) -> Response {
account_change_password_handler(
State(state.clone()),
master_realm(),
axum::extract::Extension(ClientIp("127.0.0.1".parse().unwrap())),
bearer_headers(token),
Json(ChangePasswordRequest {
current_password: current.to_string(),
new_password: new.to_string(),
}),
)
.await
}
#[tokio::test]
async fn account_change_password_federated_rejects_wrong_current_password() {
let (state, provider, user_id) = federated_state().await;
let realm_id = RealmId::new("master").unwrap();
let user = state.storage.get_user(&realm_id, &user_id).await.unwrap().unwrap();
let token = issue_token_for_user(&state, &user).await;
let resp = change_password(&state, &token, "wrong-password", "N3wPassword!x").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "current password is incorrect");
assert!(provider.updates.lock().unwrap().is_empty());
assert_eq!(
state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Password)
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn account_change_password_federated_writes_through_and_drops_local_credential() {
let (state, provider, user_id) = federated_state().await;
let realm_id = RealmId::new("master").unwrap();
let user = state.storage.get_user(&realm_id, &user_id).await.unwrap().unwrap();
let token = issue_token_for_user(&state, &user).await;
let resp = change_password(&state, &token, "correct-horse", "N3wPassword!x").await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
provider.updates.lock().unwrap().as_slice(),
[("feduser".to_string(), "N3wPassword!x".to_string())]
);
assert!(state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Password)
.await
.unwrap()
.is_empty());
}
fn base32_decode(s: &str) -> Vec<u8> {
let mut acc: u64 = 0;
let mut nbits: u32 = 0;
let mut out = Vec::new();
for c in s.chars() {
let v = match c {
'A'..='Z' => c as u8 - b'A',
'a'..='z' => c as u8 - b'a',
'2'..='7' => c as u8 - b'2' + 26,
'=' => continue,
_ => panic!("invalid base32 character: {c}"),
};
acc = (acc << 5) | u64::from(v);
nbits += 5;
if nbits >= 8 {
nbits -= 8;
out.push((acc >> nbits) as u8);
}
}
out
}
#[tokio::test]
async fn account_totp_verify_persists_credential_and_clears_enrollment() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let start = account_totp_start_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(start.status(), StatusCode::OK);
let start_json = body_json(start).await;
let secret = start_json["secret"].as_str().unwrap().to_string();
let realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
let policy = &realm.otp_policy;
let key = base32_decode(&secret);
let now = issuerd_core::utils::now_secs();
let code = totp::totp_at(
&key,
now / u64::from(policy.period_secs.max(1)),
policy.digits,
&policy.algorithm,
);
let resp = account_totp_verify_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
Json(TotpVerifyRequest { code }),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let creds = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::Totp)
.await
.unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].secret_data, secret.as_bytes());
assert!(creds[0].credential_data["last_used_step"].is_number());
assert!(state
.cache
.get(&totp_enrollment_key(&realm_id, &user_id))
.await
.unwrap()
.is_none());
}
#[test]
fn passkey_display_name_prefers_full_name_then_username() {
let realm_id = RealmId::new("master").unwrap();
let mut user = make_user(&realm_id, "webby");
assert_eq!(passkey_display_name(&user), "webby");
user.first_name = Some(DisplayName::new("Ada").unwrap());
assert_eq!(passkey_display_name(&user), "Ada");
user.last_name = Some(DisplayName::new("Lovelace").unwrap());
assert_eq!(passkey_display_name(&user), "Ada Lovelace");
user.first_name = None;
assert_eq!(passkey_display_name(&user), "Lovelace");
}
fn b64url(data: &[u8]) -> String {
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, data)
}
fn cose_ec2_key_cbor(x: &[u8], y: &[u8]) -> Vec<u8> {
let mut out = vec![0xA5]; out.extend([0x01, 0x02]); out.extend([0x03, 0x26]); out.extend([0x20, 0x01]); out.push(0x21); out.extend([0x58, 0x20]); out.extend_from_slice(x);
out.push(0x22); out.extend([0x58, 0x20]);
out.extend_from_slice(y);
out
}
fn none_attestation_object(auth_data: &[u8]) -> Vec<u8> {
assert!(auth_data.len() < 256);
let mut out = vec![0xA3]; out.push(0x63); out.extend(b"fmt");
out.push(0x64); out.extend(b"none");
out.push(0x67); out.extend(b"attStmt");
out.push(0xA0); out.push(0x68); out.extend(b"authData");
out.extend([0x58, auth_data.len() as u8]); out.extend_from_slice(auth_data);
out
}
fn soft_register_credential(challenge: &str, cred_id: &[u8]) -> serde_json::Value {
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let public_key = key_pair.public_key().as_ref();
assert_eq!(public_key.len(), 65, "expected uncompressed P-256 point");
let (x, y) = (&public_key[1..33], &public_key[33..65]);
let mut auth_data =
ring::digest::digest(&ring::digest::SHA256, b"localhost").as_ref().to_vec();
auth_data.push(0x45); auth_data.extend([0, 0, 0, 0]); auth_data.extend([0u8; 16]); auth_data.extend([0, 32]); auth_data.extend_from_slice(cred_id);
auth_data.extend(cose_ec2_key_cbor(x, y));
let client_data = serde_json::to_vec(&serde_json::json!({
"type": "webauthn.create",
"challenge": challenge,
"origin": "http://localhost:8080",
"crossOrigin": false,
}))
.unwrap();
serde_json::json!({
"id": b64url(cred_id),
"rawId": b64url(cred_id),
"response": {
"attestationObject": b64url(&none_attestation_object(&auth_data)),
"clientDataJSON": b64url(&client_data),
},
"type": "public-key",
})
}
async fn register_passkey(
state: &Arc<ServerState>,
token: &str,
cred_id_seed: u8,
label: Option<&str>,
) -> StatusCode {
let start = account_webauthn_register_start_handler(
State(state.clone()),
master_realm(),
bearer_headers(token),
)
.await;
assert_eq!(start.status(), StatusCode::OK);
let options = body_json(start).await;
let challenge = options["challenge"].as_str().unwrap().to_string();
let cred_id: Vec<u8> =
(0u8..32).map(|i| i.wrapping_mul(cred_id_seed).wrapping_add(1)).collect();
let credential = soft_register_credential(&challenge, &cred_id);
account_webauthn_register_finish_handler(
State(state.clone()),
master_realm(),
bearer_headers(token),
Json(WebAuthnRegisterFinishRequest {
label: label.map(str::to_string),
credential,
}),
)
.await
.status()
}
#[tokio::test]
async fn account_webauthn_register_finish_trims_and_stores_label() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let status = register_passkey(&state, &access_token, 7, Some(" ")).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let creds = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::WebAuthn)
.await
.unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].user_label, None, "whitespace-only labels must be dropped");
let status = register_passkey(&state, &access_token, 13, Some("Work laptop")).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let creds = state
.storage
.get_credentials(&realm_id, &user_id, CredentialType::WebAuthn)
.await
.unwrap();
assert_eq!(creds.len(), 2);
assert!(creds.iter().any(|c| c.user_label.as_deref() == Some("Work laptop")));
}
#[tokio::test]
async fn account_credentials_reports_webauthn_variants() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let user_id = admin_user_id(&state).await;
let resp = account_credentials_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json, serde_json::json!({"password": true, "totp": false, "webauthn": false}));
let cred = Credential {
id: CredentialId::new(issuerd_core::utils::generate_id()).unwrap(),
credential_type: CredentialType::WebAuthn,
user_label: None,
created_date: chrono::Utc::now(),
secret_data: b"passkey-bytes".to_vec(),
credential_data: serde_json::json!({}),
priority: 0,
};
state.storage.create_credential(&realm_id, &user_id, &cred).await.unwrap();
let resp = account_credentials_handler(
State(state.clone()),
master_realm(),
bearer_headers(&access_token),
)
.await;
let json = body_json(resp).await;
assert_eq!(json, serde_json::json!({"password": true, "totp": false, "webauthn": true}));
let carol = make_user(&realm_id, "carol");
state.storage.create_user(&realm_id, &carol).await.unwrap();
let cred = Credential {
credential_type: CredentialType::WebAuthnPasswordless,
..cred
};
state.storage.create_credential(&realm_id, &carol.id, &cred).await.unwrap();
let carol_token = issue_token_for_user(&state, &carol).await;
let resp = account_credentials_handler(
State(state.clone()),
master_realm(),
bearer_headers(&carol_token),
)
.await;
let json = body_json(resp).await;
assert_eq!(json, serde_json::json!({"password": false, "totp": false, "webauthn": true}));
}
#[tokio::test]
async fn internal_error_is_a_500_json_body() {
let resp = internal_error("boom");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let json = body_json(resp).await;
assert_eq!(json["error"], "boom");
}
#[tokio::test]
async fn policy_error_response_is_a_400_with_violation_list() {
let err = PasswordPolicyError {
violations: vec![issuerd_core::PasswordPolicyViolation {
code: "min_length".to_string(),
message: "too short".to_string(),
}],
};
let resp = policy_error_response(err);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["policyViolations"][0]["code"], "min_length");
assert!(json["error"].as_str().unwrap().contains("too short"));
}
async fn set_verify_email_enabled(state: &Arc<ServerState>, enabled: bool) {
let realm_id = RealmId::new("master").unwrap();
let mut realm = state.storage.get_realm(&realm_id).await.unwrap().unwrap();
realm.verify_email_enabled = enabled;
state.storage.update_realm(&realm).await.unwrap();
state
.cache
.delete(&issuerd_cluster::cache_keys::realm_by_name("master"))
.await
.unwrap();
}
#[tokio::test]
async fn account_update_me_applies_email_change_and_requests_verification() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
set_verify_email_enabled(&state, true).await;
let resp = update_me(
&state,
&access_token,
UpdateMeRequest {
first_name: None,
last_name: None,
email: Some(Some("admin-new@example.com".to_string())),
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["email"], "admin-new@example.com");
assert_eq!(json["email_verified"], false);
let user = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
assert_eq!(user.email.as_ref().map(|e| e.as_str()), Some("admin-new@example.com"));
assert!(!user.email_verified);
assert_eq!(user.required_actions, vec!["VERIFY_EMAIL".to_string()]);
}
#[tokio::test]
async fn account_update_me_unchanged_email_keeps_verified_state() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
set_verify_email_enabled(&state, true).await;
let resp = update_me(
&state,
&access_token,
UpdateMeRequest {
first_name: None,
last_name: None,
email: Some(Some("admin@localhost.local".to_string())),
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["email"], "admin@localhost.local");
assert_eq!(json["email_verified"], true);
let user = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
assert!(user.email_verified);
assert!(user.required_actions.is_empty());
}
#[tokio::test]
async fn account_update_me_rejects_duplicate_email() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
let bob = make_user(&realm_id, "bob");
state.storage.create_user(&realm_id, &bob).await.unwrap();
let resp = update_me(
&state,
&access_token,
UpdateMeRequest {
first_name: None,
last_name: None,
email: Some(Some("bob@example.com".to_string())),
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["error"], "email already in use");
let admin = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
assert_eq!(admin.email.as_ref().map(|e| e.as_str()), Some("admin@localhost.local"));
}
#[tokio::test]
async fn account_update_me_clearing_email_never_requests_verification() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let access_token = password_grant_token(&state).await;
set_verify_email_enabled(&state, true).await;
let resp = update_me(
&state,
&access_token,
UpdateMeRequest {
first_name: None,
last_name: None,
email: Some(None),
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert!(json["email"].is_null());
assert_eq!(json["email_verified"], false);
let user = state.storage.get_user_by_username(&realm_id, "admin").await.unwrap().unwrap();
assert!(user.email.is_none());
assert!(!user.email_verified);
assert!(
user.required_actions.is_empty(),
"a cleared email must not enqueue VERIFY_EMAIL: {:?}",
user.required_actions
);
}
#[tokio::test]
async fn account_update_me_does_not_duplicate_verify_email_action() {
let state = test_state().await;
let realm_id = RealmId::new("master").unwrap();
let mut dave = make_user(&realm_id, "dave");
dave.required_actions.push("VERIFY_EMAIL".to_string());
state.storage.create_user(&realm_id, &dave).await.unwrap();
let token = issue_token_for_user(&state, &dave).await;
set_verify_email_enabled(&state, true).await;
let resp = update_me(
&state,
&token,
UpdateMeRequest {
first_name: None,
last_name: None,
email: Some(Some("dave-new@example.com".to_string())),
username: None,
},
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let dave = state.storage.get_user_by_username(&realm_id, "dave").await.unwrap().unwrap();
assert_eq!(
dave.required_actions,
vec!["VERIFY_EMAIL".to_string()],
"VERIFY_EMAIL must not be duplicated"
);
}
fn jwt_with_payload(payload: serde_json::Value) -> String {
let b64 = |bytes: &[u8]| {
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
};
format!("{}.{}.sig", b64(br#"{"alg":"none"}"#), b64(payload.to_string().as_bytes()))
}
#[test]
fn token_is_expired_reads_the_exp_claim() {
let now = chrono::Utc::now().timestamp();
assert!(token_is_expired(&jwt_with_payload(serde_json::json!({"exp": now - 3600}))));
assert!(!token_is_expired(&jwt_with_payload(serde_json::json!({"exp": now + 3600}))));
assert!(!token_is_expired("not-a-jwt"));
assert!(!token_is_expired("a.!!!.b"));
assert!(!token_is_expired(&jwt_with_payload(serde_json::json!({"sub": "u"}))));
assert!(!token_is_expired(&jwt_with_payload(serde_json::json!({"exp": "soon"}))));
}
#[test]
fn token_is_expired_treats_exp_equal_to_now_as_valid() {
for _ in 0..10 {
let now = chrono::Utc::now().timestamp();
let token = jwt_with_payload(serde_json::json!({"exp": now}));
let result = token_is_expired(&token);
if chrono::Utc::now().timestamp() == now {
assert!(!result, "exp == now is not yet expired");
return;
}
}
}
}