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_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::{
40 authorize::should_serve_native, consume_login_token, peek_login_token, sso_redirect_url,
41 url_encode,
42};
43
44pub(crate) static ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED: &[&str] = &[
45 "org.matrix.profile",
46 "org.matrix.devices_list",
47 "org.matrix.device_view",
48 "org.matrix.device_delete",
49 "org.matrix.account_deactivate",
50 "org.matrix.cross_signing_reset",
51 "org.matrix.sessions_list",
52 "org.matrix.session_view",
53 "org.matrix.session_end",
54];
55
56static ACCOUNT_JS: &str = include_str!("account/account.js");
59
60static ACCOUNT_CSS: &str = include_str!("account/account.css");
62
63pub(super) static ACCOUNT_HEAD: &str = r#"
64 <meta charset="UTF-8">
65 <link rel="stylesheet" href="/_tuwunel/oidc/account.css">
66"#;
67
68static ACCOUNT_JS_INCLUDE: &str = r#"
69 <script src="/_tuwunel/oidc/account.js"></script>
70"#;
71
72static ACCOUNT_CACHE_CONTROL: &str = "no-store";
74
75#[derive(Debug, Default, serde::Deserialize)]
76struct AccountQueryParams {
77 action: Option<String>,
78 device_id: Option<String>,
79}
80
81#[derive(Debug, Default, serde::Deserialize)]
82pub(crate) struct AccountCallbackParams {
83 action: Option<String>,
84 device_id: Option<String>,
85 #[serde(rename = "loginToken")]
86 login_token: Option<String>,
87 displayname: Option<String>,
88}
89
90pub(crate) async fn get_account_route(
91 State(services): State<crate::State>,
92 request: Request,
93) -> impl IntoResponse {
94 let params: AccountQueryParams =
95 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
96 | Err(e) => return account_error_response(&e.into()),
97 | Ok(params) => params,
98 };
99
100 let action = params
101 .action
102 .as_deref()
103 .unwrap_or("org.matrix.sessions_list");
104
105 let device_id = params.device_id.as_deref().unwrap_or_default();
106
107 match account_auth_redirect(&services, action, device_id) {
108 | Ok(response) => response,
109 | Err(e) => account_error_response(&e),
110 }
111}
112
113fn account_auth_redirect(services: &Services, action: &str, device_id: &str) -> Result<Response> {
114 validate_account_action(action)?;
115
116 let idp_id = services.oauth.providers.get_default_id();
117 let wants_create = false;
118 let serve_native =
119 should_serve_native(services.config.oidc_native_auth, idp_id.is_some(), wants_create);
120
121 match serve_native {
122 | true => account_native_redirect(services, action, device_id),
123 | false => account_sso_redirect(services, action, device_id, idp_id.as_deref()),
124 }
125}
126
127fn account_native_redirect(
128 services: &Services,
129 action: &str,
130 device_id: &str,
131) -> Result<Response> {
132 let issuer = services.oauth.get_server()?.issuer_url()?;
133 let base = issuer.trim_end_matches('/');
134
135 let native_url = Url::parse_with_params(&format!("{base}/_tuwunel/oidc/native"), [
136 ("action", action),
137 ("device_id", device_id),
138 ])
139 .map_err(|_| err!(Request(InvalidParam("Failed to build native login URL"))))?;
140
141 Ok(account_redirect_response(Redirect::temporary(native_url.as_str())))
142}
143
144fn account_sso_redirect(
145 services: &Services,
146 action: &str,
147 device_id: &str,
148 idp_id: Option<&str>,
149) -> Result<Response> {
150 let idp_id = idp_id
151 .ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))?;
152
153 let issuer = services.oauth.get_server()?.issuer_url()?;
154 let base = issuer.trim_end_matches('/');
155
156 let callback_url =
157 Url::parse_with_params(&format!("{base}/_tuwunel/oidc/account_callback"), [
158 ("action", action),
159 ("device_id", device_id),
160 ])
161 .map_err(|_| err!(error!("Failed to build account callback URL")))?;
162
163 let sso_url = sso_redirect_url(base, idp_id, &callback_url)?;
164
165 Ok(account_redirect_response(Redirect::temporary(sso_url.as_str())))
166}
167
168pub(crate) async fn get_account_callback_route(
169 State(services): State<crate::State>,
170 request: Request,
171) -> impl IntoResponse {
172 let params: AccountCallbackParams =
173 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
174 | Err(e) => return account_error_response(&e.into()),
175 | Ok(params) => params,
176 };
177
178 match handle_account_callback(&services, Method::GET, params).await {
179 | Ok(html) => account_html_response(StatusCode::OK, html),
180 | Err(e) => account_error_response(&e),
181 }
182}
183
184pub(crate) async fn post_account_callback_route(
185 State(services): State<crate::State>,
186 Form(body): Form<AccountCallbackParams>,
187) -> impl IntoResponse {
188 match handle_account_callback(&services, Method::POST, body).await {
189 | Ok(html) => account_html_response(StatusCode::OK, html),
190 | Err(e) => account_error_response(&e),
191 }
192}
193
194pub(crate) async fn account_js_route() -> impl IntoResponse {
197 let content_type = (CONTENT_TYPE, "application/javascript; charset=utf-8");
198 let cache_control = (CACHE_CONTROL, "no-cache");
199
200 ([content_type, cache_control], ACCOUNT_JS)
201}
202
203pub(crate) async fn account_css_route() -> impl IntoResponse {
204 let content_type = (CONTENT_TYPE, "text/css; charset=utf-8");
205 let cache_control = (CACHE_CONTROL, "no-cache");
206
207 ([content_type, cache_control], ACCOUNT_CSS)
208}
209
210async fn handle_account_callback(
211 services: &Services,
212 method: Method,
213 params: AccountCallbackParams,
214) -> Result<String> {
215 let login_token = params.login_token.as_deref();
216
217 let fallback_action = method
218 .eq(&Method::GET)
219 .then_some("org.matrix.sessions_list");
220
221 let action = params
222 .action
223 .as_deref()
224 .or(fallback_action)
225 .unwrap_or_default();
226
227 services.oauth.get_server()?;
230
231 (services.config.oidc_native_auth
232 || services
233 .oauth
234 .providers
235 .get_default_id()
236 .is_some())
237 .then_some(())
238 .ok_or_else(|| {
239 err!(Config(
240 "identity_provider",
241 "No identity provider or native authentication configured"
242 ))
243 })?;
244
245 validate_account_action(action)?;
246
247 let action = normalize_account_action(action);
249
250 let user_id = match action {
259 | "org.matrix.sessions_list" => consume_login_token(services, login_token).await?,
260 | _ if method == Method::POST => consume_login_token(services, login_token).await?,
261 | _ if method == Method::GET => peek_login_token(services, login_token).await?,
262 | _ =>
263 return Err!(HttpJson(METHOD_NOT_ALLOWED, {
264 "errcode": "M_UNRECOGNIZED",
265 "error": "Unsupported account management method",
266 })),
267 };
268
269 match action {
270 | "org.matrix.sessions_list" if method == Method::GET =>
271 sessions_list_html(services, &user_id).await,
272
273 | "org.matrix.profile" if method == Method::GET =>
274 profile_html(services, &user_id, login_token.unwrap_or_default()).await,
275
276 | "org.matrix.profile" if method == Method::POST => {
277 let cleaned_dn: String = params
279 .displayname
280 .as_deref()
281 .unwrap_or("")
282 .trim()
283 .chars()
284 .filter(|c| !c.is_control())
285 .take(255)
286 .collect();
287
288 let displayname = cleaned_dn
289 .is_empty()
290 .is_false()
291 .then_some(cleaned_dn.as_str());
292
293 services
294 .profile
295 .set_displayname(&user_id, displayname, None)
296 .await?;
297
298 profile_saved_html(&user_id, displayname).await
299 },
300 | "org.matrix.session_view" if method == Method::GET =>
301 session_view_html(
302 services,
303 &user_id,
304 params.device_id.as_deref().unwrap_or_default(),
305 login_token.unwrap_or_default(),
306 )
307 .await,
308
309 | "org.matrix.session_end" if method == Method::POST =>
310 session_end_execute_html(
311 services,
312 &user_id,
313 params.device_id.as_deref().unwrap_or_default(),
314 )
315 .await,
316
317 | "org.matrix.session_end" if method == Method::GET => {
318 let device_id = params.device_id.clone().unwrap_or_default();
321 if device_id.is_empty() {
322 return Err!(Request(InvalidParam("device_id is required")));
323 }
324
325 let device_id_owned: OwnedDeviceId = device_id.into();
326 if !services
327 .users
328 .device_exists(&user_id, &device_id_owned)
329 .await
330 {
331 return Err!(Request(NotFound("Session not found")));
332 }
333
334 session_end_confirm_html(
335 &user_id,
336 device_id_owned.as_str(),
337 login_token.unwrap_or_default(),
338 )
339 .await
340 },
341 | "org.matrix.account_deactivate" if method == Method::POST =>
342 account_deactivate_execute_html(services, &user_id).await,
343
344 | "org.matrix.account_deactivate" if method == Method::GET =>
345 account_deactivate_confirm_html(&user_id, login_token.unwrap_or_default()).await,
346
347 | "org.matrix.cross_signing_reset" if method == Method::POST =>
348 cross_signing_reset_execute_html(services, &user_id).await,
349
350 | "org.matrix.cross_signing_reset" if method == Method::GET =>
351 cross_signing_reset_confirm_html(&user_id, login_token.unwrap_or_default()).await,
352
353 | _ => Err!(Request(InvalidParam("Unsupported account management action"))),
354 }
355}
356
357pub(super) fn account_redirect_response(redirect: Redirect) -> Response {
358 let mut response = redirect.into_response();
359
360 response
361 .headers_mut()
362 .insert(CACHE_CONTROL, HeaderValue::from_static(ACCOUNT_CACHE_CONTROL));
363
364 response
365 .headers_mut()
366 .insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
367
368 response
369}
370
371pub(super) fn account_html_response(status: StatusCode, html: String) -> Response {
374 let headers = [(CACHE_CONTROL, ACCOUNT_CACHE_CONTROL), (REFERRER_POLICY, "no-referrer")];
375
376 (status, headers, Html(html)).into_response()
377}
378
379pub(super) fn account_error_response(error: &Error) -> Response {
380 let msg = error.sanitized_message();
381 let code = error.status_code();
382
383 account_html_response(code, account_error_page(&msg))
384}
385
386fn account_error_page(message: &str) -> String {
387 let msg = html_escape(message);
388
389 format!(
390 r#"<!DOCTYPE html>
391 <html lang="en">
392 <head>
393 {ACCOUNT_HEAD}
394 <title>Error</title>
395 </head>
396 <body>
397 <h1 class="err">Error</h1>
398 <p>{msg}</p>
399 <div class="nav">
400 <a href="/_tuwunel/oidc/account">
401 Return to account management
402 </a>
403 </div>
404 </body>
405 </html>"#
406 )
407}
408
409fn validate_account_action(action: &str) -> Result {
410 ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED
411 .contains(&action)
412 .ok_or_else(|| err!(Request(InvalidParam("Unsupported account management action"))))
413}
414
415fn normalize_account_action(action: &str) -> &str {
416 match action {
417 | "org.matrix.devices_list" => "org.matrix.sessions_list",
418 | "org.matrix.device_view" => "org.matrix.session_view",
419 | "org.matrix.device_delete" => "org.matrix.session_end",
420 | other => other,
421 }
422}
423
424fn ts_cell(ts_secs: u64) -> String {
425 if ts_secs == 0 {
426 return "—".to_owned();
427 }
428
429 format!(r#"<time data-ts="{ts_secs}">—</time>"#)
430}