Skip to main content

mas_handlers/oauth2/device/
authorize.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use 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    // Reuse the token endpoint auth method to verify the client
123    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        // XXX: Is this really how we do empty scopes?
156        .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    /// The user code is drawn from the Crockford Base32 alphabet, which
221    /// excludes the characters which get read as `0` and `1` (and the `U`
222    /// which Crockford drops to avoid obscenities).
223    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        // Provision a client
243        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        // Test the happy path: the client is allowed to use the device code grant type
258        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        // Provision a client
287        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        // The endpoint omits verification_uri_complete from the response
302        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        // The endpoint rejects the request before looking up the client
331        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}