1use std::sync::{Arc, Mutex};
6
7use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8use tokio::net::{TcpListener, TcpStream};
9use tokio::sync::oneshot;
10use tokio::task::JoinSet;
11use tokio_util::sync::CancellationToken;
12use url::Url;
13
14use super::{AuthError, AuthPrompt, Interaction};
15
16const PAGE: &str = include_str!("oauth-page.html");
17
18fn escape_html(value: &str) -> String {
19 value
20 .replace('&', "&")
21 .replace('<', "<")
22 .replace('>', ">")
23 .replace('"', """)
24 .replace('\'', "'")
25}
26
27fn render_page(title: &str, message: &str, details: Option<&str>) -> String {
28 let details = details.map_or_else(String::new, |details| {
29 format!("<div class=\"details\">{}</div>", escape_html(details))
30 });
31 PAGE.replace("__TITLE__", &escape_html(title))
32 .replace("__HEADING__", &escape_html(title))
33 .replace("__MESSAGE__", &escape_html(message))
34 .replace("__DETAILS__", &details)
35}
36
37pub fn success_page(message: &str) -> String {
39 render_page("Authentication successful", message, None)
40}
41
42pub fn error_page(message: &str, details: Option<&str>) -> String {
44 render_page("Authentication failed", message, details)
45}
46
47pub struct Reply<T> {
49 pub status: u16,
51 pub html: String,
53 pub outcome: Option<Result<T, AuthError>>,
55}
56
57impl<T> Reply<T> {
58 pub fn page(status: u16, html: String) -> Reply<T> {
60 Reply {
61 status,
62 html,
63 outcome: None,
64 }
65 }
66}
67
68pub type Handler<T> = Box<dyn FnMut(&str, &Url, bool) -> Reply<T> + Send>;
72
73pub struct PendingPage {
75 page: oneshot::Sender<(u16, String)>,
76 sent: oneshot::Receiver<()>,
77}
78
79impl PendingPage {
80 pub async fn finish<U>(self, provider: &str, result: &Result<U, AuthError>) {
83 let page = match result {
84 Ok(_) => (
85 200,
86 success_page(&format!(
87 "Signed in to {provider}. You may now close this page."
88 )),
89 ),
90 Err(err) => (
91 502,
92 error_page(
93 &format!("{provider} sign-in failed."),
94 Some(&err.to_string()),
95 ),
96 ),
97 };
98 if self.page.send(page).is_ok() {
99 let _ = self.sent.await;
100 }
101 }
102}
103
104type Outcome<T> = (Result<T, AuthError>, Option<PendingPage>);
105
106pub struct Server<T> {
109 port: u16,
110 redirect_uri: String,
111 outcome: oneshot::Receiver<Outcome<T>>,
112 cancel: CancellationToken,
113 tasks: tokio::task::JoinHandle<()>,
114}
115
116impl<T: Send + 'static> Server<T> {
117 pub async fn start(
120 host: &str,
121 port: u16,
122 path: &str,
123 cancel: &CancellationToken,
124 handler: Handler<T>,
125 ) -> std::io::Result<Server<T>> {
126 Self::listen(host, port, path, cancel, handler, false).await
127 }
128
129 async fn listen(
133 host: &str,
134 port: u16,
135 path: &str,
136 cancel: &CancellationToken,
137 handler: Handler<T>,
138 pending: bool,
139 ) -> std::io::Result<Server<T>> {
140 let listener = TcpListener::bind((host, port)).await?;
141 let port = listener.local_addr()?.port();
142 let redirect_uri = format!("http://{host}:{port}{path}");
143 let (sender, outcome) = oneshot::channel();
144 let state = Arc::new(Mutex::new((handler, Some(sender), pending)));
145 let tasks = tokio::spawn(async move {
146 let mut connections = JoinSet::new();
147 while let Ok((stream, _)) = listener.accept().await {
148 connections.spawn(serve_connection(stream, Arc::clone(&state)));
149 }
150 });
151 Ok(Server {
152 port,
153 redirect_uri,
154 outcome,
155 cancel: cancel.clone(),
156 tasks,
157 })
158 }
159
160 pub fn redirect_uri(&self) -> &str {
162 &self.redirect_uri
163 }
164
165 pub fn port(&self) -> u16 {
167 self.port
168 }
169
170 pub async fn wait(&mut self) -> Result<T, AuthError> {
172 self.wait_pending().await.map(|(value, _)| value)
173 }
174
175 pub async fn wait_pending(&mut self) -> Result<(T, Option<PendingPage>), AuthError> {
178 match self.cancel.run_until_cancelled(&mut self.outcome).await {
179 Some(Ok((outcome, page))) => outcome.map(|value| (value, page)),
180 Some(Err(_)) => Err(AuthError::failed("OAuth callback server closed")),
181 None => Err(AuthError::Cancelled),
182 }
183 }
184
185 fn cancel(&mut self) -> Option<Result<(T, Option<PendingPage>), AuthError>> {
188 match self.outcome.try_recv() {
189 Ok((outcome, page)) => Some(outcome.map(|value| (value, page))),
190 Err(_) => {
191 self.outcome.close();
192 None
193 }
194 }
195 }
196}
197
198impl<T> Drop for Server<T> {
199 fn drop(&mut self) {
200 self.tasks.abort();
201 }
202}
203
204type Shared<T> = Arc<Mutex<(Handler<T>, Option<oneshot::Sender<Outcome<T>>>, bool)>>;
206
207async fn serve_connection<T>(mut stream: TcpStream, state: Shared<T>) {
208 let mut buffer = Vec::new();
209 let mut chunk = [0u8; 2048];
210 while !buffer.windows(4).any(|window| window == b"\r\n\r\n") {
211 match stream.read(&mut chunk).await {
212 Ok(0) | Err(_) => return,
213 Ok(read) => buffer.extend_from_slice(&chunk[..read]),
214 }
215 if buffer.len() > 16 * 1024 {
216 return;
217 }
218 }
219 let head = String::from_utf8_lossy(&buffer);
220 let mut parts = head.lines().next().unwrap_or_default().split(' ');
221 let method = parts.next().unwrap_or_default().to_owned();
222 let target = parts.next().unwrap_or("/");
223 let url = Url::parse("http://localhost").and_then(|base| base.join(target));
224 let (mut status, mut html, outcome, pending) = match url {
225 Ok(url) => {
226 let mut guard = state
227 .lock()
228 .unwrap_or_else(std::sync::PoisonError::into_inner);
229 let (handler, sender, pending) = &mut *guard;
230 let handled = sender.as_ref().is_none_or(oneshot::Sender::is_closed);
231 let reply = handler(&method, &url, handled);
232 let outcome = reply
233 .outcome
234 .and_then(|outcome| sender.take().map(|sender| (sender, outcome)));
235 (reply.status, reply.html, outcome, *pending)
236 }
237 Err(_) => (
238 404,
239 error_page("Callback route not found.", None),
240 None,
241 false,
242 ),
243 };
244 let mut sent = None;
245 let outcome = match outcome {
246 Some((sender, Ok(value))) if pending => {
247 let (page, chosen) = oneshot::channel();
248 let (done, waiting) = oneshot::channel();
249 sent = Some(done);
250 let page = PendingPage {
251 page,
252 sent: waiting,
253 };
254 let _ = sender.send((Ok(value), Some(page)));
255 if let Ok(page) = chosen.await {
257 (status, html) = page;
258 }
259 None
260 }
261 outcome => outcome,
262 };
263 let reason = reqwest::StatusCode::from_u16(status)
264 .ok()
265 .and_then(|code| code.canonical_reason())
266 .unwrap_or("Error");
267 let response = format!(
268 "HTTP/1.1 {status} {reason}\r\ncontent-type: text/html; charset=utf-8\r\ncache-control: no-store\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{html}",
269 html.len()
270 );
271 let _ = stream.write_all(response.as_bytes()).await;
272 let _ = stream.shutdown().await;
273 if let Some(sent) = sent {
274 let _ = sent.send(());
275 }
276 if let Some((sender, outcome)) = outcome {
277 let _ = sender.send((outcome, None));
278 }
279}
280
281pub fn query(url: &Url, name: &str) -> Option<String> {
283 url.query_pairs()
284 .find(|(key, _)| key == name)
285 .map(|(_, value)| value.into_owned())
286}
287
288pub(crate) async fn start_code_server(
292 provider_name: &str,
293 host: &str,
294 port: u16,
295 path: &str,
296 state: Option<String>,
297 pending: bool,
298 cancel: &CancellationToken,
299) -> std::io::Result<Server<String>> {
300 let provider = provider_name.to_owned();
301 let expected_path = path.to_owned();
302 let handler = move |method: &str, url: &Url, handled: bool| -> Reply<String> {
303 if method != "GET" || url.path() != expected_path {
304 return Reply::page(404, error_page("Callback route not found.", None));
305 }
306 if let Some(state) = &state
307 && query(url, "state").as_deref() != Some(state)
308 {
309 return Reply::page(400, error_page("State mismatch.", None));
310 }
311 if handled {
312 return Reply::page(
313 409,
314 error_page("This sign-in has already been handled.", None),
315 );
316 }
317 if let Some(error) = query(url, "error") {
318 let description = query(url, "error_description").unwrap_or(error);
319 return Reply {
320 status: 400,
321 html: error_page(
322 &format!("{provider} authorization failed."),
323 Some(&description),
324 ),
325 outcome: Some(Err(AuthError::Failed(format!(
326 "{provider} authorization failed: {description}"
327 )))),
328 };
329 }
330 let Some(code) = query(url, "code").filter(|code| !code.is_empty()) else {
331 return Reply::page(400, error_page("Missing authorization code.", None));
332 };
333 Reply {
334 status: 200,
335 html: success_page(&format!(
336 "Signed in to {provider}. You may now close this page."
337 )),
338 outcome: Some(Ok(code)),
339 }
340 };
341 Server::listen(host, port, path, cancel, Box::new(handler), pending).await
342}
343
344#[derive(Debug, PartialEq, Eq)]
346pub(crate) enum Received<T> {
347 Callback(T),
349 Manual(String),
351}
352
353pub(crate) async fn callback_or_manual<T: Send + 'static>(
357 interaction: &Interaction,
358 server: Option<&mut Server<T>>,
359 message: &str,
360 placeholder: &str,
361) -> Result<Received<(T, Option<PendingPage>)>, AuthError> {
362 let withdraw = interaction.cancel().child_token();
363 let _withdraw_on_return = withdraw.clone().drop_guard();
364 let manual = interaction.prompt_until(
365 AuthPrompt::ManualCode {
366 message: message.to_owned(),
367 placeholder: Some(placeholder.to_owned()),
368 },
369 withdraw,
370 );
371 let Some(server) = server else {
372 return manual.await.map(Received::Manual);
373 };
374 tokio::select! {
375 value = server.wait_pending() => value.map(Received::Callback),
376 input = manual => match (server.cancel(), input) {
378 (Some(Err(err)), _) => Err(err),
379 (Some(Ok(value)), Ok(_)) => Ok(Received::Callback(value)),
380 (_, input) => input.map(Received::Manual),
381 },
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::auth::AuthRequest;
389
390 async fn get(url: &str) -> (u16, String) {
391 let response = reqwest::Client::new().get(url).send().await.unwrap();
392 (response.status().as_u16(), response.text().await.unwrap())
393 }
394
395 #[tokio::test]
396 async fn answers_and_yields_code() {
397 let cancel = CancellationToken::new();
398 let mut server = start_code_server(
399 "Example",
400 "127.0.0.1",
401 0,
402 "/callback",
403 Some("s1".into()),
404 false,
405 &cancel,
406 )
407 .await
408 .unwrap();
409 let base = server.redirect_uri().to_owned();
410 assert!(base.starts_with("http://127.0.0.1:") && base.ends_with("/callback"));
411 let root = base.trim_end_matches("/callback");
412 assert_eq!(get(&format!("{root}/other")).await.0, 404);
413 assert_eq!(get(&format!("{base}?code=c&state=bad")).await.0, 400);
414 assert_eq!(get(&format!("{base}?state=s1")).await.0, 400);
415 assert_eq!(get(&format!("{base}?code=&state=s1")).await.0, 400);
416 let (status, body) = get(&format!("{base}?code=abc&state=s1")).await;
417 assert_eq!(status, 200);
418 assert!(body.contains("Signed in to Example. You may now close this page."));
419 let (status, body) = get(&format!("{base}?code=again&state=s1")).await;
420 assert_eq!(status, 409);
421 assert!(body.contains("This sign-in has already been handled."));
422 assert_eq!(server.wait().await, Ok("abc".to_owned()));
423 }
424
425 #[tokio::test]
426 async fn pasted_input_wins() {
427 let cancel = CancellationToken::new();
428 let (interaction, mut requests) = Interaction::new(cancel.clone());
429 let mut server = start_code_server("Example", "127.0.0.1", 0, "/cb", None, false, &cancel)
430 .await
431 .unwrap();
432 let answer = tokio::spawn(async move {
433 let Some(AuthRequest::Prompt { reply, .. }) = requests.recv().await else {
434 panic!("expected a prompt");
435 };
436 reply.send("pasted".into()).unwrap();
437 });
438 let received = callback_or_manual(&interaction, Some(&mut server), "Paste", "x")
439 .await
440 .unwrap();
441 answer.await.unwrap();
442 assert!(matches!(received, Received::Manual(input) if input == "pasted"));
443 let late = get(&format!("{}?code=late", server.redirect_uri())).await;
445 assert_eq!(late.0, 409);
446 }
447}