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