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
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 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 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 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 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 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}