Skip to main content

alibi_plugins/oauth_provider/
clients.rs

1use super::OAuthProviderPlugin;
2use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
3use alibi_core::session::SessionRequest;
4use alibi_core::store::CreateOAuthClient;
5use alibi_core::store::oauth_clients::{
6    OAuthClientRegistrationWriteError, PersistOAuthClientRegistration,
7};
8use alibi_core::utils::javascript::trim as js_trim;
9use alibi_core::utils::json::JsValue;
10use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthUser};
11use rand::{RngExt as _, distr::Alphanumeric};
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14
15/// Metadata accepted when an authenticated owner registers a managed client.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct CreateOAuthClientInput {
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub redirect_uris: Option<Vec<String>>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub scope: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub client_name: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub client_uri: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub logo_uri: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub contacts: Option<Vec<String>>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub tos_uri: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub policy_uri: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub software_id: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub software_version: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub software_statement: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub post_logout_redirect_uris: Option<Vec<String>>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub backchannel_logout_uri: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub backchannel_logout_session_required: Option<bool>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub token_endpoint_auth_method: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub application_type: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub jwks: Option<Value>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub jwks_uri: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub grant_types: Option<Vec<String>>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub response_types: Option<Vec<String>>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub dpop_bound_access_tokens: Option<bool>,
60}
61/// Dynamic registration metadata. Requested resources admit additions to server defaults;
62/// unlike scopes, they narrow which optional resource capabilities become durable links.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct RegisterOAuthClientInput {
65    #[serde(flatten)]
66    pub metadata: CreateOAuthClientInput,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub resources: Option<Vec<String>>,
69}
70impl OAuthProviderPlugin {
71    async fn require_client_privileges<S: AuthSchema>(
72        &self,
73        action: &'static str,
74        req: &impl SessionRequest,
75        ctx: &AuthContext<S>,
76    ) -> AuthResult<Option<EndpointResponse>> {
77        let Ok((user, session)) = Box::pin(ctx.require_cached_session(req)).await else {
78            return Ok(Some(client_privilege_denied(401)));
79        };
80        if req
81            .endpoint_call()
82            .is_some_and(|call| call.headers().is_none())
83        {
84            return Ok(Some(client_privilege_denied(400)));
85        }
86        let scope_policy = (action == "configure-client-credentials-scopes")
87            .then_some(self.config.client_credentials_scope_privileges.as_ref())
88            .flatten();
89        if action == "configure-client-credentials-scopes"
90            && self.config.client_privileges.is_none()
91            && scope_policy.is_none()
92        {
93            return Ok(Some(client_privilege_denied(401)));
94        }
95        if self.config.client_privileges.is_some() || scope_policy.is_some() {
96            let mut headers = alibi_core::Headers::new();
97            for (name, value) in req.session_headers() {
98                _ = headers.insert(name.clone(), value.clone());
99            }
100            let input = super::OAuthClientPrivilegeContext {
101                action,
102                headers,
103                user: serde_json::to_value(user)?,
104                session: serde_json::to_value(session)?,
105            };
106            for policy in self.config.client_privileges.iter().chain(scope_policy) {
107                if !policy.allowed(&input).await? {
108                    return Ok(Some(client_privilege_denied(401)));
109                }
110            }
111        }
112        Ok(None)
113    }
114    pub fn create_oauth_client_endpoint(
115        input: &CreateOAuthClientInput,
116    ) -> AuthResult<ServerEndpoint<Value>> {
117        ServerEndpoint::new("oauth-provider", "createOAuthClient").with_body(input)
118    }
119    pub(super) async fn create_client<S: AuthSchema>(
120        &self,
121        input: CreateOAuthClientInput,
122        req: &impl SessionRequest,
123        ctx: &AuthContext<S>,
124    ) -> AuthResult<EndpointResponse> {
125        let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
126        if req
127            .endpoint_call()
128            .is_some_and(|call| call.headers().is_none())
129        {
130            return Ok(EndpointResponse::error(AuthError::Api {
131                status: 400,
132                code: None,
133                message: String::new(),
134            })
135            .without_error_body());
136        }
137        req.queue_response_header("Cache-Control", "no-store");
138        req.queue_response_header("Pragma", "no-cache");
139        if let Some(response) = self.require_client_privileges("create", req, ctx).await? {
140            return Ok(response);
141        }
142        let metadata: serde_json::Map<String, Value> =
143            serde_json::from_value(serde_json::to_value(input)?)?;
144        let metadata = match self.admit_metadata(metadata, &self.config.scopes, ctx)? {
145            Ok(metadata) => metadata,
146            Err(response) => return Ok(response),
147        };
148        self.persist_client(metadata, Some(user.id().as_ref().to_owned()), ctx)
149            .await
150    }
151}
152fn random_credential() -> String {
153    rand::rng()
154        .sample_iter(Alphanumeric)
155        .map(char::from)
156        .filter(char::is_ascii_alphabetic)
157        .take(32)
158        .collect()
159}
160fn protocol_error(error: &str, description: impl Into<String>) -> AuthResult<EndpointResponse> {
161    Ok(EndpointResponse::error(AuthError::Api {
162        status: 400,
163        code: None,
164        message: String::new(),
165    })
166    .with_error_body(alibi_core::utils::json::parse_value(
167        &serde_json::to_string(&json!({"error":error,"error_description":description.into()}))?,
168    )?))
169}
170fn redirect_error(uri: &str, application: &str) -> Option<String> {
171    let Ok(url) = url::Url::parse(uri) else {
172        return Some(format!("redirect URI must be an absolute URI: {uri}"));
173    };
174    if uri.contains('#')
175        || !url.username().is_empty()
176        || url.password().is_some_and(|value| !value.is_empty())
177    {
178        return Some(format!(
179            "redirect URI must not include credentials or a fragment: {uri}"
180        ));
181    }
182    let host = url.host_str().unwrap_or_default();
183    if host.trim_end_matches('.') == "localhost" && host.ends_with('.') {
184        return Some(format!(
185            "redirect URI localhost must not include trailing dots: {uri}"
186        ));
187    }
188    let loopback = host == "localhost"
189        || host
190            .parse::<std::net::IpAddr>()
191            .is_ok_and(|ip| ip.is_loopback())
192        || host == "[::1]";
193    if application == "web" {
194        return (url.scheme() != "https" || loopback).then(|| {
195            format!("web clients require https redirect URIs on non-loopback hosts: {uri}")
196        });
197    }
198    if url.scheme() == "https" {
199        return loopback
200            .then(|| format!("native clients must not use https loopback redirect URIs: {uri}"));
201    }
202    if url.scheme() == "http" {
203        let raw_host = raw_http_host(uri);
204        return (!raw_host.as_deref().is_some_and(|host|["localhost","127.0.0.1","[::1]"].contains(&host))).then(||format!("native clients may use http only on the exact loopback hosts localhost, 127.0.0.1, or [::1]: {uri}"));
205    }
206    let scheme = url.scheme();
207    let labels = scheme.split('.').collect::<Vec<_>>();
208    let reverse = labels.len() > 1
209        && labels.iter().all(|label| {
210            !label.is_empty()
211                && !label.starts_with('-')
212                && !label.ends_with('-')
213                && label
214                    .bytes()
215                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
216        });
217    let private = uri
218        .strip_prefix(&format!("{scheme}:"))
219        .is_some_and(|path| path.starts_with('/') && !path.starts_with("//"));
220    (!reverse||!private||["file","ftp","mailto","javascript","data","vbscript"].contains(&scheme)).then(||format!("native private-use redirect URI schemes must be well-formed reverse-domain names, omit the naming authority, and must not use a reserved scheme: {uri}"))
221}
222fn raw_http_host(uri: &str) -> Option<String> {
223    if !uri
224        .get(..7)
225        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
226    {
227        return None;
228    }
229    let authority = uri.get(7..)?.split(['/', '?', '#']).next()?;
230    let host = authority.rsplit('@').next()?;
231    if host.starts_with('[') {
232        host.find(']')
233            .and_then(|end| host.get(..=end))
234            .map(str::to_ascii_lowercase)
235    } else {
236        host.split(':').next().map(str::to_ascii_lowercase)
237    }
238}
239fn private_host(host: &str) -> bool {
240    !crate::sso::endpoint_host::public(host)
241}
242
243fn remote_key_uri_error<S: AuthSchema>(uri: &str, ctx: &AuthContext<S>) -> Option<String> {
244    let Ok(url) = url::Url::parse(uri) else {
245        return Some("jwks_uri must be a valid URL".into());
246    };
247    if url.scheme() != "https" {
248        return Some("jwks_uri must use HTTPS".into());
249    }
250    if !url.username().is_empty() || url.password().is_some_and(|value| !value.is_empty()) {
251        return Some("jwks_uri must not contain credentials".into());
252    }
253    if uri.contains('#') {
254        return Some("jwks_uri must not include a fragment component".into());
255    }
256    if private_host(url.host_str().unwrap_or_default()) {
257        return Some("jwks_uri must not point to a private or reserved address".into());
258    }
259    (!ctx.config.is_redirect_target_trusted(uri)).then(|| {
260        "jwks_uri must belong to a trusted origin or the Client ID Metadata Document origin".into()
261    })
262}
263fn backchannel_uri_error(uri: &str) -> Option<String> {
264    let Ok(url) = url::Url::parse(uri) else {
265        return Some("backchannel_logout_uri must be an absolute URL".into());
266    };
267    if uri.contains('#') {
268        return Some("backchannel_logout_uri must not include a fragment component".into());
269    }
270    if url.scheme() != "https" {
271        return Some("backchannel_logout_uri must use https".into());
272    }
273    if !url.username().is_empty() || url.password().is_some_and(|value| !value.is_empty()) {
274        return Some("backchannel_logout_uri must not contain credentials".into());
275    }
276    private_host(url.host_str().unwrap_or_default())
277        .then(|| "backchannel_logout_uri must not point to a private or reserved address".into())
278}
279pub(super) fn public_jwks_error(value: &Value) -> Option<&'static str> {
280    let keys = value.get("keys").and_then(Value::as_array)?;
281    for key in keys {
282        if ["k", "d", "p", "q", "dp", "dq", "qi", "oth"]
283            .iter()
284            .any(|name| key.get(name).is_some())
285            || key.get("kty").and_then(Value::as_str) == Some("oct")
286        {
287            return Some("jwks must contain only public asymmetric keys");
288        }
289        let string = |name| {
290            key.get(name)
291                .and_then(Value::as_str)
292                .is_some_and(|value| !value.is_empty())
293        };
294        let curve = key.get("crv").and_then(Value::as_str).unwrap_or_default();
295        let kind = key.get("kty").and_then(Value::as_str).unwrap_or_default();
296        let valid = match kind {
297            "RSA" => string("n") && string("e"),
298            "EC" => ["P-256", "P-384", "P-521"].contains(&curve) && string("x") && string("y"),
299            "OKP" => curve == "Ed25519" && string("x"),
300            _ => false,
301        };
302        if !valid {
303            return Some("jwks keys must be supported public JWKs with required key parameters");
304        }
305        if let Some(algorithm) = key.get("alg") {
306            let valid = algorithm.as_str().is_some_and(|alg| match kind {
307                "RSA" => ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"].contains(&alg),
308                "EC" => matches!(
309                    (curve, alg),
310                    ("P-256", "ES256") | ("P-384", "ES384") | ("P-521", "ES512")
311                ),
312                "OKP" => alg == "EdDSA",
313                _ => false,
314            });
315            if !valid {
316                return Some(
317                    "jwks key alg must be supported for private_key_jwt and compatible with its key type and signing curve",
318                );
319            }
320        }
321    }
322    None
323}
324#[derive(Clone, Copy)]
325enum FieldKind {
326    String,
327    Boolean,
328    Uris,
329    Uri,
330    Contacts,
331    Method,
332    Application,
333    Jwks,
334    Grants,
335    Responses,
336    Scopes,
337    Subject,
338    Expiration,
339    Record,
340}
341const FIELDS: &[(&str, FieldKind)] = &[
342    ("redirect_uris", FieldKind::Uris),
343    ("scope", FieldKind::String),
344    ("client_name", FieldKind::String),
345    ("client_uri", FieldKind::String),
346    ("logo_uri", FieldKind::String),
347    ("contacts", FieldKind::Contacts),
348    ("tos_uri", FieldKind::String),
349    ("policy_uri", FieldKind::String),
350    ("software_id", FieldKind::String),
351    ("software_version", FieldKind::String),
352    ("software_statement", FieldKind::String),
353    ("post_logout_redirect_uris", FieldKind::Uris),
354    ("backchannel_logout_uri", FieldKind::Uri),
355    ("backchannel_logout_session_required", FieldKind::Boolean),
356    ("token_endpoint_auth_method", FieldKind::Method),
357    ("application_type", FieldKind::Application),
358    ("jwks", FieldKind::Jwks),
359    ("jwks_uri", FieldKind::String),
360    ("grant_types", FieldKind::Grants),
361    ("response_types", FieldKind::Responses),
362    ("dpop_bound_access_tokens", FieldKind::Boolean),
363];
364pub(super) fn validate(body: Option<&JsValue>) -> AuthResult<JsValue> {
365    validate_metadata(body, "body", FIELDS)
366}
367fn validate_metadata(
368    body: Option<&JsValue>,
369    location: &str,
370    fields: &[(&str, FieldKind)],
371) -> AuthResult<JsValue> {
372    let Some(object) = body.and_then(JsValue::as_object) else {
373        return Err(crate::endpoint::validation(format!(
374            "[{location}] Invalid input: expected object, received {}",
375            crate::authentication_helpers::json_type(body)
376        )));
377    };
378    let mut issues = Vec::new();
379    let mut output = indexmap::IndexMap::new();
380    for (name, kind) in fields {
381        let Some(value) = object.get(*name) else {
382            continue;
383        };
384        let path = format!("{location}.{name}");
385        let normalized = match (kind, value) {
386            // SafeUrlSchema's z.url() trims JS whitespace without reserializing the URL.
387            (FieldKind::Uri | FieldKind::Method, JsValue::String(value)) => {
388                JsValue::String(js_trim(value).into())
389            }
390            (FieldKind::Uris, JsValue::Array(values)) => JsValue::Array(
391                values
392                    .iter()
393                    .map(|value| {
394                        value.as_str().map_or_else(
395                            || value.clone(),
396                            |text| JsValue::String(js_trim(text).into()),
397                        )
398                    })
399                    .collect(),
400            ),
401            (FieldKind::Grants | FieldKind::Scopes, JsValue::Array(values)) => JsValue::Array(
402                values
403                    .iter()
404                    .map(|value| {
405                        value.as_str().map_or_else(
406                            || value.clone(),
407                            |text| JsValue::String(js_trim(text).into()),
408                        )
409                    })
410                    .collect(),
411            ),
412            (FieldKind::Jwks, JsValue::Object(object)) => JsValue::Object(
413                object
414                    .iter()
415                    .filter(|(name, _)| name.as_str() == "keys")
416                    .map(|(name, value)| (name.clone(), value.clone()))
417                    .collect(),
418            ),
419            _ => value.clone(),
420        };
421        validate_field(*kind, &normalized, &path, &mut issues);
422        _ = output.insert((*name).into(), normalized);
423    }
424    if !issues.is_empty() {
425        return Err(crate::endpoint::validation(issues.join("; ")));
426    }
427    Ok(JsValue::Object(output))
428}
429fn validate_field(kind: FieldKind, value: &JsValue, path: &str, issues: &mut Vec<String>) {
430    use crate::authentication_helpers::{JsonFieldKind, field_issue, json_type};
431    let mut add = |issue: String| issues.push(format!("[{path}] {issue}"));
432    match kind {
433        FieldKind::String | FieldKind::Method | FieldKind::Uri => {
434            if let Some(issue) =
435                field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
436            {
437                add(issue);
438                return;
439            }
440            if let Some(text) = value.as_str() {
441                if matches!(kind, FieldKind::Method) && js_trim(text).is_empty() {
442                    add("Too small: expected string to have >=1 characters".into());
443                }
444                if matches!(kind, FieldKind::Uri) {
445                    for issue in safe_uri_issues(text) {
446                        add(issue.into());
447                    }
448                }
449            }
450        }
451        FieldKind::Boolean => {
452            if let Some(issue) =
453                field_issue(JsonFieldKind::Boolean, Some(value), json_type(Some(value)))
454            {
455                add(issue);
456            }
457        }
458        FieldKind::Application => {
459            if ![Some("web"), Some("native")].contains(&value.as_str()) {
460                add("Invalid option: expected one of \"web\"|\"native\"".into());
461            }
462        }
463        FieldKind::Uris
464        | FieldKind::Contacts
465        | FieldKind::Grants
466        | FieldKind::Responses
467        | FieldKind::Scopes => {
468            let JsValue::Array(values) = value else {
469                add(format!(
470                    "Invalid input: expected array, received {}",
471                    json_type(Some(value))
472                ));
473                return;
474            };
475            if values.is_empty() && !matches!(kind, FieldKind::Responses | FieldKind::Scopes) {
476                add("Too small: expected array to have >=1 items".into());
477            }
478            for (index, value) in values.iter().enumerate() {
479                let item_path = format!("{path}.{index}");
480                if matches!(kind, FieldKind::Responses) {
481                    if value.as_str() != Some("code") {
482                        issues.push(format!("[{item_path}] Invalid input: expected \"code\""));
483                    }
484                    continue;
485                }
486                if let Some(issue) =
487                    field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
488                {
489                    issues.push(format!("[{item_path}] {issue}"));
490                    continue;
491                }
492                if let Some(text) = value.as_str() {
493                    if matches!(kind, FieldKind::Contacts) && text.is_empty()
494                        || matches!(kind, FieldKind::Grants | FieldKind::Scopes)
495                            && js_trim(text).is_empty()
496                    {
497                        issues.push(format!(
498                            "[{item_path}] Too small: expected string to have >=1 characters"
499                        ));
500                    }
501                    if matches!(kind, FieldKind::Uris) {
502                        for issue in safe_uri_issues(text) {
503                            issues.push(format!("[{item_path}] {issue}"));
504                        }
505                    }
506                }
507            }
508        }
509        FieldKind::Subject => {
510            if ![Some("public"), Some("pairwise")].contains(&value.as_str()) {
511                add("Invalid option: expected one of \"public\"|\"pairwise\"".into());
512            }
513        }
514        FieldKind::Expiration => {
515            if !matches!(value, JsValue::String(_) | JsValue::Number(_)) {
516                add("Invalid input".into());
517            }
518        }
519        FieldKind::Record => {
520            if value.as_object().is_none() {
521                add(format!(
522                    "Invalid input: expected record, received {}",
523                    json_type(Some(value))
524                ));
525            }
526        }
527        FieldKind::Jwks => {
528            let Some(object) = value.as_object() else {
529                add(format!(
530                    "Invalid input: expected object, received {}",
531                    json_type(Some(value))
532                ));
533                return;
534            };
535            let keys = object.get("keys");
536            let Some(JsValue::Array(values)) = keys else {
537                issues.push(format!(
538                    "[{path}.keys] Invalid input: expected array, received {}",
539                    json_type(keys)
540                ));
541                return;
542            };
543            if values.is_empty() {
544                issues.push(format!(
545                    "[{path}.keys] Too small: expected array to have >=1 items"
546                ));
547            }
548            for (index, value) in values.iter().enumerate() {
549                if !matches!(value, JsValue::Object(_)) {
550                    issues.push(format!(
551                        "[{path}.keys.{index}] Invalid input: expected record, received {}",
552                        json_type(Some(value))
553                    ));
554                }
555            }
556        }
557    }
558}
559pub(super) fn safe_uri_issues(uri: &str) -> Vec<&'static str> {
560    let Ok(url) = url::Url::parse(uri) else {
561        return vec!["Invalid URL"];
562    };
563    if ["javascript", "data", "vbscript"].contains(&url.scheme()) {
564        return vec!["URL cannot use javascript:, data:, or vbscript: scheme"];
565    }
566    let mut issues = Vec::new();
567    if uri.contains('#') {
568        issues.push("Redirect URI must not contain a fragment component");
569    }
570    let host = url
571        .host_str()
572        .unwrap_or_default()
573        .trim_matches(['[', ']'])
574        .trim_end_matches('.');
575    let loopback = host == "localhost"
576        || host.ends_with(".localhost")
577        || host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
578            std::net::IpAddr::V4(ip) => ip.is_loopback(),
579            std::net::IpAddr::V6(ip) => {
580                ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|ip| ip.is_loopback())
581            }
582        });
583    if url.scheme() == "http" && !loopback {
584        issues.push("Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)");
585    }
586    issues
587}
588
589#[derive(Debug, Deserialize)]
590pub(super) struct ReadClientInput {
591    pub client_id: String,
592}
593pub(super) fn validate_read(query: Option<&JsValue>) -> AuthResult<JsValue> {
594    crate::endpoint::validate_fields(
595        query,
596        "query",
597        &[crate::authentication_helpers::JsonField::string(
598            "client_id",
599            true,
600        )],
601    )
602}
603impl OAuthProviderPlugin {
604    /// Read an owned client without exposing its stored credential.
605    pub fn get_oauth_client_endpoint(client_id: &str) -> AuthResult<ServerEndpoint<Value>> {
606        ServerEndpoint::new("oauth-provider", "getOAuthClient")
607            .with_query(&json!({"client_id":client_id}))
608    }
609    pub(super) async fn read_client<S: AuthSchema>(
610        &self,
611        input: ReadClientInput,
612        req: &impl SessionRequest,
613        ctx: &AuthContext<S>,
614    ) -> AuthResult<EndpointResponse> {
615        let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
616        if let Some(response) = self.require_client_privileges("read", req, ctx).await? {
617            return Ok(response);
618        }
619        let Some(client) = super::discovery::resolve(ctx, &input.client_id).await? else {
620            return Ok(EndpointResponse::error(AuthError::Api {
621                status: 404,
622                code: None,
623                message: String::new(),
624            })
625            .with_error_body(alibi_core::utils::json::parse_value(
626                r#"{"error":"not_found","error_description":"client not found"}"#,
627            )?));
628        };
629        if client.user_id.as_deref() != Some(user.id().as_ref()) {
630            return Ok(EndpointResponse::error(AuthError::Api {
631                status: 401,
632                code: None,
633                message: String::new(),
634            })
635            .without_error_body()
636            .with_header("Content-Type", "application/json"));
637        }
638        EndpointResponse::json(&client_read_metadata(client)?)
639    }
640}
641
642impl OAuthProviderPlugin {
643    /// Read public display fields for any authenticated user.
644    pub fn get_oauth_client_public_endpoint(client_id: &str) -> AuthResult<ServerEndpoint<Value>> {
645        ServerEndpoint::new("oauth-provider", "getOAuthClientPublic")
646            .with_query(&json!({"client_id":client_id}))
647    }
648    pub(super) async fn read_public_client<S: AuthSchema>(
649        &self,
650        input: ReadClientInput,
651        req: &impl SessionRequest,
652        ctx: &AuthContext<S>,
653    ) -> AuthResult<EndpointResponse> {
654        _ = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
655        Box::pin(self.public_client_projection(&input.client_id, ctx)).await
656    }
657    async fn public_client_projection<S: AuthSchema>(
658        &self,
659        client_id: &str,
660        ctx: &AuthContext<S>,
661    ) -> AuthResult<EndpointResponse> {
662        let client = super::discovery::resolve(ctx, client_id).await?;
663
664        let Some(client) =
665            client.filter(|client| client.metadata.get("disabled") != Some(&json!(true)))
666        else {
667            return Ok(EndpointResponse::error(AuthError::Api {
668                status: 404,
669                code: None,
670                message: String::new(),
671            })
672            .with_error_body(alibi_core::utils::json::parse_value(
673                r#"{"error":"not_found","error_description":"client not found"}"#,
674            )?));
675        };
676        let mut display = serde_json::Map::new();
677        for name in [
678            "client_name",
679            "client_uri",
680            "logo_uri",
681            "contacts",
682            "tos_uri",
683            "policy_uri",
684        ] {
685            if let Some(value) = client.metadata.get(name) {
686                _ = display.insert(name.into(), value.clone());
687            }
688        }
689        _ = display.insert("client_id".into(), json!(client.client_id));
690        _ = display.insert("redirect_uris".into(), json!([]));
691        EndpointResponse::json(&Value::Object(display))
692    }
693}
694
695fn client_read_metadata(client: alibi_core::store::OAuthClient) -> AuthResult<Value> {
696    let mut output = client_response_metadata(serde_json::from_value(client.metadata)?);
697    _ = output.insert("client_id".into(), json!(client.client_id));
698    _ = output.insert("user_id".into(), json!(client.user_id));
699    _ = output.insert(
700        "client_id_issued_at".into(),
701        json!(client.created_at.map(|time| time.timestamp())),
702    );
703    if client.client_secret.is_some() {
704        _ = output
705            .entry("client_secret_expires_at")
706            .or_insert_with(|| json!(0));
707    }
708    _ = output.remove("client_secret");
709    _ = output.remove("client_credentials_scopes");
710    Ok(Value::Object(output))
711}
712
713/// Opaque aliases are native-internal only in the envelope; on the OAuth wire
714/// they retain their allowed extension names, never the native authority value.
715fn client_response_metadata(
716    mut metadata: serde_json::Map<String, Value>,
717) -> serde_json::Map<String, Value> {
718    if let Some(Value::Object(extensions)) = metadata.remove("metadata") {
719        metadata.extend(
720            extensions
721                .into_iter()
722                .filter(|(name, _)| !RESERVED_METADATA_FIELDS.contains(&name.as_str())),
723        );
724    }
725    metadata
726}
727impl OAuthProviderPlugin {
728    /// List durable clients belonging to the authenticated user.
729    #[must_use]
730    pub fn get_oauth_clients_endpoint() -> ServerEndpoint<Value> {
731        ServerEndpoint::new("oauth-provider", "getOAuthClients")
732    }
733    pub(super) async fn list_clients<S: AuthSchema>(
734        &self,
735        req: &impl SessionRequest,
736        ctx: &AuthContext<S>,
737    ) -> AuthResult<EndpointResponse> {
738        let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
739        if let Some(response) = self.require_client_privileges("list", req, ctx).await? {
740            return Ok(response);
741        }
742        let clients = ctx
743            .database
744            .list_oauth_clients_by_user(user.id().as_ref())
745            .await?;
746        let output: Vec<Value> = clients
747            .into_iter()
748            .map(client_read_metadata)
749            .collect::<AuthResult<_>>()?;
750        EndpointResponse::json(&output)
751    }
752}
753
754impl OAuthProviderPlugin {
755    /// Delete a durable client owned by the authenticated user.
756    pub fn delete_oauth_client_endpoint(client_id: &str) -> AuthResult<ServerEndpoint<()>> {
757        ServerEndpoint::new("oauth-provider", "deleteOAuthClient")
758            .with_body(&json!({"client_id":client_id}))
759    }
760    pub(super) async fn delete_client<S: AuthSchema>(
761        &self,
762        input: ReadClientInput,
763        req: &impl SessionRequest,
764        ctx: &AuthContext<S>,
765    ) -> AuthResult<EndpointResponse> {
766        let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
767        if let Some(response) = self.require_client_privileges("delete", req, ctx).await? {
768            return Ok(response);
769        }
770        if self
771            .config
772            .cached_trusted_clients
773            .contains(&input.client_id)
774        {
775            return status_protocol_error(
776                500,
777                "invalid_client",
778                "trusted clients must be updated manually",
779            );
780        }
781        let Some(client) = super::discovery::resolve(ctx, &input.client_id).await? else {
782            return Ok(EndpointResponse::error(AuthError::Api {
783                status: 404,
784                code: None,
785                message: String::new(),
786            })
787            .with_error_body(alibi_core::utils::json::parse_value(
788                r#"{"error":"not_found","error_description":"client not found"}"#,
789            )?));
790        };
791        if client.user_id.as_deref() != Some(user.id().as_ref()) {
792            return Ok(EndpointResponse::error(AuthError::Api {
793                status: 401,
794                code: None,
795                message: String::new(),
796            })
797            .without_error_body()
798            .with_header("Content-Type", "application/json"));
799        }
800        ctx.database.delete_oauth_client(&input.client_id).await?;
801        Ok(EndpointResponse::empty().with_header("Content-Type", "application/json"))
802    }
803}
804pub(super) fn validate_delete(body: Option<&JsValue>) -> AuthResult<JsValue> {
805    crate::endpoint::validate_fields(
806        body,
807        "body",
808        &[crate::authentication_helpers::JsonField::string(
809            "client_id",
810            true,
811        )],
812    )
813}
814
815impl OAuthProviderPlugin {
816    /// Rotate an owned confidential client secret and reveal its replacement once.
817    pub fn rotate_client_secret_endpoint(client_id: &str) -> AuthResult<ServerEndpoint<Value>> {
818        ServerEndpoint::new("oauth-provider", "rotateClientSecret")
819            .with_body(&json!({"client_id":client_id}))
820    }
821    pub(super) async fn rotate_client_secret<S: AuthSchema>(
822        &self,
823        input: ReadClientInput,
824        req: &impl SessionRequest,
825        ctx: &AuthContext<S>,
826    ) -> AuthResult<EndpointResponse> {
827        let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
828        req.queue_response_header("Cache-Control", "no-store");
829        req.queue_response_header("Pragma", "no-cache");
830        if let Some(response) = self.require_client_privileges("rotate", req, ctx).await? {
831            return Ok(response);
832        }
833        if self
834            .config
835            .cached_trusted_clients
836            .contains(&input.client_id)
837        {
838            return status_protocol_error(
839                500,
840                "invalid_client",
841                "trusted clients must be updated manually",
842            );
843        }
844        let Some(client) = super::discovery::resolve(ctx, &input.client_id).await? else {
845            return status_protocol_error(404, "not_found", "client not found");
846        };
847        if client.user_id.as_deref() != Some(user.id().as_ref()) {
848            return Ok(EndpointResponse::error(AuthError::Api {
849                status: 401,
850                code: None,
851                message: String::new(),
852            })
853            .without_error_body()
854            .with_header("Content-Type", "application/json"));
855        }
856        if client
857            .metadata
858            .get("token_endpoint_auth_method")
859            .and_then(Value::as_str)
860            == Some("none")
861            || client.client_secret.is_none()
862        {
863            return status_protocol_error(
864                400,
865                "invalid_client",
866                "secret rotation is only available for clients using client_secret authentication",
867            );
868        }
869        let plaintext = self.generate_client_secret()?;
870        let protected = super::client_secret::store(ctx, &self.config, &plaintext).await?;
871        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
872            .ok_or_else(|| AuthError::internal("Invalid client update timestamp"))?;
873        let Some(client) = ctx
874            .database
875            .update_oauth_client_secret(&input.client_id, protected, now)
876            .await?
877        else {
878            return status_protocol_error(500, "invalid_client", "unable to update client");
879        };
880        let mut output = client_read_metadata(client)?;
881        _ = output
882            .as_object_mut()
883            .ok_or_else(|| AuthError::internal("OAuth client metadata must be an object"))?
884            .insert(
885                "client_secret".into(),
886                Value::String(self.prefixed_client_secret(plaintext)),
887            );
888        EndpointResponse::json(&output)
889    }
890}
891pub(super) fn status_protocol_error(
892    status: u16,
893    error: &str,
894    description: &str,
895) -> AuthResult<EndpointResponse> {
896    Ok(EndpointResponse::error(AuthError::Api {
897        status,
898        code: None,
899        message: String::new(),
900    })
901    .with_error_body(alibi_core::utils::json::parse_value(
902        &serde_json::to_string(&json!({"error":error,"error_description":description}))?,
903    )?))
904}
905
906impl OAuthProviderPlugin {
907    fn admit_metadata<S: AuthSchema>(
908        &self,
909        metadata: serde_json::Map<String, Value>,
910        allowed_scopes: &[String],
911        ctx: &AuthContext<S>,
912    ) -> AuthResult<Result<serde_json::Map<String, Value>, EndpointResponse>> {
913        self.admit_metadata_for_source(metadata, allowed_scopes, ctx, false)
914    }
915    pub(super) fn admit_metadata_for_source<S: AuthSchema>(
916        &self,
917        mut metadata: serde_json::Map<String, Value>,
918        allowed_scopes: &[String],
919        ctx: &AuthContext<S>,
920        document: bool,
921    ) -> AuthResult<Result<serde_json::Map<String, Value>, EndpointResponse>> {
922        let auth = metadata
923            .get("token_endpoint_auth_method")
924            .and_then(Value::as_str)
925            .unwrap_or("client_secret_basic")
926            .to_owned();
927        if !self.supported_auth_methods(true).contains(&auth) {
928            return protocol_error(
929                "invalid_client_metadata",
930                format!("unsupported token_endpoint_auth_method {auth}"),
931            )
932            .map(Err);
933        }
934        let grants = metadata
935            .get("grant_types")
936            .and_then(Value::as_array)
937            .cloned()
938            .unwrap_or_else(|| vec![json!("authorization_code")]);
939        if document && grants.is_empty() {
940            return protocol_error(
941                "invalid_client_metadata",
942                "grant_types must contain at least one grant type",
943            )
944            .map(Err);
945        }
946        if document
947            && !grants.iter().any(|grant| {
948                grant.as_str().is_some_and(|grant| {
949                    self.supported_grant_types()
950                        .iter()
951                        .any(|supported| supported == grant)
952                })
953            })
954        {
955            return protocol_error(
956                "invalid_client_metadata",
957                format!(
958                    "no mutually supported grant_type among {}",
959                    grants
960                        .iter()
961                        .filter_map(Value::as_str)
962                        .collect::<Vec<_>>()
963                        .join(", ")
964                ),
965            )
966            .map(Err);
967        }
968        for grant in &grants {
969            if !document
970                && !grant.as_str().is_some_and(|grant| {
971                    self.supported_grant_types()
972                        .iter()
973                        .any(|supported| supported == grant)
974                })
975            {
976                return protocol_error(
977                    "invalid_client_metadata",
978                    format!(
979                        "unsupported grant_type {}",
980                        grant.as_str().unwrap_or_default()
981                    ),
982                )
983                .map(Err);
984            }
985        }
986        let code_grant = grants.contains(&json!("authorization_code"));
987        let redirects = metadata
988            .get("redirect_uris")
989            .and_then(Value::as_array)
990            .cloned()
991            .unwrap_or_default();
992        if code_grant && redirects.is_empty() {
993            return protocol_error(
994                "invalid_redirect_uri",
995                "Redirect URIs are required for authorization_code and implicit grant types",
996            )
997            .map(Err);
998        }
999        let application = metadata
1000            .get("application_type")
1001            .and_then(Value::as_str)
1002            .unwrap_or(if document { "native" } else { "web" });
1003        if document && !["web", "native"].contains(&application) {
1004            return protocol_error(
1005                "invalid_client_metadata",
1006                "application_type must be web or native",
1007            )
1008            .map(Err);
1009        }
1010        for redirect in &redirects {
1011            if let Some(error) = redirect_error(redirect.as_str().unwrap_or_default(), application)
1012            {
1013                return protocol_error("invalid_redirect_uri", error).map(Err);
1014            }
1015        }
1016        let responses = metadata
1017            .get("response_types")
1018            .and_then(Value::as_array)
1019            .cloned()
1020            .or_else(|| code_grant.then(|| vec![json!("code")]));
1021        if code_grant
1022            && !responses
1023                .as_ref()
1024                .is_some_and(|responses| responses.contains(&json!("code")))
1025        {
1026            return protocol_error(
1027                "invalid_client_metadata",
1028                "When 'authorization_code' grant type is used, 'code' response type must be included",
1029            ).map(Err);
1030        }
1031        if !code_grant
1032            && responses
1033                .as_ref()
1034                .is_some_and(|responses| responses.contains(&json!("code")))
1035        {
1036            return protocol_error(
1037                "invalid_client_metadata",
1038                "When 'code' response type is used, 'authorization_code' grant type must be included",
1039            ).map(Err);
1040        }
1041        if document
1042            && metadata
1043                .get("subject_type")
1044                .and_then(Value::as_str)
1045                .is_some_and(|subject| !["public", "pairwise"].contains(&subject))
1046        {
1047            return protocol_error(
1048                "invalid_client_metadata",
1049                "subject_type must be \"public\" or \"pairwise\"",
1050            )
1051            .map(Err);
1052        }
1053        if metadata.get("subject_type").and_then(Value::as_str) == Some("pairwise") {
1054            if self
1055                .config
1056                .pairwise_secret
1057                .as_ref()
1058                .is_none_or(String::is_empty)
1059            {
1060                return protocol_error(
1061                    "invalid_client_metadata",
1062                    "pairwise subject_type requires server pairwiseSecret configuration",
1063                )
1064                .map(Err);
1065            }
1066            let mut hosts = Vec::new();
1067            for redirect in &redirects {
1068                if let Some(uri) = redirect.as_str()
1069                    && let Ok(url) = url::Url::parse(uri)
1070                {
1071                    let host = format!(
1072                        "{}:{}",
1073                        url.host_str().unwrap_or_default(),
1074                        url.port().map_or_else(String::new, |port| port.to_string())
1075                    );
1076                    if !hosts.contains(&host) {
1077                        hosts.push(host);
1078                    }
1079                }
1080            }
1081            if hosts.len() > 1 {
1082                return protocol_error("invalid_client_metadata","pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host.").map(Err);
1083            }
1084        }
1085        if let Some(scope) = metadata.get("scope").and_then(Value::as_str) {
1086            for scope in scope.split(' ').filter(|scope| !scope.is_empty()) {
1087                if !allowed_scopes.iter().any(|allowed| allowed == scope) {
1088                    return protocol_error(
1089                        "invalid_scope",
1090                        format!("cannot request scope {scope}"),
1091                    )
1092                    .map(Err);
1093                }
1094            }
1095        }
1096        if metadata.contains_key("jwks") && metadata.contains_key("jwks_uri") {
1097            return protocol_error(
1098                "invalid_client_metadata",
1099                "jwks and jwks_uri are mutually exclusive",
1100            )
1101            .map(Err);
1102        }
1103        if document && metadata.get("require_pkce") == Some(&json!(false)) {
1104            return protocol_error(
1105                "invalid_client_metadata",
1106                "pkce is required for registered clients.",
1107            )
1108            .map(Err);
1109        }
1110        if let Some(uri) = metadata.get("jwks_uri").and_then(Value::as_str)
1111            && let Some(error) = remote_key_uri_error(uri, ctx).filter(|error| {
1112                !(document
1113                    && error.starts_with("jwks_uri must belong")
1114                    && metadata
1115                        .get("client_id")
1116                        .and_then(Value::as_str)
1117                        .and_then(|id| url::Url::parse(id).ok())
1118                        .zip(url::Url::parse(uri).ok())
1119                        .is_some_and(|(id, key)| id.origin() == key.origin()))
1120            })
1121        {
1122            return protocol_error("invalid_client_metadata", error).map(Err);
1123        }
1124        if let Some(jwks) = metadata.get("jwks")
1125            && document
1126            && !jwks
1127                .get("keys")
1128                .and_then(Value::as_array)
1129                .is_some_and(|keys| !keys.is_empty() && keys.iter().all(Value::is_object))
1130        {
1131            return protocol_error(
1132                "invalid_client_metadata",
1133                "jwks must be an RFC 7517 JWK Set object with a non-empty keys array",
1134            )
1135            .map(Err);
1136        }
1137        if let Some(jwks) = metadata.get("jwks")
1138            && let Some(error) = public_jwks_error(jwks)
1139        {
1140            return protocol_error("invalid_client_metadata", error).map(Err);
1141        }
1142        if auth == "private_key_jwt"
1143            && !metadata.contains_key("jwks")
1144            && !metadata.contains_key("jwks_uri")
1145        {
1146            return protocol_error(
1147                "invalid_client_metadata",
1148                "private_key_jwt requires either jwks or jwks_uri",
1149            )
1150            .map(Err);
1151        }
1152        if let Some(uri) = metadata
1153            .get("backchannel_logout_uri")
1154            .and_then(Value::as_str)
1155        {
1156            if self.config.disable_jwt_plugin {
1157                return protocol_error(
1158                    "invalid_client_metadata",
1159                    "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)",
1160                ).map(Err);
1161            }
1162            if let Some(error) = backchannel_uri_error(uri) {
1163                return protocol_error("invalid_client_metadata", error).map(Err);
1164            }
1165        }
1166        _ = metadata.insert("token_endpoint_auth_method".into(), json!(auth));
1167        if !document {
1168            _ = metadata
1169                .entry("application_type")
1170                .or_insert_with(|| json!("web"));
1171        }
1172        _ = metadata.insert("grant_types".into(), json!(grants));
1173        if let Some(responses) = responses {
1174            _ = metadata.insert("response_types".into(), json!(responses));
1175        }
1176        _ = metadata.insert("redirect_uris".into(), json!(redirects));
1177        if !document {
1178            _ = metadata.entry("disabled").or_insert_with(|| json!(false));
1179            _ = metadata
1180                .entry("dpop_bound_access_tokens")
1181                .or_insert_with(|| json!(false));
1182        }
1183        Ok(Ok(metadata))
1184    }
1185}
1186
1187#[derive(Debug, Deserialize)]
1188pub(super) struct UpdateClientInput {
1189    client_id: String,
1190    update: Value,
1191}
1192pub(super) fn validate_update(body: Option<&JsValue>) -> AuthResult<JsValue> {
1193    let mut output = crate::endpoint::validate_fields(
1194        body,
1195        "body",
1196        &[crate::authentication_helpers::JsonField::string(
1197            "client_id",
1198            true,
1199        )],
1200    )?;
1201    let fields = FIELDS
1202        .iter()
1203        .copied()
1204        .filter(|(name, _)| {
1205            ![
1206                "token_endpoint_auth_method",
1207                "jwks",
1208                "jwks_uri",
1209                "dpop_bound_access_tokens",
1210            ]
1211            .contains(name)
1212        })
1213        .collect::<Vec<_>>();
1214    let update = validate_metadata(
1215        body.and_then(|value| value.get("update")),
1216        "body.update",
1217        &fields,
1218    )?;
1219    if let JsValue::Object(object) = &mut output {
1220        _ = object.insert("update".into(), update);
1221    }
1222    Ok(output)
1223}
1224impl OAuthProviderPlugin {
1225    /// Patch metadata of a durable client owned by the authenticated user.
1226    pub fn update_oauth_client_endpoint(
1227        client_id: &str,
1228        update: &impl Serialize,
1229    ) -> AuthResult<ServerEndpoint<Value>> {
1230        ServerEndpoint::new("oauth-provider", "updateOAuthClient")
1231            .with_body(&json!({"client_id":client_id,"update":update}))
1232    }
1233
1234    pub(super) async fn update_client<S: AuthSchema>(
1235        &self,
1236        input: UpdateClientInput,
1237        req: &impl SessionRequest,
1238        ctx: &AuthContext<S>,
1239        admin: bool,
1240    ) -> AuthResult<EndpointResponse> {
1241        let user = if admin {
1242            let Some((user, _)) = Box::pin(ctx.require_cached_session(req)).await.ok() else {
1243                return Ok(EndpointResponse::error(AuthError::Api {
1244                    status: 401,
1245                    code: None,
1246                    message: String::new(),
1247                })
1248                .without_error_body()
1249                .with_header("Content-Type", "application/json"));
1250            };
1251            user
1252        } else {
1253            Box::pin(crate::helpers::ordinary_session(req, ctx))
1254                .await?
1255                .0
1256        };
1257        if let Some(response) = self.require_client_privileges("update", req, ctx).await? {
1258            return Ok(response);
1259        }
1260        if self
1261            .config
1262            .cached_trusted_clients
1263            .contains(&input.client_id)
1264        {
1265            return status_protocol_error(
1266                500,
1267                "invalid_client",
1268                "trusted clients must be updated manually",
1269            );
1270        }
1271        let Some(client) = super::discovery::resolve(ctx, &input.client_id).await? else {
1272            return status_protocol_error(404, "not_found", "client not found");
1273        };
1274        let mut updates: serde_json::Map<String, Value> = serde_json::from_value(input.update)?;
1275        // Only the global operator policy can authorize the pinned administrative
1276        // scopes-only exception. It never grants cross-owner metadata authority.
1277        let cross_owner_scopes = client.user_id.as_deref() != Some(user.id().as_ref())
1278            && admin
1279            && self.config.client_privileges.is_some()
1280            && updates.len() == 1
1281            && updates.contains_key("client_credentials_scopes");
1282        if client.user_id.as_deref() != Some(user.id().as_ref()) && !cross_owner_scopes {
1283            return Ok(client_privilege_denied(401));
1284        }
1285        if cross_owner_scopes
1286            && let Some(response) = self
1287                .require_client_privileges("configure-client-credentials-scopes", req, ctx)
1288                .await?
1289        {
1290            return Ok(response);
1291        }
1292        if updates.is_empty() {
1293            return client_update_response(client, admin);
1294        }
1295        let mut merged: serde_json::Map<String, Value> = serde_json::from_value(client.metadata)?;
1296        let scopes = updates.remove("client_credentials_scopes");
1297        let extensions = updates
1298            .remove("metadata")
1299            .and_then(|value| value.as_object().cloned());
1300        // The pinned TS adapter strips expiresAt because it is absent from the client schema.
1301        _ = updates.remove("client_secret_expires_at");
1302        merged.extend(updates);
1303        if let Some(scopes) = scopes.and_then(|value| value.as_array().cloned()) {
1304            if let Some(response) = self
1305                .admit_credential_scopes(&scopes, &merged, req, ctx, !cross_owner_scopes)
1306                .await?
1307            {
1308                return Ok(response);
1309            }
1310            _ = merged.insert("client_credentials_scopes".into(), json!(scopes));
1311        }
1312        if let Some(extensions) = extensions {
1313            let extensions = extensions
1314                .into_iter()
1315                .filter(|(name, _)| !RESERVED_METADATA_FIELDS.contains(&name.as_str()))
1316                .collect::<serde_json::Map<_, _>>();
1317            if !extensions.is_empty() {
1318                merged.retain(|name, _| {
1319                    RESERVED_METADATA_FIELDS.contains(&name.as_str())
1320                        || name == "client_discovery_id"
1321                });
1322                _ = merged.insert("metadata".into(), Value::Object(extensions));
1323            }
1324        }
1325        if !merged
1326            .get("grant_types")
1327            .and_then(Value::as_array)
1328            .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1329            || merged
1330                .get("token_endpoint_auth_method")
1331                .and_then(Value::as_str)
1332                == Some("none")
1333        {
1334            _ = merged.insert("client_credentials_scopes".into(), json!([]));
1335        }
1336        // Update admission may infer defaults, but the reference persists only the requested patch.
1337        let metadata = match self.admit_metadata(merged.clone(), &self.config.scopes, ctx)? {
1338            Ok(_) => Value::Object(merged),
1339            Err(response) => return Ok(response),
1340        };
1341        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
1342            .ok_or_else(|| AuthError::internal("Invalid client update timestamp"))?;
1343        let Some(client) = ctx
1344            .database
1345            .update_oauth_client_metadata(&input.client_id, metadata, now)
1346            .await?
1347        else {
1348            return status_protocol_error(500, "invalid_client", "unable to update client");
1349        };
1350        client_update_response(client, admin)
1351    }
1352}
1353
1354impl OAuthProviderPlugin {
1355    fn generate_client_secret(&self) -> AuthResult<String> {
1356        let generated = self
1357            .config
1358            .client_secret_generator
1359            .as_ref()
1360            .map(|generator| generator.generate())
1361            .transpose()?;
1362        Ok(generated
1363            .filter(|secret| !secret.is_empty())
1364            .unwrap_or_else(random_credential))
1365    }
1366    fn prefixed_client_secret(&self, secret: String) -> String {
1367        match self.config.client_secret_prefix.as_deref() {
1368            Some(prefix) if !prefix.is_empty() => format!("{prefix}{secret}"),
1369            _ => secret,
1370        }
1371    }
1372    async fn persist_client<S: AuthSchema>(
1373        &self,
1374        mut metadata: serde_json::Map<String, Value>,
1375        user_id: Option<String>,
1376        ctx: &AuthContext<S>,
1377    ) -> AuthResult<EndpointResponse> {
1378        let auth = metadata
1379            .get("token_endpoint_auth_method")
1380            .and_then(Value::as_str)
1381            .unwrap_or("client_secret_basic");
1382        let client_id = match &self.config.client_id_generator {
1383            Some(generator) => generator.generate()?,
1384            None => random_credential(),
1385        };
1386        let plaintext =
1387            if ["none", "private_key_jwt"].contains(&auth) || self.is_extension_auth_method(auth) {
1388                None
1389            } else {
1390                Some(self.generate_client_secret()?)
1391            };
1392        let stored = if let Some(secret) = plaintext.as_deref() {
1393            Some(super::client_secret::store(ctx, &self.config, secret).await?)
1394        } else {
1395            None
1396        };
1397        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
1398            .ok_or_else(|| AuthError::internal("Invalid client creation timestamp"))?;
1399        // Only dynamic registration passes this extension through admission. It is
1400        // response/relation data, never a field of the oauthClient model.
1401        let requested = metadata
1402            .remove("resources")
1403            .map(serde_json::from_value::<Vec<String>>)
1404            .transpose()?
1405            .unwrap_or_default();
1406        let defaults = self
1407            .config
1408            .client_registration_default_resources
1409            .as_deref()
1410            .unwrap_or_default();
1411        for identifier in &requested {
1412            if !defaults.contains(identifier)
1413                && !self
1414                    .config
1415                    .client_registration_allowed_resources
1416                    .as_ref()
1417                    .is_some_and(|allowed| allowed.contains(identifier))
1418            {
1419                return protocol_error(
1420                    "invalid_target",
1421                    format!(
1422                        "requested resource {identifier} is not allowed for client registration"
1423                    ),
1424                );
1425            }
1426        }
1427        let mut resources = Vec::new();
1428        for identifier in defaults.iter().chain(&requested) {
1429            if !resources.contains(identifier) {
1430                resources.push(identifier.clone());
1431            }
1432        }
1433        for identifier in &resources {
1434            let Some(resource) = self.get_resource(ctx, identifier).await? else {
1435                return protocol_error(
1436                    "invalid_target",
1437                    format!("requested resource {identifier} does not exist"),
1438                );
1439            };
1440            if resource.metadata.get("disabled").and_then(Value::as_bool) == Some(true) {
1441                return protocol_error(
1442                    "invalid_target",
1443                    format!("requested resource {identifier} is disabled"),
1444                );
1445            }
1446        }
1447        let administrative = metadata.contains_key("client_credentials_scopes");
1448        _ = metadata
1449            .entry("client_credentials_scopes")
1450            .or_insert_with(|| json!([]));
1451        let client = ctx
1452            .database
1453            .persist_oauth_client_registration(PersistOAuthClientRegistration {
1454                client: CreateOAuthClient {
1455                    client_id,
1456                    user_id,
1457                    client_secret: stored,
1458                    metadata: Value::Object(metadata),
1459                    created_at: now,
1460                },
1461                replacement: None,
1462                resources: resources.clone(),
1463                updated_at: now,
1464            })
1465            .await
1466            .map_err(|error| match error {
1467                OAuthClientRegistrationWriteError::Failure(error)
1468                | OAuthClientRegistrationWriteError::Collision { cause: error, .. } => error,
1469            })?
1470            .ok_or_else(|| AuthError::internal("Client creation returned no client"))?;
1471        let mut output = client_response_metadata(serde_json::from_value(client.metadata)?);
1472        if !administrative {
1473            _ = output.remove("client_credentials_scopes");
1474        }
1475        if !resources.is_empty() {
1476            _ = output.insert("resources".into(), json!(resources));
1477        }
1478        _ = output.insert("client_id".into(), json!(client.client_id));
1479        if let Some(user_id) = client.user_id {
1480            _ = output.insert("user_id".into(), json!(user_id));
1481        }
1482        _ = output.insert(
1483            "client_id_issued_at".into(),
1484            json!(client.created_at.map(|time| time.timestamp())),
1485        );
1486        if let Some(secret) = plaintext {
1487            _ = output.insert(
1488                "client_secret".into(),
1489                Value::String(self.prefixed_client_secret(secret)),
1490            );
1491            _ = output.insert("client_secret_expires_at".into(), json!(0));
1492        }
1493        Ok(EndpointResponse::json(&Value::Object(output))?.with_status(201))
1494    }
1495    /// Register a client under the provider's dynamic-registration policy.
1496    pub fn register_oauth_client_endpoint(
1497        input: &impl Serialize,
1498    ) -> AuthResult<ServerEndpoint<Value>> {
1499        ServerEndpoint::new("oauth-provider", "registerOAuthClient").with_body(input)
1500    }
1501    pub(super) async fn register_client<S: AuthSchema>(
1502        &self,
1503        body: Option<&JsValue>,
1504        req: &impl SessionRequest,
1505        ctx: &AuthContext<S>,
1506    ) -> AuthResult<EndpointResponse> {
1507        let mut metadata = match registration_metadata(body)? {
1508            Ok(metadata) => metadata,
1509            Err(response) => return Ok(response),
1510        };
1511        req.queue_response_header("Cache-Control", "no-store");
1512        req.queue_response_header("Pragma", "no-cache");
1513        if !self.config.allow_dynamic_client_registration {
1514            return status_protocol_error(403, "access_denied", "Client registration is disabled");
1515        }
1516        let session = Box::pin(ctx.require_cached_session(req)).await.ok();
1517        if session.is_some()
1518            && let Some(response) = self.require_client_privileges("create", req, ctx).await?
1519        {
1520            return Ok(response);
1521        }
1522        let mut token_authorization = None;
1523        if session.is_none()
1524            && let Some(authorization) = req.session_headers().get("authorization")
1525        {
1526            let mut parts = authorization
1527                .split(alibi_core::utils::javascript::is_whitespace)
1528                .filter(|part| !part.is_empty());
1529            if parts
1530                .next()
1531                .is_some_and(|scheme| scheme.eq_ignore_ascii_case("bearer"))
1532            {
1533                let token = parts.next();
1534                if token.is_none() || parts.next().is_some() {
1535                    return status_protocol_error(
1536                        400,
1537                        "invalid_request",
1538                        "Malformed initial access token Authorization header",
1539                    )
1540                    .map(|response| {
1541                        response.with_header("WWW-Authenticate", "Bearer error=\"invalid_request\"")
1542                    });
1543                }
1544                let mut client_metadata = metadata.clone();
1545                if let Some(resources) = body.and_then(|body| body.get("resources")) {
1546                    _ = client_metadata.insert(
1547                        "resources".into(),
1548                        alibi_core::utils::json::from_value(resources.clone())?,
1549                    );
1550                }
1551                let mut headers = alibi_core::Headers::new();
1552                for (name, value) in req.session_headers() {
1553                    _ = headers.insert(name.clone(), value.clone());
1554                }
1555                token_authorization = Some(
1556                    match self
1557                        .authorize_initial_access_token(super::OAuthInitialAccessTokenInput {
1558                            initial_access_token: token.unwrap_or_default(),
1559                            headers: &headers,
1560                            client_metadata: &client_metadata,
1561                        })
1562                        .await?
1563                    {
1564                        Ok(authorization) => authorization,
1565                        Err(response) => return Ok(response),
1566                    },
1567                );
1568            }
1569        }
1570        if session.is_none()
1571            && token_authorization.is_none()
1572            && !self.config.allow_unauthenticated_client_registration
1573        {
1574            return Ok(EndpointResponse::error(AuthError::Api {
1575                status: 401,
1576                code: None,
1577                message: String::new(),
1578            })
1579            .with_error_body(alibi_core::utils::json::parse_value(
1580                r#"{"error_description":"Authentication required for client registration"}"#,
1581            )?)
1582            .with_header("WWW-Authenticate", "Bearer"));
1583        }
1584        if session.is_none()
1585            && token_authorization.is_none()
1586            && metadata
1587                .get("grant_types")
1588                .and_then(Value::as_array)
1589                .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1590        {
1591            return status_protocol_error(
1592                400,
1593                "invalid_client_metadata",
1594                "client_credentials grant requires authenticated registration",
1595            );
1596        }
1597        if metadata
1598            .get("scope")
1599            .and_then(Value::as_str)
1600            .is_none_or(str::is_empty)
1601        {
1602            _ = metadata.insert(
1603                "scope".into(),
1604                json!(
1605                    self.config
1606                        .client_registration_default_scopes
1607                        .as_ref()
1608                        .unwrap_or(&self.config.scopes)
1609                        .join(" ")
1610                ),
1611            );
1612        }
1613        let registration_scopes = super::unique(
1614            self.config
1615                .client_registration_default_scopes
1616                .as_ref()
1617                .unwrap_or(&self.config.scopes)
1618                .iter()
1619                .chain(
1620                    self.config
1621                        .client_registration_allowed_scopes
1622                        .iter()
1623                        .flatten(),
1624                )
1625                .cloned()
1626                .collect(),
1627        );
1628        let mut metadata = match self.admit_metadata(metadata, &registration_scopes, ctx)? {
1629            Ok(metadata) => metadata,
1630            Err(response) => return Ok(response),
1631        };
1632        if let Some(resources) = body.and_then(|body| body.get("resources")) {
1633            _ = metadata.insert(
1634                "resources".into(),
1635                alibi_core::utils::json::from_value(resources.clone())?,
1636            );
1637        }
1638        _ = metadata.insert("scope".into(), json!(registration_scopes.join(" ")));
1639        if self.config.client_registration_require_pkce == Some(false)
1640            && metadata
1641                .get("token_endpoint_auth_method")
1642                .and_then(Value::as_str)
1643                != Some("none")
1644        {
1645            _ = metadata
1646                .entry("require_pkce")
1647                .or_insert_with(|| json!(false));
1648        }
1649        if let Some(reference) =
1650            token_authorization.and_then(|authorization| authorization.reference_id)
1651        {
1652            _ = metadata.insert("reference_id".into(), json!(reference));
1653        }
1654        let user_id = session.map(|(user, _)| user.id().as_ref().to_owned());
1655        self.persist_client(metadata, user_id, ctx).await
1656    }
1657}
1658fn registration_metadata(
1659    body: Option<&JsValue>,
1660) -> AuthResult<Result<serde_json::Map<String, Value>, EndpointResponse>> {
1661    let validated = match validate(body) {
1662        Ok(value) => value,
1663        Err(error) => {
1664            let message = error.error_payload().2;
1665            let first = message.split("; ").next().unwrap_or(&message);
1666            let (path, issue) = first
1667                .strip_prefix("[body.")
1668                .and_then(|text| text.split_once("] "))
1669                .unwrap_or_else(|| ("", first.trim_start_matches("[body] ")));
1670            let error = if path.starts_with("redirect_uris")
1671                || path.starts_with("post_logout_redirect_uris")
1672            {
1673                "invalid_redirect_uri"
1674            } else if path == "software_statement" {
1675                "invalid_software_statement"
1676            } else {
1677                "invalid_client_metadata"
1678            };
1679            let description = if path.is_empty() {
1680                issue.to_owned()
1681            } else if let Some(expected) = issue
1682                .strip_prefix("Invalid input: expected ")
1683                .and_then(|text| text.split_once(','))
1684            {
1685                format!("{path} must be a {}", expected.0)
1686            } else {
1687                format!("{path}: {issue}")
1688            };
1689            return status_protocol_error(400, error, &description).map(Err);
1690        }
1691    };
1692    let mut metadata: serde_json::Map<String, Value> =
1693        alibi_core::utils::json::from_value(validated)?;
1694    if metadata
1695        .get("client_name")
1696        .and_then(Value::as_str)
1697        .is_some_and(|name| js_trim(name).is_empty())
1698    {
1699        return status_protocol_error(
1700            400,
1701            "invalid_client_metadata",
1702            "client_name: client_name cannot be empty",
1703        )
1704        .map(Err);
1705    }
1706    if let Some(subject) = body.and_then(|body| body.get("subject_type")) {
1707        if !subject
1708            .as_str()
1709            .is_some_and(|value| ["public", "pairwise"].contains(&value))
1710        {
1711            return status_protocol_error(
1712                400,
1713                "invalid_client_metadata",
1714                "subject_type must be one of: public, pairwise",
1715            )
1716            .map(Err);
1717        }
1718        _ = metadata.insert("subject_type".into(), json!(subject.as_str()));
1719    }
1720    if let Some(value) = body.and_then(|body| body.get("resources")) {
1721        let Some(resources) = value.as_array() else {
1722            return status_protocol_error(400, "invalid_target", "resources must be a array")
1723                .map(Err);
1724        };
1725        for (index, value) in resources.iter().enumerate() {
1726            let Some(uri) = value.as_str() else {
1727                return status_protocol_error(
1728                    400,
1729                    "invalid_target",
1730                    &format!("resources[{index}] must be a string"),
1731                )
1732                .map(Err);
1733            };
1734            let error = match url::Url::parse(uri) {
1735                Err(_) => Some("resource must be an absolute URI"),
1736                Ok(url) => {
1737                    if uri.contains('#') {
1738                        Some("resource must not contain a fragment")
1739                    } else if ["javascript", "data", "vbscript"].contains(&url.scheme()) {
1740                        Some("resource cannot use javascript:, data:, or vbscript: scheme")
1741                    } else {
1742                        None
1743                    }
1744                }
1745            };
1746            if let Some(error) = error {
1747                return status_protocol_error(
1748                    400,
1749                    "invalid_target",
1750                    &format!("resources[{index}]: {error}"),
1751                )
1752                .map(Err);
1753            }
1754        }
1755    }
1756    if body.and_then(|body| body.get("skip_consent")).is_some() {
1757        return status_protocol_error(
1758            400,
1759            "invalid_client_metadata",
1760            "skip_consent must be a never",
1761        )
1762        .map(Err);
1763    }
1764    Ok(Ok(metadata))
1765}
1766
1767pub(super) fn validate_admin_create(body: Option<&JsValue>) -> AuthResult<JsValue> {
1768    let mut fields = Vec::new();
1769    for &(name, kind) in FIELDS {
1770        if name == "dpop_bound_access_tokens" {
1771            continue;
1772        }
1773        if name == "response_types" {
1774            fields.push(("client_credentials_scopes", FieldKind::Scopes));
1775        }
1776        fields.push((name, kind));
1777    }
1778    fields.extend([
1779        ("client_secret_expires_at", FieldKind::Expiration),
1780        ("skip_consent", FieldKind::Boolean),
1781        ("enable_end_session", FieldKind::Boolean),
1782        ("require_pkce", FieldKind::Boolean),
1783        ("dpop_bound_access_tokens", FieldKind::Boolean),
1784        ("subject_type", FieldKind::Subject),
1785        ("metadata", FieldKind::Record),
1786    ]);
1787    validate_metadata(body, "body", &fields)
1788}
1789impl OAuthProviderPlugin {
1790    /// Create a managed client with operator fields through the trusted server API.
1791    pub fn admin_create_oauth_client_endpoint(
1792        input: &impl Serialize,
1793    ) -> AuthResult<ServerEndpoint<Value>> {
1794        ServerEndpoint::new("oauth-provider", "adminCreateOAuthClient").with_body(input)
1795    }
1796    pub(super) async fn admin_create_client<S: AuthSchema>(
1797        &self,
1798        mut metadata: serde_json::Map<String, Value>,
1799        req: &impl SessionRequest,
1800        ctx: &AuthContext<S>,
1801    ) -> AuthResult<EndpointResponse> {
1802        req.queue_response_header("Cache-Control", "no-store");
1803        req.queue_response_header("Pragma", "no-cache");
1804        let Some((user, _)) = Box::pin(ctx.require_cached_session(req)).await.ok() else {
1805            return Ok(EndpointResponse::error(AuthError::Api {
1806                status: 401,
1807                code: None,
1808                message: String::new(),
1809            })
1810            .without_error_body()
1811            .with_header("Content-Type", "application/json"));
1812        };
1813        if let Some(response) = self.require_client_privileges("create", req, ctx).await? {
1814            return Ok(response);
1815        }
1816        let scopes = metadata
1817            .remove("client_credentials_scopes")
1818            .and_then(|value| value.as_array().cloned())
1819            .unwrap_or_default();
1820        if let Some(response) = self
1821            .admit_credential_scopes(&scopes, &metadata, req, ctx, true)
1822            .await?
1823        {
1824            return Ok(response);
1825        }
1826        _ = metadata.remove("client_secret_expires_at");
1827        let extensions = metadata
1828            .remove("metadata")
1829            .and_then(|value| value.as_object().cloned())
1830            .unwrap_or_default();
1831        let mut metadata = match self.admit_metadata(metadata, &self.config.scopes, ctx)? {
1832            Ok(metadata) => metadata,
1833            Err(response) => return Ok(response),
1834        };
1835        let extensions = extensions
1836            .into_iter()
1837            .filter(|(name, _)| !RESERVED_METADATA_FIELDS.contains(&name.as_str()))
1838            .collect::<serde_json::Map<_, _>>();
1839        if !extensions.is_empty() {
1840            _ = metadata.insert("metadata".into(), Value::Object(extensions));
1841        }
1842        _ = metadata.insert("client_credentials_scopes".into(), json!(scopes));
1843        self.persist_client(metadata, Some(user.id().as_ref().to_owned()), ctx)
1844            .await
1845    }
1846}
1847
1848pub(super) const RESERVED_METADATA_FIELDS: &[&str] = &[
1849    "clientId",
1850    "clientSecret",
1851    "clientDiscoveryId",
1852    "disabled",
1853    "scopes",
1854    "clientCredentialsScopes",
1855    "userId",
1856    "createdAt",
1857    "updatedAt",
1858    "expiresAt",
1859    "name",
1860    "uri",
1861    "icon",
1862    "contacts",
1863    "tos",
1864    "policy",
1865    "softwareId",
1866    "softwareVersion",
1867    "softwareStatement",
1868    "redirectUris",
1869    "postLogoutRedirectUris",
1870    "backchannelLogoutUri",
1871    "backchannelLogoutSessionRequired",
1872    "tokenEndpointAuthMethod",
1873    "grantTypes",
1874    "responseTypes",
1875    "applicationType",
1876    "jwks",
1877    "jwksUri",
1878    "requirePKCE",
1879    "dpopBoundAccessTokens",
1880    "skipConsent",
1881    "enableEndSession",
1882    "subjectType",
1883    "referenceId",
1884    "metadata",
1885    "client_id",
1886    "client_secret",
1887    "client_secret_expires_at",
1888    "scope",
1889    "user_id",
1890    "client_id_issued_at",
1891    "client_name",
1892    "client_uri",
1893    "logo_uri",
1894    "tos_uri",
1895    "policy_uri",
1896    "jwks_uri",
1897    "software_id",
1898    "software_version",
1899    "software_statement",
1900    "redirect_uris",
1901    "post_logout_redirect_uris",
1902    "backchannel_logout_uri",
1903    "backchannel_logout_session_required",
1904    "token_endpoint_auth_method",
1905    "grant_types",
1906    "response_types",
1907    "application_type",
1908    "skip_consent",
1909    "enable_end_session",
1910    "require_pkce",
1911    "dpop_bound_access_tokens",
1912    "subject_type",
1913    "reference_id",
1914    "public",
1915    "type",
1916    "resources",
1917    "client_credentials_scopes",
1918];
1919
1920impl OAuthProviderPlugin {
1921    async fn admit_credential_scopes<S: AuthSchema>(
1922        &self,
1923        scopes: &[Value],
1924        metadata: &serde_json::Map<String, Value>,
1925        req: &impl SessionRequest,
1926        ctx: &AuthContext<S>,
1927        require_privileges: bool,
1928    ) -> AuthResult<Option<EndpointResponse>> {
1929        if !scopes.is_empty() {
1930            if !metadata
1931                .get("grant_types")
1932                .and_then(Value::as_array)
1933                .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1934            {
1935                return status_protocol_error(
1936                    400,
1937                    "invalid_client_metadata",
1938                    "client_credentials_scopes requires the client_credentials grant",
1939                )
1940                .map(Some);
1941            }
1942            if metadata
1943                .get("token_endpoint_auth_method")
1944                .and_then(Value::as_str)
1945                == Some("none")
1946            {
1947                return status_protocol_error(
1948                    400,
1949                    "invalid_client_metadata",
1950                    "public clients cannot be assigned client_credentials scopes",
1951                )
1952                .map(Some);
1953            }
1954            let invalid = scopes
1955                .iter()
1956                .filter_map(Value::as_str)
1957                .filter(|scope| {
1958                    !self.config.scopes.iter().any(|allowed| allowed == scope)
1959                        || ["openid", "profile", "email", "offline_access"].contains(scope)
1960                })
1961                .collect::<Vec<_>>();
1962            if !invalid.is_empty() {
1963                return status_protocol_error(
1964                    400,
1965                    "invalid_scope",
1966                    &format!(
1967                        "The following client_credentials scopes are invalid: {}",
1968                        invalid.join(", ")
1969                    ),
1970                )
1971                .map(Some);
1972            }
1973            if require_privileges {
1974                return self
1975                    .require_client_privileges("configure-client-credentials-scopes", req, ctx)
1976                    .await;
1977            }
1978        }
1979        Ok(None)
1980    }
1981}
1982
1983fn client_privilege_denied(status: u16) -> EndpointResponse {
1984    EndpointResponse::error(AuthError::Api {
1985        status,
1986        code: None,
1987        message: String::new(),
1988    })
1989    .without_error_body()
1990    .with_header("Content-Type", "application/json")
1991}
1992
1993fn client_update_response(
1994    client: alibi_core::store::OAuthClient,
1995    admin: bool,
1996) -> AuthResult<EndpointResponse> {
1997    let scopes = client
1998        .metadata
1999        .get("client_credentials_scopes")
2000        .cloned()
2001        .unwrap_or_else(|| json!([]));
2002    let mut output = client_read_metadata(client)?;
2003    if admin {
2004        let object = output
2005            .as_object_mut()
2006            .ok_or_else(|| AuthError::internal("OAuth client metadata must be an object"))?;
2007        _ = object.insert("client_credentials_scopes".into(), scopes);
2008    }
2009    EndpointResponse::json(&output)
2010}
2011pub(super) fn validate_admin_update(body: Option<&JsValue>) -> AuthResult<JsValue> {
2012    let mut output = crate::endpoint::validate_fields(
2013        body,
2014        "body",
2015        &[crate::authentication_helpers::JsonField::string(
2016            "client_id",
2017            true,
2018        )],
2019    )?;
2020    let mut fields = FIELDS
2021        .iter()
2022        .copied()
2023        .filter(|(name, _)| {
2024            ![
2025                "token_endpoint_auth_method",
2026                "jwks",
2027                "jwks_uri",
2028                "dpop_bound_access_tokens",
2029            ]
2030            .contains(name)
2031        })
2032        .collect::<Vec<_>>();
2033    let position = fields
2034        .iter()
2035        .position(|(name, _)| *name == "response_types")
2036        .unwrap_or(fields.len());
2037    fields.insert(position, ("client_credentials_scopes", FieldKind::Scopes));
2038    fields.extend([
2039        ("client_secret_expires_at", FieldKind::Expiration),
2040        ("skip_consent", FieldKind::Boolean),
2041        ("enable_end_session", FieldKind::Boolean),
2042        ("dpop_bound_access_tokens", FieldKind::Boolean),
2043        ("metadata", FieldKind::Record),
2044    ]);
2045    let update = validate_metadata(
2046        body.and_then(|body| body.get("update")),
2047        "body.update",
2048        &fields,
2049    )?;
2050    if let JsValue::Object(object) = &mut output {
2051        _ = object.insert("update".into(), update);
2052    }
2053    Ok(output)
2054}
2055impl OAuthProviderPlugin {
2056    /// Patch operator-controlled fields of an owned client through the server-only API.
2057    pub fn admin_update_oauth_client_endpoint(
2058        client_id: &str,
2059        update: &impl Serialize,
2060    ) -> AuthResult<ServerEndpoint<Value>> {
2061        ServerEndpoint::new("oauth-provider", "adminUpdateOAuthClient")
2062            .with_body(&json!({"client_id":client_id,"update":update}))
2063    }
2064}
2065
2066#[derive(serde::Deserialize)]
2067pub(super) struct PreloginClientInput {
2068    client_id: String,
2069    oauth_query: Option<String>,
2070}
2071pub(super) fn validate_prelogin(body: Option<&JsValue>) -> AuthResult<JsValue> {
2072    crate::endpoint::validate_fields(
2073        body,
2074        "body",
2075        &[
2076            crate::authentication_helpers::JsonField::string("client_id", true),
2077            crate::authentication_helpers::JsonField::string("oauth_query", false),
2078        ],
2079    )
2080}
2081impl OAuthProviderPlugin {
2082    /// Read public client display metadata before login using a provider-issued authorization query.
2083    pub fn get_oauth_client_public_prelogin_endpoint(
2084        client_id: &str,
2085        oauth_query: Option<&str>,
2086    ) -> AuthResult<ServerEndpoint<Value>> {
2087        let mut input = serde_json::Map::from_iter([("client_id".into(), json!(client_id))]);
2088        if let Some(query) = oauth_query {
2089            _ = input.insert("oauth_query".into(), json!(query));
2090        }
2091        ServerEndpoint::new("oauth-provider", "getOAuthClientPublicPrelogin").with_body(&input)
2092    }
2093    pub(super) async fn read_prelogin_client<S: AuthSchema>(
2094        &self,
2095        input: PreloginClientInput,
2096        ctx: &AuthContext<S>,
2097    ) -> AuthResult<EndpointResponse> {
2098        let valid = super::authorization::verify_authorization_query(
2099            input.oauth_query.as_deref().unwrap_or(""),
2100            ctx.config.current_secret(),
2101        )?;
2102        // The provider's before hook rejects a supplied invalid query before endpoint middleware.
2103        if input
2104            .oauth_query
2105            .as_deref()
2106            .is_some_and(|query| !query.is_empty())
2107            && !valid
2108        {
2109            return Ok(EndpointResponse::error(AuthError::Api {
2110                status: 400,
2111                code: None,
2112                message: String::new(),
2113            })
2114            .with_error_body(alibi_core::utils::json::parse_value(
2115                r#"{"error":"invalid_signature"}"#,
2116            )?));
2117        }
2118        if !self.config.allow_public_client_prelogin {
2119            return Ok(EndpointResponse::error(AuthError::Api {
2120                status: 400,
2121                code: None,
2122                message: String::new(),
2123            })
2124            .without_error_body()
2125            .with_header("Content-Type", "application/json"));
2126        }
2127        if !valid {
2128            return Ok(EndpointResponse::error(AuthError::Api {
2129                status: 401,
2130                code: None,
2131                message: String::new(),
2132            })
2133            .with_error_body(alibi_core::utils::json::parse_value(
2134                r#"{"error":"invalid_signature"}"#,
2135            )?));
2136        }
2137        Box::pin(self.public_client_projection(&input.client_id, ctx)).await
2138    }
2139}