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            ))
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            // An absent resources value is filtered out of the pinned adapter
235            // update; likewise an update never replaces the selected row's reference.
236            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        // Preserve the original physical callback headers while changing only
315        // the continuation's logical response negotiation/session credentials.
316        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    /// List durable OAuth grants belonging to the current authenticated user.
327    #[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    /// Return the current user's durable consent identified by its stored grant ID.
371    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    /// Replace the current user's granted scopes with a subset allowed by the client.
442    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    /// Delete the current user's stored OAuth grant.
538    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}