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#[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 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 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 #[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 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 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 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 _ = 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 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 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 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 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 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}