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