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 false,
197 ))
198 .await;
199 }
200 let reference_id = self
201 .consent_reference_id(&ctx.user_view(&user), &session, &accepted)
202 .await?;
203 let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
204 .ok_or_else(|| AuthError::internal("Invalid consent timestamp"))?;
205 let resource = query.get("resource").cloned().map_or(Value::Null, |value| {
206 if value.is_array() {
207 value
208 } else {
209 json!([value])
210 }
211 });
212 let metadata = json!({"scopes":accepted,"requestedUserInfoClaims":accepted_claims,"resources":resource,"referenceId":reference_id});
213 let existing = ctx
214 .database
215 .list_oauth_consents_by_user(user.id().as_ref())
216 .await?
217 .into_iter()
218 .find(|consent| {
219 consent.client_id == client_id
220 && super::post_login::consent_matches_reference(
221 &consent.metadata,
222 reference_id.as_deref(),
223 )
224 });
225 if let Some(existing) = existing {
226 let mut updated = existing
227 .metadata
228 .as_object()
229 .cloned()
230 .ok_or_else(|| AuthError::internal("Invalid consent metadata"))?;
231 _ = updated.insert("scopes".into(), json!(accepted));
232 _ = updated.insert("requestedUserInfoClaims".into(), json!(accepted_claims));
233 if !resource.is_null() {
236 _ = updated.insert("resources".into(), resource);
237 }
238 _ = ctx
239 .database
240 .update_oauth_consent_metadata(&existing.id, Value::Object(updated), now)
241 .await?;
242 } else {
243 _ = ctx
244 .database
245 .create_oauth_consent(alibi_core::store::CreateOAuthConsent {
246 client_id,
247 user_id: Some(user.id().as_ref().to_owned()),
248 metadata,
249 created_at: now,
250 })
251 .await?;
252 }
253 if input.claims.is_some()
254 && let Some(original) = query.get("claims")
255 {
256 if let Some(filtered) =
257 super::claims_request::filter_user_info(original, &accepted_claims)
258 {
259 _ = query.insert("claims".into(), json!(serde_json::to_string(&filtered)?));
260 } else {
261 _ = query.remove("claims");
262 }
263 }
264 if input.scope.is_some() {
265 _ = query.insert("scope".into(), json!(accepted.join(" ")));
266 }
267 let remaining = prompt
268 .split(' ')
269 .filter(|prompt| *prompt != "consent" && (!login_satisfied || *prompt != "login"))
270 .collect::<Vec<_>>()
271 .join(" ");
272 if remaining.is_empty() {
273 _ = query.remove("prompt");
274 } else {
275 _ = query.insert("prompt".into(), json!(remaining));
276 }
277 if login_satisfied {
278 _ = query.remove("max_age");
279 }
280 Box::pin(self.resume_authorization(
281 query,
282 req,
283 ctx,
284 super::continuation::post_login_clearance(supplied),
285 false,
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 post_login_continuation: bool,
296 ) -> AuthResult<EndpointResponse> {
297 let Some(mut request) = req.physical_request().cloned() else {
298 return super::clients::status_protocol_error(
299 401,
300 "invalid_request",
301 "request not found",
302 );
303 };
304 request.headers = req.session_headers().clone();
305 _ = request
306 .headers
307 .insert("accept".into(), "application/json".into());
308 let input = Some(alibi_core::utils::json::parse_value(
309 &serde_json::to_string(&query)?,
310 )?);
311 let settings = super::authorization::AuthorizationSettings {
312 initial: false,
313 post_login_cleared_for_session,
314 post_login_continuation,
315 };
316 if let Some(call) = req.endpoint_call() {
319 let mut call = call.clone();
320 call.replace_headers(request.headers);
321 return Box::pin(self.authorize_flow(input, &call, ctx, settings)).await;
322 }
323 Box::pin(self.authorize_flow(input, &request, ctx, settings)).await
324 }
325}
326
327impl OAuthProviderPlugin {
328 #[must_use]
330 pub fn get_oauth_consents_endpoint() -> ServerEndpoint<Value> {
331 ServerEndpoint::new("oauth-provider", "getOAuthConsents")
332 }
333 pub(super) async fn list_consents<S: AuthSchema>(
334 &self,
335 req: &impl SessionRequest,
336 ctx: &AuthContext<S>,
337 ) -> AuthResult<EndpointResponse> {
338 let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
339 let rows = ctx
340 .database
341 .list_oauth_consents_by_user(user.id().as_ref())
342 .await?;
343 let values = rows
344 .into_iter()
345 .map(consent_value)
346 .collect::<AuthResult<Vec<_>>>()?;
347 EndpointResponse::json(&values)
348 }
349}
350fn consent_value(row: alibi_core::store::OAuthConsent) -> AuthResult<Value> {
351 let mut value: serde_json::Map<String, Value> = serde_json::from_value(row.metadata)?;
352 _ = value.insert("id".into(), json!(row.id));
353 _ = value.insert("clientId".into(), json!(row.client_id));
354 _ = value.insert("userId".into(), json!(row.user_id));
355 _ = value.insert("createdAt".into(), json!(row.created_at));
356 _ = value.insert("updatedAt".into(), json!(row.updated_at));
357 Ok(Value::Object(value))
358}
359
360#[derive(serde::Deserialize)]
361pub(super) struct ConsentId {
362 id: String,
363}
364pub(super) fn validate_id(input: Option<&JsValue>, location: &str) -> AuthResult<JsValue> {
365 crate::endpoint::validate_fields(
366 input,
367 location,
368 &[crate::authentication_helpers::JsonField::string("id", true)],
369 )
370}
371impl OAuthProviderPlugin {
372 pub fn get_oauth_consent_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
374 ServerEndpoint::new("oauth-provider", "getOAuthConsent").with_query(&json!({"id":id}))
375 }
376 pub(super) async fn read_consent<S: AuthSchema>(
377 &self,
378 input: ConsentId,
379 req: &impl SessionRequest,
380 ctx: &AuthContext<S>,
381 ) -> AuthResult<EndpointResponse> {
382 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
383 Ok(row) => row,
384 Err(response) => return Ok(response),
385 };
386 EndpointResponse::json(&consent_value(row)?)
387 }
388}
389
390#[derive(serde::Deserialize)]
391pub(super) struct ConsentUpdateInput {
392 id: String,
393 update: ConsentScopeUpdate,
394}
395#[derive(serde::Deserialize)]
396struct ConsentScopeUpdate {
397 scopes: Vec<String>,
398}
399pub(super) fn validate_update(input: Option<&JsValue>) -> AuthResult<JsValue> {
400 let mut output = validate_id(input, "body")?;
401 let update = input.and_then(|value| value.get("update"));
402 let Some(object) = update.and_then(JsValue::as_object) else {
403 return Err(crate::endpoint::validation(format!(
404 "[body.update] Invalid input: expected object, received {}",
405 crate::authentication_helpers::json_type(update)
406 )));
407 };
408 let scopes = object.get("scopes");
409 let Some(values) = scopes.and_then(JsValue::as_array) else {
410 return Err(crate::endpoint::validation(format!(
411 "[body.update.scopes] Invalid input: expected array, received {}",
412 crate::authentication_helpers::json_type(scopes)
413 )));
414 };
415 let issues = values
416 .iter()
417 .enumerate()
418 .filter_map(|(index, value)| {
419 crate::authentication_helpers::field_issue(
420 crate::authentication_helpers::JsonFieldKind::String,
421 Some(value),
422 crate::authentication_helpers::json_type(Some(value)),
423 )
424 .map(|issue| format!("[body.update.scopes.{index}] {issue}"))
425 })
426 .collect::<Vec<_>>();
427 if !issues.is_empty() {
428 return Err(crate::endpoint::validation(issues.join("; ")));
429 }
430 if let JsValue::Object(object) = &mut output {
431 _ = object.insert(
432 "update".into(),
433 JsValue::Object(
434 [("scopes".into(), JsValue::Array(values.to_vec()))]
435 .into_iter()
436 .collect(),
437 ),
438 );
439 }
440 Ok(output)
441}
442impl OAuthProviderPlugin {
443 pub fn update_oauth_consent_endpoint(
445 id: &str,
446 scopes: &[String],
447 ) -> AuthResult<ServerEndpoint<Value>> {
448 ServerEndpoint::new("oauth-provider", "updateOAuthConsent")
449 .with_body(&json!({"id":id,"update":{"scopes":scopes}}))
450 }
451 async fn owned_consent<S: AuthSchema>(
452 &self,
453 id: &str,
454 req: &impl SessionRequest,
455 ctx: &AuthContext<S>,
456 ) -> AuthResult<Result<alibi_core::store::OAuthConsent, EndpointResponse>> {
457 let (user, _) = Box::pin(crate::helpers::ordinary_session(req, ctx)).await?;
458 if id.is_empty() {
459 return Ok(Err(super::clients::status_protocol_error(
460 404,
461 "not_found",
462 "missing id parameter",
463 )?));
464 }
465 let Some(row) = ctx.database.get_oauth_consent(id).await? else {
466 return Ok(Err(super::clients::status_protocol_error(
467 404,
468 "not_found",
469 "no consent",
470 )?));
471 };
472 if row.user_id.as_deref() != Some(user.id().as_ref()) {
473 return Ok(Err(EndpointResponse::error(AuthError::Api {
474 status: 401,
475 code: None,
476 message: String::new(),
477 })
478 .without_error_body()
479 .with_header("Content-Type", "application/json")));
480 }
481 Ok(Ok(row))
482 }
483 pub(super) async fn update_consent<S: AuthSchema>(
484 &self,
485 input: ConsentUpdateInput,
486 req: &impl SessionRequest,
487 ctx: &AuthContext<S>,
488 ) -> AuthResult<EndpointResponse> {
489 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
490 Ok(row) => row,
491 Err(response) => return Ok(response),
492 };
493 let Some(client) = super::discovery::resolve(ctx, &row.client_id).await? else {
494 return super::clients::status_protocol_error(404, "not_found", "client not found");
495 };
496 let allowed = client
497 .metadata
498 .get("scope")
499 .and_then(Value::as_str)
500 .map_or_else(
501 || self.config.scopes.clone(),
502 |scope| scope.split(' ').map(str::to_owned).collect::<Vec<_>>(),
503 );
504 if !input
505 .update
506 .scopes
507 .iter()
508 .all(|scope| allowed.contains(scope))
509 {
510 let owner = client
511 .metadata
512 .get("reference_id")
513 .and_then(Value::as_str)
514 .or(client.user_id.as_deref())
515 .unwrap_or("undefined");
516 return super::clients::status_protocol_error(
517 400,
518 "invalid_request",
519 &format!("unable to provide scopes to {owner}"),
520 );
521 }
522 let mut metadata = row
523 .metadata
524 .as_object()
525 .cloned()
526 .ok_or_else(|| AuthError::internal("Invalid consent metadata"))?;
527 _ = metadata.insert("scopes".into(), json!(input.update.scopes));
528 let now = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
529 .ok_or_else(|| AuthError::internal("Invalid consent timestamp"))?;
530 let updated = ctx
531 .database
532 .update_oauth_consent_metadata(&input.id, Value::Object(metadata), now)
533 .await?;
534 EndpointResponse::json(&updated.map(consent_value).transpose()?)
535 }
536}
537
538impl OAuthProviderPlugin {
539 pub fn delete_oauth_consent_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
541 ServerEndpoint::new("oauth-provider", "deleteOAuthConsent").with_body(&json!({"id":id}))
542 }
543 pub(super) async fn delete_consent<S: AuthSchema>(
544 &self,
545 input: ConsentId,
546 req: &impl SessionRequest,
547 ctx: &AuthContext<S>,
548 ) -> AuthResult<EndpointResponse> {
549 let row = match Box::pin(self.owned_consent(&input.id, req, ctx)).await? {
550 Ok(row) => row,
551 Err(response) => return Ok(response),
552 };
553 ctx.database.delete_oauth_consent(&row.id).await?;
554 Ok(EndpointResponse::empty().with_header("Content-Type", "application/json"))
555 }
556}