Skip to main content

tuwunel_api/oidc/
device.rs

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
57/// RFC 8628 §3.1: the device authorization endpoint. Mints a `device_code` /
58/// `user_code` pair and returns the verification URIs for the user.
59pub(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
125/// RFC 8628 §3.3: the `verification_uri`. Shows a user-code entry form, or
126/// sends the user through native or SSO authentication before consent.
127pub(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	// Validating the code before authentication exposes the RFC 8628 §5.1
161	// brute-force oracle, so defer it to the authenticated callback.
162	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
212/// The authentication return target: renders consent for the authenticated
213/// user.
214pub(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	// A failed guess burns the login token (RFC 8628 §5.1; see
253	// verify_device_grant).
254	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}