1use std::{fmt::Write, net::IpAddr};
2
3use axum::{
4 extract::{Form, Request, State},
5 response::{Redirect, Response},
6};
7use const_str::format as const_format;
8use http::StatusCode;
9use itertools::Either::{Left, Right};
10use ruma::{OwnedUserId, UserId};
11use serde::Deserialize;
12use serde_json::json;
13use tuwunel_api::router::ACCOUNT_HEAD;
14use tuwunel_core::{
15 Err, Error, Result,
16 config::IdentityProvider,
17 err,
18 smallstr::SmallString,
19 utils::{self, BoolExt, hash::verify_password, html::escape as html_escape},
20};
21use tuwunel_service::{Services, users::Register};
22use url::Url;
23
24use super::{
25 account::{
26 account_error_response, account_html_response, account_redirect_response,
27 account_sso_redirect, validate_account_action,
28 },
29 authorization_sso_url,
30 device::device_sso_redirect,
31 require_account_usable, url_encode,
32};
33use crate::{ClientIp, RateLimitIp};
34
35type LoginAddress = Result<RateLimitIp, (StatusCode, &'static str)>;
36
37type AccountAction = SmallString<[u8; 32]>;
38type DeviceId = SmallString<[u8; 24]>;
39type IdpId = SmallString<[u8; 32]>;
40type ProviderChoice<'a> = (&'a str, &'a str);
41
42const LOGIN_TOKEN_LENGTH: usize = 32;
43
44#[derive(Debug, Default, Deserialize)]
45struct NativeQuery {
46 oidc_req_id: Option<String>,
47 idp_id: Option<IdpId>,
48 user_code: Option<String>,
49 action: Option<AccountAction>,
50 device_id: Option<DeviceId>,
51 view: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
55pub(crate) struct NativeSubmit {
56 #[serde(default)]
57 oidc_req_id: Option<String>,
58 #[serde(default)]
59 user_code: Option<String>,
60 #[serde(default)]
61 action: Option<AccountAction>,
62 #[serde(default)]
63 device_id: Option<DeviceId>,
64 #[serde(default)]
65 mode: Option<String>,
66 username: String,
67 password: String,
68 #[serde(default)]
69 registration_token: Option<String>,
70 #[serde(default)]
71 accept_terms: Option<String>,
72}
73
74#[derive(Clone, Copy)]
75enum Flow<'a> {
76 Account {
77 action: &'a str,
78 device_id: &'a str,
79 },
80 Authorization(&'a str),
81 Device(&'a str),
82}
83
84#[derive(Clone, Copy)]
89enum PasswordAuth {
90 Disabled,
92
93 Login,
95
96 Register,
98}
99
100pub(crate) async fn native_get_route(
107 State(services): State<crate::State>,
108 ClientIp(client): ClientIp,
109 request: Request,
110) -> Response {
111 if let Err(e) = require_native(&services) {
112 return account_error_response(&e);
113 }
114
115 let params: NativeQuery =
116 match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
117 | Ok(params) => params,
118 | Err(e) => return account_error_response(&e.into()),
119 };
120
121 let context = match parse_flow(
122 params.oidc_req_id.as_deref(),
123 params.user_code.as_deref(),
124 params.action.as_deref(),
125 params.device_id.as_deref(),
126 ) {
127 | Ok(context) => context,
128 | Err(e) => return account_error_response(&e),
129 };
130
131 if let Some(idp_id) = params.idp_id.as_deref() {
132 return provider_redirect(&services, client, context, idp_id)
133 .await
134 .unwrap_or_else(|e| account_error_response(&e));
135 }
136
137 let view = params.view.as_deref().unwrap_or("login");
138
139 render_page(&services, view, context, None)
140 .await
141 .map(|html| account_html_response(StatusCode::OK, html))
142 .unwrap_or_else(|e| account_error_response(&e))
143}
144
145fn parse_flow<'a>(
146 oidc_req_id: Option<&'a str>,
147 user_code: Option<&'a str>,
148 action: Option<&'a str>,
149 device_id: Option<&'a str>,
150) -> Result<Flow<'a>> {
151 match (
152 oidc_req_id.filter(|value| !value.is_empty()),
153 user_code.filter(|value| !value.is_empty()),
154 action.filter(|value| !value.is_empty()),
155 ) {
156 | (Some(req_id), None, None) => Ok(Flow::Authorization(req_id)),
157 | (None, Some(user_code), None) => Ok(Flow::Device(user_code)),
158 | (None, None, Some(action)) => Ok(Flow::Account {
159 action,
160 device_id: device_id.unwrap_or_default(),
161 }),
162 | _ => Err!(Request(InvalidParam(
163 "Exactly one OIDC request ID, user code, or account action is required."
164 ))),
165 }
166}
167
168async fn provider_redirect(
173 services: &Services,
174 client: IpAddr,
175 context: Flow<'_>,
176 idp_id: &str,
177) -> Result<Response> {
178 services.oauth.check_rate_limit(client)?;
179
180 let oidc = services.oauth.get_server()?;
181 let provider_id = services
182 .oauth
183 .providers
184 .find_config(idp_id)
185 .map_err(|_| err!(Request(InvalidParam("Unrecognized identity provider"))))?
186 .id();
187
188 let req_id = match context {
189 | Flow::Authorization(req_id) => req_id,
190 | Flow::Device(user_code) =>
191 return device_sso_redirect(services, user_code, Some(provider_id)),
192 | Flow::Account { action, device_id } => {
193 validate_account_action(action)?;
194
195 return account_sso_redirect(services, action, device_id, Some(provider_id));
196 },
197 };
198
199 let sso_url = authorization_sso_url(&oidc.issuer_url()?, provider_id, req_id)?;
200
201 oidc.bind_auth_request_to_provider(req_id, provider_id)
202 .await?;
203
204 Ok(account_redirect_response(Redirect::temporary(sso_url.as_str())))
205}
206
207pub(crate) async fn native_submit_route(
210 State(services): State<crate::State>,
211 ClientIp(client): ClientIp,
212 address: LoginAddress,
213 Form(body): Form<NativeSubmit>,
214) -> Response {
215 match native_submit(&services, client, address, &body).await {
216 | Ok(response) => response,
217 | Err(e) => render_submit_error(&services, &body, &e).await,
218 }
219}
220
221async fn native_submit(
222 services: &Services,
223 client: IpAddr,
224 address: LoginAddress,
225 body: &NativeSubmit,
226) -> Result<Response> {
227 require_native(services)?;
228 services.oauth.check_device_rate_limit(client)?;
230 services.oauth.check_rate_limit(client)?;
231
232 let context = parse_flow(
233 body.oidc_req_id.as_deref(),
234 body.user_code.as_deref(),
235 body.action.as_deref(),
236 body.device_id.as_deref(),
237 )?;
238
239 let user_id = match context {
240 | Flow::Authorization(req_id) =>
241 authenticate_local(services, req_id, address, body).await?,
242 | _ => verify_credentials(services, &body.username, &body.password, address).await?,
243 };
244
245 let token = utils::random_string(LOGIN_TOKEN_LENGTH);
246 let _expires_in = services
247 .users
248 .create_login_token(&user_id, &token);
249
250 let redirect = complete_redirect(services, context, &token)?;
251
252 Ok(account_redirect_response(redirect))
253}
254
255async fn render_submit_error(
260 services: &Services,
261 body: &NativeSubmit,
262 error: &Error,
263) -> Response {
264 let context = match parse_flow(
265 body.oidc_req_id.as_deref(),
266 body.user_code.as_deref(),
267 body.action.as_deref(),
268 body.device_id.as_deref(),
269 ) {
270 | Ok(context) => context,
271 | Err(e) => return account_error_response(&e),
272 };
273
274 let view = match (context, body.mode.as_deref()) {
275 | (Flow::Authorization(_), Some("register")) => "register",
276 | _ => "login",
277 };
278
279 let msg = error.sanitized_message();
280
281 render_page(services, view, context, Some(&msg))
282 .await
283 .map(|html| account_html_response(error.status_code(), html))
284 .unwrap_or_else(|e| account_error_response(&e))
285}
286
287async fn authenticate_local(
293 services: &Services,
294 req_id: &str,
295 address: LoginAddress,
296 body: &NativeSubmit,
297) -> Result<OwnedUserId> {
298 let oidc = services.oauth.get_server()?;
299
300 oidc.check_local_auth_request(req_id).await?;
301
302 if body.mode.as_deref() == Some("register") {
303 return do_register(services, req_id, body).await;
304 }
305
306 let user_id = verify_credentials(services, &body.username, &body.password, address).await?;
307
308 oidc.bind_auth_request_to_local(req_id).await?;
309
310 Ok(user_id)
311}
312
313async fn verify_credentials(
319 services: &Services,
320 username: &str,
321 password: &str,
322 address: LoginAddress,
323) -> Result<OwnedUserId> {
324 if !services.config.authentication.login.with_password {
325 return Err!(Request(Forbidden("Password login is disabled on this server.")));
326 }
327
328 let RateLimitIp(client) = address.map_err(|(_, message)| err!("{message}"))?;
329 let invalid = || err!(Request(Forbidden("Invalid username or password.")));
330 let server_name = &services.config.server_name;
331
332 let user_id = UserId::parse_with_server_name(username, server_name).map_err(|_| invalid())?;
333
334 if !services.globals.user_is_local(&user_id) {
335 return Err(invalid());
336 }
337
338 let reservation = services
340 .login_ratelimit
341 .reserve_login_attempt(&user_id, client)?;
342
343 let (user_id, hash) = match services.users.password_hash(&user_id).await {
347 | Ok(hash) => (user_id, hash),
348 | Err(_) => {
349 let lowercased = UserId::parse_with_server_name(username.to_lowercase(), server_name)
350 .map_err(|_| invalid())?;
351
352 let hash = services
353 .users
354 .password_hash(&lowercased)
355 .await
356 .map_err(|_| invalid())?;
357
358 (lowercased, hash)
359 },
360 };
361
362 let unchecked = hash.is_empty()
365 || services
366 .users
367 .origin(&user_id)
368 .await
369 .is_ok_and(|origin| origin != "password");
370
371 if unchecked {
372 services
373 .login_ratelimit
374 .refund_login_attempt(reservation)?;
375
376 return Err(invalid());
377 }
378
379 verify_password(password, &hash).map_err(|_| invalid())?;
380
381 services
382 .login_ratelimit
383 .record_login(reservation)?;
384
385 require_account_usable(services, &user_id).await?;
386
387 Ok(user_id)
388}
389
390async fn do_register(
391 services: &Services,
392 req_id: &str,
393 body: &NativeSubmit,
394) -> Result<OwnedUserId> {
395 if !services.config.allows_password_registration() {
396 return Err!(Request(Forbidden("Registration is disabled on this server.")));
397 }
398
399 let username = body.username.trim().to_lowercase();
400 if username.is_empty() {
401 return Err!(Request(InvalidUsername("A username is required.")));
402 }
403
404 if body.password.is_empty() {
405 return Err!(Request(InvalidParam("A password is required.")));
406 }
407
408 let token_required = services.registration_tokens.is_enabled().await;
411 let smtp = &services.config.smtp;
412 let email_required = smtp.connection_uri.is_some()
413 && (smtp.require_email_for_registration
414 || (token_required && smtp.require_email_for_token_registration));
415
416 if email_required {
417 return Err!(Request(Forbidden(
418 "This server requires an email to register, which this page cannot collect."
419 )));
420 }
421
422 if services
423 .config
424 .registration
425 .forbidden_usernames
426 .is_match(&username)
427 {
428 return Err!(Request(Forbidden("That username is not allowed.")));
429 }
430
431 let user_id = UserId::parse_with_server_name(&username, &services.config.server_name)
432 .map_err(|_| err!(Request(InvalidUsername("That username is not valid."))))?;
433
434 user_id.validate_strict().map_err(|_| {
435 err!(Request(InvalidUsername("That username contains disallowed characters.")))
436 })?;
437
438 if services
439 .appservice
440 .is_exclusive_user_id(&user_id)
441 .await
442 {
443 return Err!(Request(Exclusive("That username is reserved by an appservice.")));
444 }
445
446 if services.users.exists(&user_id).await {
447 return Err!(Request(UserInUse("That username is taken.")));
448 }
449
450 services.users.check_creation(&user_id).await?;
451
452 if !services.config.registration_terms.is_empty()
455 && body.accept_terms.as_deref() != Some("on")
456 {
457 return Err!(Request(Forbidden("You must accept the terms to register.")));
458 }
459
460 let token = body
461 .registration_token
462 .as_deref()
463 .unwrap_or_default();
464
465 if token_required {
467 services
468 .registration_tokens
469 .is_token_valid(token)
470 .await?;
471 }
472
473 services
475 .oauth
476 .get_server()?
477 .bind_auth_request_to_local(req_id)
478 .await?;
479
480 if token_required {
481 services
482 .registration_tokens
483 .try_consume(token)
484 .await?;
485 }
486
487 services
488 .users
489 .full_register(Register {
490 user_id: Some(&user_id),
491 password: Some(&body.password),
492 grant_first_user_admin: true,
493 ..Default::default()
494 })
495 .await?;
496
497 record_accepted_terms(services, &user_id).await?;
498
499 Ok(user_id)
500}
501
502async fn record_accepted_terms(services: &Services, user_id: &UserId) -> Result {
503 let accepted: Vec<String> = services
504 .config
505 .registration_terms
506 .values()
507 .flat_map(|policy| policy.translations.values())
508 .map(|translation| translation.url.to_string())
509 .collect();
510
511 if accepted.is_empty() {
512 return Ok(());
513 }
514
515 let event_type = "m.accepted_terms";
516 let event = json!({
517 "type": event_type,
518 "content": { "accepted": accepted },
519 });
520
521 services
522 .account_data
523 .update(None, user_id, event_type.into(), &event)
524 .await
525}
526
527fn complete_redirect(services: &Services, flow: Flow<'_>, login_token: &str) -> Result<Redirect> {
530 let issuer = services.oauth.get_server()?.issuer_url()?;
531 let base = issuer.trim_end_matches('/');
532
533 let url = match flow {
534 | Flow::Device(user_code) =>
535 Url::parse_with_params(&format!("{base}/_tuwunel/oidc/device_callback"), [
536 ("user_code", user_code),
537 ("loginToken", login_token),
538 ]),
539 | Flow::Authorization(req_id) =>
540 Url::parse_with_params(&format!("{base}/_tuwunel/oidc/_complete"), [
541 ("oidc_req_id", req_id),
542 ("loginToken", login_token),
543 ]),
544 | Flow::Account { action, device_id } =>
545 Url::parse_with_params(&format!("{base}/_tuwunel/oidc/account_callback"), [
546 ("action", action),
547 ("device_id", device_id),
548 ("loginToken", login_token),
549 ]),
550 }
551 .map_err(|_| err!(error!("Failed to build completion URL")))?;
552
553 Ok(Redirect::to(url.as_str()))
554}
555
556fn require_native(services: &Services) -> Result {
557 services.oauth.get_server()?;
558
559 services
560 .config
561 .authentication
562 .oidc
563 .native_auth
564 .then_some(())
565 .ok_or_else(|| err!(Request(NotFound("Native authentication is not enabled"))))
566}
567
568async fn render_page(
573 services: &Services,
574 view: &str,
575 context: Flow<'_>,
576 error: Option<&str>,
577) -> Result<String> {
578 let password_auth = configured_password_auth(services);
579 let Flow::Authorization(req_id) = context else {
580 let sso_options = match context {
581 | Flow::Account { .. } | Flow::Device(_) =>
582 render_sso_options("Or sign in with", context, sso_choices(services)),
583 | _ => String::new(),
584 };
585
586 return Ok(render_login(context, error, password_auth, &sso_options));
587 };
588
589 let bound = services
590 .oauth
591 .get_server()?
592 .peek_auth_request(req_id)
593 .await?
594 .idp_id;
595
596 let page = match bound.as_deref() {
597 | None if view == "register" && matches!(password_auth, PasswordAuth::Register) =>
598 render_register(services, req_id, error).await,
599
600 | Some(idp_id) => {
601 let provider = services.oauth.providers.find_config(idp_id)?;
602
603 render_bound(req_id, provider_choice(provider), error)
604 },
605
606 | None => {
607 let sso_options =
608 render_sso_options("Or sign in with", context, sso_choices(services));
609
610 render_login(context, error, password_auth, &sso_options)
611 },
612 };
613
614 Ok(page)
615}
616
617fn configured_password_auth(services: &Services) -> PasswordAuth {
618 let config = &services.config;
619
620 match (config.authentication.login.with_password, config.allows_password_registration()) {
621 | (false, _) => PasswordAuth::Disabled,
622 | (true, false) => PasswordAuth::Login,
623 | (true, true) => PasswordAuth::Register,
624 }
625}
626
627fn render_login(
628 context: Flow<'_>,
629 error: Option<&str>,
630 password_auth: PasswordAuth,
631 sso_options: &str,
632) -> String {
633 let (context_fields, register_link) = match context {
634 | Flow::Device(user_code) => {
635 let context_fields = format!(
636 r#"<input type="hidden" name="user_code" value="{}">"#,
637 html_escape(user_code),
638 );
639
640 (context_fields, String::new())
641 },
642 | Flow::Account { action, device_id } => {
643 let context_fields = format!(
644 concat!(
645 r#"<input type="hidden" name="action" value="{}">"#,
646 "\n\t\t\t",
647 r#"<input type="hidden" name="device_id" value="{}">"#,
648 ),
649 html_escape(action),
650 html_escape(device_id),
651 );
652
653 (context_fields, String::new())
654 },
655 | Flow::Authorization(req_id) => {
656 let context_fields = format!(
657 r#"<input type="hidden" name="oidc_req_id" value="{}">"#,
658 html_escape(req_id),
659 );
660
661 let register_link = matches!(password_auth, PasswordAuth::Register)
662 .then(|| {
663 format!(
664 r#"<p class="auth-nav">New to this server? <a href="/_tuwunel/oidc/native?oidc_req_id={}&view=register">Create an account</a></p>"#,
665 url_encode(req_id),
666 )
667 })
668 .unwrap_or_default();
669
670 (context_fields, register_link)
671 },
672 };
673
674 let password_form = matches!(password_auth, PasswordAuth::Login | PasswordAuth::Register)
675 .then_some(PASSWORD_FORM)
676 .unwrap_or_default();
677
678 LOGIN_HTML
679 .replace("{password_form}", password_form)
680 .replace("{register_link}", ®ister_link)
681 .replace("{sso_options}", sso_options)
682 .replace("{error}", &error_block(error))
683 .replace("{context_fields}", &context_fields)
685}
686
687async fn render_register(services: &Services, req_id: &str, error: Option<&str>) -> String {
688 let token_field = services
689 .registration_tokens
690 .is_enabled()
691 .await
692 .then_some(TOKEN_FIELD)
693 .unwrap_or_default();
694
695 REGISTER_HTML
696 .replace("{token_field}", token_field)
697 .replace("{req_id_enc}", &url_encode(req_id))
698 .replace("{terms}", &terms_block(services))
699 .replace("{error}", &error_block(error))
700 .replace("{req_id}", &html_escape(req_id))
702}
703
704fn provider_choice(provider: &IdentityProvider) -> ProviderChoice<'_> {
705 (provider.id(), provider.display_name())
706}
707
708fn render_bound(req_id: &str, provider: ProviderChoice<'_>, error: Option<&str>) -> String {
713 let sso_options =
714 render_sso_options("Continue with", Flow::Authorization(req_id), [provider]);
715
716 BOUND_HTML
717 .replace("{sso_options}", &sso_options)
718 .replace("{error}", &error_block(error))
719}
720
721fn sso_choices(services: &Services) -> impl Iterator<Item = ProviderChoice<'_>> {
727 let listed = services
728 .config
729 .identity_provider
730 .values()
731 .map(provider_choice);
732
733 let single = services
734 .oauth
735 .providers
736 .find_default_config()
737 .map(|provider| (provider.id(), "Single sign-on"));
738
739 match services.config.lists_identity_providers() {
740 | true => Left(listed),
741 | false => Right(single.into_iter()),
742 }
743}
744
745fn render_sso_options<'a, I>(heading: &str, context: Flow<'_>, providers: I) -> String
750where
751 I: IntoIterator<Item = ProviderChoice<'a>>,
752{
753 let query = match context {
754 | Flow::Authorization(req_id) => format!("oidc_req_id={}", url_encode(req_id)),
755 | Flow::Account { action, device_id } =>
756 format!("action={}&device_id={}", url_encode(action), url_encode(device_id)),
757 | Flow::Device(user_code) => format!("user_code={}", url_encode(user_code)),
758 };
759
760 let options = providers
761 .into_iter()
762 .map(|(id, name)| {
763 let name = html_escape(name)
764 .replace('{', "{")
765 .replace('}', "}");
766
767 (url_encode(id), name)
768 })
769 .fold(String::new(), |mut out, (id, name)| {
770 write!(
771 out,
772 r#"<li><a href="/_tuwunel/oidc/native?{query}&idp_id={id}">{name}</a></li>"#,
773 )
774 .ok();
775
776 out
777 });
778
779 options
780 .is_empty()
781 .is_false()
782 .then(|| {
783 format!(
784 r#"<section class="sso-options"><h2>{heading}</h2><ul>{options}</ul></section>"#
785 )
786 })
787 .unwrap_or_default()
788}
789
790fn error_block(error: Option<&str>) -> String {
791 error
792 .map(|msg| format!(r#"<p class="err">{}</p>"#, html_escape(msg)))
793 .unwrap_or_default()
794}
795
796fn terms_block(services: &Services) -> String {
797 let policies = &services.config.registration_terms;
798 if policies.is_empty() {
799 return String::new();
800 }
801
802 let links = policies
803 .values()
804 .filter_map(|policy| {
805 policy
806 .translations
807 .get("en")
808 .or_else(|| policy.translations.values().next())
809 })
810 .fold(String::new(), |mut links, translation| {
811 write!(
812 links,
813 r#"<li><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></li>"#,
814 html_escape(translation.url.as_str()),
815 html_escape(&translation.name),
816 )
817 .ok();
818
819 links
820 });
821
822 format!(
823 r#"<fieldset class="terms"><legend>Terms</legend><ul>{links}</ul><label><input type="checkbox" name="accept_terms" value="on" required> I accept the terms above.</label></fieldset>"#
824 )
825}
826
827static LOGIN_HTML: &str = const_format!(
828 r#"
829<!DOCTYPE html>
830<html lang="en">
831 <head>
832 {ACCOUNT_HEAD}
833 <title>Sign in · Tuwunel</title>
834 </head>
835 <body class="auth-page">
836 <main class="auth-card" aria-labelledby="auth-title">
837 <h1 id="auth-title">Sign in</h1>
838 <p class="auth-description">Sign in to your Tuwunel account.</p>
839 {{error}}
840 {{password_form}}
841 {{sso_options}}
842 {{register_link}}
843 </main>
844 </body>
845</html>"#
846);
847
848static REGISTER_HTML: &str = const_format!(
849 r#"
850<!DOCTYPE html>
851<html lang="en">
852 <head>
853 {ACCOUNT_HEAD}
854 <title>Create account · Tuwunel</title>
855 </head>
856 <body class="auth-page">
857 <main class="auth-card" aria-labelledby="auth-title">
858 <h1 id="auth-title">Create account</h1>
859 <p class="auth-description">Set up your account on this homeserver.</p>
860 {{error}}
861 <form class="auth-form" method="POST" action="/_tuwunel/oidc/native">
862 <input type="hidden" name="oidc_req_id" value="{{req_id}}">
863 <input type="hidden" name="mode" value="register">
864 <label for="auth-username">Username</label>
865 <input id="auth-username" type="text" name="username" autocomplete="username" autofocus required>
866 <label for="auth-password">Password</label>
867 <input id="auth-password" type="password" name="password" autocomplete="new-password" required>
868 {{token_field}}
869 {{terms}}
870 <button type="submit">Create account</button>
871 </form>
872 <p class="auth-nav">Already have an account? <a href="/_tuwunel/oidc/native?oidc_req_id={{req_id_enc}}&view=login">Sign in</a></p>
873 </main>
874 </body>
875</html>"#
876);
877
878static BOUND_HTML: &str = const_format!(
879 r#"
880<!DOCTYPE html>
881<html lang="en">
882 <head>
883 {ACCOUNT_HEAD}
884 <title>Continue signing in · Tuwunel</title>
885 </head>
886 <body class="auth-page">
887 <main class="auth-card" aria-labelledby="auth-title">
888 <h1 id="auth-title">Continue signing in</h1>
889 <p class="auth-description">Finish signing in with the provider you chose.</p>
890 {{error}}
891 {{sso_options}}
892 </main>
893 </body>
894</html>"#
895);
896
897static PASSWORD_FORM: &str = r#"<form class="auth-form" method="POST" action="/_tuwunel/oidc/native">
898 {context_fields}
899 <input type="hidden" name="mode" value="login">
900 <label for="auth-username">Username</label>
901 <input id="auth-username" type="text" name="username" autocomplete="username" autofocus required>
902 <label for="auth-password">Password</label>
903 <input id="auth-password" type="password" name="password" autocomplete="current-password" required>
904 <button type="submit">Sign in</button>
905 </form>"#;
906
907static TOKEN_FIELD: &str = r#"<label for="auth-token">Registration token</label>
908 <input id="auth-token" type="text" name="registration_token" autocomplete="off" placeholder="Enter your token" required>"#;
909
910#[cfg(test)]
911mod tests {
912 use serde_html_form::from_str;
913
914 use super::{
915 Flow, NativeQuery, PasswordAuth, error_block, parse_flow, render_bound, render_login,
916 render_sso_options,
917 };
918
919 #[test]
920 fn login_page_has_form_and_hidden_req_id() {
921 let html = render_login(Flow::Authorization("REQ123"), None, PasswordAuth::Login, "");
922
923 assert!(html.contains(r#"action="/_tuwunel/oidc/native""#));
924 assert!(html.contains(r#"name="oidc_req_id" value="REQ123""#));
925 assert!(html.contains(r#"name="username""#));
926 assert!(html.contains(r#"name="password""#));
927 assert!(!html.contains("view=register"));
928 }
929
930 #[test]
931 fn login_page_links_to_register_when_enabled() {
932 let html = render_login(Flow::Authorization("REQ123"), None, PasswordAuth::Register, "");
933
934 assert!(html.contains("oidc_req_id=REQ123&view=register"));
935 }
936
937 #[test]
938 fn login_page_offers_each_provider_with_a_bound_request() {
939 let providers =
940 [("first/provider", "First provider"), ("second", "Second {error} <provider>")];
941
942 let options =
943 render_sso_options("Or sign in with", Flow::Authorization("REQ123"), providers);
944
945 let html =
946 render_login(Flow::Authorization("REQ123"), None, PasswordAuth::Login, &options);
947
948 assert!(html.contains("oidc_req_id=REQ123&idp_id=first%2Fprovider"));
949 assert!(html.contains("oidc_req_id=REQ123&idp_id=second"));
950 assert!(html.contains("Second {error} <provider>"));
951 assert!(!html.contains("<provider>"));
952 assert!(html.contains(r#"name="password""#));
953 }
954
955 #[test]
956 fn login_page_offers_only_providers_without_password_login() {
957 let context = Flow::Authorization("REQ123");
958 let options =
959 render_sso_options("Or sign in with", context, [("first", "First provider")]);
960
961 let html = render_login(context, None, PasswordAuth::Disabled, &options);
962
963 assert!(html.contains("oidc_req_id=REQ123&idp_id=first"));
964 assert!(!html.contains("<form"));
965 assert!(!html.contains(r#"name="password""#));
966 assert!(!html.contains("{context_fields}"));
967 assert!(!html.contains("view=register"));
968 }
969
970 #[test]
971 fn bound_page_offers_only_its_provider() {
972 let html = render_bound("REQ123", ("first", "First <provider>"), Some("Already chosen"));
973
974 assert!(html.contains("oidc_req_id=REQ123&idp_id=first"));
975 assert!(html.contains("First <provider>"));
976 assert!(html.contains("Already chosen"));
977 assert!(!html.contains(r#"name="password""#));
978 assert!(!html.contains("{sso_options}"));
979 }
980
981 #[test]
982 fn login_page_escapes_error_and_req_id() {
983 let html = render_login(
984 Flow::Authorization("a<b>c"),
985 Some("<script>alert(1)</script>"),
986 PasswordAuth::Login,
987 "",
988 );
989
990 assert!(!html.contains("<script>"));
991 assert!(html.contains("<script>"));
992 assert!(!html.contains("a<b>c"));
993 assert!(html.contains("a<b>c"));
994 }
995
996 #[test]
997 fn login_page_does_not_expand_smuggled_placeholder() {
998 let html =
1000 render_login(Flow::Authorization("{error}"), Some("BOOM"), PasswordAuth::Login, "");
1001
1002 assert_eq!(html.matches("BOOM").count(), 1);
1003 assert!(html.contains(r#"value="{error}""#));
1004 }
1005
1006 #[test]
1007 fn device_login_page_has_only_hidden_user_code() {
1008 let html = render_login(Flow::Device("BCDF-GHJK"), None, PasswordAuth::Register, "");
1009
1010 assert!(html.contains(r#"name="user_code" value="BCDF-GHJK""#));
1011 assert!(!html.contains(r#"name="oidc_req_id""#));
1012 assert!(!html.contains("view=register"));
1013 }
1014
1015 #[test]
1016 fn device_login_page_preserves_code_in_provider_links() {
1017 let context = Flow::Device("BCDF/GHJK");
1018 let providers = [("first/provider", "First provider")];
1019 let options = render_sso_options("Or sign in with", context, providers);
1020 let html = render_login(context, None, PasswordAuth::Register, &options);
1021
1022 assert!(html.contains("user_code=BCDF%2FGHJK&idp_id=first%2Fprovider"));
1023 assert!(html.contains(r#"name="user_code" value="BCDF/GHJK""#));
1024 assert!(!html.contains("view=register"));
1025 }
1026
1027 #[test]
1028 fn device_login_page_escapes_and_does_not_expand_context() {
1029 let html =
1030 render_login(Flow::Device("a<{error}>"), Some("BOOM"), PasswordAuth::Register, "");
1031
1032 assert_eq!(html.matches("BOOM").count(), 1);
1033 assert!(!html.contains("a<{error}>"));
1034 assert!(html.contains(r#"value="a<{error}>""#));
1035 }
1036
1037 #[test]
1038 fn account_login_page_has_hidden_action_and_device_id() {
1039 let context = Flow::Account {
1040 action: "org.matrix.sessions_list",
1041 device_id: "",
1042 };
1043
1044 let html = render_login(context, None, PasswordAuth::Register, "");
1045
1046 assert!(html.contains(r#"name="action" value="org.matrix.sessions_list""#));
1047 assert!(html.contains(r#"name="device_id" value="""#));
1048 assert!(!html.contains(r#"name="oidc_req_id""#));
1049 assert!(!html.contains(r#"name="user_code""#));
1050 assert!(!html.contains("view=register"));
1051 }
1052
1053 #[test]
1054 fn account_provider_link_preserves_flow_and_local_login() {
1055 let action = "org.matrix.session_end";
1056 let device_id = "device&{error}/?";
1057 let context = Flow::Account { action, device_id };
1058 let providers = [("google/provider", "Google {error} <SSO>")];
1059 let options = render_sso_options("Or sign in with", context, providers);
1060
1061 let html = render_login(context, None, PasswordAuth::Register, &options);
1062 let link = html
1063 .split(r#"href="/_tuwunel/oidc/native?"#)
1064 .nth(1)
1065 .expect("provider link")
1066 .split('"')
1067 .next()
1068 .expect("provider query")
1069 .replace("&", "&");
1070
1071 let params: NativeQuery = from_str(&link).expect("provider query");
1072 let flow = parse_flow(
1073 params.oidc_req_id.as_deref(),
1074 params.user_code.as_deref(),
1075 params.action.as_deref(),
1076 params.device_id.as_deref(),
1077 )
1078 .expect("account flow");
1079
1080 assert!(matches!(flow, Flow::Account {
1081 action: selected_action,
1082 device_id: selected_device,
1083 } if selected_action == action && selected_device == device_id));
1084
1085 assert_eq!(params.idp_id.as_deref(), Some("google/provider"));
1086 assert!(html.contains("Google {error} <SSO>"));
1087 assert!(html.contains(r#"name="password""#));
1088 assert!(!html.contains("view=register"));
1089 }
1090
1091 #[test]
1092 fn account_login_page_escapes_and_does_not_expand_context() {
1093 let context = Flow::Account {
1094 action: "a<{error}>",
1095 device_id: "b<{error}>",
1096 };
1097
1098 let html = render_login(context, Some("BOOM"), PasswordAuth::Register, "");
1099
1100 assert_eq!(html.matches("BOOM").count(), 1);
1101 assert!(!html.contains("a<{error}>"));
1102 assert!(!html.contains("b<{error}>"));
1103 assert!(html.contains(r#"name="action" value="a<{error}>""#));
1104 assert!(html.contains(r#"name="device_id" value="b<{error}>""#));
1105 }
1106
1107 #[test]
1108 fn flow_requires_exactly_one_nonempty_value() {
1109 assert!(matches!(
1110 parse_flow(Some("REQ123"), None, None, None),
1111 Ok(Flow::Authorization("REQ123"))
1112 ));
1113
1114 assert!(matches!(
1115 parse_flow(None, Some("BCDF-GHJK"), None, None),
1116 Ok(Flow::Device("BCDF-GHJK"))
1117 ));
1118
1119 assert!(matches!(
1120 parse_flow(None, None, Some("org.matrix.sessions_list"), None),
1121 Ok(Flow::Account {
1122 action: "org.matrix.sessions_list",
1123 device_id: "",
1124 })
1125 ));
1126
1127 assert!(matches!(
1128 parse_flow(None, None, Some("org.matrix.session_view"), Some("DEVICE")),
1129 Ok(Flow::Account {
1130 action: "org.matrix.session_view",
1131 device_id: "DEVICE",
1132 })
1133 ));
1134
1135 assert!(matches!(
1136 parse_flow(None, None, Some("org.matrix.sessions_list"), Some("")),
1137 Ok(Flow::Account {
1138 action: "org.matrix.sessions_list",
1139 device_id: "",
1140 })
1141 ));
1142
1143 assert!(parse_flow(None, None, None, None).is_err());
1144 assert!(parse_flow(None, None, None, Some("DEVICE")).is_err());
1145 assert!(parse_flow(Some(""), None, None, None).is_err());
1146 assert!(parse_flow(None, Some(""), None, None).is_err());
1147 assert!(parse_flow(None, None, Some(""), None).is_err());
1148 assert!(parse_flow(None, None, Some(""), Some("DEVICE")).is_err());
1149 assert!(parse_flow(Some("REQ123"), Some("BCDF-GHJK"), None, None).is_err());
1150 assert!(
1151 parse_flow(Some("REQ123"), None, Some("org.matrix.sessions_list"), None).is_err()
1152 );
1153
1154 assert!(
1155 parse_flow(None, Some("BCDF-GHJK"), Some("org.matrix.sessions_list"), None).is_err()
1156 );
1157
1158 assert!(
1159 parse_flow(
1160 Some("REQ123"),
1161 Some("BCDF-GHJK"),
1162 Some("org.matrix.sessions_list"),
1163 None,
1164 )
1165 .is_err()
1166 );
1167 }
1168
1169 #[test]
1170 fn error_block_renders_only_when_present() {
1171 let block = error_block(None);
1172
1173 assert!(block.is_empty(), "{block:?}");
1174 assert!(error_block(Some("oops")).contains(r#"class="err""#));
1175 }
1176}