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, AuthUser};
7use serde_json::{Value, json};
8#[derive(serde::Deserialize)]
9pub(super) struct ConsentInput {
10 accept: bool,
11 scope: Option<String>,
12 oauth_query: Option<String>,
13 claims: Option<Value>,
14}
15pub(super) fn validate(body: Option<&JsValue>) -> AuthResult<JsValue> {
16 let mut output = crate::endpoint::validate_fields(
17 body,
18 "body",
19 &[
20 crate::authentication_helpers::JsonField {
21 name: "accept",
22 required: true,
23 kind: crate::authentication_helpers::JsonFieldKind::Boolean,
24 },
25 crate::authentication_helpers::JsonField::string("scope", false),
26 crate::authentication_helpers::JsonField::string("oauth_query", false),
27 ],
28 )?;
29 if let Some(claims) = body.and_then(|value| value.get("claims")) {
30 if !claims.is_string() && !claims.is_object() {
31 return Err(crate::endpoint::validation("[body.claims] Invalid input"));
32 }
33 let value = claims.to_json_value()?;
34 if super::claims_request::parse(&value).is_none() {
35 return Err(crate::endpoint::validation(
36 "[body.claims] claims must be a valid Claims request object",
37 ));
38 }
39 if let JsValue::Object(object) = &mut output {
40 _ = object.insert("claims".into(), claims.clone());
41 }
42 }
43 Ok(output)
44}
45impl OAuthProviderPlugin {
46 pub fn oauth2_consent_endpoint(
48 accept: bool,
49 scope: Option<&str>,
50 oauth_query: Option<&str>,
51 ) -> AuthResult<ServerEndpoint<Value>> {
52 let mut body = serde_json::Map::from_iter([("accept".into(), json!(accept))]);
53 if let Some(scope) = scope {
54 _ = body.insert("scope".into(), json!(scope));
55 }
56 if let Some(query) = oauth_query {
57 _ = body.insert("oauth_query".into(), json!(query));
58 }
59 ServerEndpoint::new("oauth-provider", "oauth2Consent").with_body(&body)
60 }
61 pub(super) async fn consent<S: AuthSchema>(
62 &self,
63 input: ConsentInput,
64 req: &impl SessionRequest,
65 ctx: &AuthContext<S>,
66 ) -> AuthResult<EndpointResponse> {
67 let supplied = input.oauth_query.as_deref().unwrap_or("");
68 if !supplied.is_empty()
69 && !super::authorization::verify_authorization_query(
70 supplied,
71 ctx.config.current_secret(),
72 )?
73 {
74 return Ok(EndpointResponse::error(AuthError::Api {
75 status: 400,
76 code: None,
77 message: String::new(),
78 })
79 .with_error_body(alibi_core::utils::json::parse_value(
80 r#"{"error":"invalid_signature"}"#,
81 )?));
82 }
83 let (user, session) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
84 if supplied.is_empty() {
85 return super::clients::status_protocol_error(
86 400,
87 "invalid_request",
88 "missing oauth query",
89 );
90 }
91 let mut query = super::continuation::interaction_query(supplied);
92 let client_id = query
93 .get("client_id")
94 .and_then(Value::as_str)
95 .unwrap_or("")
96 .to_owned();
97 if client_id.is_empty() {
98 return super::clients::status_protocol_error(
99 400,
100 "invalid_client",
101 "client_id is required",
102 );
103 }
104 let scopes = query
105 .get("scope")
106 .and_then(Value::as_str)
107 .map(|scope| scope.split(' ').map(str::to_owned).collect::<Vec<_>>())
108 .unwrap_or_default();
109 let accepted = input.scope.as_deref().map_or_else(
110 || scopes.clone(),
111 |scope| scope.split(' ').map(str::to_owned).collect::<Vec<_>>(),
112 );
113 if !accepted.iter().all(|scope| scopes.contains(scope)) {
114 return super::clients::status_protocol_error(
115 400,
116 "invalid_request",
117 "Scope not originally requested",
118 );
119 }
120 let supported = self.supported_claims();
121 let requested_claims = query
122 .get("claims")
123 .and_then(super::claims_request::parse)
124 .map(|request| {
125 request
126 .user_info
127 .into_iter()
128 .filter(|name| supported.contains(name))
129 .collect::<Vec<_>>()
130 })
131 .unwrap_or_default();
132 let accepted_claims = input
133 .claims
134 .as_ref()
135 .and_then(super::claims_request::parse)
136 .map_or_else(
137 || requested_claims.clone(),
138 |request| {
139 request
140 .user_info
141 .into_iter()
142 .filter(|name| supported.contains(name))
143 .collect::<Vec<_>>()
144 },
145 );
146 if !accepted_claims
147 .iter()
148 .all(|name| requested_claims.contains(name))
149 {
150 return super::clients::status_protocol_error(
151 400,
152 "invalid_request",
153 "Claim not originally requested",
154 );
155 }
156 if !input.accept {
157 let redirect = query
158 .get("redirect_uri")
159 .and_then(Value::as_str)
160 .unwrap_or("");
161 let state = query.get("state").and_then(Value::as_str);
162 let issuer = self
163 .metadata(ctx)?
164 .get("issuer")
165 .and_then(Value::as_str)
166 .unwrap_or("")
167 .to_owned();
168 let mut pairs = url::form_urlencoded::Serializer::new(String::new());
169 _ = pairs
170 .append_pair("error", "access_denied")
171 .append_pair("error_description", "User denied access");
172 if let Some(state) = state {
173 _ = pairs.append_pair("state", state);
174 }
175 _ = pairs.append_pair("iss", &issuer);
176 return EndpointResponse::json(
177 &json!({"redirect":true,"url":format!("{redirect}{}{}",if redirect.contains('?'){"&"}else{"?"},pairs.finish())}),
178 );
179 }
180 let prompt = query
181 .get("prompt")
182 .and_then(Value::as_str)
183 .unwrap_or("")
184 .to_owned();
185 let issued = url::form_urlencoded::parse(supplied.as_bytes())
186 .find(|(name, _)| name == "ba_iat")
187 .and_then(|(_, value)| value.parse::<i64>().ok());
188 let login_satisfied =
189 issued.is_some_and(|issued| session.created_at.timestamp_millis() >= issued);
190 if prompt.split(' ').any(|prompt| prompt == "login") && !login_satisfied {
191 return Box::pin(self.resume_authorization(
192 query,
193 req,
194 ctx,
195 super::continuation::post_login_clearance(supplied),
196 ))
197 .await;
198 }
199 let reference_id = self
200 .consent_reference_id(&ctx.user_view(&user), &session, &accepted)
201 .await?;
202 let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
203 .ok_or_else(|| AuthError::internal("Invalid consent timestamp"))?;
204 let resource = query.get("resource").cloned().map_or(Value::Null, |value| {
205 if value.is_array() {
206 value
207 } else {
208 json!([value])
209 }
210 });
211 let metadata = json!({"scopes":accepted,"requestedUserInfoClaims":accepted_claims,"resources":resource,"referenceId":reference_id});
212 let existing = ctx
213 .database
214 .list_oauth_consents_by_user(user.id().as_ref())
215 .await?
216 .into_iter()
217 .find(|consent| {
218 consent.client_id == client_id
219 && reference_id
220 .as_deref()
221 .filter(|id| !id.is_empty())
222 .is_none_or(|id| {
223 consent.metadata.get("referenceId").and_then(Value::as_str) == Some(id)
224 })
225 });
226 if let Some(existing) = existing {
227 let mut updated = existing
228 .metadata
229 .as_object()
230 .cloned()
231 .ok_or_else(|| AuthError::internal("Invalid consent metadata"))?;
232 _ = updated.insert("scopes".into(), json!(accepted));
233 _ = updated.insert("requestedUserInfoClaims".into(), json!(accepted_claims));
234 if !resource.is_null() {
237 _ = updated.insert("resources".into(), resource);
238 }
239 _ = ctx
240 .database
241 .update_oauth_consent_metadata(&existing.id, Value::Object(updated), now)
242 .await?;
243 } else {
244 _ = ctx
245 .database
246 .create_oauth_consent(alibi_core::store::CreateOAuthConsent {
247 client_id,
248 user_id: Some(user.id().as_ref().to_owned()),
249 metadata,
250 created_at: now,
251 })
252 .await?;
253 }
254 if input.claims.is_some()
255 && let Some(original) = query.get("claims")
256 {
257 if let Some(filtered) =
258 super::claims_request::filter_user_info(original, &accepted_claims)
259 {
260 _ = query.insert("claims".into(), json!(serde_json::to_string(&filtered)?));
261 } else {
262 _ = query.remove("claims");
263 }
264 }
265 if input.scope.is_some() {
266 _ = query.insert("scope".into(), json!(accepted.join(" ")));
267 }
268 let remaining = prompt
269 .split(' ')
270 .filter(|prompt| *prompt != "consent" && (!login_satisfied || *prompt != "login"))
271 .collect::<Vec<_>>()
272 .join(" ");
273 if remaining.is_empty() {
274 _ = query.remove("prompt");
275 } else {
276 _ = query.insert("prompt".into(), json!(remaining));
277 }
278 if login_satisfied {
279 _ = query.remove("max_age");
280 }
281 Box::pin(self.resume_authorization(
282 query,
283 req,
284 ctx,
285 super::continuation::post_login_clearance(supplied),
286 ))
287 .await
288 }
289 pub(super) async fn resume_authorization<S: AuthSchema>(
290 &self,
291 query: serde_json::Map<String, Value>,
292 req: &impl SessionRequest,
293 ctx: &AuthContext<S>,
294 post_login_cleared_for_session: Option<String>,
295 ) -> AuthResult<EndpointResponse> {
296 let Some(mut request) = req.physical_request().cloned() else {
297 return super::clients::status_protocol_error(
298 401,
299 "invalid_request",
300 "request not found",
301 );
302 };
303 request.headers = req.session_headers().clone();
304 _ = request
305 .headers
306 .insert("accept".into(), "application/json".into());
307 let input = Some(alibi_core::utils::json::parse_value(
308 &serde_json::to_string(&query)?,
309 )?);
310 let settings = super::authorization::AuthorizationSettings {
311 initial: false,
312 post_login_cleared_for_session,
313 };
314 if let Some(call) = req.endpoint_call() {
317 let mut call = call.clone();
318 call.replace_headers(request.headers);
319 return Box::pin(self.authorize_flow(input, &call, ctx, settings)).await;
320 }
321 Box::pin(self.authorize_flow(input, &request, ctx, settings)).await
322 }
323}
324
325impl OAuthProviderPlugin {
326 #[must_use]
328 pub fn get_oauth_consents_endpoint() -> ServerEndpoint<Value> {
329 ServerEndpoint::new("oauth-provider", "getOAuthConsents")
330 }
331 pub(super) async fn list_consents<S: AuthSchema>(
332 &self,
333 req: &impl SessionRequest,
334 ctx: &AuthContext<S>,
335 ) -> AuthResult<EndpointResponse> {
336 let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
337 let rows = ctx
338 .database
339 .list_oauth_consents_by_user(user.id().as_ref())
340 .await?;
341 let values = rows
342 .into_iter()
343 .map(consent_value)
344 .collect::<AuthResult<Vec<_>>>()?;
345 EndpointResponse::json(&values)
346 }
347}
348fn consent_value(row: alibi_core::store::OAuthConsent) -> AuthResult<Value> {
349 let mut value: serde_json::Map<String, Value> = serde_json::from_value(row.metadata)?;
350 _ = value.insert("id".into(), json!(row.id));
351 _ = value.insert("clientId".into(), json!(row.client_id));
352 _ = value.insert("userId".into(), json!(row.user_id));
353 _ = value.insert("createdAt".into(), json!(row.created_at));
354 _ = value.insert("updatedAt".into(), json!(row.updated_at));
355 Ok(Value::Object(value))
356}
357
358#[derive(serde::Deserialize)]
359pub(super) struct ConsentId {
360 id: String,
361}
362pub(super) fn validate_id(input: Option<&JsValue>, location: &str) -> AuthResult<JsValue> {
363 crate::endpoint::validate_fields(
364 input,
365 location,
366 &[crate::authentication_helpers::JsonField::string("id", true)],
367 )
368}
369impl OAuthProviderPlugin {
370 pub fn get_oauth_consent_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
372 ServerEndpoint::new("oauth-provider", "getOAuthConsent").with_query(&json!({"id":id}))
373 }
374 pub(super) async fn read_consent<S: AuthSchema>(
375 &self,
376 input: ConsentId,
377 req: &impl SessionRequest,
378 ctx: &AuthContext<S>,
379 ) -> AuthResult<EndpointResponse> {
380 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
381 Ok(row) => row,
382 Err(response) => return Ok(response),
383 };
384 EndpointResponse::json(&consent_value(row)?)
385 }
386}
387
388#[derive(serde::Deserialize)]
389pub(super) struct ConsentUpdateInput {
390 id: String,
391 update: ConsentScopeUpdate,
392}
393#[derive(serde::Deserialize)]
394struct ConsentScopeUpdate {
395 scopes: Vec<String>,
396}
397pub(super) fn validate_update(input: Option<&JsValue>) -> AuthResult<JsValue> {
398 let mut output = validate_id(input, "body")?;
399 let update = input.and_then(|value| value.get("update"));
400 let Some(object) = update.and_then(JsValue::as_object) else {
401 return Err(crate::endpoint::validation(format!(
402 "[body.update] Invalid input: expected object, received {}",
403 crate::authentication_helpers::json_type(update)
404 )));
405 };
406 let scopes = object.get("scopes");
407 let Some(values) = scopes.and_then(JsValue::as_array) else {
408 return Err(crate::endpoint::validation(format!(
409 "[body.update.scopes] Invalid input: expected array, received {}",
410 crate::authentication_helpers::json_type(scopes)
411 )));
412 };
413 let issues = values
414 .iter()
415 .enumerate()
416 .filter_map(|(index, value)| {
417 crate::authentication_helpers::field_issue(
418 crate::authentication_helpers::JsonFieldKind::String,
419 Some(value),
420 crate::authentication_helpers::json_type(Some(value)),
421 )
422 .map(|issue| format!("[body.update.scopes.{index}] {issue}"))
423 })
424 .collect::<Vec<_>>();
425 if !issues.is_empty() {
426 return Err(crate::endpoint::validation(issues.join("; ")));
427 }
428 if let JsValue::Object(object) = &mut output {
429 _ = object.insert(
430 "update".into(),
431 JsValue::Object(
432 [("scopes".into(), JsValue::Array(values.to_vec()))]
433 .into_iter()
434 .collect(),
435 ),
436 );
437 }
438 Ok(output)
439}
440impl OAuthProviderPlugin {
441 pub fn update_oauth_consent_endpoint(
443 id: &str,
444 scopes: &[String],
445 ) -> AuthResult<ServerEndpoint<Value>> {
446 ServerEndpoint::new("oauth-provider", "updateOAuthConsent")
447 .with_body(&json!({"id":id,"update":{"scopes":scopes}}))
448 }
449 async fn owned_consent<S: AuthSchema>(
450 &self,
451 id: &str,
452 req: &impl SessionRequest,
453 ctx: &AuthContext<S>,
454 ) -> AuthResult<Result<alibi_core::store::OAuthConsent, EndpointResponse>> {
455 let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
456 if id.is_empty() {
457 return Ok(Err(super::clients::status_protocol_error(
458 404,
459 "not_found",
460 "missing id parameter",
461 )?));
462 }
463 let Some(row) = ctx.database.get_oauth_consent(id).await? else {
464 return Ok(Err(super::clients::status_protocol_error(
465 404,
466 "not_found",
467 "no consent",
468 )?));
469 };
470 if row.user_id.as_deref() != Some(user.id().as_ref()) {
471 return Ok(Err(EndpointResponse::error(AuthError::Api {
472 status: 401,
473 code: None,
474 message: String::new(),
475 })
476 .without_error_body()
477 .with_header("Content-Type", "application/json")));
478 }
479 Ok(Ok(row))
480 }
481 pub(super) async fn update_consent<S: AuthSchema>(
482 &self,
483 input: ConsentUpdateInput,
484 req: &impl SessionRequest,
485 ctx: &AuthContext<S>,
486 ) -> AuthResult<EndpointResponse> {
487 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
488 Ok(row) => row,
489 Err(response) => return Ok(response),
490 };
491 let Some(client) = super::discovery::resolve(ctx, &row.client_id).await? else {
492 return super::clients::status_protocol_error(404, "not_found", "client not found");
493 };
494 let allowed = client
495 .metadata
496 .get("scope")
497 .and_then(Value::as_str)
498 .map_or_else(
499 || self.config.scopes.clone(),
500 |scope| scope.split(' ').map(str::to_owned).collect::<Vec<_>>(),
501 );
502 if !input
503 .update
504 .scopes
505 .iter()
506 .all(|scope| allowed.contains(scope))
507 {
508 let owner = client
509 .metadata
510 .get("reference_id")
511 .and_then(Value::as_str)
512 .or(client.user_id.as_deref())
513 .unwrap_or("undefined");
514 return super::clients::status_protocol_error(
515 400,
516 "invalid_request",
517 &format!("unable to provide scopes to {owner}"),
518 );
519 }
520 let mut metadata = row
521 .metadata
522 .as_object()
523 .cloned()
524 .ok_or_else(|| AuthError::internal("Invalid consent metadata"))?;
525 _ = metadata.insert("scopes".into(), json!(input.update.scopes));
526 let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
527 .ok_or_else(|| AuthError::internal("Invalid consent timestamp"))?;
528 let updated = ctx
529 .database
530 .update_oauth_consent_metadata(&input.id, Value::Object(metadata), now)
531 .await?;
532 EndpointResponse::json(&updated.map(consent_value).transpose()?)
533 }
534}
535
536impl OAuthProviderPlugin {
537 pub fn delete_oauth_consent_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
539 ServerEndpoint::new("oauth-provider", "deleteOAuthConsent").with_body(&json!({"id":id}))
540 }
541 pub(super) async fn delete_consent<S: AuthSchema>(
542 &self,
543 input: ConsentId,
544 req: &impl SessionRequest,
545 ctx: &AuthContext<S>,
546 ) -> AuthResult<EndpointResponse> {
547 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
548 Ok(row) => row,
549 Err(response) => return Ok(response),
550 };
551 ctx.database.delete_oauth_consent(&row.id).await?;
552 Ok(EndpointResponse::empty().with_header("Content-Type", "application/json"))
553 }
554}