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