mas_handlers/oauth2/device/
consent.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use anyhow::Context;
8use axum::{
9    Form,
10    extract::{Path, State},
11    response::{Html, IntoResponse, Response},
12};
13use axum_extra::TypedHeader;
14use mas_axum_utils::{
15    FancyError,
16    cookies::CookieJar,
17    csrf::{CsrfExt, ProtectedForm},
18};
19use mas_policy::Policy;
20use mas_router::UrlBuilder;
21use mas_storage::{BoxClock, BoxRepository, BoxRng};
22use mas_templates::{DeviceConsentContext, PolicyViolationContext, TemplateContext, Templates};
23use serde::Deserialize;
24use tracing::warn;
25use ulid::Ulid;
26
27use crate::{
28    BoundActivityTracker, PreferredLanguage,
29    session::{SessionOrFallback, load_session_or_fallback},
30};
31
32#[derive(Deserialize, Debug)]
33#[serde(rename_all = "lowercase")]
34enum Action {
35    Consent,
36    Reject,
37}
38
39#[derive(Deserialize, Debug)]
40pub(crate) struct ConsentForm {
41    action: Action,
42}
43
44#[tracing::instrument(name = "handlers.oauth2.device.consent.get", skip_all)]
45pub(crate) async fn get(
46    mut rng: BoxRng,
47    clock: BoxClock,
48    PreferredLanguage(locale): PreferredLanguage,
49    State(templates): State<Templates>,
50    State(url_builder): State<UrlBuilder>,
51    mut repo: BoxRepository,
52    mut policy: Policy,
53    activity_tracker: BoundActivityTracker,
54    user_agent: Option<TypedHeader<headers::UserAgent>>,
55    cookie_jar: CookieJar,
56    Path(grant_id): Path<Ulid>,
57) -> Result<Response, FancyError> {
58    let (cookie_jar, maybe_session) = match load_session_or_fallback(
59        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
60    )
61    .await?
62    {
63        SessionOrFallback::MaybeSession {
64            cookie_jar,
65            maybe_session,
66            ..
67        } => (cookie_jar, maybe_session),
68        SessionOrFallback::Fallback { response } => return Ok(response),
69    };
70
71    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
72
73    let user_agent = user_agent.map(|ua| ua.to_string());
74
75    let Some(session) = maybe_session else {
76        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
77        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
78    };
79
80    activity_tracker
81        .record_browser_session(&clock, &session)
82        .await;
83
84    // TODO: better error handling
85    let grant = repo
86        .oauth2_device_code_grant()
87        .lookup(grant_id)
88        .await?
89        .context("Device grant not found")?;
90
91    if grant.expires_at < clock.now() {
92        return Err(FancyError::from(anyhow::anyhow!("Grant is expired")));
93    }
94
95    let client = repo
96        .oauth2_client()
97        .lookup(grant.client_id)
98        .await?
99        .context("Client not found")?;
100
101    // Evaluate the policy
102    let res = policy
103        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
104            grant_type: mas_policy::GrantType::DeviceCode,
105            client: &client,
106            scope: &grant.scope,
107            user: Some(&session.user),
108            requester: mas_policy::Requester {
109                ip_address: activity_tracker.ip(),
110                user_agent,
111            },
112        })
113        .await?;
114    if !res.valid() {
115        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
116
117        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
118        let ctx = PolicyViolationContext::for_device_code_grant(grant, client)
119            .with_session(session)
120            .with_csrf(csrf_token.form_value())
121            .with_language(locale);
122
123        let content = templates.render_policy_violation(&ctx)?;
124
125        return Ok((cookie_jar, Html(content)).into_response());
126    }
127
128    let ctx = DeviceConsentContext::new(grant, client)
129        .with_session(session)
130        .with_csrf(csrf_token.form_value())
131        .with_language(locale);
132
133    let rendered = templates
134        .render_device_consent(&ctx)
135        .context("Failed to render template")?;
136
137    Ok((cookie_jar, Html(rendered)).into_response())
138}
139
140#[tracing::instrument(name = "handlers.oauth2.device.consent.post", skip_all)]
141pub(crate) async fn post(
142    mut rng: BoxRng,
143    clock: BoxClock,
144    PreferredLanguage(locale): PreferredLanguage,
145    State(templates): State<Templates>,
146    State(url_builder): State<UrlBuilder>,
147    mut repo: BoxRepository,
148    mut policy: Policy,
149    activity_tracker: BoundActivityTracker,
150    user_agent: Option<TypedHeader<headers::UserAgent>>,
151    cookie_jar: CookieJar,
152    Path(grant_id): Path<Ulid>,
153    Form(form): Form<ProtectedForm<ConsentForm>>,
154) -> Result<Response, FancyError> {
155    let form = cookie_jar.verify_form(&clock, form)?;
156    let (cookie_jar, maybe_session) = match load_session_or_fallback(
157        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
158    )
159    .await?
160    {
161        SessionOrFallback::MaybeSession {
162            cookie_jar,
163            maybe_session,
164            ..
165        } => (cookie_jar, maybe_session),
166        SessionOrFallback::Fallback { response } => return Ok(response),
167    };
168    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
169
170    let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
171
172    let Some(session) = maybe_session else {
173        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
174        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
175    };
176
177    activity_tracker
178        .record_browser_session(&clock, &session)
179        .await;
180
181    // TODO: better error handling
182    let grant = repo
183        .oauth2_device_code_grant()
184        .lookup(grant_id)
185        .await?
186        .context("Device grant not found")?;
187
188    if grant.expires_at < clock.now() {
189        return Err(FancyError::from(anyhow::anyhow!("Grant is expired")));
190    }
191
192    let client = repo
193        .oauth2_client()
194        .lookup(grant.client_id)
195        .await?
196        .context("Client not found")?;
197
198    // Evaluate the policy
199    let res = policy
200        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
201            grant_type: mas_policy::GrantType::DeviceCode,
202            client: &client,
203            scope: &grant.scope,
204            user: Some(&session.user),
205            requester: mas_policy::Requester {
206                ip_address: activity_tracker.ip(),
207                user_agent,
208            },
209        })
210        .await?;
211    if !res.valid() {
212        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
213
214        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
215        let ctx = PolicyViolationContext::for_device_code_grant(grant, client)
216            .with_session(session)
217            .with_csrf(csrf_token.form_value())
218            .with_language(locale);
219
220        let content = templates.render_policy_violation(&ctx)?;
221
222        return Ok((cookie_jar, Html(content)).into_response());
223    }
224
225    let grant = if grant.is_pending() {
226        match form.action {
227            Action::Consent => {
228                repo.oauth2_device_code_grant()
229                    .fulfill(&clock, grant, &session)
230                    .await?
231            }
232            Action::Reject => {
233                repo.oauth2_device_code_grant()
234                    .reject(&clock, grant, &session)
235                    .await?
236            }
237        }
238    } else {
239        // XXX: In case we're not pending, let's just return the grant as-is
240        // since it might just be a form resubmission, and feedback is nice enough
241        warn!(
242            oauth2_device_code.id = %grant.id,
243            browser_session.id = %session.id,
244            user.id = %session.user.id,
245            "Grant is not pending",
246        );
247        grant
248    };
249
250    repo.save().await?;
251
252    let ctx = DeviceConsentContext::new(grant, client)
253        .with_session(session)
254        .with_csrf(csrf_token.form_value())
255        .with_language(locale);
256
257    let rendered = templates
258        .render_device_consent(&ctx)
259        .context("Failed to render template")?;
260
261    Ok((cookie_jar, Html(rendered)).into_response())
262}