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 #[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 let auth_req = oidc
71 .peek_auth_request(¶ms.oidc_req_id)
72 .await?;
73
74 if approval_waived(&services, &auth_req.redirect_uri) {
75 return release_code(&services, &auth_req, ¶ms).await;
76 }
77
78 let user_id = peek_login_token(&services, Some(¶ms.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: ¶ms.oidc_req_id,
98 login_token: ¶ms.login_token,
99 };
100
101 Ok(account_html_response(StatusCode::OK, approval.render()))
102}
103
104pub(crate) async fn post_complete_route(
110 State(services): State<crate::State>,
111 ClientIp(client): ClientIp,
112 Form(params): Form<CompleteParams>,
113) -> Response {
114 match handle_approval(&services, client, ¶ms).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
136fn approved(action: Option<&str>) -> bool { action == Some("approve") }
142
143impl Approval<'_> {
144 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
193fn 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
210async fn accept_code(services: &Services, params: &CompleteParams) -> Result<Response> {
216 let auth_req = services
217 .oauth
218 .get_server()?
219 .peek_auth_request(¶ms.oidc_req_id)
220 .await?;
221
222 release_code(services, &auth_req, params).await
223}
224
225async 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(¶ms.oidc_req_id, auth_req)
257 .await?;
258
259 let user_id = consume_login_token(services, Some(¶ms.login_token)).await?;
260
261 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
300async fn refuse_code(services: &Services, params: &CompleteParams) -> Result<Response> {
306 services
307 .oauth
308 .get_server()?
309 .retire_auth_request(¶ms.oidc_req_id)
310 .await;
311
312 consume_login_token(services, Some(¶ms.login_token))
313 .await
314 .ok();
315
316 Ok(account_html_response(StatusCode::OK, DENIED_HTML.to_owned()))
317}
318
319fn 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 assert!(needs("io.element.android:/?code=a&state=b", true));
406 assert!(needs("io.element.android:/?code=a&state=b", false));
407 assert!(needs("https://element.io/oauth/ios/io.element.elementx?code=a", true));
409 assert!(!needs("https://app.example.com/cb?code=a", false));
411 assert!(!needs("http://127.0.0.1/cb?code=a", true));
413 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("&"));
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="<&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("<script>"));
461 }
462
463 #[test]
464 fn client_metadata_cannot_reach_the_token_slot() {
465 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}