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