mas_handlers/oauth2/device/
consent.rs1use 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 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 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 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 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 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}