Menu
Public repository / 1f023d15b3f586c38eb60898e38dc03c4e77bed2
AkurAI-IDP / src/routes/admin/clients.rs
Latest change c8b55575efb4c7fda55b2e81778acf2136e2fd09 - admin: PATCH /admin/clients/{id} updates a client without touching its secret (OLI-303) by Ólafur Búi Ólafsson
use axum::{
Json, Router,
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::db::with_db;
use crate::lib::audit;
use crate::lib::clients;
use crate::lib::crypto::{generate_secure_token, sha256};
#[derive(Deserialize)]
pub struct ListQuery {
pub tenant_id: Option<String>,
}
#[derive(Serialize)]
pub struct ClientRow {
pub id: String,
pub tenant_id: String,
pub name: String,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub scopes: Vec<String>,
pub first_party: bool,
pub created_at: i64,
}
#[derive(Deserialize)]
pub struct CreateClient {
pub name: String,
pub tenant_id: String,
pub redirect_uris: Vec<String>,
pub grant_types: Option<Vec<String>>,
pub scopes: Option<Vec<String>>,
pub first_party: Option<bool>,
/// `none` registers a public PKCE-only client with no secret.
pub token_endpoint_auth_method: Option<String>,
pub platform_wide: Option<bool>,
/// Access-token audience; defaults to the client id.
pub audience: Option<String>,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_clients).post(create_client))
.route(
"/{id}",
axum::routing::delete(delete_client).patch(update_client),
)
.route("/{id}/rotate-secret", post(rotate_secret))
}
fn parse_json_array(s: &str) -> Vec<String> {
crate::lib::parse_json_or_space_separated(s)
}
async fn list_clients(Query(q): Query<ListQuery>) -> impl IntoResponse {
let result = with_db(|conn| {
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match &q.tenant_id {
Some(tid) => (
"SELECT id, tenant_id, name, redirect_uris, grant_types, scopes, first_party, created_at FROM clients WHERE tenant_id = ?1".to_string(),
vec![Box::new(tid.clone()) as Box<dyn rusqlite::types::ToSql>],
),
None => (
"SELECT id, tenant_id, name, redirect_uris, grant_types, scopes, first_party, created_at FROM clients".to_string(),
vec![],
),
};
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
let redirect_uris_raw: String = row.get(3)?;
let grant_types_raw: String = row.get(4)?;
let scopes_raw: String = row.get(5)?;
Ok(ClientRow {
id: row.get(0)?,
tenant_id: row.get(1)?,
name: row.get(2)?,
redirect_uris: parse_json_array(&redirect_uris_raw),
grant_types: parse_json_array(&grant_types_raw),
scopes: parse_json_array(&scopes_raw),
first_party: row.get::<_, i64>(6)? != 0,
created_at: row.get(7)?,
})
})?;
let mut clients = Vec::new();
for r in rows {
clients.push(r?);
}
Ok::<_, rusqlite::Error>(clients)
});
match result {
Ok(clients) => Json(json!(clients)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn create_client(Json(body): Json<CreateClient>) -> impl IntoResponse {
let id = generate_secure_token(16);
let auth_method = body
.token_endpoint_auth_method
.clone()
.unwrap_or_else(|| "client_secret_basic".to_string());
if ![
"client_secret_basic",
"client_secret_post",
clients::AUTH_METHOD_NONE,
]
.contains(&auth_method.as_str())
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "unsupported token_endpoint_auth_method"})),
)
.into_response();
}
let is_public = auth_method == clients::AUTH_METHOD_NONE;
let client_secret = (!is_public).then(|| generate_secure_token(40));
let secret_hash = client_secret.as_deref().map(sha256).unwrap_or_default();
let platform_wide = body.platform_wide.unwrap_or(false);
let audience = body
.audience
.clone()
.filter(|a| !a.trim().is_empty())
.unwrap_or_else(|| id.clone());
let now = chrono::Utc::now().timestamp();
let grant_types = body
.grant_types
.unwrap_or_else(|| vec!["authorization_code".to_string()]);
let scopes = body.scopes.unwrap_or_else(|| {
vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
]
});
let first_party = body.first_party.unwrap_or(false);
let redirect_uris_json =
serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string());
let grant_types_json = serde_json::to_string(&grant_types).unwrap_or_else(|_| "[]".to_string());
let scopes_json = serde_json::to_string(&scopes).unwrap_or_else(|_| "[]".to_string());
let result = with_db(|conn| {
conn.execute(
"INSERT INTO clients (id, tenant_id, name, client_secret_hash, redirect_uris, grant_types, scopes, first_party, token_endpoint_auth_method, platform_wide, audience, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![id, body.tenant_id, body.name, secret_hash, redirect_uris_json, grant_types_json, scopes_json, first_party as i64, auth_method, platform_wide as i64, audience, now],
)
});
match result {
Ok(_) => {
audit::log_audit(
Some(&body.tenant_id),
None,
audit::CLIENT_CREATED,
None,
Some(&json!({"client_id": id, "name": body.name})),
);
(
StatusCode::CREATED,
Json(json!({
"id": id,
"client_secret": client_secret,
"name": body.name,
"tenant_id": body.tenant_id,
"redirect_uris": body.redirect_uris,
"grant_types": grant_types,
"scopes": scopes,
"first_party": first_party,
"token_endpoint_auth_method": auth_method,
"platform_wide": platform_wide,
"audience": audience
})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn delete_client(Path(id): Path<String>) -> impl IntoResponse {
let result = with_db(|conn| conn.execute("DELETE FROM clients WHERE id = ?1", [&id]));
match result {
Ok(0) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "client not found"})),
)
.into_response(),
Ok(_) => {
audit::log_audit(
None,
None,
audit::CLIENT_DELETED,
None,
Some(&json!({"client_id": id})),
);
Json(json!({"ok": true})).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn rotate_secret(Path(id): Path<String>) -> impl IntoResponse {
let new_secret = generate_secure_token(40);
let new_hash = sha256(&new_secret);
let result = with_db(|conn| {
conn.execute(
"UPDATE clients SET client_secret_hash = ?1 WHERE id = ?2",
rusqlite::params![new_hash, id],
)
});
match result {
Ok(0) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "client not found"})),
)
.into_response(),
Ok(_) => {
audit::log_audit(
None,
None,
audit::CLIENT_SECRET_ROTATED,
None,
Some(&json!({"client_id": id})),
);
Json(json!({"id": id, "client_secret": new_secret})).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct UpdateClient {
pub name: Option<String>,
pub redirect_uris: Option<Vec<String>>,
pub scopes: Option<Vec<String>>,
pub grant_types: Option<Vec<String>>,
pub audience: Option<String>,
pub first_party: Option<bool>,
}
#[derive(Debug, PartialEq)]
pub enum UpdateError {
Invalid(String),
NotFound,
Db(String),
}
fn validate_redirect_uri(uri: &str) -> Result<(), String> {
let invalid = || format!("invalid redirect_uri: {uri}");
let rest = uri
.strip_prefix("https://")
.or_else(|| uri.strip_prefix("http://"))
.ok_or_else(invalid)?;
let host = rest.split(['/', '?']).next().unwrap_or("");
if host.is_empty() || uri.contains('#') || uri.chars().any(char::is_whitespace) {
return Err(invalid());
}
Ok(())
}
fn client_snapshot(
conn: &rusqlite::Connection,
id: &str,
) -> Result<Option<serde_json::Value>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT tenant_id, name, redirect_uris, grant_types, scopes, first_party, audience FROM clients WHERE id = ?1",
)?;
let mut rows = stmt.query([id])?;
let Some(row) = rows.next()? else {
return Ok(None);
};
Ok(Some(json!({
"tenant_id": row.get::<_, String>(0)?,
"name": row.get::<_, String>(1)?,
"redirect_uris": parse_json_array(&row.get::<_, String>(2)?),
"grant_types": parse_json_array(&row.get::<_, String>(3)?),
"scopes": parse_json_array(&row.get::<_, String>(4)?),
"first_party": row.get::<_, i64>(5)? != 0,
"audience": row.get::<_, String>(6)?,
})))
}
/// Applies a partial update and returns `(before, after)`. Never touches the secret.
pub fn apply_client_update(
conn: &rusqlite::Connection,
id: &str,
patch: &UpdateClient,
) -> Result<(serde_json::Value, serde_json::Value), UpdateError> {
let db = |e: rusqlite::Error| UpdateError::Db(e.to_string());
if let Some(uris) = &patch.redirect_uris {
if uris.is_empty() {
return Err(UpdateError::Invalid(
"redirect_uris must not be empty".into(),
));
}
uris.iter()
.try_for_each(|u| validate_redirect_uri(u))
.map_err(UpdateError::Invalid)?;
}
if patch.name.as_deref().is_some_and(|n| n.trim().is_empty())
|| patch
.audience
.as_deref()
.is_some_and(|a| a.trim().is_empty())
{
return Err(UpdateError::Invalid(
"name and audience must not be blank".into(),
));
}
let before = client_snapshot(conn, id)
.map_err(db)?
.ok_or(UpdateError::NotFound)?;
let to_json = |v: &Vec<String>| serde_json::to_string(v).unwrap_or_else(|_| "[]".into());
conn.execute(
"UPDATE clients SET
name = COALESCE(?2, name),
redirect_uris = COALESCE(?3, redirect_uris),
scopes = COALESCE(?4, scopes),
grant_types = COALESCE(?5, grant_types),
audience = COALESCE(?6, audience),
first_party = COALESCE(?7, first_party)
WHERE id = ?1",
rusqlite::params![
id,
patch.name,
patch.redirect_uris.as_ref().map(to_json),
patch.scopes.as_ref().map(to_json),
patch.grant_types.as_ref().map(to_json),
patch.audience,
patch.first_party.map(i64::from),
],
)
.map_err(db)?;
let after = client_snapshot(conn, id)
.map_err(db)?
.ok_or(UpdateError::NotFound)?;
Ok((before, after))
}
async fn update_client(
Path(id): Path<String>,
Json(patch): Json<UpdateClient>,
) -> impl IntoResponse {
let result = with_db(|conn| {
let (before, after) = apply_client_update(conn, &id, &patch)?;
let tenant = after["tenant_id"].as_str().map(str::to_owned);
audit::log_audit_event(
conn,
tenant.as_deref(),
None,
audit::CLIENT_UPDATED,
None,
Some(&json!({"client_id": id, "before": before, "after": after})),
);
Ok::<_, UpdateError>(after)
});
match result {
Ok(after) => Json(json!({"id": id, "client": after})).into_response(),
Err(UpdateError::Invalid(m)) => {
(StatusCode::BAD_REQUEST, Json(json!({"error": m}))).into_response()
}
Err(UpdateError::NotFound) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "client not found"})),
)
.into_response(),
Err(UpdateError::Db(m)) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": m}))).into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn seeded() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::schema::create_tables(&conn);
conn.execute(
"INSERT INTO tenants (id, name, slug, created_at) VALUES ('t1','T','t',1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO clients (id, tenant_id, name, client_secret_hash, redirect_uris, grant_types, scopes, first_party, token_endpoint_auth_method, platform_wide, audience, created_at) VALUES ('c1','t1','Old','HASH','[\"http://127.0.0.1:1/cb\"]','[\"authorization_code\"]','[\"openid\"]',0,'client_secret_basic',0,'c1',1)",
[],
)
.unwrap();
conn
}
fn patch(v: serde_json::Value) -> UpdateClient {
serde_json::from_value(v).unwrap()
}
#[test]
fn valid_patch_changes_only_given_fields_and_keeps_secret() {
let conn = seeded();
let (before, after) = apply_client_update(
&conn,
"c1",
&patch(json!({"name": "New", "redirect_uris": ["http://127.0.0.1:1/cb", "http://10.0.0.1:8090/auth/callback"], "first_party": true})),
)
.unwrap();
assert_eq!(before["name"], "Old");
assert_eq!(after["name"], "New");
assert_eq!(after["redirect_uris"].as_array().unwrap().len(), 2);
assert_eq!(after["scopes"], json!(["openid"]));
assert_eq!(after["first_party"], true);
let hash: String = conn
.query_row(
"SELECT client_secret_hash FROM clients WHERE id='c1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(hash, "HASH");
}
#[test]
fn invalid_uri_is_rejected_and_nothing_changes() {
let conn = seeded();
for bad in [
"javascript:alert(1)",
"http://",
"http://a/b#frag",
"ftp://x/cb",
"http://a b/cb",
] {
let err = apply_client_update(
&conn,
"c1",
&patch(json!({"name": "X", "redirect_uris": [bad]})),
)
.unwrap_err();
assert!(matches!(err, UpdateError::Invalid(_)), "{bad}");
}
assert_eq!(
client_snapshot(&conn, "c1").unwrap().unwrap()["name"],
"Old"
);
}
#[test]
fn unknown_id_is_not_found() {
let conn = seeded();
assert_eq!(
apply_client_update(&conn, "nope", &patch(json!({"name": "X"}))).unwrap_err(),
UpdateError::NotFound
);
}
#[test]
fn secret_fields_are_rejected_by_the_body_schema() {
assert!(serde_json::from_value::<UpdateClient>(json!({"client_secret": "x"})).is_err());
}
#[test]
fn audit_row_carries_before_and_after() {
let conn = seeded();
let (before, after) =
apply_client_update(&conn, "c1", &patch(json!({"name": "New"}))).unwrap();
audit::log_audit_event(
&conn,
Some("t1"),
None,
audit::CLIENT_UPDATED,
None,
Some(&json!({"client_id": "c1", "before": before, "after": after})),
);
let meta: String = conn
.query_row(
"SELECT metadata FROM audit_log WHERE event='CLIENT_UPDATED'",
[],
|r| r.get(0),
)
.unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta).unwrap();
assert_eq!(meta["before"]["name"], "Old");
assert_eq!(meta["after"]["name"], "New");
}
}