Skip to main content

mas_handlers/oauth2/device/
consent.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use std::{sync::Arc, time::Duration};
8
9use anyhow::Context;
10use axum::{
11    Form,
12    extract::{Path, State},
13    response::{Html, IntoResponse, Response},
14};
15use axum_extra::TypedHeader;
16use mas_axum_utils::{
17    InternalError,
18    cookies::CookieJar,
19    csrf::{CsrfExt, ProtectedForm},
20};
21use mas_data_model::{BoxClock, BoxRng, MatrixUser};
22use mas_matrix::HomeserverConnection;
23use mas_policy::Policy;
24use mas_router::UrlBuilder;
25use mas_storage::BoxRepository;
26use mas_templates::{DeviceConsentContext, PolicyViolationContext, TemplateContext, Templates};
27use serde::Deserialize;
28use tracing::warn;
29use ulid::Ulid;
30
31use crate::{
32    BoundActivityTracker, PreferredLanguage, SiteConfig,
33    session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
34};
35
36#[derive(Deserialize, Debug)]
37#[serde(rename_all = "lowercase")]
38enum Action {
39    Consent,
40    Reject,
41}
42
43#[derive(Deserialize, Debug)]
44pub(crate) struct ConsentForm {
45    action: Action,
46
47    // HTML form checkboxes are only sent when ticked, hence the Option.
48    #[serde(default)]
49    confirm_device: Option<String>,
50}
51
52#[tracing::instrument(name = "handlers.oauth2.device.consent.get", skip_all)]
53pub(crate) async fn get(
54    mut rng: BoxRng,
55    clock: BoxClock,
56    PreferredLanguage(locale): PreferredLanguage,
57    State(templates): State<Templates>,
58    State(url_builder): State<UrlBuilder>,
59    State(homeserver): State<Arc<dyn HomeserverConnection>>,
60    State(site_config): State<SiteConfig>,
61    mut repo: BoxRepository,
62    mut policy: Policy,
63    activity_tracker: BoundActivityTracker,
64    user_agent: Option<TypedHeader<headers::UserAgent>>,
65    cookie_jar: CookieJar,
66    Path(grant_id): Path<Ulid>,
67) -> Result<Response, InternalError> {
68    if !site_config.device_code_grant_enabled {
69        return Err(InternalError::from_anyhow(anyhow::anyhow!(
70            "The Device Authorization Grant is disabled"
71        )));
72    }
73    let (cookie_jar, maybe_session) = match load_session_or_fallback(
74        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
75    )
76    .await?
77    {
78        SessionOrFallback::MaybeSession {
79            cookie_jar,
80            maybe_session,
81            ..
82        } => (cookie_jar, maybe_session),
83        SessionOrFallback::Fallback { response } => return Ok(response),
84    };
85
86    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
87
88    let user_agent = user_agent.map(|ua| ua.to_string());
89
90    let Some(session) = maybe_session else {
91        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
92        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
93    };
94
95    activity_tracker
96        .record_browser_session(&clock, &session)
97        .await;
98
99    // TODO: better error handling
100    let grant = repo
101        .oauth2_device_code_grant()
102        .lookup(grant_id)
103        .await?
104        .context("Device grant not found")
105        .map_err(InternalError::from_anyhow)?;
106
107    if grant.expires_at < clock.now() {
108        return Err(InternalError::from_anyhow(anyhow::anyhow!(
109            "Grant is expired"
110        )));
111    }
112
113    let client = repo
114        .oauth2_client()
115        .lookup(grant.client_id)
116        .await?
117        .context("Client not found")
118        .map_err(InternalError::from_anyhow)?;
119
120    let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
121
122    // We can close the repository early, we don't need it at this point
123    repo.save().await?;
124
125    // Evaluate the policy
126    let res = policy
127        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
128            grant_type: mas_policy::GrantType::DeviceCode,
129            client: &client,
130            session_counts: Some(session_counts),
131            scope: &grant.scope,
132            user: Some(&session.user),
133            requester: mas_policy::Requester {
134                ip_address: activity_tracker.ip(),
135                user_agent,
136            },
137        })
138        .await?;
139    if !res.valid() {
140        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
141
142        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
143        let ctx = PolicyViolationContext::for_device_code_grant(grant, client, res.violations)
144            .with_session(session)
145            .with_csrf(csrf_token.form_value())
146            .with_language(locale);
147
148        let content = templates.render_policy_violation(&ctx)?;
149
150        return Ok((cookie_jar, Html(content)).into_response());
151    }
152
153    // Fetch informations about the user. This is purely cosmetic, so we let it
154    // fail and put a 1s timeout to it in case we fail to query it
155    // XXX: we're likely to need this in other places
156    let localpart = &session.user.username;
157    let display_name = match tokio::time::timeout(
158        Duration::from_secs(1),
159        homeserver.query_user(localpart),
160    )
161    .await
162    {
163        Ok(Ok(user)) => user.displayname,
164        Ok(Err(err)) => {
165            tracing::warn!(
166                error = &*err as &dyn std::error::Error,
167                localpart,
168                "Failed to query user"
169            );
170            None
171        }
172        Err(_) => {
173            tracing::warn!(localpart, "Timed out while querying user");
174            None
175        }
176    };
177
178    let matrix_user = MatrixUser {
179        mxid: homeserver.mxid(localpart),
180        display_name,
181    };
182
183    let ctx = DeviceConsentContext::new(grant, client, matrix_user)
184        .with_session(session)
185        .with_csrf(csrf_token.form_value())
186        .with_language(locale);
187
188    let rendered = templates
189        .render_device_consent(&ctx)
190        .context("Failed to render template")
191        .map_err(InternalError::from_anyhow)?;
192
193    Ok((cookie_jar, Html(rendered)).into_response())
194}
195
196#[tracing::instrument(name = "handlers.oauth2.device.consent.post", skip_all)]
197pub(crate) async fn post(
198    mut rng: BoxRng,
199    clock: BoxClock,
200    PreferredLanguage(locale): PreferredLanguage,
201    State(templates): State<Templates>,
202    State(url_builder): State<UrlBuilder>,
203    State(homeserver): State<Arc<dyn HomeserverConnection>>,
204    State(site_config): State<SiteConfig>,
205    mut repo: BoxRepository,
206    mut policy: Policy,
207    activity_tracker: BoundActivityTracker,
208    user_agent: Option<TypedHeader<headers::UserAgent>>,
209    cookie_jar: CookieJar,
210    Path(grant_id): Path<Ulid>,
211    Form(form): Form<ProtectedForm<ConsentForm>>,
212) -> Result<Response, InternalError> {
213    if !site_config.device_code_grant_enabled {
214        return Err(InternalError::from_anyhow(anyhow::anyhow!(
215            "The Device Authorization Grant is disabled"
216        )));
217    }
218    let form = cookie_jar.verify_form(&clock, form)?;
219    let (cookie_jar, maybe_session) = match load_session_or_fallback(
220        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
221    )
222    .await?
223    {
224        SessionOrFallback::MaybeSession {
225            cookie_jar,
226            maybe_session,
227            ..
228        } => (cookie_jar, maybe_session),
229        SessionOrFallback::Fallback { response } => return Ok(response),
230    };
231    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
232
233    let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
234
235    let Some(session) = maybe_session else {
236        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
237        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
238    };
239
240    activity_tracker
241        .record_browser_session(&clock, &session)
242        .await;
243
244    // TODO: better error handling
245    let grant = repo
246        .oauth2_device_code_grant()
247        .lookup(grant_id)
248        .await?
249        .context("Device grant not found")
250        .map_err(InternalError::from_anyhow)?;
251
252    if grant.expires_at < clock.now() {
253        return Err(InternalError::from_anyhow(anyhow::anyhow!(
254            "Grant is expired"
255        )));
256    }
257
258    let client = repo
259        .oauth2_client()
260        .lookup(grant.client_id)
261        .await?
262        .context("Client not found")
263        .map_err(InternalError::from_anyhow)?;
264
265    let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
266
267    // Evaluate the policy
268    let res = policy
269        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
270            grant_type: mas_policy::GrantType::DeviceCode,
271            client: &client,
272            session_counts: Some(session_counts),
273            scope: &grant.scope,
274            user: Some(&session.user),
275            requester: mas_policy::Requester {
276                ip_address: activity_tracker.ip(),
277                user_agent,
278            },
279        })
280        .await?;
281    if !res.valid() {
282        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
283
284        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
285        let ctx = PolicyViolationContext::for_device_code_grant(grant, client, res.violations)
286            .with_session(session)
287            .with_csrf(csrf_token.form_value())
288            .with_language(locale);
289
290        let content = templates.render_policy_violation(&ctx)?;
291
292        return Ok((cookie_jar, Html(content)).into_response());
293    }
294
295    let grant = if grant.is_pending() {
296        match form.action {
297            Action::Consent => {
298                // The user must explicitly tick the "confirm this is my device" box.
299                // The browser enforces `required` client-side; this is the
300                // server-side safety net.
301                if form.confirm_device.is_none() {
302                    return Err(InternalError::from_anyhow(anyhow::anyhow!(
303                        "The device must be confirmed before consent can be granted"
304                    )));
305                }
306
307                repo.oauth2_device_code_grant()
308                    .fulfill(&clock, grant, &session, Some(locale.to_string()))
309                    .await?
310            }
311            Action::Reject => {
312                repo.oauth2_device_code_grant()
313                    .reject(&clock, grant, &session)
314                    .await?
315            }
316        }
317    } else {
318        // XXX: In case we're not pending, let's just return the grant as-is
319        // since it might just be a form resubmission, and feedback is nice enough
320        warn!(
321            oauth2_device_code.id = %grant.id,
322            browser_session.id = %session.id,
323            user.id = %session.user.id,
324            "Grant is not pending",
325        );
326        grant
327    };
328
329    repo.save().await?;
330
331    // Fetch informations about the user. This is purely cosmetic, so we let it
332    // fail and put a 1s timeout to it in case we fail to query it
333    // XXX: we're likely to need this in other places
334    let localpart = &session.user.username;
335    let display_name = match tokio::time::timeout(
336        Duration::from_secs(1),
337        homeserver.query_user(localpart),
338    )
339    .await
340    {
341        Ok(Ok(user)) => user.displayname,
342        Ok(Err(err)) => {
343            tracing::warn!(
344                error = &*err as &dyn std::error::Error,
345                localpart,
346                "Failed to query user"
347            );
348            None
349        }
350        Err(_) => {
351            tracing::warn!(localpart, "Timed out while querying user");
352            None
353        }
354    };
355
356    let matrix_user = MatrixUser {
357        mxid: homeserver.mxid(localpart),
358        display_name,
359    };
360
361    let ctx = DeviceConsentContext::new(grant, client, matrix_user)
362        .with_session(session)
363        .with_csrf(csrf_token.form_value())
364        .with_language(locale);
365
366    let rendered = templates
367        .render_device_consent(&ctx)
368        .context("Failed to render template")
369        .map_err(InternalError::from_anyhow)?;
370
371    Ok((cookie_jar, Html(rendered)).into_response())
372}