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}
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    /// Accept or deny a provider-issued authorization request as the current user.
31    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    /// List durable OAuth grants belonging to the current authenticated user.
222    #[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    /// Return the current user's durable consent identified by its stored grant ID.
266    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    /// Replace the current user's granted scopes with a subset allowed by the client.
337    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    /// Delete the current user's stored OAuth grant.
433    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}