Skip to main content

yapi_ai/auth/
callback.rs

1//! The loopback server that receives an OAuth redirect, and the race between
2//! it and a pasted code. Port of `oauth/callback-server.ts` and
3//! `utils/oauth-page.ts`.
4
5use 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('<', "&lt;")
22        .replace('>', "&gt;")
23        .replace('"', "&quot;")
24        .replace('\'', "&#39;")
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
37/// pi's success page.
38pub fn success_page(message: &str) -> String {
39    render_page("Authentication successful", message, None)
40}
41
42/// pi's failure page.
43pub fn error_page(message: &str, details: Option<&str>) -> String {
44    render_page("Authentication failed", message, details)
45}
46
47/// The reply to one request, and what it means for the sign-in.
48pub struct Reply<T> {
49    /// HTTP status.
50    pub status: u16,
51    /// The page.
52    pub html: String,
53    /// Ends the wait with a value or an error.
54    pub outcome: Option<Result<T, AuthError>>,
55}
56
57impl<T> Reply<T> {
58    /// A page that does not end the wait.
59    pub fn page(status: u16, html: String) -> Reply<T> {
60        Reply {
61            status,
62            html,
63            outcome: None,
64        }
65    }
66}
67
68/// Answers one request, given its method, its URL and whether the sign-in
69/// is already handled: an earlier reply ended the wait, or the waiter
70/// cancelled it.
71pub type Handler<T> = Box<dyn FnMut(&str, &Url, bool) -> Reply<T> + Send>;
72
73/// The browser's page for a callback whose sign-in finishes first.
74pub struct PendingPage {
75    page: oneshot::Sender<(u16, String)>,
76    sent: oneshot::Receiver<()>,
77}
78
79impl PendingPage {
80    /// Answers with pi's page for `result`, signed in or 502 with the error,
81    /// and waits until it is sent.
82    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
106/// A running loopback server. Dropping it closes the listener and every open
107/// connection.
108pub 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    /// Listens on `host:port` (0 picks a port) and answers each request with
118    /// `handler`, until a reply has an outcome.
119    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    /// [`Server::start`], where with `pending` the reply that yields a value
130    /// waits for the [`PendingPage`] from [`Server::wait_pending`], as pi's
131    /// `complete` option.
132    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    /// `http://<host>:<port><path>`, with the port the listener bound.
161    pub fn redirect_uri(&self) -> &str {
162        &self.redirect_uri
163    }
164
165    /// The port the listener bound.
166    pub fn port(&self) -> u16 {
167        self.port
168    }
169
170    /// The outcome of the first conclusive request.
171    pub async fn wait(&mut self) -> Result<T, AuthError> {
172        self.wait_pending().await.map(|(value, _)| value)
173    }
174
175    /// [`Server::wait`], with the page a pending server holds until the
176    /// sign-in finishes.
177    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    /// pi's `cancel()`: stops waiting unless a request already ended the
186    /// wait, whose outcome it returns. Later requests are handled.
187    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
204/// The handler, the sender of the outcome, and whether a value's page waits.
205type 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            // A sign-in that ends without choosing keeps the handler's page.
256            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
281/// The first value of the query parameter `name`.
282pub 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
288/// A server for pi's shared callback: GET `path` with `code` (and `state` when
289/// expected) yields the code, and a request after the sign-in is handled gets
290/// 409. With `pending`, the code's page waits for the sign-in to finish.
291pub(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/// Where the authorization arrived from.
345#[derive(Debug, PartialEq, Eq)]
346pub(crate) enum Received<T> {
347    /// The browser reached the loopback server.
348    Callback(T),
349    /// The user pasted input.
350    Manual(String),
351}
352
353/// Waits for the browser callback or for pasted input, whichever comes first.
354/// Without a server only the prompt is used. Port of
355/// `waitForCallbackOrManualInput`.
356pub(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        // A callback that arrived first still wins, unless the prompt failed.
377        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        // pi's `cancel()`: the browser arriving later finds the sign-in handled.
444        let late = get(&format!("{}?code=late", server.redirect_uri())).await;
445        assert_eq!(late.0, 409);
446    }
447}