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	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
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 =
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
210/// The authentication return target: renders consent for the authenticated
211/// user.
212pub(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	// A failed guess burns the login token (RFC 8628 §5.1; see
251	// verify_device_grant).
252	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}