Skip to main content

tuwunel_api/oidc/
account.rs

1#[cfg(test)]
2mod tests;
3
4mod account_deactivate;
5mod cross_signing_reset;
6mod profile;
7mod profile_saved;
8mod session_end_confirm;
9mod session_end_execute;
10mod session_list;
11mod session_view;
12
13use axum::{
14	extract::{Form, Request, State},
15	response::{Html, IntoResponse, Redirect, Response},
16};
17use http::{
18	HeaderValue, Method, StatusCode,
19	header::{CACHE_CONTROL, CONTENT_TYPE, REFERRER_POLICY},
20};
21use ruma::OwnedDeviceId;
22use tuwunel_core::{
23	Err, Error, Result, err,
24	utils::{BoolExt, html::escape as html_escape},
25};
26use tuwunel_service::Services;
27use url::Url;
28
29use self::{
30	account_deactivate::{account_deactivate_confirm_html, account_deactivate_execute_html},
31	cross_signing_reset::{cross_signing_reset_confirm_html, cross_signing_reset_execute_html},
32	profile::profile_html,
33	profile_saved::profile_saved_html,
34	session_end_confirm::session_end_confirm_html,
35	session_end_execute::session_end_execute_html,
36	session_list::sessions_list_html,
37	session_view::session_view_html,
38};
39use super::{
40	authorize::should_serve_native, consume_login_token, peek_login_token, sso_redirect_url,
41	url_encode,
42};
43
44pub(crate) static ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED: &[&str] = &[
45	"org.matrix.profile",
46	"org.matrix.devices_list",
47	"org.matrix.device_view",
48	"org.matrix.device_delete",
49	"org.matrix.account_deactivate",
50	"org.matrix.cross_signing_reset",
51	"org.matrix.sessions_list",
52	"org.matrix.session_view",
53	"org.matrix.session_end",
54];
55
56/// Raw JS served at `/_tuwunel/oidc/account.js`.
57/// Referenced via `<script src>` for CSP compatibility.
58static ACCOUNT_JS: &str = include_str!("account/account.js");
59
60/// Shared stylesheet served at `/_tuwunel/oidc/account.css`.
61static ACCOUNT_CSS: &str = include_str!("account/account.css");
62
63pub(super) static ACCOUNT_HEAD: &str = r#"
64	<meta charset="UTF-8">
65	<link rel="stylesheet" href="/_tuwunel/oidc/account.css">
66"#;
67
68static ACCOUNT_JS_INCLUDE: &str = r#"
69	<script src="/_tuwunel/oidc/account.js"></script>
70"#;
71
72/// Cache-control header value.
73static ACCOUNT_CACHE_CONTROL: &str = "no-store";
74
75#[derive(Debug, Default, serde::Deserialize)]
76struct AccountQueryParams {
77	action: Option<String>,
78	device_id: Option<String>,
79}
80
81#[derive(Debug, Default, serde::Deserialize)]
82pub(crate) struct AccountCallbackParams {
83	action: Option<String>,
84	device_id: Option<String>,
85	#[serde(rename = "loginToken")]
86	login_token: Option<String>,
87	displayname: Option<String>,
88}
89
90pub(crate) async fn get_account_route(
91	State(services): State<crate::State>,
92	request: Request,
93) -> impl IntoResponse {
94	let params: AccountQueryParams =
95		match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
96			| Err(e) => return account_error_response(&e.into()),
97			| Ok(params) => params,
98		};
99
100	let action = params
101		.action
102		.as_deref()
103		.unwrap_or("org.matrix.sessions_list");
104
105	let device_id = params.device_id.as_deref().unwrap_or_default();
106
107	match account_auth_redirect(&services, action, device_id) {
108		| Ok(response) => response,
109		| Err(e) => account_error_response(&e),
110	}
111}
112
113fn account_auth_redirect(services: &Services, action: &str, device_id: &str) -> Result<Response> {
114	validate_account_action(action)?;
115
116	let idp_id = services.oauth.providers.get_default_id();
117	let wants_create = false;
118	let serve_native =
119		should_serve_native(services.config.oidc_native_auth, idp_id.is_some(), wants_create);
120
121	match serve_native {
122		| true => account_native_redirect(services, action, device_id),
123		| false => account_sso_redirect(services, action, device_id, idp_id.as_deref()),
124	}
125}
126
127fn account_native_redirect(
128	services: &Services,
129	action: &str,
130	device_id: &str,
131) -> Result<Response> {
132	let issuer = services.oauth.get_server()?.issuer_url()?;
133	let base = issuer.trim_end_matches('/');
134
135	let native_url = Url::parse_with_params(&format!("{base}/_tuwunel/oidc/native"), [
136		("action", action),
137		("device_id", device_id),
138	])
139	.map_err(|_| err!(Request(InvalidParam("Failed to build native login URL"))))?;
140
141	Ok(account_redirect_response(Redirect::temporary(native_url.as_str())))
142}
143
144fn account_sso_redirect(
145	services: &Services,
146	action: &str,
147	device_id: &str,
148	idp_id: Option<&str>,
149) -> Result<Response> {
150	let idp_id = idp_id
151		.ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))?;
152
153	let issuer = services.oauth.get_server()?.issuer_url()?;
154	let base = issuer.trim_end_matches('/');
155
156	let callback_url =
157		Url::parse_with_params(&format!("{base}/_tuwunel/oidc/account_callback"), [
158			("action", action),
159			("device_id", device_id),
160		])
161		.map_err(|_| err!(error!("Failed to build account callback URL")))?;
162
163	let sso_url = sso_redirect_url(base, idp_id, &callback_url)?;
164
165	Ok(account_redirect_response(Redirect::temporary(sso_url.as_str())))
166}
167
168pub(crate) async fn get_account_callback_route(
169	State(services): State<crate::State>,
170	request: Request,
171) -> impl IntoResponse {
172	let params: AccountCallbackParams =
173		match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
174			| Err(e) => return account_error_response(&e.into()),
175			| Ok(params) => params,
176		};
177
178	match handle_account_callback(&services, Method::GET, params).await {
179		| Ok(html) => account_html_response(StatusCode::OK, html),
180		| Err(e) => account_error_response(&e),
181	}
182}
183
184pub(crate) async fn post_account_callback_route(
185	State(services): State<crate::State>,
186	Form(body): Form<AccountCallbackParams>,
187) -> impl IntoResponse {
188	match handle_account_callback(&services, Method::POST, body).await {
189		| Ok(html) => account_html_response(StatusCode::OK, html),
190		| Err(e) => account_error_response(&e),
191	}
192}
193
194// no-cache: revalidate on every request so a server update takes effect
195// immediately
196pub(crate) async fn account_js_route() -> impl IntoResponse {
197	let content_type = (CONTENT_TYPE, "application/javascript; charset=utf-8");
198	let cache_control = (CACHE_CONTROL, "no-cache");
199
200	([content_type, cache_control], ACCOUNT_JS)
201}
202
203pub(crate) async fn account_css_route() -> impl IntoResponse {
204	let content_type = (CONTENT_TYPE, "text/css; charset=utf-8");
205	let cache_control = (CACHE_CONTROL, "no-cache");
206
207	([content_type, cache_control], ACCOUNT_CSS)
208}
209
210async fn handle_account_callback(
211	services: &Services,
212	method: Method,
213	params: AccountCallbackParams,
214) -> Result<String> {
215	let login_token = params.login_token.as_deref();
216
217	let fallback_action = method
218		.eq(&Method::GET)
219		.then_some("org.matrix.sessions_list");
220
221	let action = params
222		.action
223		.as_deref()
224		.or(fallback_action)
225		.unwrap_or_default();
226
227	// Validations before consuming the token so that an invalid action does not
228	// burn the user's single-use login_token needlessly.
229	services.oauth.get_server()?;
230
231	(services.config.oidc_native_auth
232		|| services
233			.oauth
234			.providers
235			.get_default_id()
236			.is_some())
237	.then_some(())
238	.ok_or_else(|| {
239		err!(Config(
240			"identity_provider",
241			"No identity provider or native authentication configured"
242		))
243	})?;
244
245	validate_account_action(action)?;
246
247	// MSC4191 stable action names dispatch through the prototype aliases.
248	let action = normalize_account_action(action);
249
250	// Read-only pages consume the token immediately. Pages with a POST confirmation
251	// step peek at the token so it can be embedded in the form and consumed only
252	// when the user confirms the action. This avoids creating a second short-lived
253	// token on every GET, preventing accumulation of orphaned tokens when the user
254	// navigates back. sessions_list: read-only, consumes the token immediately.
255	// session_view: read-only display, but has a "Sign out" link that POSTs later —
256	// use peek so the same token can be submitted in the confirmation form.
257	// session_end / profile: confirmation-form flow, use peek (consumed on POST).
258	let user_id = match action {
259		| "org.matrix.sessions_list" => consume_login_token(services, login_token).await?,
260		| _ if method == Method::POST => consume_login_token(services, login_token).await?,
261		| _ if method == Method::GET => peek_login_token(services, login_token).await?,
262		| _ =>
263			return Err!(HttpJson(METHOD_NOT_ALLOWED, {
264				"errcode": "M_UNRECOGNIZED",
265				"error": "Unsupported account management method",
266			})),
267	};
268
269	match action {
270		| "org.matrix.sessions_list" if method == Method::GET =>
271			sessions_list_html(services, &user_id).await,
272
273		| "org.matrix.profile" if method == Method::GET =>
274			profile_html(services, &user_id, login_token.unwrap_or_default()).await,
275
276		| "org.matrix.profile" if method == Method::POST => {
277			// Sanitize: strip control chars, limit to 255 Unicode code points.
278			let cleaned_dn: String = params
279				.displayname
280				.as_deref()
281				.unwrap_or("")
282				.trim()
283				.chars()
284				.filter(|c| !c.is_control())
285				.take(255)
286				.collect();
287
288			let displayname = cleaned_dn
289				.is_empty()
290				.is_false()
291				.then_some(cleaned_dn.as_str());
292
293			services
294				.profile
295				.set_displayname(&user_id, displayname, None)
296				.await?;
297
298			profile_saved_html(&user_id, displayname).await
299		},
300		| "org.matrix.session_view" if method == Method::GET =>
301			session_view_html(
302				services,
303				&user_id,
304				params.device_id.as_deref().unwrap_or_default(),
305				login_token.unwrap_or_default(),
306			)
307			.await,
308
309		| "org.matrix.session_end" if method == Method::POST =>
310			session_end_execute_html(
311				services,
312				&user_id,
313				params.device_id.as_deref().unwrap_or_default(),
314			)
315			.await,
316
317		| "org.matrix.session_end" if method == Method::GET => {
318			// Authenticate first (peek), then show a POST confirmation form.
319			// Actual deletion happens only on POST to prevent CSRF via GET.
320			let device_id = params.device_id.clone().unwrap_or_default();
321			if device_id.is_empty() {
322				return Err!(Request(InvalidParam("device_id is required")));
323			}
324
325			let device_id_owned: OwnedDeviceId = device_id.into();
326			if !services
327				.users
328				.device_exists(&user_id, &device_id_owned)
329				.await
330			{
331				return Err!(Request(NotFound("Session not found")));
332			}
333
334			session_end_confirm_html(
335				&user_id,
336				device_id_owned.as_str(),
337				login_token.unwrap_or_default(),
338			)
339			.await
340		},
341		| "org.matrix.account_deactivate" if method == Method::POST =>
342			account_deactivate_execute_html(services, &user_id).await,
343
344		| "org.matrix.account_deactivate" if method == Method::GET =>
345			account_deactivate_confirm_html(&user_id, login_token.unwrap_or_default()).await,
346
347		| "org.matrix.cross_signing_reset" if method == Method::POST =>
348			cross_signing_reset_execute_html(services, &user_id).await,
349
350		| "org.matrix.cross_signing_reset" if method == Method::GET =>
351			cross_signing_reset_confirm_html(&user_id, login_token.unwrap_or_default()).await,
352
353		| _ => Err!(Request(InvalidParam("Unsupported account management action"))),
354	}
355}
356
357pub(super) fn account_redirect_response(redirect: Redirect) -> Response {
358	let mut response = redirect.into_response();
359
360	response
361		.headers_mut()
362		.insert(CACHE_CONTROL, HeaderValue::from_static(ACCOUNT_CACHE_CONTROL));
363
364	response
365		.headers_mut()
366		.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
367
368	response
369}
370
371// Prevent the login token in the callback URL from leaking via the Referer
372// header to any embedded resources.
373pub(super) fn account_html_response(status: StatusCode, html: String) -> Response {
374	let headers = [(CACHE_CONTROL, ACCOUNT_CACHE_CONTROL), (REFERRER_POLICY, "no-referrer")];
375
376	(status, headers, Html(html)).into_response()
377}
378
379pub(super) fn account_error_response(error: &Error) -> Response {
380	let msg = error.sanitized_message();
381	let code = error.status_code();
382
383	account_html_response(code, account_error_page(&msg))
384}
385
386fn account_error_page(message: &str) -> String {
387	let msg = html_escape(message);
388
389	format!(
390		r#"<!DOCTYPE html>
391		<html lang="en">
392			<head>
393				{ACCOUNT_HEAD}
394				<title>Error</title>
395			</head>
396			<body>
397				<h1 class="err">Error</h1>
398				<p>{msg}</p>
399				<div class="nav">
400					<a href="/_tuwunel/oidc/account">
401						Return to account management
402					</a>
403				</div>
404			</body>
405		</html>"#
406	)
407}
408
409fn validate_account_action(action: &str) -> Result {
410	ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED
411		.contains(&action)
412		.ok_or_else(|| err!(Request(InvalidParam("Unsupported account management action"))))
413}
414
415fn normalize_account_action(action: &str) -> &str {
416	match action {
417		| "org.matrix.devices_list" => "org.matrix.sessions_list",
418		| "org.matrix.device_view" => "org.matrix.session_view",
419		| "org.matrix.device_delete" => "org.matrix.session_end",
420		| other => other,
421	}
422}
423
424fn ts_cell(ts_secs: u64) -> String {
425	if ts_secs == 0 {
426		return "—".to_owned();
427	}
428
429	format!(r#"<time data-ts="{ts_secs}">—</time>"#)
430}