Skip to main content

tuwunel_api/oidc/
complete.rs

1use std::iter::once;
2
3use axum::{
4	extract::State,
5	response::{IntoResponse, Redirect, Response},
6};
7use http::StatusCode;
8use serde::Deserialize;
9use tuwunel_core::{Result, err, utils::html::escape as html_escape};
10use url::{Url, form_urlencoded};
11
12use super::account::{ACCOUNT_HEAD, account_html_response};
13
14#[derive(Debug, Deserialize)]
15pub(crate) struct CompleteParams {
16	oidc_req_id: String,
17	#[serde(rename = "loginToken")]
18	login_token: String,
19}
20
21pub(crate) async fn complete_route(
22	State(services): State<crate::State>,
23	request: axum::extract::Request,
24) -> Result<Response> {
25	let query = request.uri().query().unwrap_or_default();
26	let params: CompleteParams = serde_html_form::from_str(query)?;
27
28	let oidc = services.oauth.get_server()?;
29
30	// Validate the auth request first (before consuming the login_token) so that
31	// a crafted request with an invalid oidc_req_id cannot burn a valid token.
32	let auth_req = oidc
33		.take_auth_request(&params.oidc_req_id)
34		.await?;
35
36	let user_id = services
37		.users
38		.find_from_login_token(&params.login_token)
39		.await
40		.map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))?;
41
42	let code = oidc.create_auth_code(&auth_req, user_id);
43	let redirect_url = Url::parse(&auth_req.redirect_uri)
44		.map_err(|_| err!(Request(InvalidParam("Invalid redirect_uri"))))
45		.map(|mut url| {
46			let pairs = once(("code", code.as_str()))
47				.chain(auth_req.state.as_deref().map(|s| ("state", s)));
48
49			match auth_req.response_mode.as_deref() {
50				| Some("fragment") => {
51					let body = form_urlencoded::Serializer::new(String::new())
52						.extend_pairs(pairs)
53						.finish();
54
55					url.set_fragment(Some(&body));
56				},
57				| _ => {
58					url.query_pairs_mut().extend_pairs(pairs);
59				},
60			}
61
62			url
63		})?;
64
65	let native = redirect_url.scheme() == "https"
66		&& oidc
67			.get_client(&auth_req.client_id)
68			.await
69			.is_ok_and(|client| client.application_type.as_deref() == Some("native"));
70
71	Ok(if needs_interstitial(&redirect_url, native) {
72		account_html_response(StatusCode::OK, complete_continue_html(redirect_url.as_str()))
73	} else {
74		Redirect::temporary(redirect_url.as_str()).into_response()
75	})
76}
77
78/// Whether the auth code is handed back via a "Continue" interstitial (a user
79/// gesture) rather than a direct redirect. True for private-use reverse-DNS app
80/// schemes (RFC 8252, e.g. `io.element.android`), which Chrome will not
81/// auto-follow, and for a native client's `https` universal link, which iOS
82/// opens into the app only on a user navigation, not a silent 3xx. Web `https`
83/// and native `http` loopback redirect directly; a `javascript:` or `data:`
84/// target is neither dotted nor `https`, so it stays an inert `Location`, never
85/// a clickable link.
86fn needs_interstitial(redirect_url: &Url, native: bool) -> bool {
87	redirect_url.scheme().contains('.') || (native && redirect_url.scheme() == "https")
88}
89
90fn complete_continue_html(redirect_url: &str) -> String {
91	let href = html_escape(redirect_url);
92
93	format!(
94		r#"<!DOCTYPE html>
95		<html lang="en">
96			<head>
97				{ACCOUNT_HEAD}
98				<title>Continue</title>
99			</head>
100			<body>
101				<h1>Almost there</h1>
102				<p>Continue to return to your app and finish signing in.</p>
103				<div class="nav">
104					<a href="{href}">Continue</a>
105				</div>
106			</body>
107		</html>"#
108	)
109}
110
111#[cfg(test)]
112mod tests {
113	use url::Url;
114
115	use super::{complete_continue_html, needs_interstitial};
116
117	#[test]
118	fn interstitial_for_native_or_reverse_dns() {
119		let needs = |u: &str, native: bool| needs_interstitial(&Url::parse(u).unwrap(), native);
120
121		// Reverse-DNS app scheme (Android): interstitial regardless of client type.
122		assert!(needs("io.element.android:/?code=a&state=b", true));
123		assert!(needs("io.element.android:/?code=a&state=b", false));
124		// Native https universal link (Element X iOS): now interstitial.
125		assert!(needs("https://element.io/oauth/ios/io.element.elementx?code=a", true));
126		// Web https client (Element Web): direct redirect, no friction.
127		assert!(!needs("https://app.example.com/cb?code=a", false));
128		// Native http loopback (desktop local server): direct redirect.
129		assert!(!needs("http://127.0.0.1/cb?code=a", true));
130		// Dangerous bare schemes never become a clickable link, even when native.
131		assert!(!needs("javascript:alert(1)", true));
132		assert!(!needs("data:text/html,x", true));
133	}
134
135	#[test]
136	fn continue_html_links_escaped_redirect() {
137		let html = complete_continue_html("io.element.android:/?code=a&state=b");
138
139		assert!(html.contains(r#"href="io.element.android:"#));
140		assert!(html.contains("&amp;"));
141		assert!(html.contains("Continue"));
142	}
143}