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