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