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 std::collections::BTreeMap;
28
29use axum::extract::{Path, State};
30use axum::http::{HeaderMap, StatusCode};
31use axum::response::{IntoResponse, Json, Response};
32use serde::{Deserialize, Serialize};
33use serde_json::json;
34
35use crate::ctx::AuthCtx;
36use crate::state::AdminApiKeys;
37
38use super::auth_params;
39use super::issuer_validation;
40use super::types::{OidcClient, TokenAuthMethod, UpstreamProvider};
41
42/// Auth + Zanzibar gate shared by every OIDC admin handler. Resolves a
43/// [`crate::gate::Caller`] from the request, then enforces the
44/// `auth#system#admin` role (same role as the cross-cutting admin
45/// router — OIDC client/upstream CRUD is operator-level concern, not
46/// per-tenant). Admin api-key callers bypass as break-glass.
47pub(crate) async fn require_admin(
48    headers: &HeaderMap,
49    ctx: &AuthCtx,
50    keys: &AdminApiKeys,
51) -> Result<crate::gate::Caller, Box<Response>> {
52    crate::gate::require_role_for(headers, ctx, keys, "auth", "system", "admin").await
53}
54
55// =====================================================================
56//   /admin/oidc/clients
57// =====================================================================
58
59/// Body for `POST /admin/oidc/clients`. We accept the canonical
60/// [`OidcClient`] shape minus `created_at` (stamped server-side) and
61/// minus `client_secret_hash` (we mint it for confidential clients).
62#[derive(Clone, Debug, Deserialize)]
63pub struct CreateClientBody {
64    pub client_id: Option<String>,
65    pub redirect_uris: Vec<String>,
66    pub name: String,
67    pub logo_url: Option<String>,
68    #[serde(default = "default_auth_method")]
69    pub token_endpoint_auth_method: String,
70    #[serde(default = "default_scopes")]
71    pub default_scopes: Vec<String>,
72    #[serde(default = "default_true")]
73    pub require_consent: bool,
74    #[serde(default = "default_grant_types")]
75    pub grant_types: Vec<String>,
76    #[serde(default = "default_response_types")]
77    pub response_types: Vec<String>,
78    #[serde(default = "default_true")]
79    pub pkce_required: bool,
80    pub backchannel_logout_uri: Option<String>,
81}
82
83fn default_auth_method() -> String {
84    "client_secret_basic".to_string()
85}
86fn default_scopes() -> Vec<String> {
87    vec!["openid".to_string()]
88}
89fn default_true() -> bool {
90    true
91}
92fn default_grant_types() -> Vec<String> {
93    vec![
94        "authorization_code".to_string(),
95        "refresh_token".to_string(),
96    ]
97}
98fn default_response_types() -> Vec<String> {
99    vec!["code".to_string()]
100}
101
102/// Returned ONCE on create / rotate-secret. The plaintext is never
103/// readable again — operators MUST capture it from this response.
104#[derive(Clone, Debug, Serialize)]
105pub struct CreateClientResponse {
106    pub client: OidcClient,
107    /// Plaintext bearer for confidential clients. `None` for `none`
108    /// (PKCE-only) clients.
109    pub client_secret: Option<String>,
110}
111
112pub async fn create_client(
113    State(ctx): State<AuthCtx>,
114    State(keys): State<AdminApiKeys>,
115    headers: HeaderMap,
116    Json(body): Json<CreateClientBody>,
117) -> Response {
118    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
119        return *r;
120    }
121    let provider = match ctx.oidc_provider.as_ref() {
122        Some(p) => p,
123        None => return svc_unavailable("oidc_provider not enabled"),
124    };
125    if body.redirect_uris.is_empty() {
126        return bad_request("redirect_uris must be non-empty");
127    }
128    for u in &body.redirect_uris {
129        if url::Url::parse(u).is_err() {
130            return bad_request(&format!("redirect_uri {u:?} is not a URL"));
131        }
132    }
133    let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
134        Some(m) => m,
135        None => {
136            return bad_request(&format!(
137                "unknown token_endpoint_auth_method {:?}",
138                body.token_endpoint_auth_method
139            ));
140        }
141    };
142    let client_id = body.client_id.clone().unwrap_or_else(|| {
143        format!(
144            "ocl_{}",
145            data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<12>())
146        )
147    });
148    let plaintext_secret = match auth_method {
149        TokenAuthMethod::None => None,
150        _ => Some(format!(
151            "ocs_{}",
152            data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
153        )),
154    };
155    let secret_hash = match &plaintext_secret {
156        Some(s) => {
157            let hasher = crate::password::PasswordHasher::default();
158            match hasher.hash(s) {
159                Ok(h) => Some(h),
160                Err(e) => return server_error(&format!("hash secret: {e}")),
161            }
162        }
163        None => None,
164    };
165    let client = OidcClient {
166        client_id: client_id.clone(),
167        client_secret_hash: secret_hash,
168        redirect_uris: body.redirect_uris,
169        name: body.name,
170        logo_url: body.logo_url,
171        token_endpoint_auth_method: auth_method,
172        default_scopes: body.default_scopes,
173        require_consent: body.require_consent,
174        grant_types: body.grant_types,
175        response_types: body.response_types,
176        pkce_required: body.pkce_required,
177        backchannel_logout_uri: body.backchannel_logout_uri,
178        created_at: now_secs(),
179    };
180    if let Err(e) = provider.clients.create(&client).await {
181        return server_error(&format!("persist client: {e}"));
182    }
183    (
184        StatusCode::CREATED,
185        Json(CreateClientResponse {
186            client,
187            client_secret: plaintext_secret,
188        }),
189    )
190        .into_response()
191}
192
193pub async fn list_clients(
194    State(ctx): State<AuthCtx>,
195    State(keys): State<AdminApiKeys>,
196    headers: HeaderMap,
197) -> Response {
198    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
199        return *r;
200    }
201    let provider = match ctx.oidc_provider.as_ref() {
202        Some(p) => p,
203        None => return svc_unavailable("oidc_provider not enabled"),
204    };
205    match provider.clients.list().await {
206        Ok(list) => (StatusCode::OK, Json(list)).into_response(),
207        Err(e) => server_error(&format!("list clients: {e}")),
208    }
209}
210
211pub async fn get_client(
212    State(ctx): State<AuthCtx>,
213    State(keys): State<AdminApiKeys>,
214    headers: HeaderMap,
215    Path(id): Path<String>,
216) -> Response {
217    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
218        return *r;
219    }
220    let provider = match ctx.oidc_provider.as_ref() {
221        Some(p) => p,
222        None => return svc_unavailable("oidc_provider not enabled"),
223    };
224    match provider.clients.get(&id).await {
225        Ok(Some(c)) => (StatusCode::OK, Json(c)).into_response(),
226        Ok(None) => (
227            StatusCode::NOT_FOUND,
228            Json(json!({"error": "unknown client_id"})),
229        )
230            .into_response(),
231        Err(e) => server_error(&format!("get client: {e}")),
232    }
233}
234
235/// Body for `PUT /admin/oidc/clients/{id}` — same shape as create
236/// minus the auto-minted fields. Operators send the full record they
237/// want persisted.
238#[derive(Clone, Debug, Deserialize)]
239pub struct UpdateClientBody {
240    pub redirect_uris: Vec<String>,
241    pub name: String,
242    pub logo_url: Option<String>,
243    pub token_endpoint_auth_method: String,
244    pub default_scopes: Vec<String>,
245    pub require_consent: bool,
246    pub grant_types: Vec<String>,
247    pub response_types: Vec<String>,
248    pub pkce_required: bool,
249    pub backchannel_logout_uri: Option<String>,
250}
251
252pub async fn update_client(
253    State(ctx): State<AuthCtx>,
254    State(keys): State<AdminApiKeys>,
255    headers: HeaderMap,
256    Path(id): Path<String>,
257    Json(body): Json<UpdateClientBody>,
258) -> Response {
259    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
260        return *r;
261    }
262    let provider = match ctx.oidc_provider.as_ref() {
263        Some(p) => p,
264        None => return svc_unavailable("oidc_provider not enabled"),
265    };
266    let existing = match provider.clients.get(&id).await {
267        Ok(Some(c)) => c,
268        Ok(None) => {
269            return (
270                StatusCode::NOT_FOUND,
271                Json(json!({"error": "unknown client_id"})),
272            )
273                .into_response();
274        }
275        Err(e) => return server_error(&format!("client lookup: {e}")),
276    };
277    let auth_method = match TokenAuthMethod::parse(&body.token_endpoint_auth_method) {
278        Some(m) => m,
279        None => {
280            return bad_request(&format!(
281                "unknown token_endpoint_auth_method {:?}",
282                body.token_endpoint_auth_method
283            ));
284        }
285    };
286    let updated = OidcClient {
287        client_id: existing.client_id,
288        client_secret_hash: existing.client_secret_hash,
289        redirect_uris: body.redirect_uris,
290        name: body.name,
291        logo_url: body.logo_url,
292        token_endpoint_auth_method: auth_method,
293        default_scopes: body.default_scopes,
294        require_consent: body.require_consent,
295        grant_types: body.grant_types,
296        response_types: body.response_types,
297        pkce_required: body.pkce_required,
298        backchannel_logout_uri: body.backchannel_logout_uri,
299        created_at: existing.created_at,
300    };
301    if let Err(e) = provider.clients.update(&updated).await {
302        return server_error(&format!("update client: {e}"));
303    }
304    (StatusCode::OK, Json(updated)).into_response()
305}
306
307pub async fn delete_client(
308    State(ctx): State<AuthCtx>,
309    State(keys): State<AdminApiKeys>,
310    headers: HeaderMap,
311    Path(id): Path<String>,
312) -> Response {
313    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
314        return *r;
315    }
316    let provider = match ctx.oidc_provider.as_ref() {
317        Some(p) => p,
318        None => return svc_unavailable("oidc_provider not enabled"),
319    };
320    match provider.clients.delete(&id).await {
321        Ok(true) => StatusCode::NO_CONTENT.into_response(),
322        Ok(false) => (
323            StatusCode::NOT_FOUND,
324            Json(json!({"error": "unknown client_id"})),
325        )
326            .into_response(),
327        Err(e) => server_error(&format!("delete client: {e}")),
328    }
329}
330
331/// `POST /admin/oidc/clients/{id}/rotate-secret` — mints a fresh
332/// client_secret, hashes it, persists it, returns the plaintext ONCE.
333#[derive(Clone, Debug, Serialize)]
334pub struct RotateSecretResponse {
335    pub client_id: String,
336    pub client_secret: String,
337}
338
339pub async fn rotate_client_secret(
340    State(ctx): State<AuthCtx>,
341    State(keys): State<AdminApiKeys>,
342    headers: HeaderMap,
343    Path(id): Path<String>,
344) -> Response {
345    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
346        return *r;
347    }
348    let provider = match ctx.oidc_provider.as_ref() {
349        Some(p) => p,
350        None => return svc_unavailable("oidc_provider not enabled"),
351    };
352    let plaintext = format!(
353        "ocs_{}",
354        data_encoding::BASE64URL_NOPAD.encode(&random_bytes::<24>())
355    );
356    let hasher = crate::password::PasswordHasher::default();
357    let hash = match hasher.hash(&plaintext) {
358        Ok(h) => h,
359        Err(e) => return server_error(&format!("hash secret: {e}")),
360    };
361    match provider.clients.rotate_secret_hash(&id, &hash).await {
362        Ok(true) => (
363            StatusCode::OK,
364            Json(RotateSecretResponse {
365                client_id: id,
366                client_secret: plaintext,
367            }),
368        )
369            .into_response(),
370        Ok(false) => (
371            StatusCode::NOT_FOUND,
372            Json(json!({"error": "unknown client_id"})),
373        )
374            .into_response(),
375        Err(e) => server_error(&format!("rotate secret: {e}")),
376    }
377}
378
379// =====================================================================
380//   /admin/oidc/upstream
381// =====================================================================
382
383/// Body for the upsert path — `slug` is the natural key.
384#[derive(Clone, Debug, Deserialize)]
385pub struct UpstreamBody {
386    pub slug: String,
387    pub issuer: String,
388    pub client_id: String,
389    pub client_secret: String,
390    pub display_name: String,
391    pub icon_url: Option<String>,
392    #[serde(default = "default_true")]
393    pub enabled: bool,
394    #[serde(default)]
395    pub scopes: Option<Vec<String>>,
396    #[serde(default)]
397    pub auth_params: Option<BTreeMap<String, String>>,
398}
399
400pub async fn upsert_upstream(
401    State(ctx): State<AuthCtx>,
402    State(keys): State<AdminApiKeys>,
403    headers: HeaderMap,
404    Json(body): Json<UpstreamBody>,
405) -> Response {
406    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
407        return *r;
408    }
409    let provider = match ctx.oidc_provider.as_ref() {
410        Some(p) => p,
411        None => return svc_unavailable("oidc_provider not enabled"),
412    };
413
414    // Issuer validation — scheme/host/userinfo/fragment + literal-IP
415    // private-range rejection. Gated by allow_insecure_issuers (false
416    // until config plumbing lands).
417    if let Err(e) = issuer_validation::validate_issuer(&body.issuer, false) {
418        return bad_request(&format!("issuer rejected: {e}"));
419    }
420
421    let auth_params_map = body.auth_params.unwrap_or_default();
422    let mut errors: Vec<serde_json::Value> = Vec::new();
423    for (k, v) in &auth_params_map {
424        if let Err(e) = auth_params::validate_pair(k, v) {
425            errors.push(json!({"key": k, "error": format!("{e}")}));
426        }
427    }
428    if !errors.is_empty() {
429        return (
430            StatusCode::BAD_REQUEST,
431            Json(json!({
432                "error": "invalid_request",
433                "error_description": "auth_params validation failed",
434                "auth_params_errors": errors,
435            })),
436        )
437            .into_response();
438    }
439
440    let scopes_vec = match body.scopes {
441        Some(v) if !v.is_empty() => normalize_scopes(v),
442        _ => crate::oidc::DEFAULT_UPSTREAM_SCOPES
443            .iter()
444            .map(|s| s.to_string())
445            .collect(),
446    };
447
448    let row = UpstreamProvider {
449        slug: body.slug,
450        issuer: body.issuer,
451        client_id: body.client_id,
452        client_secret: body.client_secret,
453        display_name: body.display_name,
454        icon_url: body.icon_url,
455        enabled: body.enabled,
456        scopes: scopes_vec,
457        auth_params: auth_params_map,
458    };
459    if let Err(e) = provider.upstream.upsert(&row).await {
460        return server_error(&format!("upsert upstream: {e}"));
461    }
462    if let Some(registry) = &ctx.oidc {
463        let row_clone = row.clone();
464        let public_url = provider.public_url.clone();
465        let registry = registry.clone();
466        let slug = row.slug.clone();
467        let handle = tokio::spawn(async move {
468            super::sync_upstream_to_registry(&registry, &row_clone, &public_url).await;
469        });
470        tokio::spawn(async move {
471            if let Err(e) = handle.await {
472                tracing::error!(
473                    slug = %slug,
474                    panic = e.is_panic(),
475                    cancelled = e.is_cancelled(),
476                    "registry sync task did not complete cleanly: {e}"
477                );
478            }
479        });
480    }
481    (StatusCode::OK, Json(row)).into_response()
482}
483
484/// Make sure `openid` is always present — operators sometimes omit it
485/// even though every OIDC IdP requires it for an authentication request.
486fn normalize_scopes(mut scopes: Vec<String>) -> Vec<String> {
487    if !scopes.iter().any(|s| s == "openid") {
488        scopes.insert(0, "openid".to_string());
489    }
490    scopes
491}
492
493pub async fn list_upstream(
494    State(ctx): State<AuthCtx>,
495    State(keys): State<AdminApiKeys>,
496    headers: HeaderMap,
497) -> Response {
498    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
499        return *r;
500    }
501    let provider = match ctx.oidc_provider.as_ref() {
502        Some(p) => p,
503        None => return svc_unavailable("oidc_provider not enabled"),
504    };
505    match provider.upstream.list().await {
506        Ok(list) => (StatusCode::OK, Json(list)).into_response(),
507        Err(e) => server_error(&format!("list upstream: {e}")),
508    }
509}
510
511pub async fn get_upstream(
512    State(ctx): State<AuthCtx>,
513    State(keys): State<AdminApiKeys>,
514    headers: HeaderMap,
515    Path(slug): Path<String>,
516) -> Response {
517    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
518        return *r;
519    }
520    let provider = match ctx.oidc_provider.as_ref() {
521        Some(p) => p,
522        None => return svc_unavailable("oidc_provider not enabled"),
523    };
524    match provider.upstream.get(&slug).await {
525        Ok(Some(u)) => (StatusCode::OK, Json(u)).into_response(),
526        Ok(None) => (
527            StatusCode::NOT_FOUND,
528            Json(json!({"error": "unknown slug"})),
529        )
530            .into_response(),
531        Err(e) => server_error(&format!("get upstream: {e}")),
532    }
533}
534
535pub async fn delete_upstream(
536    State(ctx): State<AuthCtx>,
537    State(keys): State<AdminApiKeys>,
538    headers: HeaderMap,
539    Path(slug): Path<String>,
540) -> Response {
541    if let Err(r) = require_admin(&headers, &ctx, &keys).await {
542        return *r;
543    }
544    let provider = match ctx.oidc_provider.as_ref() {
545        Some(p) => p,
546        None => return svc_unavailable("oidc_provider not enabled"),
547    };
548    match provider.upstream.delete(&slug).await {
549        Ok(true) => {
550            if let Some(registry) = &ctx.oidc {
551                registry.remove(&slug);
552            }
553            StatusCode::NO_CONTENT.into_response()
554        }
555        Ok(false) => (
556            StatusCode::NOT_FOUND,
557            Json(json!({"error": "unknown slug"})),
558        )
559            .into_response(),
560        Err(e) => server_error(&format!("delete upstream: {e}")),
561    }
562}
563
564// =====================================================================
565//   helpers
566// =====================================================================
567
568fn bad_request(msg: &str) -> Response {
569    (
570        StatusCode::BAD_REQUEST,
571        Json(json!({"error": "invalid_request", "error_description": msg})),
572    )
573        .into_response()
574}
575
576fn server_error(msg: &str) -> Response {
577    (
578        StatusCode::INTERNAL_SERVER_ERROR,
579        Json(json!({"error": "server_error", "error_description": msg})),
580    )
581        .into_response()
582}
583
584fn svc_unavailable(msg: &str) -> Response {
585    (
586        StatusCode::SERVICE_UNAVAILABLE,
587        Json(json!({"error": "service_unavailable", "error_description": msg})),
588    )
589        .into_response()
590}
591
592fn now_secs() -> f64 {
593    use std::time::{SystemTime, UNIX_EPOCH};
594    SystemTime::now()
595        .duration_since(UNIX_EPOCH)
596        .unwrap_or_default()
597        .as_secs_f64()
598}
599
600fn random_bytes<const N: usize>() -> [u8; N] {
601    use rand::RngCore;
602    let mut buf = [0u8; N];
603    rand::rng().fill_bytes(&mut buf);
604    buf
605}
606
607// Admin-gate behaviour is covered in `crate::gate::tests` and the
608// integration-test suite — `require_admin` here is a one-line wrapper
609// over `gate::require_role_for`, so a per-handler test would just
610// duplicate gate.rs's coverage.