Skip to main content

assay_auth/oidc_provider/
admin.rs

1//! Admin HTTP API for OIDC client + upstream provider management.
2//!
3//! Auth: every handler in this module requires a valid bearer token
4//! from `auth.admin_api_keys`. The check happens at the handler entry
5//! via [`require_admin`] — keeps the gating obvious and per-route
6//! testable.
7//!
8//! Surface:
9//!
10//! - `GET    /admin/oidc/clients`
11//! - `POST   /admin/oidc/clients` — returns the plaintext `client_secret` ONCE.
12//! - `GET    /admin/oidc/clients/{id}`
13//! - `PUT    /admin/oidc/clients/{id}`
14//! - `DELETE /admin/oidc/clients/{id}`
15//! - `POST   /admin/oidc/clients/{id}/rotate-secret` — new secret ONCE.
16//!
17//! - `GET    /admin/oidc/upstream`
18//! - `POST   /admin/oidc/upstream`            → upsert by slug.
19//! - `GET    /admin/oidc/upstream/{slug}`
20//! - `DELETE /admin/oidc/upstream/{slug}`
21//!
22//! Choice (v0.2.0): admin auth is api-key only. Session-based admin
23//! (Zanzibar role check) lands in v0.2.1; the trait surface already
24//! supports it (the `AdminApiKeys` extractor would just become
25//! `AdminAuth { keys, session }`).
26
27use axum::extract::{Path, State};
28use axum::http::{HeaderMap, StatusCode};
29use axum::response::{IntoResponse, Json, Response};
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32
33use crate::ctx::AuthCtx;
34use crate::state::AdminApiKeys;
35
36use super::types::{OidcClient, TokenAuthMethod, UpstreamProvider};
37
38/// Auth + Zanzibar gate shared by every OIDC admin handler. Resolves a
39/// [`crate::gate::Caller`] from the request, then enforces the
40/// `auth#system#admin` role (same role as the cross-cutting admin
41/// router — OIDC client/upstream CRUD is operator-level concern, not
42/// per-tenant). Admin api-key callers bypass as break-glass.
43pub(crate) async fn require_admin(
44    headers: &HeaderMap,
45    ctx: &AuthCtx,
46    keys: &AdminApiKeys,
47) -> Result<crate::gate::Caller, Box<Response>> {
48    crate::gate::require_role_for(headers, ctx, keys, "auth", "system", "admin").await
49}
50
51// =====================================================================
52//   /admin/oidc/clients
53// =====================================================================
54
55/// Body for `POST /admin/oidc/clients`. We accept the canonical
56/// [`OidcClient`] shape minus `created_at` (stamped server-side) and
57/// minus `client_secret_hash` (we mint it for confidential clients).
58#[derive(Clone, Debug, Deserialize)]
59pub struct CreateClientBody {
60    pub client_id: Option<String>,
61    pub redirect_uris: Vec<String>,
62    pub name: String,
63    pub logo_url: Option<String>,
64    #[serde(default = "default_auth_method")]
65    pub token_endpoint_auth_method: String,
66    #[serde(default = "default_scopes")]
67    pub default_scopes: Vec<String>,
68    #[serde(default = "default_true")]
69    pub require_consent: bool,
70    #[serde(default = "default_grant_types")]
71    pub grant_types: Vec<String>,
72    #[serde(default = "default_response_types")]
73    pub response_types: Vec<String>,
74    #[serde(default = "default_true")]
75    pub pkce_required: bool,
76    pub backchannel_logout_uri: Option<String>,
77}
78
79fn default_auth_method() -> String {
80    "client_secret_basic".to_string()
81}
82fn default_scopes() -> Vec<String> {
83    vec!["openid".to_string()]
84}
85fn default_true() -> bool {
86    true
87}
88fn default_grant_types() -> Vec<String> {
89    vec!["authorization_code".to_string(), "refresh_token".to_string()]
90}
91fn default_response_types() -> Vec<String> {
92    vec!["code".to_string()]
93}
94
95/// Returned ONCE on create / rotate-secret. The plaintext is never
96/// readable again — operators MUST capture it from this response.
97#[derive(Clone, Debug, Serialize)]
98pub struct CreateClientResponse {
99    pub client: OidcClient,
100    /// Plaintext bearer for confidential clients. `None` for `none`
101    /// (PKCE-only) clients.
102    pub client_secret: Option<String>,
103}
104
105pub async fn create_client(
106    State(ctx): State<AuthCtx>,
107    State(keys): State<AdminApiKeys>,
108    headers: HeaderMap,
109    Json(body): Json<CreateClientBody>,
110) -> Response {
111    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
112        return *r;
113    }
114    let provider = match ctx.oidc_provider.as_ref() {
115        Some(p) => p,
116        None => return svc_unavailable("oidc_provider not enabled"),
117    };
118    if body.redirect_uris.is_empty() {
119        return bad_request("redirect_uris must be non-empty");
120    }
121    for u in &body.redirect_uris {
122        if url::Url::parse(u).is_err() {
123            return bad_request(&format!("redirect_uri {u:?} is not a URL"));
124        }
125    }
126    let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
127        Some(m) => m,
128        None => {
129            return bad_request(&format!(
130                "unknown token_endpoint_auth_method {:?}",
131                body.token_endpoint_auth_method
132            ));
133        }
134    };
135    let client_id = body.client_id.clone().unwrap_or_else(|| {
136        format!(
137            "ocl_{}",
138            data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<12>())
139        )
140    });
141    let plaintext_secret = match auth_method {
142        TokenAuthMethod::None => None,
143        _ => Some(format!(
144            "ocs_{}",
145            data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
146        )),
147    };
148    let secret_hash = match &plaintext_secret {
149        Some(s) => {
150            let hasher = crate::password::PasswordHasher::default();
151            match hasher.hash(s) {
152                Ok(h) => Some(h),
153                Err(e) => return server_error(&format!("hash secret: {e}")),
154            }
155        }
156        None => None,
157    };
158    let client = OidcClient {
159        client_id: client_id.clone(),
160        client_secret_hash: secret_hash,
161        redirect_uris: body.redirect_uris,
162        name: body.name,
163        logo_url: body.logo_url,
164        token_endpoint_auth_method: auth_method,
165        default_scopes: body.default_scopes,
166        require_consent: body.require_consent,
167        grant_types: body.grant_types,
168        response_types: body.response_types,
169        pkce_required: body.pkce_required,
170        backchannel_logout_uri: body.backchannel_logout_uri,
171        created_at: now_secs(),
172    };
173    if let Err(e) = provider.clients.create(&client).await {
174        return server_error(&format!("persist client: {e}"));
175    }
176    (
177        StatusCode::CREATED,
178        Json(CreateClientResponse {
179            client,
180            client_secret: plaintext_secret,
181        }),
182    )
183        .into_response()
184}
185
186pub async fn list_clients(
187    State(ctx): State<AuthCtx>,
188    State(keys): State<AdminApiKeys>,
189    headers: HeaderMap,
190) -> Response {
191    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
192        return *r;
193    }
194    let provider = match ctx.oidc_provider.as_ref() {
195        Some(p) => p,
196        None => return svc_unavailable("oidc_provider not enabled"),
197    };
198    match provider.clients.list().await {
199        Ok(list) => (StatusCode::OK, Json(list)).into_response(),
200        Err(e) => server_error(&format!("list clients: {e}")),
201    }
202}
203
204pub async fn get_client(
205    State(ctx): State<AuthCtx>,
206    State(keys): State<AdminApiKeys>,
207    headers: HeaderMap,
208    Path(id): Path<String>,
209) -> Response {
210    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
211        return *r;
212    }
213    let provider = match ctx.oidc_provider.as_ref() {
214        Some(p) => p,
215        None => return svc_unavailable("oidc_provider not enabled"),
216    };
217    match provider.clients.get(&id).await {
218        Ok(Some(c)) => (StatusCode::OK, Json(c)).into_response(),
219        Ok(None) => (
220            StatusCode::NOT_FOUND,
221            Json(json!({"error": "unknown client_id"})),
222        )
223            .into_response(),
224        Err(e) => server_error(&format!("get client: {e}")),
225    }
226}
227
228/// Body for `PUT /admin/oidc/clients/{id}` — same shape as create
229/// minus the auto-minted fields. Operators send the full record they
230/// want persisted.
231#[derive(Clone, Debug, Deserialize)]
232pub struct UpdateClientBody {
233    pub redirect_uris: Vec<String>,
234    pub name: String,
235    pub logo_url: Option<String>,
236    pub token_endpoint_auth_method: String,
237    pub default_scopes: Vec<String>,
238    pub require_consent: bool,
239    pub grant_types: Vec<String>,
240    pub response_types: Vec<String>,
241    pub pkce_required: bool,
242    pub backchannel_logout_uri: Option<String>,
243}
244
245pub async fn update_client(
246    State(ctx): State<AuthCtx>,
247    State(keys): State<AdminApiKeys>,
248    headers: HeaderMap,
249    Path(id): Path<String>,
250    Json(body): Json<UpdateClientBody>,
251) -> Response {
252    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
253        return *r;
254    }
255    let provider = match ctx.oidc_provider.as_ref() {
256        Some(p) => p,
257        None => return svc_unavailable("oidc_provider not enabled"),
258    };
259    let existing = match provider.clients.get(&id).await {
260        Ok(Some(c)) => c,
261        Ok(None) => {
262            return (
263                StatusCode::NOT_FOUND,
264                Json(json!({"error": "unknown client_id"})),
265            )
266                .into_response();
267        }
268        Err(e) => return server_error(&format!("client lookup: {e}")),
269    };
270    let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
271        Some(m) => m,
272        None => {
273            return bad_request(&format!(
274                "unknown token_endpoint_auth_method {:?}",
275                body.token_endpoint_auth_method
276            ));
277        }
278    };
279    let updated = OidcClient {
280        client_id: existing.client_id,
281        client_secret_hash: existing.client_secret_hash,
282        redirect_uris: body.redirect_uris,
283        name: body.name,
284        logo_url: body.logo_url,
285        token_endpoint_auth_method: auth_method,
286        default_scopes: body.default_scopes,
287        require_consent: body.require_consent,
288        grant_types: body.grant_types,
289        response_types: body.response_types,
290        pkce_required: body.pkce_required,
291        backchannel_logout_uri: body.backchannel_logout_uri,
292        created_at: existing.created_at,
293    };
294    if let Err(e) = provider.clients.update(&updated).await {
295        return server_error(&format!("update client: {e}"));
296    }
297    (StatusCode::OK, Json(updated)).into_response()
298}
299
300pub async fn delete_client(
301    State(ctx): State<AuthCtx>,
302    State(keys): State<AdminApiKeys>,
303    headers: HeaderMap,
304    Path(id): Path<String>,
305) -> Response {
306    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
307        return *r;
308    }
309    let provider = match ctx.oidc_provider.as_ref() {
310        Some(p) => p,
311        None => return svc_unavailable("oidc_provider not enabled"),
312    };
313    match provider.clients.delete(&id).await {
314        Ok(true) => StatusCode::NO_CONTENT.into_response(),
315        Ok(false) => (
316            StatusCode::NOT_FOUND,
317            Json(json!({"error": "unknown client_id"})),
318        )
319            .into_response(),
320        Err(e) => server_error(&format!("delete client: {e}")),
321    }
322}
323
324/// `POST /admin/oidc/clients/{id}/rotate-secret` — mints a fresh
325/// client_secret, hashes it, persists it, returns the plaintext ONCE.
326#[derive(Clone, Debug, Serialize)]
327pub struct RotateSecretResponse {
328    pub client_id: String,
329    pub client_secret: String,
330}
331
332pub async fn rotate_client_secret(
333    State(ctx): State<AuthCtx>,
334    State(keys): State<AdminApiKeys>,
335    headers: HeaderMap,
336    Path(id): Path<String>,
337) -> Response {
338    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
339        return *r;
340    }
341    let provider = match ctx.oidc_provider.as_ref() {
342        Some(p) => p,
343        None => return svc_unavailable("oidc_provider not enabled"),
344    };
345    let plaintext = format!(
346        "ocs_{}",
347        data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
348    );
349    let hasher = crate::password::PasswordHasher::default();
350    let hash = match hasher.hash(&plaintext) {
351        Ok(h) => h,
352        Err(e) => return server_error(&format!("hash secret: {e}")),
353    };
354    match provider.clients.rotate_secret_hash(&id, &hash).await {
355        Ok(true) => (
356            StatusCode::OK,
357            Json(RotateSecretResponse {
358                client_id: id,
359                client_secret: plaintext,
360            }),
361        )
362            .into_response(),
363        Ok(false) => (
364            StatusCode::NOT_FOUND,
365            Json(json!({"error": "unknown client_id"})),
366        )
367            .into_response(),
368        Err(e) => server_error(&format!("rotate secret: {e}")),
369    }
370}
371
372// =====================================================================
373//   /admin/oidc/upstream
374// =====================================================================
375
376/// Body for the upsert path — `slug` is the natural key.
377#[derive(Clone, Debug, Deserialize)]
378pub struct UpstreamBody {
379    pub slug: String,
380    pub issuer: String,
381    pub client_id: String,
382    pub client_secret: String,
383    pub display_name: String,
384    pub icon_url: Option<String>,
385    #[serde(default = "default_true")]
386    pub enabled: bool,
387}
388
389pub async fn upsert_upstream(
390    State(ctx): State<AuthCtx>,
391    State(keys): State<AdminApiKeys>,
392    headers: HeaderMap,
393    Json(body): Json<UpstreamBody>,
394) -> Response {
395    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
396        return *r;
397    }
398    let provider = match ctx.oidc_provider.as_ref() {
399        Some(p) => p,
400        None => return svc_unavailable("oidc_provider not enabled"),
401    };
402    let row = UpstreamProvider {
403        slug: body.slug,
404        issuer: body.issuer,
405        client_id: body.client_id,
406        client_secret: body.client_secret,
407        display_name: body.display_name,
408        icon_url: body.icon_url,
409        enabled: body.enabled,
410    };
411    if let Err(e) = provider.upstream.upsert(&row).await {
412        return server_error(&format!("upsert upstream: {e}"));
413    }
414    (StatusCode::OK, Json(row)).into_response()
415}
416
417pub async fn list_upstream(
418    State(ctx): State<AuthCtx>,
419    State(keys): State<AdminApiKeys>,
420    headers: HeaderMap,
421) -> Response {
422    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
423        return *r;
424    }
425    let provider = match ctx.oidc_provider.as_ref() {
426        Some(p) => p,
427        None => return svc_unavailable("oidc_provider not enabled"),
428    };
429    match provider.upstream.list().await {
430        Ok(list) => (StatusCode::OK, Json(list)).into_response(),
431        Err(e) => server_error(&format!("list upstream: {e}")),
432    }
433}
434
435pub async fn get_upstream(
436    State(ctx): State<AuthCtx>,
437    State(keys): State<AdminApiKeys>,
438    headers: HeaderMap,
439    Path(slug): Path<String>,
440) -> Response {
441    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
442        return *r;
443    }
444    let provider = match ctx.oidc_provider.as_ref() {
445        Some(p) => p,
446        None => return svc_unavailable("oidc_provider not enabled"),
447    };
448    match provider.upstream.get(&slug).await {
449        Ok(Some(u)) => (StatusCode::OK, Json(u)).into_response(),
450        Ok(None) => (
451            StatusCode::NOT_FOUND,
452            Json(json!({"error": "unknown slug"})),
453        )
454            .into_response(),
455        Err(e) => server_error(&format!("get upstream: {e}")),
456    }
457}
458
459pub async fn delete_upstream(
460    State(ctx): State<AuthCtx>,
461    State(keys): State<AdminApiKeys>,
462    headers: HeaderMap,
463    Path(slug): Path<String>,
464) -> Response {
465    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
466        return *r;
467    }
468    let provider = match ctx.oidc_provider.as_ref() {
469        Some(p) => p,
470        None => return svc_unavailable("oidc_provider not enabled"),
471    };
472    match provider.upstream.delete(&slug).await {
473        Ok(true) => StatusCode::NO_CONTENT.into_response(),
474        Ok(false) => (
475            StatusCode::NOT_FOUND,
476            Json(json!({"error": "unknown slug"})),
477        )
478            .into_response(),
479        Err(e) => server_error(&format!("delete upstream: {e}")),
480    }
481}
482
483// =====================================================================
484//   helpers
485// =====================================================================
486
487fn bad_request(msg: &str) -> Response {
488    (
489        StatusCode::BAD_REQUEST,
490        Json(json!({"error": "invalid_request", "error_description": msg})),
491    )
492        .into_response()
493}
494
495fn server_error(msg: &str) -> Response {
496    (
497        StatusCode::INTERNAL_SERVER_ERROR,
498        Json(json!({"error": "server_error", "error_description": msg})),
499    )
500        .into_response()
501}
502
503fn svc_unavailable(msg: &str) -> Response {
504    (
505        StatusCode::SERVICE_UNAVAILABLE,
506        Json(json!({"error": "service_unavailable", "error_description": msg})),
507    )
508        .into_response()
509}
510
511fn now_secs() -> f64 {
512    use std::time::{SystemTime, UNIX_EPOCH};
513    SystemTime::now()
514        .duration_since(UNIX_EPOCH)
515        .unwrap_or_default()
516        .as_secs_f64()
517}
518
519fn random_bytes<const N: usize>() -> [u8; N] {
520    use rand::RngCore;
521    let mut buf = [0u8; N];
522    rand::rng().fill_bytes(&mut buf);
523    buf
524}
525
526// Admin-gate behaviour is covered in `crate::gate::tests` and the
527// integration-test suite — `require_admin` here is a one-line wrapper
528// over `gate::require_role_for`, so a per-handler test would just
529// duplicate gate.rs's coverage.