Skip to main content

mas_handlers/oauth2/device/
consent.rs

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