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