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