mas_handlers/oauth2/device/
consent.rs1use 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 #[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 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 repo.save().await?;
124
125 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 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 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 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 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 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 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}