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 ruma::{OwnedUserId, UserId};
10use serde::Deserialize;
11use serde_json::json;
12use tuwunel_core::{
13	Err, Result, err,
14	utils::{self, hash, html::escape as html_escape},
15};
16use tuwunel_service::{Services, users::Register};
17use url::Url;
18
19use super::{
20	account::{
21		ACCOUNT_HEAD, account_error_response, account_html_response, account_redirect_response,
22	},
23	url_encode,
24};
25use crate::ClientIp;
26
27const LOGIN_TOKEN_LENGTH: usize = 32;
28
29#[derive(Debug, Default, Deserialize)]
30struct NativeQuery {
31	oidc_req_id: Option<String>,
32	view: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
36pub(crate) struct NativeSubmit {
37	oidc_req_id: String,
38	#[serde(default)]
39	mode: Option<String>,
40	username: String,
41	password: String,
42	#[serde(default)]
43	registration_token: Option<String>,
44	#[serde(default)]
45	accept_terms: Option<String>,
46}
47
48/// Renders the native login or registration page bound to a pending
49/// authorization request.
50pub(crate) async fn native_get_route(
51	State(services): State<crate::State>,
52	request: Request,
53) -> Response {
54	if let Err(e) = require_native(&services) {
55		return account_error_response(&e);
56	}
57
58	let params: NativeQuery =
59		match serde_html_form::from_str(request.uri().query().unwrap_or_default()) {
60			| Ok(params) => params,
61			| Err(e) => return account_error_response(&e.into()),
62		};
63
64	let req_id = params.oidc_req_id.as_deref().unwrap_or_default();
65	let view = params.view.as_deref().unwrap_or("login");
66
67	account_html_response(StatusCode::OK, render_page(&services, view, req_id, None).await)
68}
69
70/// Authenticates (or registers) the submitted credentials and hands the
71/// resulting login token to the OIDC `_complete` endpoint to finish the
72/// authorization-code flow.
73pub(crate) async fn native_submit_route(
74	State(services): State<crate::State>,
75	ClientIp(client): ClientIp,
76	Form(body): Form<NativeSubmit>,
77) -> Response {
78	match native_submit(&services, client, &body).await {
79		| Ok(response) => response,
80		| Err(e) => {
81			let view = match body.mode.as_deref() {
82				| Some("register") => "register",
83				| _ => "login",
84			};
85
86			let msg = e.sanitized_message();
87			let html = render_page(&services, view, &body.oidc_req_id, Some(&msg)).await;
88
89			account_html_response(e.status_code(), html)
90		},
91	}
92}
93
94async fn native_submit(
95	services: &Services,
96	client: IpAddr,
97	body: &NativeSubmit,
98) -> Result<Response> {
99	require_native(services)?;
100	// Always-on anti-brute-force floor; the oidc_rc_* throttle below is opt-in.
101	services.oauth.check_device_rate_limit(client)?;
102	services.oauth.check_rate_limit(client)?;
103
104	let user_id = match body.mode.as_deref() {
105		| Some("register") => do_register(services, body).await?,
106		| _ => verify_credentials(services, &body.username, &body.password).await?,
107	};
108
109	let token = utils::random_string(LOGIN_TOKEN_LENGTH);
110	let _expires_in = services
111		.users
112		.create_login_token(&user_id, &token);
113
114	let redirect = complete_redirect(services, &body.oidc_req_id, &token)?;
115
116	Ok(account_redirect_response(redirect))
117}
118
119/// Authenticate a local account by password, mirroring the `/login` password
120/// flow (`password_login`): password-origin accounts only, uniform error.
121async fn verify_credentials(
122	services: &Services,
123	username: &str,
124	password: &str,
125) -> Result<OwnedUserId> {
126	let invalid = || err!(Request(Forbidden("Invalid username or password.")));
127	let server_name = &services.config.server_name;
128
129	let user_id = UserId::parse_with_server_name(username, server_name).map_err(|_| invalid())?;
130
131	if !services.globals.user_is_local(&user_id) {
132		return Err(invalid());
133	}
134
135	// Native registration lowercases the localpart, so resolve to whichever case
136	// carries the password, mirroring `/login`.
137	let (user_id, hash) = match services.users.password_hash(&user_id).await {
138		| Ok(hash) => (user_id, hash),
139		| Err(_) => {
140			let lowercased = UserId::parse_with_server_name(username.to_lowercase(), server_name)
141				.map_err(|_| invalid())?;
142
143			let hash = services
144				.users
145				.password_hash(&lowercased)
146				.await
147				.map_err(|_| invalid())?;
148
149			(lowercased, hash)
150		},
151	};
152
153	// SSO/LDAP-origin accounts must authenticate through their provider.
154	if services
155		.users
156		.origin(&user_id)
157		.await
158		.is_ok_and(|origin| origin != "password")
159	{
160		return Err(invalid());
161	}
162
163	if hash.is_empty() {
164		return Err(invalid());
165	}
166
167	hash::verify_password(password, &hash).map_err(|_| invalid())?;
168
169	Ok(user_id)
170}
171
172async fn do_register(services: &Services, body: &NativeSubmit) -> Result<OwnedUserId> {
173	if !services.config.allow_registration {
174		return Err!(Request(Forbidden("Registration is disabled on this server.")));
175	}
176
177	let username = body.username.trim().to_lowercase();
178	if username.is_empty() {
179		return Err!(Request(InvalidUsername("A username is required.")));
180	}
181
182	if body.password.is_empty() {
183		return Err!(Request(InvalidParam("A password is required.")));
184	}
185
186	// This page cannot collect a 3PID, so refuse rather than silently bypass a
187	// mandatory-email policy.
188	let token_required = services.registration_tokens.is_enabled().await;
189	let smtp = &services.config.smtp;
190	let email_required = smtp.connection_uri.is_some()
191		&& (smtp.require_email_for_registration
192			|| (token_required && smtp.require_email_for_token_registration));
193
194	if email_required {
195		return Err!(Request(Forbidden(
196			"This server requires an email to register, which this page cannot collect."
197		)));
198	}
199
200	if services
201		.config
202		.forbidden_usernames
203		.is_match(&username)
204	{
205		return Err!(Request(Forbidden("That username is not allowed.")));
206	}
207
208	let user_id = UserId::parse_with_server_name(&username, &services.config.server_name)
209		.map_err(|_| err!(Request(InvalidUsername("That username is not valid."))))?;
210
211	user_id.validate_strict().map_err(|_| {
212		err!(Request(InvalidUsername("That username contains disallowed characters.")))
213	})?;
214
215	if services
216		.appservice
217		.is_exclusive_user_id(&user_id)
218		.await
219	{
220		return Err!(Request(Exclusive("That username is reserved by an appservice.")));
221	}
222
223	if services.users.exists(&user_id).await {
224		return Err!(Request(UserInUse("That username is taken.")));
225	}
226
227	// Acceptance is checked before any token is consumed, so a missing checkbox
228	// does not burn a single-use registration token.
229	if !services.config.registration_terms.is_empty()
230		&& body.accept_terms.as_deref() != Some("on")
231	{
232		return Err!(Request(Forbidden("You must accept the terms to register.")));
233	}
234
235	if token_required {
236		let token = body
237			.registration_token
238			.as_deref()
239			.unwrap_or_default();
240
241		services
242			.registration_tokens
243			.try_consume(token)
244			.await?;
245	}
246
247	services
248		.users
249		.full_register(Register {
250			user_id: Some(&user_id),
251			password: Some(&body.password),
252			grant_first_user_admin: true,
253			..Default::default()
254		})
255		.await?;
256
257	record_accepted_terms(services, &user_id).await?;
258
259	Ok(user_id)
260}
261
262async fn record_accepted_terms(services: &Services, user_id: &UserId) -> Result {
263	let accepted: Vec<String> = services
264		.config
265		.registration_terms
266		.values()
267		.flat_map(|policy| policy.translations.values())
268		.map(|translation| translation.url.to_string())
269		.collect();
270
271	if accepted.is_empty() {
272		return Ok(());
273	}
274
275	let event_type = "m.accepted_terms";
276	let event = json!({
277		"type": event_type,
278		"content": { "accepted": accepted },
279	});
280
281	services
282		.account_data
283		.update(None, user_id, event_type.into(), &event)
284		.await
285}
286
287fn complete_redirect(services: &Services, req_id: &str, login_token: &str) -> Result<Redirect> {
288	let issuer = services.oauth.get_server()?.issuer_url()?;
289	let base = issuer.trim_end_matches('/');
290
291	let url = Url::parse(&format!("{base}/_tuwunel/oidc/_complete"))
292		.map_err(|_| err!(error!("Failed to build complete URL")))
293		.map(|mut url| {
294			url.query_pairs_mut()
295				.append_pair("oidc_req_id", req_id)
296				.append_pair("loginToken", login_token);
297
298			url
299		})?;
300
301	Ok(Redirect::temporary(url.as_str()))
302}
303
304fn require_native(services: &Services) -> Result {
305	services.oauth.get_server()?;
306
307	services
308		.config
309		.oidc_native_auth
310		.then_some(())
311		.ok_or_else(|| err!(Request(NotFound("Native authentication is not enabled"))))
312}
313
314async fn render_page(
315	services: &Services,
316	view: &str,
317	req_id: &str,
318	error: Option<&str>,
319) -> String {
320	let registration_enabled = services.config.allow_registration;
321
322	match view {
323		| "register" if registration_enabled => render_register(services, req_id, error).await,
324		| _ => render_login(req_id, error, registration_enabled),
325	}
326}
327
328fn render_login(req_id: &str, error: Option<&str>, show_register: bool) -> String {
329	let register_link = show_register
330		.then(|| {
331			format!(
332				r#"<p class="nav">No account? <a href="/_tuwunel/oidc/native?oidc_req_id={}&amp;view=register">Create one</a>.</p>"#,
333				url_encode(req_id),
334			)
335		})
336		.unwrap_or_default();
337
338	LOGIN_HTML
339		.replace("{register_link}", &register_link)
340		.replace("{error}", &error_block(error))
341		// Fill the caller-supplied {req_id} last so it cannot smuggle a placeholder.
342		.replace("{req_id}", &html_escape(req_id))
343}
344
345async fn render_register(services: &Services, req_id: &str, error: Option<&str>) -> String {
346	let token_field = services
347		.registration_tokens
348		.is_enabled()
349		.await
350		.then_some(TOKEN_FIELD)
351		.unwrap_or_default();
352
353	REGISTER_HTML
354		.replace("{token_field}", token_field)
355		.replace("{req_id_enc}", &url_encode(req_id))
356		.replace("{terms}", &terms_block(services))
357		.replace("{error}", &error_block(error))
358		// Fill the caller-supplied {req_id} last so it cannot smuggle a placeholder.
359		.replace("{req_id}", &html_escape(req_id))
360}
361
362fn error_block(error: Option<&str>) -> String {
363	error
364		.map(|msg| format!(r#"<p class="err">{}</p>"#, html_escape(msg)))
365		.unwrap_or_default()
366}
367
368fn terms_block(services: &Services) -> String {
369	let policies = &services.config.registration_terms;
370	if policies.is_empty() {
371		return String::new();
372	}
373
374	let links = policies
375		.values()
376		.filter_map(|policy| {
377			policy
378				.translations
379				.get("en")
380				.or_else(|| policy.translations.values().next())
381		})
382		.fold(String::new(), |mut links, translation| {
383			write!(
384				links,
385				r#"<li><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></li>"#,
386				html_escape(translation.url.as_str()),
387				html_escape(&translation.name),
388			)
389			.ok();
390
391			links
392		});
393
394	format!(
395		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>"#
396	)
397}
398
399static LOGIN_HTML: &str = const_format!(
400	r#"
401<!DOCTYPE html>
402<html lang="en">
403	<head>
404		{ACCOUNT_HEAD}
405		<title>Sign In</title>
406	</head>
407	<body>
408		<h1>Sign In</h1>
409		{{error}}
410		<form method="POST" action="/_tuwunel/oidc/native">
411			<input type="hidden" name="oidc_req_id" value="{{req_id}}">
412			<input type="hidden" name="mode" value="login">
413			<label>
414				Username
415				<input type="text" name="username" autocomplete="username" autofocus required>
416			</label>
417			<label>
418				Password
419				<input type="password" name="password" autocomplete="current-password" required>
420			</label>
421			<button type="submit">Sign in</button>
422		</form>
423		{{register_link}}
424	</body>
425</html>"#
426);
427
428static REGISTER_HTML: &str = const_format!(
429	r#"
430<!DOCTYPE html>
431<html lang="en">
432	<head>
433		{ACCOUNT_HEAD}
434		<title>Create Account</title>
435	</head>
436	<body>
437		<h1>Create Account</h1>
438		{{error}}
439		<form method="POST" action="/_tuwunel/oidc/native">
440			<input type="hidden" name="oidc_req_id" value="{{req_id}}">
441			<input type="hidden" name="mode" value="register">
442			<label>
443				Username
444				<input type="text" name="username" autocomplete="username" autofocus required>
445			</label>
446			<label>
447				Password
448				<input type="password" name="password" autocomplete="new-password" required>
449			</label>
450			{{token_field}}
451			{{terms}}
452			<button type="submit">Create account</button>
453		</form>
454		<p class="nav">Have an account? <a href="/_tuwunel/oidc/native?oidc_req_id={{req_id_enc}}&amp;view=login">Sign in</a>.</p>
455	</body>
456</html>"#
457);
458
459static TOKEN_FIELD: &str = r#"<label>
460				Registration token
461				<input type="text" name="registration_token" autocomplete="off" required>
462			</label>"#;
463
464#[cfg(test)]
465mod tests {
466	use super::{error_block, render_login};
467
468	#[test]
469	fn login_page_has_form_and_hidden_req_id() {
470		let html = render_login("REQ123", None, false);
471
472		assert!(html.contains(r#"action="/_tuwunel/oidc/native""#));
473		assert!(html.contains(r#"name="oidc_req_id" value="REQ123""#));
474		assert!(html.contains(r#"name="username""#));
475		assert!(html.contains(r#"name="password""#));
476		assert!(!html.contains("view=register"));
477	}
478
479	#[test]
480	fn login_page_links_to_register_when_enabled() {
481		let html = render_login("REQ123", None, true);
482
483		assert!(html.contains("oidc_req_id=REQ123&amp;view=register"));
484	}
485
486	#[test]
487	fn login_page_escapes_error_and_req_id() {
488		let html = render_login("a<b>c", Some("<script>alert(1)</script>"), false);
489
490		assert!(!html.contains("<script>"));
491		assert!(html.contains("&lt;script&gt;"));
492		assert!(!html.contains("a<b>c"));
493		assert!(html.contains("a&lt;b&gt;c"));
494	}
495
496	#[test]
497	fn login_page_does_not_expand_smuggled_placeholder() {
498		// A req_id of "{error}" must not be re-expanded by the later error fill.
499		let html = render_login("{error}", Some("BOOM"), false);
500
501		assert_eq!(html.matches("BOOM").count(), 1);
502		assert!(html.contains(r#"value="{error}""#));
503	}
504
505	#[test]
506	fn error_block_renders_only_when_present() {
507		assert!(error_block(None).is_empty());
508		assert!(error_block(Some("oops")).contains(r#"class="err""#));
509	}
510}