use zeroize::Zeroizing;
use crate::errors::{CoreError, CoreResult};
use crate::hibp::{self, HibpClient, HibpEnforcement};
use crate::password::{check_password_policy, hash_password};
use crate::time::SharedClock;
use crate::tokens;
use chrono::Utc;
use sui_id_shared::ids::{ClientId, UserId};
use sui_id_store::models::{AuditLogRow, ClientRow, CredentialRow, HibpMode, UserRow};
use sui_id_store::repos::{
audit, auth_codes, clients, credentials, refresh_tokens, sessions, user_totp,
user_webauthn_credentials, users,
};
use crate::cache::Caches;
use sui_id_store::Database;
async fn audit_ok(db: &Database, actor: UserId, action: &str, target: Option<String>) {
audit_with_note(db, actor, action, target, None).await;
}
async fn audit_with_note(
db: &Database,
actor: UserId,
action: &str,
target: Option<String>,
note: Option<String>,
) {
let _ = audit::append(
db,
&AuditLogRow {
at: Utc::now(),
actor: Some(actor),
action: action.to_owned(),
target,
result: "ok".into(),
note,
},
).await;
}
pub async fn require_admin(db: &Database, user_id: UserId) -> CoreResult<()> {
let user = users::get(db, user_id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::Forbidden,
other => CoreError::from(other),
})?;
if user.is_admin && !user.is_disabled && !user.is_deleted {
Ok(())
} else {
Err(CoreError::Forbidden)
}
}
pub struct CreateUserSpec<'a> {
pub username: &'a str,
pub password: &'a str,
pub display_name: Option<&'a str>,
pub email: Option<&'a str>,
pub is_admin: bool,
}
pub async fn create_user(
db: &Database,
clock: &SharedClock,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: sui_id_store::models::HibpMode,
actor: UserId,
spec: CreateUserSpec<'_>,
) -> CoreResult<UserRow> {
require_admin(db, actor).await?;
if spec.username.trim().is_empty() {
return Err(CoreError::BadRequest("username must not be empty".into()));
}
check_password_policy(spec.password)?;
let hibp_result = hibp::enforce_hibp(hibp_mode, hibp_client, spec.password).await;
let hibp_warned = matches!(hibp_result, HibpEnforcement::AllowedWithWarning { .. });
let now = clock.now();
let row = UserRow {
id: UserId::new(),
username: spec.username.to_owned(),
display_name: spec.display_name.map(str::to_owned),
email: spec
.email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned),
email_normalized: spec
.email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(sui_id_shared::normalize_email),
email_verified_at: None,
preferred_lang: None,
is_admin: spec.is_admin,
role: if spec.is_admin { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
is_disabled: false,
is_deleted: false,
user_uuid: uuid::Uuid::new_v4(),
created_at: now,
updated_at: now,
failed_login_count: 0,
locked_until: None,
};
users::create(db, &row).await.map_err(|e| match e {
sui_id_store::StoreError::Conflict => CoreError::Conflict("username already in use".into()),
other => CoreError::from(other),
})?;
let hash = hash_password(spec.password)?;
credentials::upsert(
db,
&CredentialRow {
user_id: row.id,
password_hash: hash,
must_change: false,
updated_at: now,
},
).await?;
let action = if hibp_warned { "user.create_warned_hibp" } else { "user.create" };
audit_ok(db, actor, action, Some(row.id.to_string())).await;
Ok(row)
}
pub async fn list_users(db: &Database, actor: UserId) -> CoreResult<Vec<UserRow>> {
require_admin(db, actor).await?;
Ok(users::list(db).await?)
}
pub async fn set_user_disabled(
db: &Database,
actor: UserId,
target: UserId,
disabled: bool,
reason: Option<String>,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if actor == target && disabled {
return Err(CoreError::BadRequest(
"cannot disable your own account; have another administrator do it".into(),
));
}
users::set_disabled(db, target, disabled).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
if disabled {
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
}
audit_with_note(
db,
actor,
if disabled { "user.disable" } else { "user.enable" },
Some(target.to_string()),
if disabled { reason } else { None },
).await;
Ok(())
}
pub async fn delete_user(
db: &Database,
actor: UserId,
target: UserId,
reason: Option<String>,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if actor == target {
return Err(CoreError::BadRequest(
"cannot delete your own account".into(),
));
}
users::soft_delete(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
audit_with_note(db, actor, "user.delete", Some(target.to_string()), reason).await;
Ok(())
}
pub struct MfaResetReport {
pub totp_removed: bool,
pub passkeys_removed: usize,
}
pub async fn admin_reset_mfa(
db: &Database,
actor: UserId,
target: UserId,
reason: Option<String>,
) -> CoreResult<MfaResetReport> {
require_admin(db, actor).await?;
let _user = users::get(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
let totp_removed = match user_totp::delete(db, target).await {
Ok(()) => true,
Err(sui_id_store::StoreError::NotFound) => false,
Err(e) => return Err(CoreError::from(e)),
};
let creds = user_webauthn_credentials::list_for_user(db, target).await?;
let mut passkeys_removed = 0;
for c in creds {
user_webauthn_credentials::delete(db, c.id, target).await?;
passkeys_removed += 1;
}
let sys_note = format!(
"totp={} passkeys={}",
if totp_removed { "removed" } else { "absent" },
passkeys_removed
);
let note = match reason {
Some(r) => format!("{sys_note} reason={r}"),
None => sys_note,
};
let _ = audit::append(
db,
&sui_id_store::models::AuditLogRow {
at: chrono::Utc::now(),
actor: Some(actor),
action: "mfa.admin_reset".into(),
target: Some(target.to_string()),
result: "ok".into(),
note: Some(note),
},
).await;
Ok(MfaResetReport {
totp_removed,
passkeys_removed,
})
}
pub async fn reset_user_password(
db: &Database,
clock: &SharedClock,
hibp_client: Option<&dyn HibpClient>,
hibp_mode: HibpMode,
actor: UserId,
target: UserId,
new_password: &str,
) -> CoreResult<()> {
require_admin(db, actor).await?;
check_password_policy(new_password)?;
if matches!(
hibp::enforce_hibp(hibp_mode, hibp_client, new_password).await,
HibpEnforcement::Blocked { .. }
) {
return Err(CoreError::BadRequest(
"New password found in known data breaches. Please choose a different password.".into(),
));
}
let hash = hash_password(new_password)?;
let now = clock.now();
credentials::upsert(
db,
&CredentialRow {
user_id: target,
password_hash: hash,
must_change: false,
updated_at: now,
},
).await?;
sessions::revoke_all_for_user(db, target).await?;
refresh_tokens::revoke_all_for_user(db, target).await?;
auth_codes::invalidate_all_for_user(db, target).await?;
audit_ok(db, actor, "user.reset_password", Some(target.to_string())).await;
Ok(())
}
pub struct CreatedClient {
pub row: ClientRow,
pub generated_secret: Option<String>,
}
pub struct CreateClientSpec<'a> {
pub name: &'a str,
pub redirect_uris: &'a [String],
pub confidential: bool,
pub allowed_scopes: &'a str,
pub post_logout_redirect_uris: &'a [String],
}
pub async fn create_client(
db: &Database,
clock: &SharedClock,
actor: UserId,
spec: CreateClientSpec<'_>,
_caches: &Caches,
) -> CoreResult<CreatedClient> {
require_admin(db, actor).await?;
if spec.name.trim().is_empty() {
return Err(CoreError::BadRequest("client name must not be empty".into()));
}
if spec.redirect_uris.is_empty() {
return Err(CoreError::BadRequest(
"at least one redirect_uri must be provided".into(),
));
}
for uri in spec.redirect_uris {
validate_redirect_uri(uri)?;
}
for uri in spec.post_logout_redirect_uris {
validate_redirect_uri(uri)?;
}
for tok in spec.allowed_scopes.split_whitespace() {
if !tok
.chars()
.all(|c| c == '!' || ('#'..='[').contains(&c) || (']'..='~').contains(&c))
{
return Err(CoreError::BadRequest(format!(
"invalid character in scope token {tok:?}"
)));
}
}
let secret_plain = if spec.confidential {
Some(tokens::random_token(32))
} else {
None
};
let secret_hash = match secret_plain.as_deref() {
Some(s) => Some(hash_password(s)?),
None => None,
};
let now = clock.now();
let row = ClientRow {
id: ClientId::new(),
name: spec.name.to_owned(),
confidential: spec.confidential,
secret_hash,
redirect_uris: spec.redirect_uris.to_vec(),
allowed_scopes: spec.allowed_scopes.to_owned(),
post_logout_redirect_uris: spec.post_logout_redirect_uris.to_vec(),
is_disabled: false,
is_deleted: false,
consent_policy: sui_id_store::models::ConsentPolicy::default(),
created_at: now,
updated_at: now,
};
clients::create(db, &row).await?;
audit_ok(db, actor, "client.create", Some(row.id.to_string())).await;
Ok(CreatedClient {
row,
generated_secret: secret_plain,
})
}
pub async fn set_client_allowed_scopes(
db: &Database,
actor: UserId,
target: ClientId,
scopes: &str,
) -> CoreResult<()> {
require_admin(db, actor).await?;
for tok in scopes.split_whitespace() {
if !tok
.chars()
.all(|c| c == '!' || ('#'..='[').contains(&c) || (']'..='~').contains(&c))
{
return Err(CoreError::BadRequest(format!(
"invalid character in scope token {tok:?}"
)));
}
}
clients::set_allowed_scopes(db, target, scopes).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
audit_ok(db, actor, "client.set_allowed_scopes", Some(target.to_string())).await;
Ok(())
}
pub async fn set_client_post_logout_redirect_uris(
db: &Database,
actor: UserId,
target: ClientId,
uris: &[String],
) -> CoreResult<()> {
require_admin(db, actor).await?;
for uri in uris {
validate_redirect_uri(uri)?;
}
clients::set_post_logout_redirect_uris(db, target, uris).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
audit_ok(
db,
actor,
"client.set_post_logout_redirect_uris",
Some(target.to_string()),
).await;
Ok(())
}
pub async fn update_client_basic(
db: &Database,
actor: UserId,
target: ClientId,
name: &str,
redirect_uris: &[String],
caches: &Caches,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if name.trim().is_empty() {
return Err(CoreError::BadRequest("client name must not be empty".into()));
}
if redirect_uris.is_empty() {
return Err(CoreError::BadRequest(
"at least one redirect_uri must be provided".into(),
));
}
for uri in redirect_uris {
validate_redirect_uri(uri)?;
}
clients::update_basic(db, target, Some(name.trim()), Some(redirect_uris)).await.map_err(|e| {
match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
}
})?;
audit_ok(db, actor, "client.update", Some(target.to_string())).await;
if let Err(e) = caches.redirect_origins.rebuild(db).await {
tracing::warn!(error = %e, "cache rebuild failed after update_client");
}
Ok(())
}
pub async fn get_client(db: &Database, actor: UserId, target: ClientId) -> CoreResult<ClientRow> {
require_admin(db, actor).await?;
clients::get(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})
}
pub async fn list_clients(db: &Database, actor: UserId) -> CoreResult<Vec<ClientRow>> {
require_admin(db, actor).await?;
Ok(clients::list(db).await?)
}
pub async fn update_client(
db: &Database,
actor: UserId,
target: ClientId,
name: Option<&str>,
redirect_uris: Option<&[String]>,
_caches: &Caches,
) -> CoreResult<()> {
require_admin(db, actor).await?;
if let Some(uris) = redirect_uris {
if uris.is_empty() {
return Err(CoreError::BadRequest(
"at least one redirect_uri must remain".into(),
));
}
for u in uris {
validate_redirect_uri(u)?;
}
}
clients::update_basic(db, target, name, redirect_uris).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
audit_ok(db, actor, "client.update", Some(target.to_string())).await;
Ok(())
}
pub async fn set_client_disabled(
db: &Database,
_clock: &SharedClock,
actor: UserId,
target: ClientId,
disabled: bool,
reason: Option<String>,
caches: &Caches,
) -> CoreResult<()> {
require_admin(db, actor).await?;
clients::set_disabled(db, target, disabled).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
if disabled {
refresh_tokens::revoke_all_for_client(db, target).await?;
}
audit_with_note(
db,
actor,
if disabled { "client.disable" } else { "client.enable" },
Some(target.to_string()),
if disabled { reason } else { None },
).await;
if let Err(e) = caches.redirect_origins.rebuild(db).await {
tracing::warn!(error = %e, "cache rebuild failed after set_client_disabled");
}
Ok(())
}
pub async fn delete_client(
db: &Database,
actor: UserId,
target: ClientId,
reason: Option<String>,
caches: &Caches,
) -> CoreResult<()> {
require_admin(db, actor).await?;
clients::soft_delete(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
if let Err(e) = caches.redirect_origins.rebuild(db).await {
tracing::warn!(error = %e, "cache rebuild failed after delete_client");
}
refresh_tokens::revoke_all_for_client(db, target).await?;
audit_with_note(db, actor, "client.delete", Some(target.to_string()), reason).await;
Ok(())
}
pub async fn list_signing_keys(
db: &Database,
actor: UserId,
) -> CoreResult<Vec<sui_id_store::models::SigningKeyRow>> {
require_admin(db, actor).await?;
Ok(sui_id_store::repos::signing_keys::list_published(db).await?)
}
pub async fn rotate_signing_key(
db: &Database,
clock: &SharedClock,
keyring_path: &str,
actor: UserId,
reason: Option<String>,
caches: &Caches,
) -> CoreResult<sui_id_shared::ids::SigningKeyId> {
use ed25519_dalek::SigningKey;
use sui_id_shared::ids::SigningKeyId;
use sui_id_store::repos::signing_keys;
require_admin(db, actor).await?;
let mut secret = Zeroizing::new([0u8; 32]);
getrandom::fill(secret.as_mut()).expect("system RNG unavailable");
let sk = SigningKey::from_bytes(&secret);
let pk = sk.verifying_key();
let new_id = SigningKeyId::new();
signing_keys::rotate_atomic(
db,
new_id,
"EdDSA",
sk.to_bytes().as_ref(),
pk.to_bytes().as_ref(),
).await?;
if let Err(e) = caches.jwks.rebuild(db).await {
tracing::warn!(error = %e, "cache rebuild failed after rotate_signing_key");
}
let _ = clock;
let _ = keyring_path;
audit_with_note(db, actor, "signing_key.rotate", Some(new_id.to_string()), reason).await;
Ok(new_id)
}
pub async fn delete_signing_key(
db: &Database,
clock: &SharedClock,
actor: UserId,
target: sui_id_shared::ids::SigningKeyId,
reason: Option<String>,
caches: &Caches,
) -> CoreResult<()> {
require_admin(db, actor).await?;
sui_id_store::repos::signing_keys::delete(db, target).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
sui_id_store::StoreError::Conflict => CoreError::Conflict(
"cannot delete the active signing key; rotate first".into(),
),
other => CoreError::from(other),
})?;
let _ = clock;
audit_with_note(db, actor, "signing_key.delete", Some(target.to_string()), reason).await;
if let Err(e) = caches.jwks.rebuild(db).await {
tracing::warn!(error = %e, "cache rebuild failed after delete_signing_key");
}
Ok(())
}
fn validate_redirect_uri(uri: &str) -> CoreResult<()> {
let parsed = url::Url::parse(uri).map_err(|_| {
CoreError::BadRequest(format!("redirect_uri is not a valid URL: {uri}"))
})?;
let scheme = parsed.scheme();
let host = parsed.host_str().unwrap_or("");
let ok = match scheme {
"https" => true,
"http" => matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1"),
_ => false,
};
if !ok {
return Err(CoreError::BadRequest(format!(
"redirect_uri must use https (http permitted only on loopback): {uri}"
)));
}
if parsed.fragment().is_some() {
return Err(CoreError::BadRequest(
"redirect_uri must not contain a fragment".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_redirect_is_accepted() {
validate_redirect_uri("https://app.example.com/callback").expect("ok");
}
#[test]
fn http_loopback_is_accepted() {
validate_redirect_uri("http://localhost:8080/cb").expect("ok");
validate_redirect_uri("http://127.0.0.1/cb").expect("ok");
}
#[test]
fn http_non_loopback_is_rejected() {
let r = validate_redirect_uri("http://example.com/cb");
assert!(matches!(r, Err(CoreError::BadRequest(_))));
}
#[test]
fn fragment_is_rejected() {
let r = validate_redirect_uri("https://x/cb#frag");
assert!(matches!(r, Err(CoreError::BadRequest(_))));
}
#[test]
fn non_http_scheme_is_rejected() {
let r = validate_redirect_uri("javascript:alert(1)");
assert!(matches!(r, Err(CoreError::BadRequest(_))));
}
}
pub async fn rotate_client_secret(
db: &Database,
clock: &SharedClock,
actor: UserId,
client_id: ClientId,
reason: Option<String>,
) -> CoreResult<String> {
require_admin(db, actor).await?;
let client = clients::get(db, client_id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
if !client.confidential {
return Err(CoreError::BadRequest(
"cannot rotate secret for a public (PKCE-only) client".into(),
));
}
let new_secret = tokens::random_token(32);
let new_hash = crate::password::hash_password(&new_secret)?;
clients::set_secret_hash(db, client_id, Some(&new_hash), clock.now()).await
.map_err(CoreError::from)?;
audit_with_note(
db, actor, "client.rotate_secret", Some(client_id.to_string()), reason
).await;
Ok(new_secret)
}