Skip to main content

mas_handlers/oauth2/authorization/
consent.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::{sync::Arc, time::Duration};
9
10use axum::{
11    extract::{Form, Path, State},
12    response::{Html, IntoResponse, Response},
13};
14use axum_extra::TypedHeader;
15use hyper::StatusCode;
16use mas_axum_utils::{
17    GenericError, InternalError,
18    cookies::CookieJar,
19    csrf::{CsrfExt, ProtectedForm},
20};
21use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser};
22use mas_keystore::Keystore;
23use mas_matrix::HomeserverConnection;
24use mas_policy::Policy;
25use mas_router::{PostAuthAction, UrlBuilder};
26use mas_storage::{
27    BoxRepository,
28    oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository},
29};
30use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates};
31use oauth2_types::requests::AuthorizationResponse;
32use thiserror::Error;
33use ulid::Ulid;
34
35use super::callback::CallbackDestination;
36use crate::{
37    BoundActivityTracker, PreferredLanguage, impl_from_error_for_route,
38    oauth2::generate_id_token,
39    session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
40};
41
42#[derive(Debug, Error)]
43pub enum RouteError {
44    #[error(transparent)]
45    Internal(Box<dyn std::error::Error + Send + Sync>),
46
47    #[error(transparent)]
48    Csrf(#[from] mas_axum_utils::csrf::CsrfError),
49
50    #[error("Authorization grant not found")]
51    GrantNotFound,
52
53    #[error("Authorization grant {0} already used")]
54    GrantNotPending(Ulid),
55
56    #[error("Failed to load client {0}")]
57    NoSuchClient(Ulid),
58}
59
60impl_from_error_for_route!(mas_templates::TemplateError);
61impl_from_error_for_route!(mas_storage::RepositoryError);
62impl_from_error_for_route!(mas_policy::LoadError);
63impl_from_error_for_route!(mas_policy::EvaluationError);
64impl_from_error_for_route!(crate::session::SessionLoadError);
65impl_from_error_for_route!(crate::oauth2::IdTokenSignatureError);
66impl_from_error_for_route!(super::callback::IntoCallbackDestinationError);
67impl_from_error_for_route!(super::callback::CallbackDestinationError);
68
69impl IntoResponse for RouteError {
70    fn into_response(self) -> axum::response::Response {
71        match self {
72            Self::Internal(e) => InternalError::new(e).into_response(),
73            e @ Self::NoSuchClient(_) => InternalError::new(Box::new(e)).into_response(),
74            e @ Self::GrantNotFound => GenericError::new(StatusCode::NOT_FOUND, e).into_response(),
75            e @ Self::GrantNotPending(_) => {
76                GenericError::new(StatusCode::CONFLICT, e).into_response()
77            }
78            e @ Self::Csrf(_) => GenericError::new(StatusCode::BAD_REQUEST, e).into_response(),
79        }
80    }
81}
82
83#[tracing::instrument(
84    name = "handlers.oauth2.authorization.consent.get",
85    fields(grant.id = %grant_id),
86    skip_all,
87)]
88pub(crate) async fn get(
89    mut rng: BoxRng,
90    clock: BoxClock,
91    PreferredLanguage(locale): PreferredLanguage,
92    State(templates): State<Templates>,
93    State(url_builder): State<UrlBuilder>,
94    State(homeserver): State<Arc<dyn HomeserverConnection>>,
95    mut policy: Policy,
96    mut repo: BoxRepository,
97    activity_tracker: BoundActivityTracker,
98    user_agent: Option<TypedHeader<headers::UserAgent>>,
99    cookie_jar: CookieJar,
100    Path(grant_id): Path<Ulid>,
101) -> Result<Response, RouteError> {
102    let (cookie_jar, maybe_session) = match load_session_or_fallback(
103        cookie_jar,
104        &clock,
105        &mut rng,
106        &templates,
107        &locale,
108        Some(PostAuthAction::continue_grant(grant_id)),
109        &mut repo,
110    )
111    .await?
112    {
113        SessionOrFallback::MaybeSession {
114            cookie_jar,
115            maybe_session,
116            ..
117        } => (cookie_jar, maybe_session),
118        SessionOrFallback::Fallback { response } => return Ok(response),
119    };
120
121    let user_agent = user_agent.map(|ua| ua.to_string());
122
123    let grant = repo
124        .oauth2_authorization_grant()
125        .lookup(grant_id)
126        .await?
127        .ok_or(RouteError::GrantNotFound)?;
128
129    let client = repo
130        .oauth2_client()
131        .lookup(grant.client_id)
132        .await?
133        .ok_or(RouteError::NoSuchClient(grant.client_id))?;
134
135    if !matches!(grant.stage, AuthorizationGrantStage::Pending) {
136        return Err(RouteError::GrantNotPending(grant.id));
137    }
138
139    let Some(session) = maybe_session else {
140        let login = mas_router::Login::and_continue_grant(grant_id);
141        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
142    };
143
144    activity_tracker
145        .record_browser_session(&clock, &session)
146        .await;
147
148    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
149
150    let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
151
152    // We can close the repository early, we don't need it at this point
153    repo.save().await?;
154
155    let res = policy
156        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
157            user: Some(&session.user),
158            client: &client,
159            session_counts: Some(session_counts),
160            scope: &grant.scope,
161            grant_type: mas_policy::GrantType::AuthorizationCode,
162            requester: mas_policy::Requester {
163                ip_address: activity_tracker.ip(),
164                user_agent,
165            },
166        })
167        .await?;
168    if !res.valid() {
169        let ctx = PolicyViolationContext::for_authorization_grant(grant, client, res.violations)
170            .with_session(session)
171            .with_csrf(csrf_token.form_value())
172            .with_language(locale);
173
174        let content = templates.render_policy_violation(&ctx)?;
175
176        return Ok((cookie_jar, Html(content)).into_response());
177    }
178
179    // Fetch informations about the user. This is purely cosmetic, so we let it
180    // fail and put a 1s timeout to it in case we fail to query it
181    // XXX: we're likely to need this in other places
182    let localpart = &session.user.username;
183    let display_name = match tokio::time::timeout(
184        Duration::from_secs(1),
185        homeserver.query_user(localpart),
186    )
187    .await
188    {
189        Ok(Ok(user)) => user.displayname,
190        Ok(Err(err)) => {
191            tracing::warn!(
192                error = &*err as &dyn std::error::Error,
193                localpart,
194                "Failed to query user"
195            );
196            None
197        }
198        Err(_) => {
199            tracing::warn!(localpart, "Timed out while querying user");
200            None
201        }
202    };
203
204    let matrix_user = MatrixUser {
205        mxid: homeserver.mxid(localpart),
206        display_name,
207    };
208
209    let ctx = ConsentContext::new(grant, client, matrix_user)
210        .with_session(session)
211        .with_csrf(csrf_token.form_value())
212        .with_language(locale);
213
214    let content = templates.render_consent(&ctx)?;
215
216    Ok((cookie_jar, Html(content)).into_response())
217}
218
219#[tracing::instrument(
220    name = "handlers.oauth2.authorization.consent.post",
221    fields(grant.id = %grant_id),
222    skip_all,
223)]
224pub(crate) async fn post(
225    mut rng: BoxRng,
226    clock: BoxClock,
227    PreferredLanguage(locale): PreferredLanguage,
228    State(templates): State<Templates>,
229    State(key_store): State<Keystore>,
230    mut policy: Policy,
231    mut repo: BoxRepository,
232    activity_tracker: BoundActivityTracker,
233    user_agent: Option<TypedHeader<headers::UserAgent>>,
234    cookie_jar: CookieJar,
235    State(url_builder): State<UrlBuilder>,
236    Path(grant_id): Path<Ulid>,
237    Form(form): Form<ProtectedForm<()>>,
238) -> Result<Response, RouteError> {
239    cookie_jar.verify_form(&clock, form)?;
240
241    let (cookie_jar, maybe_session) = match load_session_or_fallback(
242        cookie_jar,
243        &clock,
244        &mut rng,
245        &templates,
246        &locale,
247        Some(PostAuthAction::continue_grant(grant_id)),
248        &mut repo,
249    )
250    .await?
251    {
252        SessionOrFallback::MaybeSession {
253            cookie_jar,
254            maybe_session,
255            ..
256        } => (cookie_jar, maybe_session),
257        SessionOrFallback::Fallback { response } => return Ok(response),
258    };
259
260    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
261
262    let user_agent = user_agent.map(|ua| ua.to_string());
263
264    let grant = repo
265        .oauth2_authorization_grant()
266        .lookup(grant_id)
267        .await?
268        .ok_or(RouteError::GrantNotFound)?;
269    let callback_destination = CallbackDestination::try_from(&grant)?;
270
271    let Some(browser_session) = maybe_session else {
272        let next = PostAuthAction::continue_grant(grant_id);
273        let login = mas_router::Login::and_then(next);
274        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
275    };
276
277    activity_tracker
278        .record_browser_session(&clock, &browser_session)
279        .await;
280
281    let client = repo
282        .oauth2_client()
283        .lookup(grant.client_id)
284        .await?
285        .ok_or(RouteError::NoSuchClient(grant.client_id))?;
286
287    if !matches!(grant.stage, AuthorizationGrantStage::Pending) {
288        return Err(RouteError::GrantNotPending(grant.id));
289    }
290
291    let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user).await?;
292
293    let res = policy
294        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
295            user: Some(&browser_session.user),
296            client: &client,
297            session_counts: Some(session_counts),
298            scope: &grant.scope,
299            grant_type: mas_policy::GrantType::AuthorizationCode,
300            requester: mas_policy::Requester {
301                ip_address: activity_tracker.ip(),
302                user_agent,
303            },
304        })
305        .await?;
306
307    if !res.valid() {
308        let ctx = PolicyViolationContext::for_authorization_grant(grant, client, res.violations)
309            .with_session(browser_session)
310            .with_csrf(csrf_token.form_value())
311            .with_language(locale);
312
313        let content = templates.render_policy_violation(&ctx)?;
314
315        return Ok((cookie_jar, Html(content)).into_response());
316    }
317
318    // All good, let's fulfill the grant with the browser session.
319    // The OAuth2 session will be created later at token exchange time.
320    let grant = repo
321        .oauth2_authorization_grant()
322        .fulfill(&clock, &browser_session, grant)
323        .await?;
324
325    let mut params = AuthorizationResponse::default();
326
327    // Did they request an ID token?
328    if grant.response_type_id_token {
329        // Fetch the last authentication
330        let last_authentication = repo
331            .browser_session()
332            .get_last_authentication(&browser_session)
333            .await?;
334
335        params.id_token = Some(generate_id_token(
336            &mut rng,
337            &clock,
338            &url_builder,
339            &key_store,
340            &client,
341            Some(&grant),
342            &browser_session,
343            None,
344            last_authentication.as_ref(),
345        )?);
346    }
347
348    // Did they request an auth code?
349    if let Some(code) = grant.code {
350        params.code = Some(code.code);
351    }
352
353    repo.save().await?;
354
355    Ok((
356        cookie_jar,
357        callback_destination.go(&templates, &locale, params)?,
358    )
359        .into_response())
360}