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