1use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::utils::json::JsValue;
6use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema};
7use serde_json::{Value, json};
8pub(super) const ALGORITHMS: &[&str] = &["EdDSA", "ES256", "ES512", "PS256", "RS256"];
9pub(super) fn validate_create(input: Option<&JsValue>) -> AuthResult<JsValue> {
10 validate_body(input, true)
11}
12pub(super) fn validate_update(input: Option<&JsValue>) -> AuthResult<JsValue> {
13 validate_body(input, false)
14}
15fn validate_body(input: Option<&JsValue>, require_identifier: bool) -> AuthResult<JsValue> {
16 use crate::authentication_helpers::{JsonFieldKind, field_issue, json_type};
17 let mut output = crate::endpoint::validate_fields(input, "body", &[])?;
18 let mut issues = Vec::new();
19 let identifier = input.and_then(|input| input.get("identifier"));
20 let identifier_issue = if identifier.is_none() && require_identifier {
23 Some("Invalid input: expected nonoptional, received undefined".into())
24 } else if identifier.is_some() {
25 field_issue(
26 JsonFieldKind::NonEmptyString,
27 identifier,
28 json_type(identifier),
29 )
30 } else {
31 None
32 };
33 if let Some(issue) = identifier_issue {
34 issues.push(format!("[body.identifier] {issue}"));
35 }
36 if let (Some(identifier), JsValue::Object(output)) = (identifier, &mut output) {
37 _ = output.insert("identifier".into(), identifier.clone());
38 }
39 for name in [
40 "name",
41 "accessTokenTtl",
42 "refreshTokenTtl",
43 "signingAlgorithm",
44 "signingKeyId",
45 "allowedScopes",
46 "customClaims",
47 "dpopBoundAccessTokensRequired",
48 "disabled",
49 "metadata",
50 ] {
51 let Some(value) = input.and_then(|input| input.get(name)) else {
52 continue;
53 };
54 if matches!(value, JsValue::Null)
55 && !["name", "dpopBoundAccessTokensRequired", "disabled"].contains(&name)
56 {
57 if let JsValue::Object(output) = &mut output {
58 _ = output.insert(name.into(), value.clone());
59 }
60 continue;
61 }
62 let issue = match name {
63 "name" | "signingKeyId" => {
64 field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
65 }
66 "dpopBoundAccessTokensRequired" | "disabled" => {
67 field_issue(JsonFieldKind::Boolean, Some(value), json_type(Some(value)))
68 }
69 "customClaims" | "metadata" => {
70 field_issue(JsonFieldKind::Record, Some(value), json_type(Some(value)))
71 }
72 "signingAlgorithm" => field_issue(
73 JsonFieldKind::OneOf(ALGORITHMS),
74 Some(value),
75 json_type(Some(value)),
76 ),
77 "accessTokenTtl" | "refreshTokenTtl" => match value.as_f64() {
78 None => Some(format!(
79 "Invalid input: expected number, received {}",
80 json_type(Some(value))
81 )),
82 Some(number) if number.fract() != 0.0 => {
83 Some("Invalid input: expected int, received number".into())
84 }
85 Some(number) if number > 9_007_199_254_740_991.0 => {
86 Some("Too big: expected int to be <=9007199254740991".into())
87 }
88 Some(number) if number <= 0.0 => Some("Too small: expected number to be >0".into()),
89 Some(_) => None,
90 },
91 "allowedScopes" => {
92 if let Some(values) = value.as_array() {
93 for (index, value) in values.iter().enumerate() {
94 if let Some(issue) =
95 field_issue(JsonFieldKind::String, Some(value), json_type(Some(value)))
96 {
97 issues.push(format!("[body.{name}.{index}] {issue}"));
98 }
99 }
100 None
101 } else {
102 Some(format!(
103 "Invalid input: expected array, received {}",
104 json_type(Some(value))
105 ))
106 }
107 }
108 _ => None,
109 };
110 if let Some(issue) = issue {
111 issues.push(format!("[body.{name}] {issue}"));
112 }
113 if let JsValue::Object(output) = &mut output {
114 _ = output.insert(name.into(), value.clone());
115 }
116 }
117 if !issues.is_empty() {
118 return Err(crate::endpoint::validation(issues.join("; ")));
119 }
120 Ok(output)
121}
122pub(super) fn resource_value(row: alibi_core::store::OAuthResource) -> AuthResult<Value> {
123 let mut value: serde_json::Map<String, Value> = serde_json::from_value(row.metadata)?;
124 _ = value.insert("id".into(), json!(row.id));
125 _ = value.insert("identifier".into(), json!(row.identifier));
126 _ = value.insert("createdAt".into(), json!(row.created_at));
127 _ = value.insert("updatedAt".into(), json!(row.updated_at));
128 Ok(Value::Object(value))
129}
130impl OAuthProviderPlugin {
131 pub fn admin_create_oauth_resource_endpoint(
133 input: &impl serde::Serialize,
134 ) -> AuthResult<ServerEndpoint<Value>> {
135 ServerEndpoint::new("oauth-provider", "adminCreateOAuthResource").with_body(input)
136 }
137 pub(super) async fn create_resource<S: AuthSchema>(
138 &self,
139 input: Value,
140 req: &impl SessionRequest,
141 ctx: &AuthContext<S>,
142 ) -> AuthResult<EndpointResponse> {
143 if let Some(response) = Box::pin(self.resource_admission(req, ctx, "create", None)).await? {
144 return Ok(response);
145 }
146 let identifier = input
147 .get("identifier")
148 .and_then(Value::as_str)
149 .unwrap_or("");
150 if let Some(reason) = self.identifier_error(identifier).await? {
151 return super::clients::status_protocol_error(400, "invalid_target", &reason);
152 }
153 let mut metadata = input
154 .as_object()
155 .cloned()
156 .ok_or_else(|| AuthError::internal("Invalid resource body"))?;
157 _ = metadata.remove("identifier");
158 _ = metadata.entry("name").or_insert_with(|| json!(identifier));
159 for name in [
160 "accessTokenTtl",
161 "refreshTokenTtl",
162 "signingAlgorithm",
163 "signingKeyId",
164 "allowedScopes",
165 "customClaims",
166 "metadata",
167 ] {
168 _ = metadata.entry(name).or_insert(Value::Null);
169 }
170 for name in ["dpopBoundAccessTokensRequired", "disabled"] {
171 _ = metadata.entry(name).or_insert_with(|| json!(false));
172 }
173 _ = metadata.insert("policyVersion".into(), json!(1));
174 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
175 .ok_or_else(|| AuthError::internal("Invalid resource timestamp"))?;
176 let result = ctx
177 .database
178 .create_oauth_resource(alibi_core::store::CreateOAuthResource {
179 identifier: identifier.into(),
180 metadata: Value::Object(metadata),
181 created_at: now,
182 })
183 .await;
184 let row = match result {
185 Ok(row) => row,
186 Err(error)
187 if {
188 let message = error.to_string().to_ascii_lowercase();
189 message.contains("unique") || message.contains("duplicate")
190 } =>
191 {
192 return super::clients::status_protocol_error(
193 400,
194 "invalid_request",
195 &format!("resource {identifier} already exists"),
196 );
197 }
198 Err(error) => return Err(error),
199 };
200 super::resource_cache::invalidate(&row.identifier).await;
201 EndpointResponse::json(&resource_value(row)?)
202 }
203 pub(super) async fn identifier_error(&self, identifier: &str) -> AuthResult<Option<String>> {
206 if let Some(validator) = &self.config.identifier_validator {
207 return Ok((!validator.validate(identifier).await?)
208 .then(|| format!("resource identifier {identifier} failed validation")));
209 }
210 Ok(resource_uri_issue(identifier)
211 .map(|issue| format!("resource identifier {identifier} is invalid: {issue}")))
212 }
213 pub(super) async fn resource_admission<S: AuthSchema>(
214 &self,
215 req: &impl SessionRequest,
216 ctx: &AuthContext<S>,
217 action: &'static str,
218 resource_id: Option<&str>,
219 ) -> AuthResult<Option<EndpointResponse>> {
220 let Ok((user, session)) = Box::pin(ctx.require_cached_session(req)).await else {
221 return Ok(Some(
222 EndpointResponse::error(AuthError::Api {
223 status: 401,
224 code: None,
225 message: String::new(),
226 })
227 .without_error_body()
228 .with_header("Content-Type", "application/json"),
229 ));
230 };
231 if req
232 .endpoint_call()
233 .is_some_and(|call| call.headers().is_none())
234 {
235 return Ok(Some(
236 EndpointResponse::error(AuthError::Api {
237 status: 400,
238 code: None,
239 message: String::new(),
240 })
241 .without_error_body()
242 .with_header("Content-Type", "application/json"),
243 ));
244 }
245 let Some(privileges) = &self.config.resource_privileges else {
246 return Ok(None);
247 };
248 let mut headers = alibi_core::Headers::new();
249 for (name, value) in req.session_headers() {
250 _ = headers.insert(name.clone(), value.clone());
251 }
252 match privileges
253 .allowed(&super::OAuthResourcePrivilegeContext {
254 action,
255 headers,
256 user: serde_json::to_value(user)?,
257 session: serde_json::to_value(session)?,
258 resource_id: resource_id.map(str::to_owned),
259 })
260 .await
261 {
262 Ok(true) => Ok(None),
263 Ok(false) => Ok(Some(
264 EndpointResponse::error(AuthError::Api {
265 status: 401,
266 code: None,
267 message: String::new(),
268 })
269 .without_error_body()
270 .with_header("Content-Type", "application/json"),
271 )),
272 Err(error) if alibi_core::endpoint::is_endpoint_api_error(&error) => {
273 Ok(Some(EndpointResponse::error(error)))
274 }
275 Err(error) => Err(error),
276 }
277 }
278}
279
280impl OAuthProviderPlugin {
281 #[must_use]
283 pub fn admin_list_oauth_resources_endpoint() -> ServerEndpoint<Value> {
284 ServerEndpoint::new("oauth-provider", "adminListOAuthResources")
285 }
286 pub(super) async fn list_resources<S: AuthSchema>(
287 &self,
288 req: &impl SessionRequest,
289 ctx: &AuthContext<S>,
290 ) -> AuthResult<EndpointResponse> {
291 if let Some(response) = Box::pin(self.resource_admission(req, ctx, "list", None)).await? {
292 return Ok(response);
293 }
294 let rows = ctx.database.list_oauth_resources().await?;
295 let values = rows
296 .into_iter()
297 .map(resource_value)
298 .collect::<AuthResult<Vec<_>>>()?;
299 EndpointResponse::json(&values)
300 }
301}
302
303pub(super) fn decode_parameter(params: Option<&JsValue>, name: &str) -> AuthResult<String> {
304 let raw = params.and_then(|params| params.get(name)).map_or_else(
305 || Ok("undefined".to_owned()),
306 |value| value.coerce_string().map_err(AuthError::internal),
307 )?;
308 for (index, byte) in raw.bytes().enumerate() {
309 if byte == b'%'
310 && (!raw
311 .as_bytes()
312 .get(index + 1)
313 .is_some_and(u8::is_ascii_hexdigit)
314 || !raw
315 .as_bytes()
316 .get(index + 2)
317 .is_some_and(u8::is_ascii_hexdigit))
318 {
319 return Ok(raw);
320 }
321 }
322 Ok(urlencoding::decode(&raw).map_or_else(|_| raw.clone(), std::borrow::Cow::into_owned))
323}
324impl OAuthProviderPlugin {
325 pub fn admin_get_oauth_resource_endpoint(
327 identifier: &str,
328 ) -> AuthResult<ServerEndpoint<Value>> {
329 ServerEndpoint::new("oauth-provider", "adminGetOAuthResource")
330 .with_params(&json!({"identifier":identifier}))
331 }
332 pub(super) async fn read_resource<S: AuthSchema>(
333 &self,
334 params: Option<&JsValue>,
335 req: &impl SessionRequest,
336 ctx: &AuthContext<S>,
337 ) -> AuthResult<EndpointResponse> {
338 let identifier = decode_parameter(params, "identifier")?;
339 if let Some(response) =
340 Box::pin(self.resource_admission(req, ctx, "read", Some(&identifier))).await?
341 {
342 return Ok(response);
343 }
344 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
345 return super::clients::status_protocol_error(
346 404,
347 "not_found",
348 &format!("resource {identifier} not found"),
349 );
350 };
351 EndpointResponse::json(&resource_value(row)?)
352 }
353}
354
355impl OAuthProviderPlugin {
356 pub fn admin_update_oauth_resource_endpoint(
358 identifier: &str,
359 patch: &impl serde::Serialize,
360 ) -> AuthResult<ServerEndpoint<Value>> {
361 ServerEndpoint::new("oauth-provider", "adminUpdateOAuthResource")
362 .with_params(&json!({"identifier":identifier}))?
363 .with_body(patch)
364 }
365 pub(super) async fn update_resource<S: AuthSchema>(
366 &self,
367 params: Option<&JsValue>,
368 input: Value,
369 req: &impl SessionRequest,
370 ctx: &AuthContext<S>,
371 ) -> AuthResult<EndpointResponse> {
372 let identifier = decode_parameter(params, "identifier")?;
373 if let Some(response) =
374 Box::pin(self.resource_admission(req, ctx, "update", Some(&identifier))).await?
375 {
376 return Ok(response);
377 }
378 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
379 return super::clients::status_protocol_error(
380 404,
381 "not_found",
382 &format!("resource {identifier} not found"),
383 );
384 };
385 let mut metadata = row
386 .metadata
387 .as_object()
388 .cloned()
389 .ok_or_else(|| AuthError::internal("Invalid resource metadata"))?;
390 for (name, value) in input
391 .as_object()
392 .ok_or_else(|| AuthError::internal("Invalid resource update"))?
393 {
394 if name != "identifier" {
395 _ = metadata.insert(name.clone(), value.clone());
396 }
397 }
398 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
399 .ok_or_else(|| AuthError::internal("Invalid resource update timestamp"))?;
400 _ = ctx
401 .database
402 .update_oauth_resource(&identifier, Value::Object(metadata), now)
403 .await?;
404 super::resource_cache::invalidate(&identifier).await;
405 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
406 return super::clients::status_protocol_error(
407 404,
408 "not_found",
409 &format!("resource {identifier} not found"),
410 );
411 };
412 EndpointResponse::json(&resource_value(row)?)
413 }
414}
415
416impl OAuthProviderPlugin {
417 pub fn admin_delete_oauth_resource_endpoint(
419 identifier: &str,
420 ) -> AuthResult<ServerEndpoint<Value>> {
421 ServerEndpoint::new("oauth-provider", "adminDeleteOAuthResource")
422 .with_params(&json!({"identifier":identifier}))
423 }
424 pub(super) async fn delete_resource<S: AuthSchema>(
425 &self,
426 params: Option<&JsValue>,
427 req: &impl SessionRequest,
428 ctx: &AuthContext<S>,
429 ) -> AuthResult<EndpointResponse> {
430 let identifier = decode_parameter(params, "identifier")?;
431 if let Some(response) =
432 Box::pin(self.resource_admission(req, ctx, "delete", Some(&identifier))).await?
433 {
434 return Ok(response);
435 }
436 if ctx
437 .database
438 .get_oauth_resource(&identifier)
439 .await?
440 .is_none()
441 {
442 return super::clients::status_protocol_error(
443 404,
444 "not_found",
445 &format!("resource {identifier} not found"),
446 );
447 }
448 ctx.database.delete_oauth_resource(&identifier).await?;
449 super::resource_cache::invalidate(&identifier).await;
450 EndpointResponse::json(&json!({"deleted":true}))
451 }
452}
453
454impl OAuthProviderPlugin {
455 pub fn admin_link_client_resource_endpoint(
457 identifier: &str,
458 client_id: &str,
459 ) -> AuthResult<ServerEndpoint<Value>> {
460 ServerEndpoint::new("oauth-provider", "adminLinkClientResource")
461 .with_params(&json!({"identifier":identifier,"client_id":client_id}))
462 }
463 pub(super) async fn link_resource<S: AuthSchema>(
464 &self,
465 params: Option<&JsValue>,
466 req: &impl SessionRequest,
467 ctx: &AuthContext<S>,
468 ) -> AuthResult<EndpointResponse> {
469 let identifier = decode_parameter(params, "identifier")?;
470 let client_id = decode_parameter(params, "client_id")?;
471 if let Some(response) =
472 Box::pin(self.resource_admission(req, ctx, "link", Some(&identifier))).await?
473 {
474 return Ok(response);
475 }
476 if ctx
477 .database
478 .get_oauth_resource(&identifier)
479 .await?
480 .is_none()
481 {
482 return super::clients::status_protocol_error(
483 404,
484 "not_found",
485 &format!("resource {identifier} not found"),
486 );
487 }
488 if ctx.database.get_oauth_client(&client_id).await?.is_none() {
489 return super::clients::status_protocol_error(
490 404,
491 "not_found",
492 &format!("client {client_id} not found"),
493 );
494 }
495 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
496 .ok_or_else(|| AuthError::internal("Invalid resource link timestamp"))?;
497 match ctx
498 .database
499 .create_oauth_client_resource(alibi_core::store::CreateOAuthClientResource {
500 client_id,
501 resource_id: identifier,
502 created_at: now,
503 })
504 .await
505 {
506 Ok(_) => EndpointResponse::json(&json!({"linked":true})),
507 Err(error)
508 if {
509 let message = error.to_string().to_ascii_lowercase();
510 message.contains("unique") || message.contains("duplicate")
511 } =>
512 {
513 EndpointResponse::json(&json!({"linked":true,"alreadyLinked":true}))
514 }
515 Err(error) => Err(error),
516 }
517 }
518}
519
520impl OAuthProviderPlugin {
521 pub fn admin_unlink_client_resource_endpoint(
523 identifier: &str,
524 client_id: &str,
525 ) -> AuthResult<ServerEndpoint<Value>> {
526 ServerEndpoint::new("oauth-provider", "adminUnlinkClientResource")
527 .with_params(&json!({"identifier":identifier,"client_id":client_id}))
528 }
529 pub(super) async fn unlink_resource<S: AuthSchema>(
530 &self,
531 params: Option<&JsValue>,
532 req: &impl SessionRequest,
533 ctx: &AuthContext<S>,
534 ) -> AuthResult<EndpointResponse> {
535 let identifier = decode_parameter(params, "identifier")?;
536 let client_id = decode_parameter(params, "client_id")?;
537 if let Some(response) =
538 Box::pin(self.resource_admission(req, ctx, "unlink", Some(&identifier))).await?
539 {
540 return Ok(response);
541 }
542 ctx.database
543 .delete_oauth_client_resource(&client_id, &identifier)
544 .await?;
545 EndpointResponse::json(&json!({"unlinked":true}))
546 }
547}
548pub(super) fn resource_uri_issue(uri: &str) -> Option<&'static str> {
551 let Ok(url) = url::Url::parse(uri) else {
552 return Some("resource must be an absolute URI");
553 };
554 if uri.contains('#') {
555 Some("resource must not contain a fragment")
556 } else if ["javascript", "data", "vbscript"].contains(&url.scheme()) {
557 Some("resource cannot use javascript:, data:, or vbscript: scheme")
558 } else {
559 None
560 }
561}