Skip to main content

tuwunel_api_oidc/
native.rs

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/// Password authentication the login page offers beside the providers.
85///
86/// Read from `authentication.login.with_password` and the registration
87/// settings; registration implies login.
88#[derive(Clone, Copy)]
89enum PasswordAuth {
90	/// Password login is disabled, so only the providers are offered.
91	Disabled,
92
93	/// Existing accounts sign in with a password.
94	Login,
95
96	/// Accounts may also be registered with a password.
97	Register,
98}
99
100/// Renders the native login or registration page for a pending authorization,
101/// device, or account flow.
102///
103/// A provider chosen on that page arrives here as `idp_id`, and the route
104/// redirects to it instead. When the request is already bound to a provider,
105/// the page offers only that provider.
106pub(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
168/// Send the browser to the provider chosen on the login page.
169///
170/// Authorization requests bind to the provider before redirecting. Account
171/// management returns to its existing callback with the action and device.
172async 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
207/// Authenticates submitted credentials and sends the login token to the
208/// authorization completion, device-consent, or account-management callback.
209pub(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	// Always-on anti-brute-force floor; the oidc_rc_* throttle below is opt-in.
229	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
255/// Re-render the page a failed submission came from, carrying its error.
256///
257/// A submission whose flow cannot be parsed, or whose request has gone, gets
258/// the error page instead.
259async 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
287/// Authenticate through the local branch of an authorization request.
288///
289/// A request bound to a provider is refused before any credential is checked.
290/// A login claims the request once the password verifies, and registration
291/// claims it before creating the account.
292async 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
313/// Authenticate a local account by password, mirroring the `/login` password
314/// flow (`password_login`).
315///
316/// Refused while password login is disabled. Only password-origin accounts
317/// pass, and every credential failure returns one uniform error.
318async 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	// Share `/login` buckets so switching routes cannot renew the allowance.
339	let reservation = services
340		.login_ratelimit
341		.reserve_login_attempt(&user_id, client)?;
342
343	// Native registration lowercases the localpart, so resolve to whichever case
344	// carries the password, mirroring `/login`. An unknown account keeps the
345	// reservation as a wrong password does.
346	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	// Deactivated accounts, and SSO/LDAP-origin ones that must authenticate
363	// through their provider, have no password here to check.
364	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	// This page cannot collect a 3PID, so refuse rather than silently bypass a
409	// mandatory-email policy.
410	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	// Acceptance is checked before any token is consumed, so a missing checkbox
453	// does not burn a single-use registration token.
454	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	// Validate before claiming, so a mistyped token leaves provider choice open.
466	if token_required {
467		services
468			.registration_tokens
469			.is_token_valid(token)
470			.await?;
471	}
472
473	// Claim this branch before consuming the token or creating the account.
474	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
527/// Redirects with 303 so the browser cannot replay the password form into the
528/// completion or callback route.
529fn 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
568/// Render the page for a flow, reading an authorization request's binding.
569///
570/// A request bound to a provider offers only that provider, whatever view was
571/// asked for. An unknown or expired request is an error rather than a page.
572async 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={}&amp;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}", &register_link)
681		.replace("{sso_options}", sso_options)
682		.replace("{error}", &error_block(error))
683		// Fill caller-supplied fields last so they cannot smuggle a placeholder.
684		.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		// Fill the caller-supplied {req_id} last so it cannot smuggle a placeholder.
701		.replace("{req_id}", &html_escape(req_id))
702}
703
704fn provider_choice(provider: &IdentityProvider) -> ProviderChoice<'_> {
705	(provider.id(), provider.display_name())
706}
707
708/// Offer only the provider a pending request is bound to.
709///
710/// A user who leaves that provider before finishing returns here, and the
711/// request can complete only through it.
712fn 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
721/// Providers the login page offers, as the client login flows list them.
722///
723/// When `authentication.login.single_sso` or
724/// `authentication.login.sso_custom_providers_page` replaces that list, the page
725/// offers one single sign-on entry for the default provider instead.
726fn 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
745/// List each provider as a link that preserves the authentication flow.
746///
747/// Names are HTML-escaped with braces encoded too, since the result is filled in
748/// before the error and context placeholders.
749fn 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={}&amp;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('{', "&#123;")
765				.replace('}', "&#125;");
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}&amp;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}}&amp;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&amp;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&amp;idp_id=first%2Fprovider"));
949		assert!(html.contains("oidc_req_id=REQ123&amp;idp_id=second"));
950		assert!(html.contains("Second &#123;error&#125; &lt;provider&gt;"));
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&amp;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&amp;idp_id=first"));
975		assert!(html.contains("First &lt;provider&gt;"));
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("&lt;script&gt;"));
992		assert!(!html.contains("a<b>c"));
993		assert!(html.contains("a&lt;b&gt;c"));
994	}
995
996	#[test]
997	fn login_page_does_not_expand_smuggled_placeholder() {
998		// A req_id of "{error}" must not be re-expanded by the later error fill.
999		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&amp;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&lt;{error}&gt;""#));
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("&amp;", "&");
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 &#123;error&#125; &lt;SSO&gt;"));
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&lt;{error}&gt;""#));
1104		assert!(html.contains(r#"name="device_id" value="b&lt;{error}&gt;""#));
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}