tuwunel_api/oidc/
complete.rs1use 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 let auth_req = oidc
33 .take_auth_request(¶ms.oidc_req_id)
34 .await?;
35
36 let user_id = services
37 .users
38 .find_from_login_token(¶ms.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
78fn 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 assert!(needs("io.element.android:/?code=a&state=b", true));
123 assert!(needs("io.element.android:/?code=a&state=b", false));
124 assert!(needs("https://element.io/oauth/ios/io.element.elementx?code=a", true));
126 assert!(!needs("https://app.example.com/cb?code=a", false));
128 assert!(!needs("http://127.0.0.1/cb?code=a", true));
130 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("&"));
141 assert!(html.contains("Continue"));
142 }
143}