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 authorize::should_serve_native, consume_login_token, oauth_error, peek_login_token,
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 =
164 should_serve_native(services.config.oidc_native_auth, idp_id.is_some(), false);
165
166 match serve_native {
167 | true => device_native_redirect(services, user_code),
168 | false => device_sso_redirect(services, user_code, idp_id.as_deref()),
169 }
170}
171
172fn device_native_redirect(services: &Services, user_code: &str) -> Result<Response> {
173 let issuer = services.oauth.get_server()?.issuer_url()?;
174 let base = issuer.trim_end_matches('/');
175
176 let native_url = Url::parse(&format!("{base}/_tuwunel/oidc/native"))
177 .map(|mut url| {
178 url.query_pairs_mut()
179 .append_pair("user_code", user_code);
180 url
181 })
182 .map_err(|_| err!(Request(InvalidParam("Failed to build native login URL"))))?;
183
184 Ok(device_redirect_response(Redirect::temporary(native_url.as_str())))
185}
186
187fn device_sso_redirect(
188 services: &Services,
189 user_code: &str,
190 idp_id: Option<&str>,
191) -> Result<Response> {
192 let idp_id = idp_id
193 .ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))?;
194
195 let issuer = services.oauth.get_server()?.issuer_url()?;
196 let base = issuer.trim_end_matches('/');
197
198 let mut callback_url = Url::parse(&format!("{base}/_tuwunel/oidc/device_callback"))
199 .map_err(|_| err!(Request(InvalidParam("Failed to build device callback URL"))))?;
200
201 callback_url
202 .query_pairs_mut()
203 .append_pair("user_code", user_code);
204
205 let sso_url = sso_redirect_url(base, idp_id, &callback_url)?;
206
207 Ok(device_redirect_response(Redirect::temporary(sso_url.as_str())))
208}
209
210pub(crate) async fn get_device_callback_route(
213 State(services): State<crate::State>,
214 ClientIp(client): ClientIp,
215 request: Request,
216) -> impl IntoResponse {
217 if services
218 .oauth
219 .check_device_rate_limit(client)
220 .is_err()
221 {
222 return device_html_response(
223 StatusCode::TOO_MANY_REQUESTS,
224 error_html("Too many requests. Please wait and try again."),
225 );
226 }
227
228 let params: DeviceCallbackParams =
229 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
230 | Err(e) => return device_error_response(&e.into()),
231 | Ok(params) => params,
232 };
233
234 match handle_device_callback_get(&services, params).await {
235 | Ok(html) => device_html_response(StatusCode::OK, html),
236 | Err(e) => device_error_response(&e),
237 }
238}
239
240async fn handle_device_callback_get(
241 services: &Services,
242 params: DeviceCallbackParams,
243) -> Result<String> {
244 let token = params.login_token.as_deref();
245 let user_id = peek_login_token(services, token).await?;
246
247 let user_code = params.user_code.as_deref().unwrap_or_default();
248 let server = services.oauth.get_server()?;
249
250 let grant = match server.verify_device_grant(user_code).await {
253 | Ok(grant) => grant,
254 | Err(e) => {
255 consume_login_token(services, token).await.ok();
256
257 return Err(e);
258 },
259 };
260
261 let client_name = server
262 .get_client(&grant.client_id)
263 .await
264 .ok()
265 .and_then(|client| client.client_name);
266
267 let client_label = client_name.as_deref().unwrap_or(&grant.client_id);
268
269 Ok(consent_html(
270 &user_id,
271 client_label,
272 &grant.user_code,
273 &grant.scope,
274 token.unwrap_or_default(),
275 ))
276}
277
278pub(crate) async fn post_device_callback_route(
279 State(services): State<crate::State>,
280 ClientIp(client): ClientIp,
281 Form(body): Form<DeviceCallbackParams>,
282) -> impl IntoResponse {
283 if services
284 .oauth
285 .check_device_rate_limit(client)
286 .is_err()
287 {
288 return device_html_response(
289 StatusCode::TOO_MANY_REQUESTS,
290 error_html("Too many requests. Please wait and try again."),
291 );
292 }
293
294 match handle_device_callback_post(&services, body).await {
295 | Ok(html) => device_html_response(StatusCode::OK, html),
296 | Err(e) => device_error_response(&e),
297 }
298}
299
300async fn handle_device_callback_post(
301 services: &Services,
302 body: DeviceCallbackParams,
303) -> Result<String> {
304 let user_code = body.user_code.as_deref().unwrap_or_default();
305 let action = body.action.as_deref().unwrap_or_default();
306 let user_id = consume_login_token(services, body.login_token.as_deref()).await?;
307 let server = services.oauth.get_server()?;
308
309 match action {
310 | "approve" => {
311 let idp_id = services.oauth.providers.get_default_id();
312 server
313 .approve_device_grant(user_code, user_id, idp_id)
314 .await?;
315
316 Ok(result_html(
317 "Device approved",
318 "You have signed in. Return to your device; it will continue automatically.",
319 ))
320 },
321
322 | "deny" => {
323 server.deny_device_grant(user_code).await?;
324
325 Ok(result_html(
326 "Sign-in denied",
327 "The sign-in request was denied. You can close this page.",
328 ))
329 },
330
331 | _ => Err!(Request(InvalidParam("Unknown action"))),
332 }
333}
334
335fn device_redirect_response(redirect: Redirect) -> Response {
336 ([(CACHE_CONTROL, "no-store"), (REFERRER_POLICY, "no-referrer")], redirect).into_response()
337}
338
339fn device_html_response(status: StatusCode, html: String) -> Response {
340 let headers = [(CACHE_CONTROL, "no-store"), (REFERRER_POLICY, "no-referrer")];
341
342 (status, headers, Html(html)).into_response()
343}
344
345fn device_error_response(error: &Error) -> Response {
346 device_html_response(error.status_code(), error_html(&error.sanitized_message()))
347}