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 #[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 let auth_req = oidc
70 .peek_auth_request(¶ms.oidc_req_id)
71 .await?;
72
73 if approval_waived(&services, &auth_req.redirect_uri) {
74 return release_code(&services, &auth_req, ¶ms).await;
75 }
76
77 let user_id = peek_login_token(&services, Some(¶ms.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: ¶ms.oidc_req_id,
97 login_token: ¶ms.login_token,
98 };
99
100 Ok(account_html_response(StatusCode::OK, approval.render()))
101}
102
103pub(crate) async fn post_complete_route(
109 State(services): State<crate::State>,
110 ClientIp(client): ClientIp,
111 Form(params): Form<CompleteParams>,
112) -> Response {
113 match handle_approval(&services, client, ¶ms).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
135fn approved(action: Option<&str>) -> bool { action == Some("approve") }
141
142impl Approval<'_> {
143 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
192fn 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
208async fn accept_code(services: &Services, params: &CompleteParams) -> Result<Response> {
214 let auth_req = services
215 .oauth
216 .get_server()?
217 .peek_auth_request(¶ms.oidc_req_id)
218 .await?;
219
220 release_code(services, &auth_req, params).await
221}
222
223async 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(¶ms.oidc_req_id, auth_req)
255 .await?;
256
257 let user_id = consume_login_token(services, Some(¶ms.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
294async fn refuse_code(services: &Services, params: &CompleteParams) -> Result<Response> {
300 services
301 .oauth
302 .get_server()?
303 .retire_auth_request(¶ms.oidc_req_id)
304 .await;
305
306 consume_login_token(services, Some(¶ms.login_token))
307 .await
308 .ok();
309
310 Ok(account_html_response(StatusCode::OK, DENIED_HTML.to_owned()))
311}
312
313fn 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 assert!(needs("io.element.android:/?code=a&state=b", true));
400 assert!(needs("io.element.android:/?code=a&state=b", false));
401 assert!(needs("https://element.io/oauth/ios/io.element.elementx?code=a", true));
403 assert!(!needs("https://app.example.com/cb?code=a", false));
405 assert!(!needs("http://127.0.0.1/cb?code=a", true));
407 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("&"));
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="<&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("<script>"));
455 }
456
457 #[test]
458 fn client_metadata_cannot_reach_the_token_slot() {
459 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}