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