Skip to main content

alibi_plugins/oauth_provider/
consent.rs

1//! User consent over a provider-issued authorization request.
2use 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    /// Accept or deny a provider-issued authorization request as the current user.
47    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            // An absent resources value is filtered out of the pinned adapter
234            // update; likewise an update never replaces the selected row's reference.
235            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        // Preserve the original physical callback headers while changing only
317        // the continuation's logical response negotiation/session credentials.
318        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    /// List durable OAuth grants belonging to the current authenticated user.
329    #[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    /// Return the current user's durable consent identified by its stored grant ID.
373    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    /// Replace the current user's granted scopes with a subset allowed by the client.
444    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    /// Delete the current user's stored OAuth grant.
540    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}