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 let Ok(uri) = url::Url::parse(identifier) else {
151 return super::clients::status_protocol_error(
152 400,
153 "invalid_target",
154 &format!("resource identifier {identifier} must be an absolute URI (RFC 8707 §2)"),
155 );
156 };
157 if uri.fragment().is_some_and(|fragment| !fragment.is_empty()) {
158 return super::clients::status_protocol_error(
159 400,
160 "invalid_target",
161 &format!(
162 "resource identifier {identifier} must not contain a URI fragment (RFC 8707 §2)"
163 ),
164 );
165 }
166 let mut metadata = input
167 .as_object()
168 .cloned()
169 .ok_or_else(|| AuthError::internal("Invalid resource body"))?;
170 _ = metadata.remove("identifier");
171 _ = metadata.entry("name").or_insert_with(|| json!(identifier));
172 for name in [
173 "accessTokenTtl",
174 "refreshTokenTtl",
175 "signingAlgorithm",
176 "signingKeyId",
177 "allowedScopes",
178 "customClaims",
179 "metadata",
180 ] {
181 _ = metadata.entry(name).or_insert(Value::Null);
182 }
183 for name in ["dpopBoundAccessTokensRequired", "disabled"] {
184 _ = metadata.entry(name).or_insert_with(|| json!(false));
185 }
186 _ = metadata.insert("policyVersion".into(), json!(1));
187 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
188 .ok_or_else(|| AuthError::internal("Invalid resource timestamp"))?;
189 let result = ctx
190 .database
191 .create_oauth_resource(alibi_core::store::CreateOAuthResource {
192 identifier: identifier.into(),
193 metadata: Value::Object(metadata),
194 created_at: now,
195 })
196 .await;
197 let row = match result {
198 Ok(row) => row,
199 Err(error)
200 if {
201 let message = error.to_string().to_ascii_lowercase();
202 message.contains("unique") || message.contains("duplicate")
203 } =>
204 {
205 return super::clients::status_protocol_error(
206 400,
207 "invalid_request",
208 &format!("resource {identifier} already exists"),
209 );
210 }
211 Err(error) => return Err(error),
212 };
213 super::resource_cache::invalidate(&row.identifier).await;
214 EndpointResponse::json(&resource_value(row)?)
215 }
216 async fn resource_admission<S: AuthSchema>(
217 &self,
218 req: &impl SessionRequest,
219 ctx: &AuthContext<S>,
220 action: &'static str,
221 resource_id: Option<&str>,
222 ) -> AuthResult<Option<EndpointResponse>> {
223 let Ok((user, session)) = Box::pin(ctx.require_cached_session(req)).await else {
224 return Ok(Some(
225 EndpointResponse::error(AuthError::Api {
226 status: 401,
227 code: None,
228 message: String::new(),
229 })
230 .without_error_body()
231 .with_header("Content-Type", "application/json"),
232 ));
233 };
234 if req
235 .endpoint_call()
236 .is_some_and(|call| call.headers().is_none())
237 {
238 return Ok(Some(
239 EndpointResponse::error(AuthError::Api {
240 status: 400,
241 code: None,
242 message: String::new(),
243 })
244 .without_error_body()
245 .with_header("Content-Type", "application/json"),
246 ));
247 }
248 let Some(privileges) = &self.config.resource_privileges else {
249 return Ok(None);
250 };
251 let mut headers = alibi_core::Headers::new();
252 for (name, value) in req.session_headers() {
253 _ = headers.insert(name.clone(), value.clone());
254 }
255 match privileges
256 .allowed(&super::OAuthResourcePrivilegeContext {
257 action,
258 headers,
259 user: serde_json::to_value(user)?,
260 session: serde_json::to_value(session)?,
261 resource_id: resource_id.map(str::to_owned),
262 })
263 .await
264 {
265 Ok(true) => Ok(None),
266 Ok(false) => Ok(Some(
267 EndpointResponse::error(AuthError::Api {
268 status: 401,
269 code: None,
270 message: String::new(),
271 })
272 .without_error_body()
273 .with_header("Content-Type", "application/json"),
274 )),
275 Err(error) if alibi_core::endpoint::is_endpoint_api_error(&error) => {
276 Ok(Some(EndpointResponse::error(error)))
277 }
278 Err(error) => Err(error),
279 }
280 }
281}
282
283impl OAuthProviderPlugin {
284 #[must_use]
286 pub fn admin_list_oauth_resources_endpoint() -> ServerEndpoint<Value> {
287 ServerEndpoint::new("oauth-provider", "adminListOAuthResources")
288 }
289 pub(super) async fn list_resources<S: AuthSchema>(
290 &self,
291 req: &impl SessionRequest,
292 ctx: &AuthContext<S>,
293 ) -> AuthResult<EndpointResponse> {
294 if let Some(response) = Box::pin(self.resource_admission(req, ctx, "list", None)).await? {
295 return Ok(response);
296 }
297 let rows = ctx.database.list_oauth_resources().await?;
298 let values = rows
299 .into_iter()
300 .map(resource_value)
301 .collect::<AuthResult<Vec<_>>>()?;
302 EndpointResponse::json(&values)
303 }
304}
305
306pub(super) fn decode_parameter(params: Option<&JsValue>, name: &str) -> AuthResult<String> {
307 let raw = params.and_then(|params| params.get(name)).map_or_else(
308 || Ok("undefined".to_owned()),
309 |value| value.coerce_string().map_err(AuthError::internal),
310 )?;
311 for (index, byte) in raw.bytes().enumerate() {
312 if byte == b'%'
313 && (!raw
314 .as_bytes()
315 .get(index + 1)
316 .is_some_and(u8::is_ascii_hexdigit)
317 || !raw
318 .as_bytes()
319 .get(index + 2)
320 .is_some_and(u8::is_ascii_hexdigit))
321 {
322 return Ok(raw);
323 }
324 }
325 Ok(urlencoding::decode(&raw).map_or_else(|_| raw.clone(), std::borrow::Cow::into_owned))
326}
327impl OAuthProviderPlugin {
328 pub fn admin_get_oauth_resource_endpoint(
330 identifier: &str,
331 ) -> AuthResult<ServerEndpoint<Value>> {
332 ServerEndpoint::new("oauth-provider", "adminGetOAuthResource")
333 .with_params(&json!({"identifier":identifier}))
334 }
335 pub(super) async fn read_resource<S: AuthSchema>(
336 &self,
337 params: Option<&JsValue>,
338 req: &impl SessionRequest,
339 ctx: &AuthContext<S>,
340 ) -> AuthResult<EndpointResponse> {
341 let identifier = decode_parameter(params, "identifier")?;
342 if let Some(response) =
343 Box::pin(self.resource_admission(req, ctx, "read", Some(&identifier))).await?
344 {
345 return Ok(response);
346 }
347 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
348 return super::clients::status_protocol_error(
349 404,
350 "not_found",
351 &format!("resource {identifier} not found"),
352 );
353 };
354 EndpointResponse::json(&resource_value(row)?)
355 }
356}
357
358impl OAuthProviderPlugin {
359 pub fn admin_update_oauth_resource_endpoint(
361 identifier: &str,
362 patch: &impl serde::Serialize,
363 ) -> AuthResult<ServerEndpoint<Value>> {
364 ServerEndpoint::new("oauth-provider", "adminUpdateOAuthResource")
365 .with_params(&json!({"identifier":identifier}))?
366 .with_body(patch)
367 }
368 pub(super) async fn update_resource<S: AuthSchema>(
369 &self,
370 params: Option<&JsValue>,
371 input: Value,
372 req: &impl SessionRequest,
373 ctx: &AuthContext<S>,
374 ) -> AuthResult<EndpointResponse> {
375 let identifier = decode_parameter(params, "identifier")?;
376 if let Some(response) =
377 Box::pin(self.resource_admission(req, ctx, "update", Some(&identifier))).await?
378 {
379 return Ok(response);
380 }
381 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
382 return super::clients::status_protocol_error(
383 404,
384 "not_found",
385 &format!("resource {identifier} not found"),
386 );
387 };
388 let mut metadata = row
389 .metadata
390 .as_object()
391 .cloned()
392 .ok_or_else(|| AuthError::internal("Invalid resource metadata"))?;
393 for (name, value) in input
394 .as_object()
395 .ok_or_else(|| AuthError::internal("Invalid resource update"))?
396 {
397 if name != "identifier" {
398 _ = metadata.insert(name.clone(), value.clone());
399 }
400 }
401 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
402 .ok_or_else(|| AuthError::internal("Invalid resource update timestamp"))?;
403 _ = ctx
404 .database
405 .update_oauth_resource(&identifier, Value::Object(metadata), now)
406 .await?;
407 super::resource_cache::invalidate(&identifier).await;
408 let Some(row) = ctx.database.get_oauth_resource(&identifier).await? else {
409 return super::clients::status_protocol_error(
410 404,
411 "not_found",
412 &format!("resource {identifier} not found"),
413 );
414 };
415 EndpointResponse::json(&resource_value(row)?)
416 }
417}
418
419impl OAuthProviderPlugin {
420 pub fn admin_delete_oauth_resource_endpoint(
422 identifier: &str,
423 ) -> AuthResult<ServerEndpoint<Value>> {
424 ServerEndpoint::new("oauth-provider", "adminDeleteOAuthResource")
425 .with_params(&json!({"identifier":identifier}))
426 }
427 pub(super) async fn delete_resource<S: AuthSchema>(
428 &self,
429 params: Option<&JsValue>,
430 req: &impl SessionRequest,
431 ctx: &AuthContext<S>,
432 ) -> AuthResult<EndpointResponse> {
433 let identifier = decode_parameter(params, "identifier")?;
434 if let Some(response) =
435 Box::pin(self.resource_admission(req, ctx, "delete", Some(&identifier))).await?
436 {
437 return Ok(response);
438 }
439 if ctx
440 .database
441 .get_oauth_resource(&identifier)
442 .await?
443 .is_none()
444 {
445 return super::clients::status_protocol_error(
446 404,
447 "not_found",
448 &format!("resource {identifier} not found"),
449 );
450 }
451 ctx.database.delete_oauth_resource(&identifier).await?;
452 super::resource_cache::invalidate(&identifier).await;
453 EndpointResponse::json(&json!({"deleted":true}))
454 }
455}
456
457impl OAuthProviderPlugin {
458 pub fn admin_link_client_resource_endpoint(
460 identifier: &str,
461 client_id: &str,
462 ) -> AuthResult<ServerEndpoint<Value>> {
463 ServerEndpoint::new("oauth-provider", "adminLinkClientResource")
464 .with_params(&json!({"identifier":identifier,"client_id":client_id}))
465 }
466 pub(super) async fn link_resource<S: AuthSchema>(
467 &self,
468 params: Option<&JsValue>,
469 req: &impl SessionRequest,
470 ctx: &AuthContext<S>,
471 ) -> AuthResult<EndpointResponse> {
472 let identifier = decode_parameter(params, "identifier")?;
473 let client_id = decode_parameter(params, "client_id")?;
474 if let Some(response) =
475 Box::pin(self.resource_admission(req, ctx, "link", Some(&identifier))).await?
476 {
477 return Ok(response);
478 }
479 if ctx
480 .database
481 .get_oauth_resource(&identifier)
482 .await?
483 .is_none()
484 {
485 return super::clients::status_protocol_error(
486 404,
487 "not_found",
488 &format!("resource {identifier} not found"),
489 );
490 }
491 if ctx.database.get_oauth_client(&client_id).await?.is_none() {
492 return super::clients::status_protocol_error(
493 404,
494 "not_found",
495 &format!("client {client_id} not found"),
496 );
497 }
498 let now = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
499 .ok_or_else(|| AuthError::internal("Invalid resource link timestamp"))?;
500 match ctx
501 .database
502 .create_oauth_client_resource(alibi_core::store::CreateOAuthClientResource {
503 client_id,
504 resource_id: identifier,
505 created_at: now,
506 })
507 .await
508 {
509 Ok(_) => EndpointResponse::json(&json!({"linked":true})),
510 Err(error)
511 if {
512 let message = error.to_string().to_ascii_lowercase();
513 message.contains("unique") || message.contains("duplicate")
514 } =>
515 {
516 EndpointResponse::json(&json!({"linked":true,"alreadyLinked":true}))
517 }
518 Err(error) => Err(error),
519 }
520 }
521}
522
523impl OAuthProviderPlugin {
524 pub fn admin_unlink_client_resource_endpoint(
526 identifier: &str,
527 client_id: &str,
528 ) -> AuthResult<ServerEndpoint<Value>> {
529 ServerEndpoint::new("oauth-provider", "adminUnlinkClientResource")
530 .with_params(&json!({"identifier":identifier,"client_id":client_id}))
531 }
532 pub(super) async fn unlink_resource<S: AuthSchema>(
533 &self,
534 params: Option<&JsValue>,
535 req: &impl SessionRequest,
536 ctx: &AuthContext<S>,
537 ) -> AuthResult<EndpointResponse> {
538 let identifier = decode_parameter(params, "identifier")?;
539 let client_id = decode_parameter(params, "client_id")?;
540 if let Some(response) =
541 Box::pin(self.resource_admission(req, ctx, "unlink", Some(&identifier))).await?
542 {
543 return Ok(response);
544 }
545 ctx.database
546 .delete_oauth_client_resource(&client_id, &identifier)
547 .await?;
548 EndpointResponse::json(&json!({"unlinked":true}))
549 }
550}