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.admit_credential_scopes(&scopes, &merged)? {
1094                return Ok(response);
1095            }
1096            _ = merged.insert("client_credentials_scopes".into(), json!(scopes));
1097        }
1098        if let Some(extensions) = extensions {
1099            let extensions = extensions
1100                .into_iter()
1101                .filter(|(name, _)| !RESERVED_METADATA_FIELDS.contains(&name.as_str()))
1102                .collect::<serde_json::Map<_, _>>();
1103            if !extensions.is_empty() {
1104                merged.retain(|name, _| RESERVED_METADATA_FIELDS.contains(&name.as_str()));
1105                merged.extend(extensions);
1106            }
1107        }
1108        if !merged
1109            .get("grant_types")
1110            .and_then(Value::as_array)
1111            .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1112            || merged
1113                .get("token_endpoint_auth_method")
1114                .and_then(Value::as_str)
1115                == Some("none")
1116        {
1117            _ = merged.insert("client_credentials_scopes".into(), json!([]));
1118        }
1119        let metadata = match self.admit_metadata(merged, ctx)? {
1120            Ok(metadata) => Value::Object(metadata),
1121            Err(response) => return Ok(response),
1122        };
1123        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
1124            .ok_or_else(|| AuthError::internal("Invalid client update timestamp"))?;
1125        let Some(client) = ctx
1126            .database
1127            .update_oauth_client_metadata(&input.client_id, metadata, now)
1128            .await?
1129        else {
1130            return status_protocol_error(500, "invalid_client", "unable to update client");
1131        };
1132        client_update_response(client, admin)
1133    }
1134}
1135
1136impl OAuthProviderPlugin {
1137    async fn persist_client<S: AuthSchema>(
1138        &self,
1139        metadata: serde_json::Map<String, Value>,
1140        user_id: Option<String>,
1141        ctx: &AuthContext<S>,
1142    ) -> AuthResult<EndpointResponse> {
1143        let auth = metadata
1144            .get("token_endpoint_auth_method")
1145            .and_then(Value::as_str)
1146            .unwrap_or("client_secret_basic");
1147        let client_id = random_credential();
1148        let plaintext = (!["none", "private_key_jwt"].contains(&auth)).then(random_credential);
1149        let stored = plaintext
1150            .as_deref()
1151            .map(|secret| {
1152                if self.config.disable_jwt_plugin {
1153                    crate::token_crypto::encrypt_with_config(secret, &ctx.config)
1154                } else {
1155                    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(secret.as_bytes())))
1156                }
1157            })
1158            .transpose()?;
1159        let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
1160            .ok_or_else(|| AuthError::internal("Invalid client creation timestamp"))?;
1161        let client = ctx
1162            .database
1163            .create_oauth_client(alibi_core::store::CreateOAuthClient {
1164                client_id,
1165                user_id,
1166                client_secret: stored,
1167                metadata: Value::Object(metadata),
1168                created_at: now,
1169            })
1170            .await?;
1171        let mut output: serde_json::Map<String, Value> = serde_json::from_value(client.metadata)?;
1172        _ = output.insert("client_id".into(), json!(client.client_id));
1173        if let Some(user_id) = client.user_id {
1174            _ = output.insert("user_id".into(), json!(user_id));
1175        }
1176        _ = output.insert(
1177            "client_id_issued_at".into(),
1178            json!(client.created_at.map(|time| time.timestamp())),
1179        );
1180        if let Some(secret) = plaintext {
1181            _ = output.insert("client_secret".into(), json!(secret));
1182            _ = output.insert("client_secret_expires_at".into(), json!(0));
1183        }
1184        Ok(EndpointResponse::json(&Value::Object(output))?.with_status(201))
1185    }
1186    /// Register a client under the provider's dynamic-registration policy.
1187    pub fn register_oauth_client_endpoint(
1188        input: &CreateOAuthClientInput,
1189    ) -> AuthResult<ServerEndpoint<Value>> {
1190        ServerEndpoint::new("oauth-provider", "registerOAuthClient").with_body(input)
1191    }
1192    pub(super) async fn register_client<S: AuthSchema>(
1193        &self,
1194        body: Option<&JsValue>,
1195        req: &impl SessionRequest,
1196        ctx: &AuthContext<S>,
1197    ) -> AuthResult<EndpointResponse> {
1198        let mut metadata = match registration_metadata(body)? {
1199            Ok(metadata) => metadata,
1200            Err(response) => return Ok(response),
1201        };
1202        req.queue_response_header("Cache-Control", "no-store");
1203        req.queue_response_header("Pragma", "no-cache");
1204        if !self.config.allow_dynamic_client_registration {
1205            return status_protocol_error(403, "access_denied", "Client registration is disabled");
1206        }
1207        let session = Box::pin(ctx.require_cached_session(req)).await.ok();
1208        if session.is_none() && !self.config.allow_unauthenticated_client_registration {
1209            return Ok(EndpointResponse::error(AuthError::Api {
1210                status: 401,
1211                code: None,
1212                message: String::new(),
1213            })
1214            .with_error_body(alibi_core::utils::json::parse_value(
1215                r#"{"error_description":"Authentication required for client registration"}"#,
1216            )?)
1217            .with_header("WWW-Authenticate", "Bearer"));
1218        }
1219        if session.is_none()
1220            && metadata
1221                .get("grant_types")
1222                .and_then(Value::as_array)
1223                .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1224        {
1225            return status_protocol_error(
1226                400,
1227                "invalid_client_metadata",
1228                "client_credentials grant requires authenticated registration",
1229            );
1230        }
1231        if metadata
1232            .get("scope")
1233            .and_then(Value::as_str)
1234            .is_none_or(str::is_empty)
1235        {
1236            _ = metadata.insert("scope".into(), json!(self.config.scopes.join(" ")));
1237        }
1238        let mut metadata = match self.admit_metadata(metadata, ctx)? {
1239            Ok(metadata) => metadata,
1240            Err(response) => return Ok(response),
1241        };
1242        if let Some(resources) = body
1243            .and_then(|body| body.get("resources"))
1244            .and_then(JsValue::as_array)
1245            && let Some(resource) = resources.first().and_then(JsValue::as_str)
1246        {
1247            return status_protocol_error(
1248                400,
1249                "invalid_target",
1250                &format!("requested resource {resource} is not allowed for client registration"),
1251            );
1252        }
1253        _ = metadata.insert("scope".into(), json!(self.config.scopes.join(" ")));
1254        let user_id = session.map(|(user, _)| user.id().as_ref().to_owned());
1255        self.persist_client(metadata, user_id, ctx).await
1256    }
1257}
1258fn registration_metadata(
1259    body: Option<&JsValue>,
1260) -> AuthResult<Result<serde_json::Map<String, Value>, EndpointResponse>> {
1261    let validated = match validate(body) {
1262        Ok(value) => value,
1263        Err(error) => {
1264            let message = error.error_payload().2;
1265            let first = message.split("; ").next().unwrap_or(&message);
1266            let (path, issue) = first
1267                .strip_prefix("[body.")
1268                .and_then(|text| text.split_once("] "))
1269                .unwrap_or_else(|| ("", first.trim_start_matches("[body] ")));
1270            let error = if path.starts_with("redirect_uris")
1271                || path.starts_with("post_logout_redirect_uris")
1272            {
1273                "invalid_redirect_uri"
1274            } else if path == "software_statement" {
1275                "invalid_software_statement"
1276            } else {
1277                "invalid_client_metadata"
1278            };
1279            let description = if path.is_empty() {
1280                issue.to_owned()
1281            } else if let Some(expected) = issue
1282                .strip_prefix("Invalid input: expected ")
1283                .and_then(|text| text.split_once(','))
1284            {
1285                format!("{path} must be a {}", expected.0)
1286            } else {
1287                format!("{path}: {issue}")
1288            };
1289            return status_protocol_error(400, error, &description).map(Err);
1290        }
1291    };
1292    let mut metadata: serde_json::Map<String, Value> =
1293        alibi_core::utils::json::from_value(validated)?;
1294    if metadata
1295        .get("client_name")
1296        .and_then(Value::as_str)
1297        .is_some_and(|name| name.trim().is_empty())
1298    {
1299        return status_protocol_error(
1300            400,
1301            "invalid_client_metadata",
1302            "client_name: client_name cannot be empty",
1303        )
1304        .map(Err);
1305    }
1306    if let Some(subject) = body.and_then(|body| body.get("subject_type")) {
1307        if !subject
1308            .as_str()
1309            .is_some_and(|value| ["public", "pairwise"].contains(&value))
1310        {
1311            return status_protocol_error(
1312                400,
1313                "invalid_client_metadata",
1314                "subject_type must be one of: public, pairwise",
1315            )
1316            .map(Err);
1317        }
1318        _ = metadata.insert("subject_type".into(), json!(subject.as_str()));
1319    }
1320    if let Some(value) = body.and_then(|body| body.get("resources")) {
1321        let Some(resources) = value.as_array() else {
1322            return status_protocol_error(400, "invalid_target", "resources must be a array")
1323                .map(Err);
1324        };
1325        for (index, value) in resources.iter().enumerate() {
1326            let Some(uri) = value.as_str() else {
1327                return status_protocol_error(
1328                    400,
1329                    "invalid_target",
1330                    &format!("resources[{index}] must be a string"),
1331                )
1332                .map(Err);
1333            };
1334            let error = match url::Url::parse(uri) {
1335                Err(_) => Some("resource must be an absolute URI"),
1336                Ok(url) => {
1337                    if uri.contains('#') {
1338                        Some("resource must not contain a fragment")
1339                    } else if ["javascript", "data", "vbscript"].contains(&url.scheme()) {
1340                        Some("resource cannot use javascript:, data:, or vbscript: scheme")
1341                    } else {
1342                        None
1343                    }
1344                }
1345            };
1346            if let Some(error) = error {
1347                return status_protocol_error(
1348                    400,
1349                    "invalid_target",
1350                    &format!("resources[{index}]: {error}"),
1351                )
1352                .map(Err);
1353            }
1354        }
1355    }
1356    if body.and_then(|body| body.get("skip_consent")).is_some() {
1357        return status_protocol_error(
1358            400,
1359            "invalid_client_metadata",
1360            "skip_consent must be a never",
1361        )
1362        .map(Err);
1363    }
1364    Ok(Ok(metadata))
1365}
1366
1367pub(super) fn validate_admin_create(body: Option<&JsValue>) -> AuthResult<JsValue> {
1368    let mut fields = Vec::new();
1369    for &(name, kind) in FIELDS {
1370        if name == "dpop_bound_access_tokens" {
1371            continue;
1372        }
1373        if name == "response_types" {
1374            fields.push(("client_credentials_scopes", FieldKind::Scopes));
1375        }
1376        fields.push((name, kind));
1377    }
1378    fields.extend([
1379        ("client_secret_expires_at", FieldKind::Expiration),
1380        ("skip_consent", FieldKind::Boolean),
1381        ("enable_end_session", FieldKind::Boolean),
1382        ("require_pkce", FieldKind::Boolean),
1383        ("dpop_bound_access_tokens", FieldKind::Boolean),
1384        ("subject_type", FieldKind::Subject),
1385        ("metadata", FieldKind::Record),
1386    ]);
1387    validate_metadata(body, "body", &fields)
1388}
1389impl OAuthProviderPlugin {
1390    /// Create a managed client with operator fields through the trusted server API.
1391    pub fn admin_create_oauth_client_endpoint(
1392        input: &impl Serialize,
1393    ) -> AuthResult<ServerEndpoint<Value>> {
1394        ServerEndpoint::new("oauth-provider", "adminCreateOAuthClient").with_body(input)
1395    }
1396    pub(super) async fn admin_create_client<S: AuthSchema>(
1397        &self,
1398        mut metadata: serde_json::Map<String, Value>,
1399        req: &impl SessionRequest,
1400        ctx: &AuthContext<S>,
1401    ) -> AuthResult<EndpointResponse> {
1402        req.queue_response_header("Cache-Control", "no-store");
1403        req.queue_response_header("Pragma", "no-cache");
1404        let Some((user, _)) = Box::pin(ctx.require_cached_session(req)).await.ok() else {
1405            return Ok(EndpointResponse::error(AuthError::Api {
1406                status: 401,
1407                code: None,
1408                message: String::new(),
1409            })
1410            .without_error_body()
1411            .with_header("Content-Type", "application/json"));
1412        };
1413        let scopes = metadata
1414            .remove("client_credentials_scopes")
1415            .and_then(|value| value.as_array().cloned())
1416            .unwrap_or_default();
1417        if let Some(response) = self.admit_credential_scopes(&scopes, &metadata)? {
1418            return Ok(response);
1419        }
1420        _ = metadata.remove("client_secret_expires_at");
1421        let extensions = metadata
1422            .remove("metadata")
1423            .and_then(|value| value.as_object().cloned())
1424            .unwrap_or_default();
1425        let mut metadata = match self.admit_metadata(metadata, ctx)? {
1426            Ok(metadata) => metadata,
1427            Err(response) => return Ok(response),
1428        };
1429        for (name, value) in extensions {
1430            if !RESERVED_METADATA_FIELDS.contains(&name.as_str()) {
1431                _ = metadata.insert(name, value);
1432            }
1433        }
1434        _ = metadata.insert("client_credentials_scopes".into(), json!([]));
1435        self.persist_client(metadata, Some(user.id().as_ref().to_owned()), ctx)
1436            .await
1437    }
1438}
1439
1440const RESERVED_METADATA_FIELDS: &[&str] = &[
1441    "clientId",
1442    "clientSecret",
1443    "clientDiscoveryId",
1444    "disabled",
1445    "scopes",
1446    "clientCredentialsScopes",
1447    "userId",
1448    "createdAt",
1449    "updatedAt",
1450    "expiresAt",
1451    "name",
1452    "uri",
1453    "icon",
1454    "contacts",
1455    "tos",
1456    "policy",
1457    "softwareId",
1458    "softwareVersion",
1459    "softwareStatement",
1460    "redirectUris",
1461    "postLogoutRedirectUris",
1462    "backchannelLogoutUri",
1463    "backchannelLogoutSessionRequired",
1464    "tokenEndpointAuthMethod",
1465    "grantTypes",
1466    "responseTypes",
1467    "applicationType",
1468    "jwks",
1469    "jwksUri",
1470    "requirePKCE",
1471    "dpopBoundAccessTokens",
1472    "skipConsent",
1473    "enableEndSession",
1474    "subjectType",
1475    "referenceId",
1476    "metadata",
1477    "client_id",
1478    "client_secret",
1479    "client_secret_expires_at",
1480    "scope",
1481    "user_id",
1482    "client_id_issued_at",
1483    "client_name",
1484    "client_uri",
1485    "logo_uri",
1486    "tos_uri",
1487    "policy_uri",
1488    "jwks_uri",
1489    "software_id",
1490    "software_version",
1491    "software_statement",
1492    "redirect_uris",
1493    "post_logout_redirect_uris",
1494    "backchannel_logout_uri",
1495    "backchannel_logout_session_required",
1496    "token_endpoint_auth_method",
1497    "grant_types",
1498    "response_types",
1499    "application_type",
1500    "skip_consent",
1501    "enable_end_session",
1502    "require_pkce",
1503    "dpop_bound_access_tokens",
1504    "subject_type",
1505    "reference_id",
1506    "public",
1507    "type",
1508    "resources",
1509    "client_credentials_scopes",
1510];
1511
1512impl OAuthProviderPlugin {
1513    fn admit_credential_scopes(
1514        &self,
1515        scopes: &[Value],
1516        metadata: &serde_json::Map<String, Value>,
1517    ) -> AuthResult<Option<EndpointResponse>> {
1518        if !scopes.is_empty() {
1519            if !metadata
1520                .get("grant_types")
1521                .and_then(Value::as_array)
1522                .is_some_and(|grants| grants.contains(&json!("client_credentials")))
1523            {
1524                return status_protocol_error(
1525                    400,
1526                    "invalid_client_metadata",
1527                    "client_credentials_scopes requires the client_credentials grant",
1528                )
1529                .map(Some);
1530            }
1531            if metadata
1532                .get("token_endpoint_auth_method")
1533                .and_then(Value::as_str)
1534                == Some("none")
1535            {
1536                return status_protocol_error(
1537                    400,
1538                    "invalid_client_metadata",
1539                    "public clients cannot be assigned client_credentials scopes",
1540                )
1541                .map(Some);
1542            }
1543            let invalid = scopes
1544                .iter()
1545                .filter_map(Value::as_str)
1546                .filter(|scope| {
1547                    !self.config.scopes.iter().any(|allowed| allowed == scope)
1548                        || ["openid", "profile", "email", "offline_access"].contains(scope)
1549                })
1550                .collect::<Vec<_>>();
1551            if !invalid.is_empty() {
1552                return status_protocol_error(
1553                    400,
1554                    "invalid_scope",
1555                    &format!(
1556                        "The following client_credentials scopes are invalid: {}",
1557                        invalid.join(", ")
1558                    ),
1559                )
1560                .map(Some);
1561            }
1562            return Ok(Some(
1563                EndpointResponse::error(AuthError::Api {
1564                    status: 401,
1565                    code: None,
1566                    message: String::new(),
1567                })
1568                .without_error_body()
1569                .with_header("Content-Type", "application/json"),
1570            ));
1571        }
1572        Ok(None)
1573    }
1574}
1575
1576fn client_update_response(
1577    client: alibi_core::store::OAuthClient,
1578    admin: bool,
1579) -> AuthResult<EndpointResponse> {
1580    let scopes = client
1581        .metadata
1582        .get("client_credentials_scopes")
1583        .cloned()
1584        .unwrap_or_else(|| json!([]));
1585    let mut output = client_read_metadata(client)?;
1586    if admin {
1587        let object = output
1588            .as_object_mut()
1589            .ok_or_else(|| AuthError::internal("OAuth client metadata must be an object"))?;
1590        _ = object.insert("client_credentials_scopes".into(), scopes);
1591    }
1592    EndpointResponse::json(&output)
1593}
1594pub(super) fn validate_admin_update(body: Option<&JsValue>) -> AuthResult<JsValue> {
1595    let mut output = crate::endpoint::validate_fields(
1596        body,
1597        "body",
1598        &[crate::authentication_helpers::JsonField::string(
1599            "client_id",
1600            true,
1601        )],
1602    )?;
1603    let mut fields = FIELDS
1604        .iter()
1605        .copied()
1606        .filter(|(name, _)| {
1607            ![
1608                "token_endpoint_auth_method",
1609                "jwks",
1610                "jwks_uri",
1611                "dpop_bound_access_tokens",
1612            ]
1613            .contains(name)
1614        })
1615        .collect::<Vec<_>>();
1616    let position = fields
1617        .iter()
1618        .position(|(name, _)| *name == "response_types")
1619        .unwrap_or(fields.len());
1620    fields.insert(position, ("client_credentials_scopes", FieldKind::Scopes));
1621    fields.extend([
1622        ("client_secret_expires_at", FieldKind::Expiration),
1623        ("skip_consent", FieldKind::Boolean),
1624        ("enable_end_session", FieldKind::Boolean),
1625        ("dpop_bound_access_tokens", FieldKind::Boolean),
1626        ("metadata", FieldKind::Record),
1627    ]);
1628    let update = validate_metadata(
1629        body.and_then(|body| body.get("update")),
1630        "body.update",
1631        &fields,
1632    )?;
1633    if let JsValue::Object(object) = &mut output {
1634        _ = object.insert("update".into(), update);
1635    }
1636    Ok(output)
1637}
1638impl OAuthProviderPlugin {
1639    /// Patch operator-controlled fields of an owned client through the server-only API.
1640    pub fn admin_update_oauth_client_endpoint(
1641        client_id: &str,
1642        update: &impl Serialize,
1643    ) -> AuthResult<ServerEndpoint<Value>> {
1644        ServerEndpoint::new("oauth-provider", "adminUpdateOAuthClient")
1645            .with_body(&json!({"client_id":client_id,"update":update}))
1646    }
1647}
1648
1649#[derive(serde::Deserialize)]
1650pub(super) struct PreloginClientInput {
1651    client_id: String,
1652    oauth_query: Option<String>,
1653}
1654pub(super) fn validate_prelogin(body: Option<&JsValue>) -> AuthResult<JsValue> {
1655    crate::endpoint::validate_fields(
1656        body,
1657        "body",
1658        &[
1659            crate::authentication_helpers::JsonField::string("client_id", true),
1660            crate::authentication_helpers::JsonField::string("oauth_query", false),
1661        ],
1662    )
1663}
1664impl OAuthProviderPlugin {
1665    /// Read public client display metadata before login using a provider-issued authorization query.
1666    pub fn get_oauth_client_public_prelogin_endpoint(
1667        client_id: &str,
1668        oauth_query: Option<&str>,
1669    ) -> AuthResult<ServerEndpoint<Value>> {
1670        let mut input = serde_json::Map::from_iter([("client_id".into(), json!(client_id))]);
1671        if let Some(query) = oauth_query {
1672            _ = input.insert("oauth_query".into(), json!(query));
1673        }
1674        ServerEndpoint::new("oauth-provider", "getOAuthClientPublicPrelogin").with_body(&input)
1675    }
1676    pub(super) async fn read_prelogin_client<S: AuthSchema>(
1677        &self,
1678        input: PreloginClientInput,
1679        ctx: &AuthContext<S>,
1680    ) -> AuthResult<EndpointResponse> {
1681        let valid = super::authorization::verify_authorization_query(
1682            input.oauth_query.as_deref().unwrap_or(""),
1683            ctx.config.current_secret(),
1684        )?;
1685        // The provider's before hook rejects a supplied invalid query before endpoint middleware.
1686        if input
1687            .oauth_query
1688            .as_deref()
1689            .is_some_and(|query| !query.is_empty())
1690            && !valid
1691        {
1692            return Ok(EndpointResponse::error(AuthError::Api {
1693                status: 400,
1694                code: None,
1695                message: String::new(),
1696            })
1697            .with_error_body(alibi_core::utils::json::parse_value(
1698                r#"{"error":"invalid_signature"}"#,
1699            )?));
1700        }
1701        if !self.config.allow_public_client_prelogin {
1702            return Ok(EndpointResponse::error(AuthError::Api {
1703                status: 400,
1704                code: None,
1705                message: String::new(),
1706            })
1707            .without_error_body()
1708            .with_header("Content-Type", "application/json"));
1709        }
1710        if !valid {
1711            return Ok(EndpointResponse::error(AuthError::Api {
1712                status: 401,
1713                code: None,
1714                message: String::new(),
1715            })
1716            .with_error_body(alibi_core::utils::json::parse_value(
1717                r#"{"error":"invalid_signature"}"#,
1718            )?));
1719        }
1720        Box::pin(self.public_client_projection(&input.client_id, ctx)).await
1721    }
1722}