mas_handlers/oauth2/device/
authorize.rs1use axum::{Json, extract::State, response::IntoResponse};
9use axum_extra::typed_header::TypedHeader;
10use chrono::Duration;
11use headers::{CacheControl, Pragma};
12use hyper::StatusCode;
13use mas_axum_utils::{
14 RecordAsRequester,
15 client_authorization::{ClientAuthorization, CredentialsVerificationError},
16 record_error,
17};
18use mas_data_model::{BoxClock, BoxRng, generate_user_code};
19use mas_keystore::Encrypter;
20use mas_router::UrlBuilder;
21use mas_storage::{BoxRepository, oauth2::OAuth2DeviceCodeGrantParams};
22use oauth2_types::{
23 errors::{ClientError, ClientErrorCode},
24 requests::{DeviceAuthorizationRequest, DeviceAuthorizationResponse, GrantType},
25 scope::ScopeToken,
26};
27use rand::distr::{Alphanumeric, SampleString};
28use thiserror::Error;
29use ulid::Ulid;
30
31use crate::{BoundActivityTracker, SiteConfig, impl_from_error_for_route};
32
33#[derive(Debug, Error)]
34pub(crate) enum RouteError {
35 #[error(transparent)]
36 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
37
38 #[error("client not found")]
39 ClientNotFound,
40
41 #[error("client {0} is not allowed to use the device code grant")]
42 ClientNotAllowed(Ulid),
43
44 #[error("the Device Authorization Grant is disabled")]
45 DeviceCodeGrantDisabled,
46
47 #[error("invalid client credentials for client {client_id}")]
48 InvalidClientCredentials {
49 client_id: Ulid,
50 #[source]
51 source: CredentialsVerificationError,
52 },
53
54 #[error("could not verify client credentials for client {client_id}")]
55 ClientCredentialsVerification {
56 client_id: Ulid,
57 #[source]
58 source: CredentialsVerificationError,
59 },
60}
61
62impl_from_error_for_route!(mas_storage::RepositoryError);
63
64impl IntoResponse for RouteError {
65 fn into_response(self) -> axum::response::Response {
66 let sentry_event_id = record_error!(self, Self::Internal(_));
67
68 let response = match self {
69 Self::Internal(_) | Self::ClientCredentialsVerification { .. } => (
70 StatusCode::INTERNAL_SERVER_ERROR,
71 Json(ClientError::from(ClientErrorCode::ServerError)),
72 ),
73 Self::ClientNotFound | Self::InvalidClientCredentials { .. } => (
74 StatusCode::UNAUTHORIZED,
75 Json(ClientError::from(ClientErrorCode::InvalidClient)),
76 ),
77 Self::ClientNotAllowed(_) => (
78 StatusCode::UNAUTHORIZED,
79 Json(ClientError::from(ClientErrorCode::UnauthorizedClient)),
80 ),
81 Self::DeviceCodeGrantDisabled => (
82 StatusCode::BAD_REQUEST,
83 Json(
84 ClientError::from(ClientErrorCode::InvalidRequest).with_description(
85 "The Device Authorization Grant is disabled on this server".to_owned(),
86 ),
87 ),
88 ),
89 };
90
91 (sentry_event_id, response).into_response()
92 }
93}
94
95#[tracing::instrument(
96 name = "handlers.oauth2.device.request.post",
97 fields(client.id = client_authorization.client_id()),
98 skip_all,
99)]
100pub(crate) async fn post(
101 mut rng: BoxRng,
102 clock: BoxClock,
103 mut repo: BoxRepository,
104 user_agent: Option<TypedHeader<headers::UserAgent>>,
105 activity_tracker: BoundActivityTracker,
106 State(url_builder): State<UrlBuilder>,
107 State(http_client): State<reqwest::Client>,
108 State(encrypter): State<Encrypter>,
109 State(site_config): State<SiteConfig>,
110 client_authorization: ClientAuthorization<DeviceAuthorizationRequest>,
111) -> Result<impl IntoResponse, RouteError> {
112 if !site_config.device_code_grant_enabled {
113 return Err(RouteError::DeviceCodeGrantDisabled);
114 }
115
116 let client = client_authorization
117 .credentials
118 .fetch(&mut repo)
119 .await?
120 .ok_or(RouteError::ClientNotFound)?;
121
122 let method = client
124 .token_endpoint_auth_method
125 .as_ref()
126 .ok_or(RouteError::ClientNotAllowed(client.id))?;
127
128 client_authorization
129 .credentials
130 .verify(&http_client, &encrypter, method, &client)
131 .await
132 .map_err(|err| {
133 if err.is_internal() {
134 RouteError::ClientCredentialsVerification {
135 client_id: client.id,
136 source: err,
137 }
138 } else {
139 RouteError::InvalidClientCredentials {
140 client_id: client.id,
141 source: err,
142 }
143 }
144 })?;
145
146 client.maybe_record_as_requester();
147
148 if !client.grant_types.contains(&GrantType::DeviceCode) {
149 return Err(RouteError::ClientNotAllowed(client.id));
150 }
151
152 let scope = client_authorization
153 .form
154 .and_then(|f| f.scope)
155 .unwrap_or(std::iter::empty::<ScopeToken>().collect());
157
158 let expires_in = Duration::microseconds(20 * 60 * 1000 * 1000);
159
160 let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
161 let ip_address = activity_tracker.ip();
162
163 let device_code = Alphanumeric.sample_string(&mut rng, 32);
164 let user_code = generate_user_code(&mut rng);
165
166 let device_code = repo
167 .oauth2_device_code_grant()
168 .add(
169 &mut rng,
170 &clock,
171 OAuth2DeviceCodeGrantParams {
172 client: &client,
173 scope,
174 device_code,
175 user_code,
176 expires_in,
177 user_agent,
178 ip_address,
179 },
180 )
181 .await?;
182
183 repo.save().await?;
184
185 let verification_uri_complete = site_config
186 .device_code_user_code_auto_fill_enabled
187 .then(|| url_builder.device_code_link_full(device_code.user_code.clone()));
188
189 let response = DeviceAuthorizationResponse {
190 device_code: device_code.device_code,
191 user_code: device_code.user_code,
192 verification_uri: url_builder.device_code_link(),
193 verification_uri_complete,
194 expires_in,
195 interval: Some(Duration::microseconds(5 * 1000 * 1000)),
196 };
197
198 Ok((
199 StatusCode::OK,
200 TypedHeader(CacheControl::new().with_no_store()),
201 TypedHeader(Pragma::no_cache()),
202 Json(response),
203 ))
204}
205
206#[cfg(test)]
207mod tests {
208 use hyper::{Request, StatusCode};
209 use mas_data_model::SiteConfig;
210 use mas_router::SimpleRoute;
211 use oauth2_types::{
212 errors::{ClientError, ClientErrorCode},
213 registration::ClientRegistrationResponse,
214 requests::DeviceAuthorizationResponse,
215 };
216 use sqlx::PgPool;
217
218 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config};
219
220 fn assert_user_code_is_well_formed(user_code: &str) {
224 assert_eq!(user_code.len(), 6);
225 for c in user_code.chars() {
226 assert!(
227 c.is_ascii_digit() || c.is_ascii_uppercase(),
228 "user code {user_code:?} has {c:?}, which is not an uppercase alphanumeric"
229 );
230 assert!(
231 !matches!(c, 'I' | 'L' | 'O' | 'U'),
232 "user code {user_code:?} has an excluded character {c:?}"
233 );
234 }
235 }
236
237 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
238 async fn test_device_code_request(pool: PgPool) {
239 setup();
240 let state = TestState::from_pool(pool).await.unwrap();
241
242 let request =
244 Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
245 "client_uri": "https://example.com/",
246 "token_endpoint_auth_method": "none",
247 "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
248 "response_types": [],
249 }));
250
251 let response = state.request(request).await;
252 response.assert_status(StatusCode::CREATED);
253
254 let response: ClientRegistrationResponse = response.json();
255 let client_id = response.client_id;
256
257 let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
259 serde_json::json!({
260 "client_id": client_id,
261 "scope": "openid",
262 }),
263 );
264 let response = state.request(request).await;
265 response.assert_status(StatusCode::OK);
266
267 let response: DeviceAuthorizationResponse = response.json();
268 assert_eq!(response.device_code.len(), 32);
269 assert_user_code_is_well_formed(&response.user_code);
270 assert!(response.verification_uri_complete.is_some());
271 }
272
273 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
274 async fn test_device_code_request_no_auto_fill(pool: PgPool) {
275 setup();
276 let state = TestState::from_pool_with_site_config(
277 pool,
278 SiteConfig {
279 device_code_user_code_auto_fill_enabled: false,
280 ..test_site_config()
281 },
282 )
283 .await
284 .unwrap();
285
286 let request =
288 Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
289 "client_uri": "https://example.com/",
290 "token_endpoint_auth_method": "none",
291 "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
292 "response_types": [],
293 }));
294
295 let response = state.request(request).await;
296 response.assert_status(StatusCode::CREATED);
297
298 let response: ClientRegistrationResponse = response.json();
299 let client_id = response.client_id;
300
301 let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
303 serde_json::json!({
304 "client_id": client_id,
305 "scope": "openid",
306 }),
307 );
308 let response = state.request(request).await;
309 response.assert_status(StatusCode::OK);
310
311 let response: DeviceAuthorizationResponse = response.json();
312 assert_eq!(response.device_code.len(), 32);
313 assert_user_code_is_well_formed(&response.user_code);
314 assert!(response.verification_uri_complete.is_none());
315 }
316
317 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
318 async fn test_device_code_request_disabled(pool: PgPool) {
319 setup();
320 let state = TestState::from_pool_with_site_config(
321 pool,
322 SiteConfig {
323 device_code_grant_enabled: false,
324 ..test_site_config()
325 },
326 )
327 .await
328 .unwrap();
329
330 let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
332 serde_json::json!({
333 "client_id": "some-client-id",
334 "scope": "openid",
335 }),
336 );
337 let response = state.request(request).await;
338 response.assert_status(StatusCode::BAD_REQUEST);
339
340 let error: ClientError = response.json();
341 assert_eq!(error.error, ClientErrorCode::InvalidRequest);
342 assert!(
343 error
344 .error_description
345 .unwrap()
346 .contains("Device Authorization Grant is disabled")
347 );
348 }
349}