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