1mod consent;
2mod entry;
3mod error;
4mod result;
5
6use axum::{
7 Json,
8 extract::{Form, Request, State},
9 response::{Html, IntoResponse, Redirect, Response},
10};
11use http::{
12 StatusCode,
13 header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, PRAGMA, REFERRER_POLICY},
14};
15use ruma::OwnedUserId;
16use serde::Deserialize;
17use serde_json::json;
18use tuwunel_core::{Err, Error, Result, err};
19use tuwunel_service::{
20 Services,
21 oauth::server::{DEVICE_GRANT_INTERVAL_SECS, DEVICE_GRANT_LIFETIME, format_user_code},
22};
23use url::Url;
24
25use self::{consent::consent_html, entry::entry_html, error::error_html, result::result_html};
26use super::{oauth_error, url_encode};
27use crate::ClientIp;
28
29static DEVICE_CSP: &str = "default-src 'none'; style-src 'self'; form-action 'self'; \
32 frame-ancestors 'none'; base-uri 'none';";
33
34static DEVICE_HEAD: &str = r#"
35 <meta charset="UTF-8">
36 <link rel="stylesheet" href="/_tuwunel/oidc/account.css">
37"#;
38
39#[derive(Debug, Deserialize)]
40pub(crate) struct DeviceAuthRequest {
41 client_id: Option<String>,
42 scope: Option<String>,
43}
44
45#[derive(Debug, Default, Deserialize)]
46struct DeviceVerifyParams {
47 user_code: Option<String>,
48}
49
50#[derive(Debug, Default, Deserialize)]
51pub(crate) struct DeviceCallbackParams {
52 user_code: Option<String>,
53
54 #[serde(rename = "loginToken")]
55 login_token: Option<String>,
56
57 action: Option<String>,
58}
59
60pub(crate) async fn device_authorization_route(
63 State(services): State<crate::State>,
64 ClientIp(client): ClientIp,
65 Form(body): Form<DeviceAuthRequest>,
66) -> impl IntoResponse {
67 let inner = if services
68 .oauth
69 .check_device_rate_limit(client)
70 .is_err()
71 {
72 oauth_error(StatusCode::TOO_MANY_REQUESTS, "slow_down", "Too many requests")
73 } else {
74 device_authorization(&services, &body)
75 .await
76 .unwrap_or_else(device_authorization_error)
77 };
78
79 ([(CACHE_CONTROL, "no-store"), (PRAGMA, "no-cache")], inner).into_response()
80}
81
82async fn device_authorization(services: &Services, body: &DeviceAuthRequest) -> Result<Response> {
83 let client_id = body
84 .client_id
85 .as_deref()
86 .ok_or_else(|| err!(Request(InvalidParam("client_id is required"))))?;
87
88 let server = services.oauth.get_server()?;
89 if server.get_client(client_id).await.is_err() {
90 return Ok(oauth_error(StatusCode::UNAUTHORIZED, "invalid_client", "Unknown client_id"));
91 }
92
93 let scope = body.scope.as_deref().unwrap_or_default();
94 let grant = server.create_device_grant(client_id, scope);
95 let user_code = format_user_code(&grant.user_code);
96
97 let issuer = server.issuer_url()?;
98 let base = issuer.trim_end_matches('/');
99 let verification_uri = format!("{base}/_tuwunel/oidc/device");
100 let verification_uri_complete =
101 format!("{verification_uri}?user_code={}", url_encode(&user_code));
102
103 let response = json!({
104 "device_code": grant.device_code,
105 "user_code": user_code,
106 "verification_uri": verification_uri,
107 "verification_uri_complete": verification_uri_complete,
108 "expires_in": DEVICE_GRANT_LIFETIME.as_secs(),
109 "interval": DEVICE_GRANT_INTERVAL_SECS,
110 });
111
112 Ok(Json(response).into_response())
113}
114
115#[expect(clippy::needless_pass_by_value)]
116fn device_authorization_error(e: Error) -> Response {
117 if !e.status_code().is_client_error() {
118 return oauth_error(
119 StatusCode::INTERNAL_SERVER_ERROR,
120 "server_error",
121 "An internal error occurred",
122 );
123 }
124
125 oauth_error(StatusCode::BAD_REQUEST, "invalid_request", &e.sanitized_message())
126}
127
128pub(crate) async fn get_device_route(
131 State(services): State<crate::State>,
132 ClientIp(client): ClientIp,
133 request: Request,
134) -> impl IntoResponse {
135 if services
136 .oauth
137 .check_device_rate_limit(client)
138 .is_err()
139 {
140 return device_html_response(
141 StatusCode::TOO_MANY_REQUESTS,
142 entry_html(Some("Too many requests. Please wait and try again.")),
143 );
144 }
145
146 let params: DeviceVerifyParams =
147 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
148 | Err(e) => return device_error_response(&e.into()),
149 | Ok(params) => params,
150 };
151
152 match handle_device_verify(&services, params.user_code.as_deref()) {
153 | Ok(response) => response,
154 | Err(e) => device_error_response(&e),
155 }
156}
157
158fn handle_device_verify(services: &Services, user_code: Option<&str>) -> Result<Response> {
159 let Some(user_code) = user_code.filter(|code| !code.is_empty()) else {
160 return Ok(device_html_response(StatusCode::OK, entry_html(None)));
161 };
162
163 device_sso_redirect(services, user_code)
167}
168
169fn device_sso_redirect(services: &Services, user_code: &str) -> Result<Response> {
170 let idp_id = services
171 .oauth
172 .providers
173 .get_default_id()
174 .ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))?;
175
176 let issuer = services.oauth.get_server()?.issuer_url()?;
177 let base = issuer.trim_end_matches('/');
178
179 let mut callback_url = Url::parse(&format!("{base}/_tuwunel/oidc/device_callback"))
180 .map_err(|_| err!(Request(InvalidParam("Failed to build device callback URL"))))?;
181
182 callback_url
183 .query_pairs_mut()
184 .append_pair("user_code", user_code);
185
186 let idp_id_enc = url_encode(&idp_id);
187 let mut sso_url =
188 Url::parse(&format!("{base}/_matrix/client/v3/login/sso/redirect/{idp_id_enc}"))
189 .map_err(|_| err!(Request(InvalidParam("Failed to build SSO URL"))))?;
190
191 sso_url
192 .query_pairs_mut()
193 .append_pair("redirectUrl", callback_url.as_str());
194
195 Ok(device_redirect_response(Redirect::temporary(sso_url.as_str())))
196}
197
198pub(crate) async fn get_device_callback_route(
200 State(services): State<crate::State>,
201 ClientIp(client): ClientIp,
202 request: Request,
203) -> impl IntoResponse {
204 if services
205 .oauth
206 .check_device_rate_limit(client)
207 .is_err()
208 {
209 return device_html_response(
210 StatusCode::TOO_MANY_REQUESTS,
211 error_html("Too many requests. Please wait and try again."),
212 );
213 }
214
215 let params: DeviceCallbackParams =
216 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
217 | Err(e) => return device_error_response(&e.into()),
218 | Ok(params) => params,
219 };
220
221 match handle_device_callback_get(&services, params).await {
222 | Ok(html) => device_html_response(StatusCode::OK, html),
223 | Err(e) => device_error_response(&e),
224 }
225}
226
227async fn handle_device_callback_get(
228 services: &Services,
229 params: DeviceCallbackParams,
230) -> Result<String> {
231 let token = params.login_token.as_deref();
232 let user_id = peek_login_token(services, token).await?;
233
234 let user_code = params.user_code.as_deref().unwrap_or_default();
235 let server = services.oauth.get_server()?;
236
237 let grant = match server.verify_device_grant(user_code).await {
240 | Ok(grant) => grant,
241 | Err(e) => {
242 consume_login_token(services, token).await.ok();
243
244 return Err(e);
245 },
246 };
247
248 let client_name = server
249 .get_client(&grant.client_id)
250 .await
251 .ok()
252 .and_then(|client| client.client_name);
253
254 let client_label = client_name.as_deref().unwrap_or(&grant.client_id);
255
256 Ok(consent_html(
257 &user_id,
258 client_label,
259 &grant.user_code,
260 &grant.scope,
261 token.unwrap_or_default(),
262 ))
263}
264
265pub(crate) async fn post_device_callback_route(
266 State(services): State<crate::State>,
267 ClientIp(client): ClientIp,
268 Form(body): Form<DeviceCallbackParams>,
269) -> impl IntoResponse {
270 if services
271 .oauth
272 .check_device_rate_limit(client)
273 .is_err()
274 {
275 return device_html_response(
276 StatusCode::TOO_MANY_REQUESTS,
277 error_html("Too many requests. Please wait and try again."),
278 );
279 }
280
281 match handle_device_callback_post(&services, body).await {
282 | Ok(html) => device_html_response(StatusCode::OK, html),
283 | Err(e) => device_error_response(&e),
284 }
285}
286
287async fn handle_device_callback_post(
288 services: &Services,
289 body: DeviceCallbackParams,
290) -> Result<String> {
291 let user_code = body.user_code.as_deref().unwrap_or_default();
292 let action = body.action.as_deref().unwrap_or_default();
293 let user_id = consume_login_token(services, body.login_token.as_deref()).await?;
294 let server = services.oauth.get_server()?;
295
296 match action {
297 | "approve" => {
298 let idp_id = services.oauth.providers.get_default_id();
299 server
300 .approve_device_grant(user_code, user_id, idp_id)
301 .await?;
302
303 Ok(result_html(
304 "Device approved",
305 "You have signed in. Return to your device; it will continue automatically.",
306 ))
307 },
308
309 | "deny" => {
310 server.deny_device_grant(user_code).await?;
311
312 Ok(result_html(
313 "Sign-in denied",
314 "The sign-in request was denied. You can close this page.",
315 ))
316 },
317
318 | _ => Err!(Request(InvalidParam("Unknown action"))),
319 }
320}
321
322async fn peek_login_token(services: &Services, token: Option<&str>) -> Result<OwnedUserId> {
323 let token = token.ok_or_else(|| err!(Request(Forbidden("Missing login token"))))?;
324
325 services
326 .users
327 .peek_login_token(token)
328 .await
329 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
330}
331
332async fn consume_login_token(services: &Services, token: Option<&str>) -> Result<OwnedUserId> {
333 let token = token.ok_or_else(|| err!(Request(Forbidden("Missing login token"))))?;
334
335 services
336 .users
337 .find_from_login_token(token)
338 .await
339 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
340}
341
342fn device_redirect_response(redirect: Redirect) -> Response {
343 ([(CACHE_CONTROL, "no-store"), (REFERRER_POLICY, "no-referrer")], redirect).into_response()
344}
345
346fn device_html_response(status: StatusCode, html: String) -> Response {
347 let headers = [
348 (CACHE_CONTROL, "no-store"),
349 (CONTENT_SECURITY_POLICY, DEVICE_CSP),
350 (REFERRER_POLICY, "no-referrer"),
351 ];
352
353 (status, headers, Html(html)).into_response()
354}
355
356fn device_error_response(error: &Error) -> Response {
357 device_html_response(error.status_code(), error_html(&error.sanitized_message()))
358}