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