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
54static ACCOUNT_JS: &str = include_str!("account/account.js");
57
58static 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
65static 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
136pub(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
189pub(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 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 let action = normalize_account_action(action);
244
245 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 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 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
366pub(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
404pub(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}