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