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
53static ACCOUNT_JS: &str = include_str!("account/account.js");
56
57static 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
69static ACCOUNT_CACHE_CONTROL: &str = "no-store";
71
72static 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
150pub(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 account_management_idp_id(services)?;
186 validate_account_action(action)?;
187
188 let action = normalize_account_action(action);
190
191 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 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 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
340pub(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
383async 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
397async 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}