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