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