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