use std::collections::BTreeMap;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Json, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::ctx::AuthCtx;
use crate::state::AdminApiKeys;
use super::auth_params;
use super::issuer_validation;
use super::types::{OidcClient, TokenAuthMethod, UpstreamProvider};
pub(crate) async fn require_admin(
headers: &HeaderMap,
_ctx: &AuthCtx,
keys: &AdminApiKeys,
) -> Result<(), Box<Response>> {
crate::gate::require_admin_bearer(headers, keys)
}
#[derive(Clone, Debug, Deserialize)]
pub struct CreateClientBody {
pub client_id: Option<String>,
pub redirect_uris: Vec<String>,
pub name: String,
pub logo_url: Option<String>,
#[serde(default = "default_auth_method")]
pub token_endpoint_auth_method: String,
#[serde(default = "default_scopes")]
pub default_scopes: Vec<String>,
#[serde(default = "default_true")]
pub require_consent: bool,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
#[serde(default = "default_response_types")]
pub response_types: Vec<String>,
#[serde(default = "default_true")]
pub pkce_required: bool,
pub backchannel_logout_uri: Option<String>,
}
fn default_auth_method() -> String {
"client_secret_basic".to_string()
}
fn default_scopes() -> Vec<String> {
vec!["openid".to_string()]
}
fn default_true() -> bool {
true
}
fn default_grant_types() -> Vec<String> {
vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
]
}
fn default_response_types() -> Vec<String> {
vec!["code".to_string()]
}
#[derive(Clone, Debug, Serialize)]
pub struct CreateClientResponse {
pub client: OidcClient,
pub client_secret: Option<String>,
}
pub async fn create_client(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Json(body): Json<CreateClientBody>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
if body.redirect_uris.is_empty() {
return bad_request("redirect_uris must be non-empty");
}
for u in &body.redirect_uris {
if url::Url::parse(u).is_err() {
return bad_request(&format!("redirect_uri {u:?} is not a URL"));
}
}
let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
Some(m) => m,
None => {
return bad_request(&format!(
"unknown token_endpoint_auth_method {:?}",
body.token_endpoint_auth_method
));
}
};
let client_id = body.client_id.clone().unwrap_or_else(|| {
format!(
"ocl_{}",
data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<12>())
)
});
let plaintext_secret = match auth_method {
TokenAuthMethod::None => None,
_ => Some(format!(
"ocs_{}",
data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
)),
};
let secret_hash = match &plaintext_secret {
Some(s) => {
let hasher = crate::password::PasswordHasher::default();
match hasher.hash(s) {
Ok(h) => Some(h),
Err(e) => return server_error(&format!("hash secret: {e}")),
}
}
None => None,
};
let client = OidcClient {
client_id: client_id.clone(),
client_secret_hash: secret_hash,
redirect_uris: body.redirect_uris,
name: body.name,
logo_url: body.logo_url,
token_endpoint_auth_method: auth_method,
default_scopes: body.default_scopes,
require_consent: body.require_consent,
grant_types: body.grant_types,
response_types: body.response_types,
pkce_required: body.pkce_required,
backchannel_logout_uri: body.backchannel_logout_uri,
created_at: now_secs(),
};
if let Err(e) = provider.clients.create(&client).await {
return server_error(&format!("persist client: {e}"));
}
(
StatusCode::CREATED,
Json(CreateClientResponse {
client,
client_secret: plaintext_secret,
}),
)
.into_response()
}
pub async fn list_clients(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.clients.list().await {
Ok(list) => (StatusCode::OK, Json(list)).into_response(),
Err(e) => server_error(&format!("list clients: {e}")),
}
}
pub async fn get_client(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.clients.get(&id).await {
Ok(Some(c)) => (StatusCode::OK, Json(c)).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown client_id"})),
)
.into_response(),
Err(e) => server_error(&format!("get client: {e}")),
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateClientBody {
pub redirect_uris: Vec<String>,
pub name: String,
pub logo_url: Option<String>,
pub token_endpoint_auth_method: String,
pub default_scopes: Vec<String>,
pub require_consent: bool,
pub grant_types: Vec<String>,
pub response_types: Vec<String>,
pub pkce_required: bool,
pub backchannel_logout_uri: Option<String>,
}
pub async fn update_client(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<UpdateClientBody>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
let existing = match provider.clients.get(&id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown client_id"})),
)
.into_response();
}
Err(e) => return server_error(&format!("client lookup: {e}")),
};
let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
Some(m) => m,
None => {
return bad_request(&format!(
"unknown token_endpoint_auth_method {:?}",
body.token_endpoint_auth_method
));
}
};
let updated = OidcClient {
client_id: existing.client_id,
client_secret_hash: existing.client_secret_hash,
redirect_uris: body.redirect_uris,
name: body.name,
logo_url: body.logo_url,
token_endpoint_auth_method: auth_method,
default_scopes: body.default_scopes,
require_consent: body.require_consent,
grant_types: body.grant_types,
response_types: body.response_types,
pkce_required: body.pkce_required,
backchannel_logout_uri: body.backchannel_logout_uri,
created_at: existing.created_at,
};
if let Err(e) = provider.clients.update(&updated).await {
return server_error(&format!("update client: {e}"));
}
(StatusCode::OK, Json(updated)).into_response()
}
pub async fn delete_client(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.clients.delete(&id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown client_id"})),
)
.into_response(),
Err(e) => server_error(&format!("delete client: {e}")),
}
}
#[derive(Clone, Debug, Serialize)]
pub struct RotateSecretResponse {
pub client_id: String,
pub client_secret: String,
}
pub async fn rotate_client_secret(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
let plaintext = format!(
"ocs_{}",
data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
);
let hasher = crate::password::PasswordHasher::default();
let hash = match hasher.hash(&plaintext) {
Ok(h) => h,
Err(e) => return server_error(&format!("hash secret: {e}")),
};
match provider.clients.rotate_secret_hash(&id, &hash).await {
Ok(true) => (
StatusCode::OK,
Json(RotateSecretResponse {
client_id: id,
client_secret: plaintext,
}),
)
.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown client_id"})),
)
.into_response(),
Err(e) => server_error(&format!("rotate secret: {e}")),
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpstreamBody {
pub slug: String,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub display_name: String,
pub icon_url: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub scopes: Option<Vec<String>>,
#[serde(default)]
pub auth_params: Option<BTreeMap<String, String>>,
}
pub async fn upsert_upstream(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Json(body): Json<UpstreamBody>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
if let Err(e) = issuer_validation::validate_issuer(&body.issuer, false) {
return bad_request(&format!("issuer rejected: {e}"));
}
let auth_params_map = body.auth_params.unwrap_or_default();
let mut errors: Vec<serde_json::Value> = Vec::new();
for (k, v) in &auth_params_map {
if let Err(e) = auth_params::validate_pair(k, v) {
errors.push(json!({"key": k, "error": format!("{e}")}));
}
}
if !errors.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_request",
"error_description": "auth_params validation failed",
"auth_params_errors": errors,
})),
)
.into_response();
}
let scopes_vec = match body.scopes {
Some(v) if !v.is_empty() => normalize_scopes(v),
_ => crate::oidc::DEFAULT_UPSTREAM_SCOPES
.iter()
.map(|s| s.to_string())
.collect(),
};
let row = UpstreamProvider {
slug: body.slug,
issuer: body.issuer,
client_id: body.client_id,
client_secret: body.client_secret,
display_name: body.display_name,
icon_url: body.icon_url,
enabled: body.enabled,
scopes: scopes_vec,
auth_params: auth_params_map,
};
if let Err(e) = provider.upstream.upsert(&row).await {
return server_error(&format!("upsert upstream: {e}"));
}
if let Some(registry) = &ctx.oidc {
let row_clone = row.clone();
let public_url = provider.public_url.clone();
let registry = registry.clone();
let slug = row.slug.clone();
let handle = tokio::spawn(async move {
super::sync_upstream_to_registry(®istry, &row_clone, &public_url).await;
});
tokio::spawn(async move {
if let Err(e) = handle.await {
tracing::error!(
slug = %slug,
panic = e.is_panic(),
cancelled = e.is_cancelled(),
"registry sync task did not complete cleanly: {e}"
);
}
});
}
(StatusCode::OK, Json(row)).into_response()
}
fn normalize_scopes(mut scopes: Vec<String>) -> Vec<String> {
if !scopes.iter().any(|s| s == "openid") {
scopes.insert(0, "openid".to_string());
}
scopes
}
pub async fn list_upstream(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.upstream.list().await {
Ok(list) => (StatusCode::OK, Json(list)).into_response(),
Err(e) => server_error(&format!("list upstream: {e}")),
}
}
pub async fn get_upstream(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.upstream.get(&slug).await {
Ok(Some(u)) => (StatusCode::OK, Json(u)).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown slug"})),
)
.into_response(),
Err(e) => server_error(&format!("get upstream: {e}")),
}
}
pub async fn delete_upstream(
State(ctx): State<AuthCtx>,
State(keys): State<AdminApiKeys>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Response {
if let Err(r) = require_admin(&headers, &ctx, &keys).await {
return *r;
}
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => return svc_unavailable("oidc_provider not enabled"),
};
match provider.upstream.delete(&slug).await {
Ok(true) => {
if let Some(registry) = &ctx.oidc {
registry.remove(&slug);
}
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown slug"})),
)
.into_response(),
Err(e) => server_error(&format!("delete upstream: {e}")),
}
}
fn bad_request(msg: &str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid_request", "error_description": msg})),
)
.into_response()
}
fn server_error(msg: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "server_error", "error_description": msg})),
)
.into_response()
}
fn svc_unavailable(msg: &str) -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "service_unavailable", "error_description": msg})),
)
.into_response()
}
fn now_secs() -> f64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn random_bytes<const N: usize>() -> [u8; N] {
use rand::RngCore;
let mut buf = [0u8; N];
rand::rng().fill_bytes(&mut buf);
buf
}