Skip to main content

tuwunel_api_oidc/
complete.rs

1use std::{iter::once, net::IpAddr};
2
3use axum::{Form, extract::State, response::Response};
4use const_str::format as const_format;
5use http::StatusCode;
6use ruma::UserId;
7use serde::Deserialize;
8use tuwunel_api::router::ACCOUNT_HEAD;
9use tuwunel_core::{Err, Result, err, utils::html::escape as html_escape};
10use tuwunel_service::{Services, oauth::server::AuthRequest};
11use url::{Url, form_urlencoded::Serializer};
12
13use super::{
14	account::{account_error_response, account_html_response},
15	consume_login_token, peek_login_token, redirect_allowlisted, require_account_usable,
16};
17use crate::ClientIp;
18
19#[derive(Debug, Deserialize)]
20pub(crate) struct CompleteParams {
21	oidc_req_id: String,
22
23	#[serde(rename = "loginToken")]
24	login_token: String,
25
26	/// The button the user pressed on the approval form.
27	///
28	/// Absent on the authentication provider's return leg, which is a GET and
29	/// carries no form at all.
30	#[serde(default)]
31	action: Option<String>,
32}
33
34struct Approval<'a> {
35	user_id: &'a UserId,
36	client_name: &'a str,
37	client_uri: &'a str,
38	redirect_uri: &'a str,
39	scope: &'a str,
40	req_id: &'a str,
41	login_token: &'a str,
42}
43
44static DENIED_HTML: &str = const_format!(
45	r#"
46<!DOCTYPE html>
47<html lang="en">
48	<head>
49		{ACCOUNT_HEAD}
50		<title>Sign-in refused</title>
51	</head>
52	<body>
53		<h1>Sign-in refused</h1>
54		<p>Nothing was shared with the application. You can close this page.</p>
55	</body>
56</html>"#
57);
58
59pub(crate) async fn complete_route(
60	State(services): State<crate::State>,
61	request: axum::extract::Request,
62) -> Result<Response> {
63	let query = request.uri().query().unwrap_or_default();
64	let params: CompleteParams = serde_html_form::from_str(query)?;
65
66	let oidc = services.oauth.get_server()?;
67
68	// Validate the auth request first (before consuming the login_token) so that
69	// a crafted request with an invalid oidc_req_id cannot burn a valid token.
70	let auth_req = oidc
71		.peek_auth_request(&params.oidc_req_id)
72		.await?;
73
74	if approval_waived(&services, &auth_req.redirect_uri) {
75		return release_code(&services, &auth_req, &params).await;
76	}
77
78	let user_id = peek_login_token(&services, Some(&params.login_token)).await?;
79	let client = oidc.get_client(&auth_req.client_id).await.ok();
80
81	let name = client
82		.as_ref()
83		.and_then(|client| client.client_name.as_deref())
84		.unwrap_or(&auth_req.client_id);
85
86	let website = client
87		.as_ref()
88		.and_then(|client| client.client_uri.as_deref())
89		.unwrap_or(&auth_req.redirect_uri);
90
91	let approval = Approval {
92		user_id: &user_id,
93		client_name: name,
94		client_uri: website,
95		redirect_uri: &auth_req.redirect_uri,
96		scope: &auth_req.scope,
97		req_id: &params.oidc_req_id,
98		login_token: &params.login_token,
99	};
100
101	Ok(account_html_response(StatusCode::OK, approval.render()))
102}
103
104/// The approval form's target.
105///
106/// Approving mints the authorization code and hands it to the client; anything
107/// else, the Deny button included, refuses and burns both single-use
108/// credentials. An unrecognized action therefore fails closed.
109pub(crate) async fn post_complete_route(
110	State(services): State<crate::State>,
111	ClientIp(client): ClientIp,
112	Form(params): Form<CompleteParams>,
113) -> Response {
114	// A browser is on the other end of this form, so failures render as a page
115	// rather than the API error envelope.
116	match handle_approval(&services, client, &params).await {
117		| Ok(response) => response,
118		| Err(e) => account_error_response(&e),
119	}
120}
121
122async fn handle_approval(
123	services: &Services,
124	client: IpAddr,
125	params: &CompleteParams,
126) -> Result<Response> {
127	services.oauth.check_rate_limit(client)?;
128
129	if approved(params.action.as_deref()) {
130		accept_code(services, params).await
131	} else {
132		refuse_code(services, params).await
133	}
134}
135
136/// Whether the submitted form approved the sign-in.
137///
138/// Only the exact `approve` action does. The Deny button, an unknown action and
139/// a submission carrying no action at all are all refusals, so a form this
140/// server did not write cannot talk its way into a code.
141fn approved(action: Option<&str>) -> bool { action == Some("approve") }
142
143impl Approval<'_> {
144	/// Render the approval prompt.
145	///
146	/// Every field is HTML-escaped on the way in, the client-supplied ones
147	/// included, since a client names itself at registration and nothing there
148	/// constrains the text. One `format!` substitutes them all, so no value can
149	/// land in another's slot.
150	fn render(&self) -> String {
151		let user = html_escape(self.user_id.as_str());
152		let client = html_escape(self.client_name);
153		let website = html_escape(self.client_uri);
154		let redirect = html_escape(self.redirect_uri);
155		let scope = html_escape(self.scope);
156		let req_id = html_escape(self.req_id);
157		let token = html_escape(self.login_token);
158
159		format!(
160			r#"<!DOCTYPE html>
161		<html lang="en">
162			<head>
163				{ACCOUNT_HEAD}
164				<title>Authorize application</title>
165			</head>
166			<body>
167				<h1>Authorize application</h1>
168				<p>An application is asking to sign in as <strong>{user}</strong>.</p>
169				<p>Application: <strong>{client}</strong></p>
170				<p>Website: <code>{website}</code></p>
171				<p>Sign-in is handed back to: <code>{redirect}</code></p>
172				<p>Requested access: <code>{scope}</code></p>
173				<p class="warn">
174					Approve only if you started this sign-in yourself. Approving gives
175					this application access to your account.
176				</p>
177				<form method="POST" action="/_tuwunel/oidc/_complete">
178					<input type="hidden" name="oidc_req_id" value="{req_id}">
179					<input type="hidden" name="loginToken" value="{token}">
180					<button type="submit" name="action" value="approve" class="primary">
181						Approve
182					</button>
183					<button type="submit" name="action" value="deny" class="danger">
184						Deny
185					</button>
186				</form>
187			</body>
188		</html>"#
189		)
190	}
191}
192
193/// Whether the authorization code goes out without asking the user first.
194///
195/// Two conditions waive the prompt: the operator turned it off with
196/// `authentication.oidc.require_client_approval`, or listed this redirect target in
197/// the
198/// registration allowlist. Neither holds on a server running open dynamic
199/// registration, where an attacker can register a client of their own and phish
200/// an authorization link. An initial access token deliberately does not waive
201/// it, since closing registration says nothing about the clients that were
202/// already registered when it closed.
203fn approval_waived(services: &Services, redirect_uri: &str) -> bool {
204	let config = &services.config.authentication.oidc;
205
206	!config.require_client_approval
207		|| redirect_allowlisted(&config.registration_allowed_redirect_hosts, redirect_uri)
208}
209
210/// Mint the authorization code for an approved sign-in.
211///
212/// The pending request is read here rather than carried across from the page,
213/// because the approval arrives as its own HTTP request. Retiring it is
214/// `release_code`'s job.
215async fn accept_code(services: &Services, params: &CompleteParams) -> Result<Response> {
216	let auth_req = services
217		.oauth
218		.get_server()?
219		.peek_auth_request(&params.oidc_req_id)
220		.await?;
221
222	release_code(services, &auth_req, params).await
223}
224
225/// Retire the pending authorization request, mint the code, and hand it to the
226/// client's redirect target.
227///
228/// The request is retired before the code exists, so a resubmitted form finds
229/// nothing to mint against. The login token is spent on the same pass, which
230/// makes this the one-shot tail of both entry points; a request that vanished
231/// or changed since it was read keeps the token, as a refused peek does.
232async fn release_code(
233	services: &Services,
234	auth_req: &AuthRequest,
235	params: &CompleteParams,
236) -> Result<Response> {
237	let oidc = services.oauth.get_server()?;
238	let redirect_url = Url::parse(&auth_req.redirect_uri)
239		.map_err(|_| err!(Request(InvalidParam("Invalid redirect_uri"))))?;
240
241	let scheme = redirect_url.scheme();
242
243	if !matches!(scheme, "http" | "https") && !scheme.contains('.') {
244		return Err!(Request(InvalidParam("Invalid redirect_uri scheme")));
245	}
246
247	let native = scheme == "https"
248		&& oidc
249			.get_client(&auth_req.client_id)
250			.await?
251			.application_type
252			.as_deref()
253			== Some("native");
254
255	let auth_req = oidc
256		.take_auth_request(&params.oidc_req_id, auth_req)
257		.await?;
258
259	let user_id = consume_login_token(services, Some(&params.login_token)).await?;
260
261	// Account-state refusal also retires the request and spends the login token.
262	require_account_usable(services, &user_id).await?;
263
264	let code = oidc.create_auth_code(&auth_req, user_id);
265	let redirect_url = code_redirect(redirect_url, &auth_req, &code);
266	let html = if needs_interstitial(&redirect_url, native) {
267		complete_continue_html(redirect_url.as_str())
268	} else {
269		complete_refresh_html(redirect_url.as_str())
270	};
271
272	Ok(account_html_response(StatusCode::OK, html))
273}
274
275fn code_redirect(url: Url, auth_req: &AuthRequest, code: &str) -> Url {
276	let pairs = once(("code", code)).chain(auth_req.state.as_deref().map(|s| ("state", s)));
277
278	match auth_req.response_mode.as_deref() {
279		| mode if mode != Some("fragment") => with_query(url, pairs),
280		| _ => {
281			let body = Serializer::new(String::new())
282				.extend_pairs(pairs)
283				.finish();
284
285			with_fragment(url, &body)
286		},
287	}
288}
289
290fn with_query<'a>(mut url: Url, pairs: impl Iterator<Item = (&'a str, &'a str)>) -> Url {
291	url.query_pairs_mut().extend_pairs(pairs);
292	url
293}
294
295fn with_fragment(mut url: Url, fragment: &str) -> Url {
296	url.set_fragment(Some(fragment));
297	url
298}
299
300/// Discard a refused authorization.
301///
302/// Both single-use credentials are burned even when the request is stale, so a
303/// refusal cannot be resumed by replaying the form. The request is retired
304/// without being read, since nothing here needs its contents.
305async fn refuse_code(services: &Services, params: &CompleteParams) -> Result<Response> {
306	services
307		.oauth
308		.get_server()?
309		.retire_auth_request(&params.oidc_req_id)
310		.await;
311
312	consume_login_token(services, Some(&params.login_token))
313		.await
314		.ok();
315
316	Ok(account_html_response(StatusCode::OK, DENIED_HTML.to_owned()))
317}
318
319/// Whether returning the authorization code requires a user gesture.
320///
321/// Private-use reverse-DNS schemes and native HTTPS universal links need a
322/// Continue link to open the application. Web HTTPS and native HTTP loopback
323/// callbacks use an automatic HTML handoff.
324fn needs_interstitial(redirect_url: &Url, native: bool) -> bool {
325	redirect_url.scheme().contains('.') || (native && redirect_url.scheme() == "https")
326}
327
328fn complete_continue_html(redirect_url: &str) -> String {
329	let href = html_escape(redirect_url);
330
331	format!(
332		r#"<!DOCTYPE html>
333		<html lang="en">
334			<head>
335				{ACCOUNT_HEAD}
336				<title>Finish signing in ยท Tuwunel</title>
337			</head>
338			<body class="auth-page">
339				<main class="auth-card auth-complete" aria-labelledby="auth-title">
340					<h1 id="auth-title">Finish signing in</h1>
341					<p class="auth-description">Continue to your app to complete sign-in.</p>
342					<a class="auth-continue-link" href="{href}">Continue</a>
343				</main>
344			</body>
345		</html>"#
346	)
347}
348
349fn complete_refresh_html(redirect_url: &str) -> String {
350	REFRESH_HTML.replace("{href}", &html_escape(redirect_url))
351}
352
353static REFRESH_HTML: &str = r#"
354<!DOCTYPE html>
355<html lang="en">
356	<head>
357		<meta charset="UTF-8">
358		<meta http-equiv="refresh" content="0; URL={href}">
359		<title>Continue</title>
360	</head>
361	<body>
362		<p>Continue to finish signing in.</p>
363		<a href="{href}">Continue</a>
364	</body>
365</html>"#;
366
367#[cfg(test)]
368mod tests {
369	use ruma::user_id;
370	use url::Url;
371
372	use super::{
373		Approval, approved, complete_continue_html, complete_refresh_html, needs_interstitial,
374	};
375
376	fn approval(client_name: &str, login_token: &str) -> String {
377		Approval {
378			user_id: user_id!("@alice:example.com"),
379			client_name,
380			client_uri: "https://attacker.example/app",
381			redirect_uri: "https://attacker.example/callback",
382			scope: "urn:matrix:org.matrix.msc2967.client:api:*",
383			req_id: "reqid",
384			login_token,
385		}
386		.render()
387	}
388
389	#[test]
390	fn only_the_approve_action_approves() {
391		assert!(approved(Some("approve")));
392
393		assert!(!approved(Some("deny")));
394		assert!(!approved(Some("Approve")));
395		assert!(!approved(Some("approve ")));
396		assert!(!approved(Some("")));
397		assert!(!approved(None));
398	}
399
400	#[test]
401	fn interstitial_for_native_or_reverse_dns() {
402		let needs = |u: &str, native: bool| needs_interstitial(&Url::parse(u).unwrap(), native);
403
404		// Reverse-DNS app scheme (Android): interstitial regardless of client type.
405		assert!(needs("io.element.android:/?code=a&state=b", true));
406		assert!(needs("io.element.android:/?code=a&state=b", false));
407		// Native https universal link (Element X iOS): now interstitial.
408		assert!(needs("https://element.io/oauth/ios/io.element.elementx?code=a", true));
409		// Web https client (Element Web): direct redirect, no friction.
410		assert!(!needs("https://app.example.com/cb?code=a", false));
411		// Native http loopback (desktop local server): direct redirect.
412		assert!(!needs("http://127.0.0.1/cb?code=a", true));
413		// Dangerous bare schemes never become a clickable link, even when native.
414		assert!(!needs("javascript:alert(1)", true));
415		assert!(!needs("data:text/html,x", true));
416	}
417
418	#[test]
419	fn continue_html_links_escaped_redirect() {
420		let html = complete_continue_html("io.element.android:/?code=a&state=b");
421
422		assert!(html.contains(r#"href="io.element.android:"#));
423		assert!(html.contains("&amp;"));
424		assert!(html.contains("auth-card auth-complete"));
425		assert!(html.contains("Finish signing in"));
426		assert!(html.contains("Continue"));
427		assert!(!html.contains("http-equiv=\"refresh\""));
428	}
429
430	#[test]
431	fn refresh_html_escapes_both_destinations() {
432		let html = complete_refresh_html("https://client.example/cb?x=\"<&code=a#state={href}");
433		let escaped = "https://client.example/cb?x=&quot;&lt;&amp;code=a#state={href}";
434
435		assert!(html.contains(&format!(r#"content="0; URL={escaped}""#)));
436		assert!(html.contains(&format!(r#"href="{escaped}""#)));
437		assert!(!html.contains("<script"));
438		assert!(!html.contains("stylesheet"));
439		assert!(!html.contains("<form"));
440	}
441
442	#[test]
443	fn approval_names_the_client_and_carries_the_token() {
444		let html = approval("Test client", "tok");
445
446		assert!(html.contains("@alice:example.com"));
447		assert!(html.contains("Test client"));
448		assert!(html.contains("https://attacker.example/callback"));
449		assert!(html.contains(r#"name="loginToken" value="tok""#));
450		assert!(html.contains(r#"name="oidc_req_id" value="reqid""#));
451		assert!(html.contains(r#"value="approve""#));
452		assert!(html.contains(r#"value="deny""#));
453	}
454
455	#[test]
456	fn approval_escapes_client_metadata() {
457		let html = approval("<script>alert(1)</script>", "tok");
458
459		assert!(!html.contains("<script>"));
460		assert!(html.contains("&lt;script&gt;"));
461	}
462
463	#[test]
464	fn client_metadata_cannot_reach_the_token_slot() {
465		// A client naming itself after a template placeholder stays literal text.
466		let html = approval("{token}{req_id}{user}", "supersecret");
467
468		assert_eq!(html.matches("supersecret").count(), 1);
469		assert!(html.contains("{token}{req_id}{user}"));
470	}
471}