1mod profile;
2mod profile_saved;
3mod session_end_confirm;
4mod session_end_execute;
5mod session_list;
6mod session_view;
7
8use axum::{
9 extract::{Form, Request, State},
10 response::{Html, IntoResponse, Redirect, Response},
11};
12use futures::StreamExt;
13use http::{
14 HeaderValue, Method, StatusCode,
15 header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, REFERRER_POLICY},
16};
17use ruma::{OwnedDeviceId, OwnedRoomId};
18use tuwunel_core::{
19 Err, Error, Result, err,
20 utils::{BoolExt, html::escape as html_escape},
21};
22use tuwunel_service::{Services, users::propagation_default};
23use url::Url;
24
25use self::{
26 profile::profile_html, profile_saved::profile_saved_html,
27 session_end_confirm::session_end_confirm_html, session_end_execute::session_end_execute_html,
28 session_list::sessions_list_html, session_view::session_view_html,
29};
30use super::url_encode;
31
32pub(crate) static ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED: &[&str] = &[
33 "org.matrix.profile",
34 "org.matrix.sessions_list",
35 "org.matrix.session_view",
36 "org.matrix.session_end",
37];
38
39static ACCOUNT_JS: &str = include_str!("account/account.js");
42
43static ACCOUNT_CSS: &str = include_str!("account/account.css");
45
46static ACCOUNT_HEAD: &str = r#"
47 <meta charset="UTF-8">
48 <link rel="stylesheet" href="/_tuwunel/oidc/account.css">
49"#;
50
51static ACCOUNT_JS_INCLUDE: &str = r#"
52 <script src="/_tuwunel/oidc/account.js"></script>
53"#;
54
55static ACCOUNT_CACHE_CONTROL: &str = "no-store";
57
58static ACCOUNT_CSP: &[&str] = &[
64 "default-src 'none';",
65 "script-src 'self';",
66 "style-src 'self';",
67 "form-action 'self';",
68 "frame-ancestors 'none';",
69 "base-uri 'none';",
70];
71
72#[derive(Debug, Default, serde::Deserialize)]
73struct AccountQueryParams {
74 action: Option<String>,
75 device_id: Option<String>,
76}
77
78#[derive(Debug, Default, serde::Deserialize)]
79pub(crate) struct AccountCallbackParams {
80 action: Option<String>,
81 device_id: Option<String>,
82 #[serde(rename = "loginToken")]
83 login_token: Option<String>,
84 displayname: Option<String>,
85}
86
87pub(crate) async fn get_account_route(
88 State(services): State<crate::State>,
89 request: Request,
90) -> impl IntoResponse {
91 let params: AccountQueryParams =
92 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
93 | Err(e) => return account_error_response(&e.into()),
94 | Ok(params) => params,
95 };
96
97 let action = params
98 .action
99 .as_deref()
100 .unwrap_or("org.matrix.sessions_list");
101
102 let device_id = params.device_id.as_deref().unwrap_or_default();
103
104 match account_sso_redirect(&services, action, device_id) {
105 | Ok(redirect) => account_redirect_response(redirect),
106 | Err(e) => account_error_response(&e),
107 }
108}
109
110pub(crate) async fn get_account_callback_route(
111 State(services): State<crate::State>,
112 request: Request,
113) -> impl IntoResponse {
114 let params: AccountCallbackParams =
115 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
116 | Err(e) => return account_error_response(&e.into()),
117 | Ok(params) => params,
118 };
119
120 match handle_account_callback(&services, Method::GET, params).await {
121 | Ok(html) => account_html_response(StatusCode::OK, html),
122 | Err(e) => account_error_response(&e),
123 }
124}
125
126pub(crate) async fn post_account_callback_route(
127 State(services): State<crate::State>,
128 Form(body): Form<AccountCallbackParams>,
129) -> impl IntoResponse {
130 match handle_account_callback(&services, Method::POST, body).await {
131 | Ok(html) => account_html_response(StatusCode::OK, html),
132 | Err(e) => account_error_response(&e),
133 }
134}
135
136pub(crate) async fn account_js_route() -> impl IntoResponse {
139 let content_type = (CONTENT_TYPE, "application/javascript; charset=utf-8");
140 let cache_control = (CACHE_CONTROL, "no-cache");
141
142 ([content_type, cache_control], ACCOUNT_JS)
143}
144
145pub(crate) async fn account_css_route() -> impl IntoResponse {
146 let content_type = (CONTENT_TYPE, "text/css; charset=utf-8");
147 let cache_control = (CACHE_CONTROL, "no-cache");
148
149 ([content_type, cache_control], ACCOUNT_CSS)
150}
151
152async fn handle_account_callback(
153 services: &Services,
154 method: Method,
155 params: AccountCallbackParams,
156) -> Result<String> {
157 let login_token = params.login_token.as_deref();
158
159 let fallback_action = method
160 .eq(&Method::GET)
161 .then_some("org.matrix.sessions_list");
162
163 let action = params
164 .action
165 .as_deref()
166 .or(fallback_action)
167 .unwrap_or_default();
168
169 account_management_idp_id(services)?;
172 validate_account_action(action)?;
173
174 let user_id = match action {
183 | "org.matrix.sessions_list" => consume_login_token(services, login_token).await?,
184 | _ if method == Method::POST => consume_login_token(services, login_token).await?,
185 | _ if method == Method::GET => peek_login_token(services, login_token).await?,
186 | _ =>
187 return Err!(HttpJson(METHOD_NOT_ALLOWED, {
188 "errcode": "M_UNRECOGNIZED",
189 "error": "Unsupported account management method",
190 })),
191 };
192
193 match action {
194 | "org.matrix.sessions_list" if method == Method::GET =>
195 sessions_list_html(services, &user_id).await,
196
197 | "org.matrix.profile" if method == Method::GET =>
198 profile_html(services, &user_id, login_token.unwrap_or_default()).await,
199
200 | "org.matrix.profile" if method == Method::POST => {
201 let cleaned_dn: String = params
203 .displayname
204 .as_deref()
205 .unwrap_or("")
206 .trim()
207 .chars()
208 .filter(|c| !c.is_control())
209 .take(255)
210 .collect();
211
212 let displayname = cleaned_dn
213 .is_empty()
214 .is_false()
215 .then_some(cleaned_dn.as_str());
216
217 let all_joined_rooms: Vec<OwnedRoomId> = services
218 .state_cache
219 .rooms_joined(&user_id)
220 .map(ToOwned::to_owned)
221 .collect()
222 .await;
223
224 services
225 .users
226 .update_displayname(
227 &user_id,
228 displayname,
229 &all_joined_rooms,
230 propagation_default(
231 services
232 .server
233 .config
234 .preserve_room_profile_overrides,
235 ),
236 )
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 | _ => Err!(Request(InvalidParam("Unsupported account management action"))),
283 }
284}
285
286fn account_sso_redirect(services: &Services, action: &str, device_id: &str) -> Result<Redirect> {
287 validate_account_action(action)?;
288
289 let default_idp = account_management_idp_id(services)?;
290 let idp_id_enc = url_encode(&default_idp);
291
292 let issuer = services.oauth.get_server()?.issuer_url()?;
293 let base = issuer.trim_end_matches('/');
294
295 let mut callback_url = Url::parse(&format!("{base}/_tuwunel/oidc/account_callback"))
296 .map_err(|_| err!(error!("Failed to build account callback URL")))?;
297
298 callback_url
299 .query_pairs_mut()
300 .append_pair("action", action)
301 .append_pair("device_id", device_id);
302
303 let mut sso_url =
304 Url::parse(&format!("{base}/_matrix/client/v3/login/sso/redirect/{idp_id_enc}"))
305 .map_err(|_| err!(error!("Failed to build SSO URL")))?;
306
307 sso_url
308 .query_pairs_mut()
309 .append_pair("redirectUrl", callback_url.as_str());
310
311 Ok(Redirect::temporary(sso_url.as_str()))
312}
313
314fn account_redirect_response(redirect: Redirect) -> Response {
315 let mut response = redirect.into_response();
316
317 response
318 .headers_mut()
319 .insert(CACHE_CONTROL, HeaderValue::from_static(ACCOUNT_CACHE_CONTROL));
320
321 response
322 .headers_mut()
323 .insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
324
325 response
326}
327
328fn account_html_response(status: StatusCode, html: String) -> Response {
331 let csp = ACCOUNT_CSP.join("");
332 let headers = [
333 (CACHE_CONTROL, ACCOUNT_CACHE_CONTROL),
334 (CONTENT_SECURITY_POLICY, csp.as_str()),
335 (REFERRER_POLICY, "no-referrer"),
336 ];
337
338 (status, headers, Html(html)).into_response()
339}
340
341fn account_error_response(error: &Error) -> Response {
342 let msg = error.sanitized_message();
343 let code = error.status_code();
344
345 account_html_response(code, account_error_page(&msg))
346}
347
348fn account_error_page(message: &str) -> String {
349 let msg = html_escape(message);
350
351 format!(
352 r#"<!DOCTYPE html>
353 <html lang="en">
354 <head>
355 {ACCOUNT_HEAD}
356 <title>Error</title>
357 </head>
358 <body>
359 <h1 class="err">Error</h1>
360 <p>{msg}</p>
361 <div class="nav">
362 <a href="/_tuwunel/oidc/account">
363 Return to account management
364 </a>
365 </div>
366 </body>
367 </html>"#
368 )
369}
370
371async fn consume_login_token(
373 services: &Services,
374 token: Option<&str>,
375) -> Result<ruma::OwnedUserId> {
376 let token = token.ok_or(err!(Request(Forbidden("Missing login token"))))?;
377
378 services
379 .users
380 .find_from_login_token(token)
381 .await
382 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
383}
384
385async fn peek_login_token(services: &Services, token: Option<&str>) -> Result<ruma::OwnedUserId> {
389 let token = token.ok_or(err!(Request(Forbidden("Missing login token"))))?;
390
391 services
392 .users
393 .peek_login_token(token)
394 .await
395 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
396}
397
398fn account_management_idp_id(services: &Services) -> Result<String> {
399 if services.config.identity_provider.len() != 1 {
400 return Err!(Request(InvalidParam(
401 "Account management requires exactly one configured identity provider"
402 )));
403 }
404
405 services
406 .oauth
407 .providers
408 .get_default_id()
409 .ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))
410}
411
412fn validate_account_action(action: &str) -> Result {
413 ACCOUNT_MANAGEMENT_ACTIONS_SUPPORTED
414 .contains(&action)
415 .ok_or_else(|| err!(Request(InvalidParam("Unsupported account management action"))))
416}
417
418fn ts_cell(ts_secs: u64) -> String {
419 if ts_secs == 0 {
420 return "—".to_owned();
421 }
422
423 format!(r#"<time data-ts="{ts_secs}">—</time>"#)
424}