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
44pub(crate) async fn get(
45    mut rng: BoxRng,
46    clock: BoxClock,
47    PreferredLanguage(locale): PreferredLanguage,
48    State(templates): State<Templates>,
49    State(url_builder): State<UrlBuilder>,
50    mut repo: BoxRepository,
51    mut policy: Policy,
52    activity_tracker: BoundActivityTracker,
53    user_agent: Option<TypedHeader<headers::UserAgent>>,
54    cookie_jar: CookieJar,
55    Path(grant_id): Path<Ulid>,
56) -> Result<Response, FancyError> {
57    let (cookie_jar, maybe_session) = match load_session_or_fallback(
58        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
59    )
60    .await?
61    {
62        SessionOrFallback::MaybeSession {
63            cookie_jar,
64            maybe_session,
65            ..
66        } => (cookie_jar, maybe_session),
67        SessionOrFallback::Fallback { response } => return Ok(response),
68    };
69
70    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
71
72    let user_agent = user_agent.map(|ua| ua.to_string());
73
74    let Some(session) = maybe_session else {
75        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
76        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
77    };
78
79    activity_tracker
80        .record_browser_session(&clock, &session)
81        .await;
82
83    // TODO: better error handling
84    let grant = repo
85        .oauth2_device_code_grant()
86        .lookup(grant_id)
87        .await?
88        .context("Device grant not found")?;
89
90    if grant.expires_at < clock.now() {
91        return Err(FancyError::from(anyhow::anyhow!("Grant is expired")));
92    }
93
94    let client = repo
95        .oauth2_client()
96        .lookup(grant.client_id)
97        .await?
98        .context("Client not found")?;
99
100    // Evaluate the policy
101    let res = policy
102        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
103            grant_type: mas_policy::GrantType::DeviceCode,
104            client: &client,
105            scope: &grant.scope,
106            user: Some(&session.user),
107            requester: mas_policy::Requester {
108                ip_address: activity_tracker.ip(),
109                user_agent,
110            },
111        })
112        .await?;
113    if !res.valid() {
114        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
115
116        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
117        let ctx = PolicyViolationContext::for_device_code_grant(grant, client)
118            .with_session(session)
119            .with_csrf(csrf_token.form_value())
120            .with_language(locale);
121
122        let content = templates.render_policy_violation(&ctx)?;
123
124        return Ok((cookie_jar, Html(content)).into_response());
125    }
126
127    let ctx = DeviceConsentContext::new(grant, client)
128        .with_session(session)
129        .with_csrf(csrf_token.form_value())
130        .with_language(locale);
131
132    let rendered = templates
133        .render_device_consent(&ctx)
134        .context("Failed to render template")?;
135
136    Ok((cookie_jar, Html(rendered)).into_response())
137}
138
139pub(crate) async fn post(
140    mut rng: BoxRng,
141    clock: BoxClock,
142    PreferredLanguage(locale): PreferredLanguage,
143    State(templates): State<Templates>,
144    State(url_builder): State<UrlBuilder>,
145    mut repo: BoxRepository,
146    mut policy: Policy,
147    activity_tracker: BoundActivityTracker,
148    user_agent: Option<TypedHeader<headers::UserAgent>>,
149    cookie_jar: CookieJar,
150    Path(grant_id): Path<Ulid>,
151    Form(form): Form<ProtectedForm<ConsentForm>>,
152) -> Result<Response, FancyError> {
153    let form = cookie_jar.verify_form(&clock, form)?;
154    let (cookie_jar, maybe_session) = match load_session_or_fallback(
155        cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
156    )
157    .await?
158    {
159        SessionOrFallback::MaybeSession {
160            cookie_jar,
161            maybe_session,
162            ..
163        } => (cookie_jar, maybe_session),
164        SessionOrFallback::Fallback { response } => return Ok(response),
165    };
166    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
167
168    let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
169
170    let Some(session) = maybe_session else {
171        let login = mas_router::Login::and_continue_device_code_grant(grant_id);
172        return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
173    };
174
175    activity_tracker
176        .record_browser_session(&clock, &session)
177        .await;
178
179    // TODO: better error handling
180    let grant = repo
181        .oauth2_device_code_grant()
182        .lookup(grant_id)
183        .await?
184        .context("Device grant not found")?;
185
186    if grant.expires_at < clock.now() {
187        return Err(FancyError::from(anyhow::anyhow!("Grant is expired")));
188    }
189
190    let client = repo
191        .oauth2_client()
192        .lookup(grant.client_id)
193        .await?
194        .context("Client not found")?;
195
196    // Evaluate the policy
197    let res = policy
198        .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
199            grant_type: mas_policy::GrantType::DeviceCode,
200            client: &client,
201            scope: &grant.scope,
202            user: Some(&session.user),
203            requester: mas_policy::Requester {
204                ip_address: activity_tracker.ip(),
205                user_agent,
206            },
207        })
208        .await?;
209    if !res.valid() {
210        warn!(violation = ?res, "Device code grant for client {} denied by policy", client.id);
211
212        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
213        let ctx = PolicyViolationContext::for_device_code_grant(grant, client)
214            .with_session(session)
215            .with_csrf(csrf_token.form_value())
216            .with_language(locale);
217
218        let content = templates.render_policy_violation(&ctx)?;
219
220        return Ok((cookie_jar, Html(content)).into_response());
221    }
222
223    let grant = if grant.is_pending() {
224        match form.action {
225            Action::Consent => {
226                repo.oauth2_device_code_grant()
227                    .fulfill(&clock, grant, &session)
228                    .await?
229            }
230            Action::Reject => {
231                repo.oauth2_device_code_grant()
232                    .reject(&clock, grant, &session)
233                    .await?
234            }
235        }
236    } else {
237        // XXX: In case we're not pending, let's just return the grant as-is
238        // since it might just be a form resubmission, and feedback is nice enough
239        warn!(
240            oauth2_device_code.id = %grant.id,
241            browser_session.id = %session.id,
242            user.id = %session.user.id,
243            "Grant is not pending",
244        );
245        grant
246    };
247
248    repo.save().await?;
249
250    let ctx = DeviceConsentContext::new(grant, client)
251        .with_session(session)
252        .with_csrf(csrf_token.form_value())
253        .with_language(locale);
254
255    let rendered = templates
256        .render_device_consent(&ctx)
257        .context("Failed to render template")?;
258
259    Ok((cookie_jar, Html(rendered)).into_response())
260}