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