Skip to main content

tuwunel_api/oidc/
account.rs

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