use crate::errors::{CoreError, CoreResult};
use crate::password::hash_password;
use crate::time::SharedClock;
use crate::tokens;
use crate::cache::Caches;
use sui_id_shared::ids::{ClientId, UserId};
use sui_id_store::models::ClientRow;
use sui_id_store::repos::{
clients, refresh_tokens,
};
use sui_id_store::Database;
use super::{audit_ok, audit_with_note, require_admin};
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(())
}
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 crate::errors::CoreError;
use super::validate_redirect_uri;
#[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)
}