Skip to main content

origin_auth_loopback/
lib.rs

1//! Loopback redirect listener (ADR-0015, RFC 8252).
2//!
3//! Binds an ephemeral port on `127.0.0.1`, serves exactly the one redirect the
4//! provider sends, and shuts down. No custom URL scheme registration, no
5//! platform-specific behaviour, and testable with a plain TCP client.
6
7mod query;
8
9use async_trait::async_trait;
10use origin_auth::{AuthorizationCode, RedirectListener};
11use origin_domain::{AppError, Result};
12use std::net::{Ipv4Addr, SocketAddr};
13use std::time::Duration;
14use tokio::io::{AsyncReadExt, AsyncWriteExt};
15use tokio::net::{TcpListener, TcpStream};
16
17/// Path the provider redirects to.
18const CALLBACK_PATH: &str = "/callback";
19
20/// How long to wait for the user to finish in the browser.
21///
22/// Without a limit, a user who closes the tab leaves the listener — and whatever is
23/// awaiting it — alive for the rest of the session.
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
25
26/// Largest request we will read. A redirect is a few hundred bytes; anything larger is
27/// not the browser we are waiting for.
28const MAX_REQUEST_BYTES: usize = 8 * 1024;
29
30#[derive(Debug)]
31pub struct LoopbackRedirect {
32    listener: TcpListener,
33    redirect_uri: String,
34    timeout: Duration,
35}
36
37impl LoopbackRedirect {
38    /// Bind an ephemeral port.
39    ///
40    /// Ephemeral by default so two Origin applications authorizing at the same time
41    /// cannot collide.
42    pub async fn bind() -> Result<Self> {
43        Self::bind_port(0).await
44    }
45
46    /// Bind a fixed port, for providers that require the redirect URI to be registered
47    /// in advance and reject a dynamic port.
48    pub async fn bind_port(port: u16) -> Result<Self> {
49        let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
50            .await
51            .map_err(|error| {
52                AppError::configuration(format!("cannot bind loopback redirect port: {error}"))
53            })?;
54
55        let address = listener.local_addr().map_err(|error| {
56            AppError::internal(format!("cannot read loopback redirect address: {error}"))
57        })?;
58
59        let redirect_uri = format!("http://127.0.0.1:{}{CALLBACK_PATH}", address.port());
60        tracing::debug!(%redirect_uri, "loopback redirect listening");
61
62        Ok(Self {
63            listener,
64            redirect_uri,
65            timeout: DEFAULT_TIMEOUT,
66        })
67    }
68
69    pub fn with_timeout(mut self, timeout: Duration) -> Self {
70        self.timeout = timeout;
71        self
72    }
73
74    async fn accept_redirect(&self, expected_state: &str) -> Result<AuthorizationCode> {
75        loop {
76            let (stream, _) = self.listener.accept().await.map_err(|error| {
77                AppError::internal(format!("loopback redirect accept failed: {error}"))
78            })?;
79
80            match self.handle(stream, expected_state).await {
81                // Browsers request `/favicon.ico` and sometimes pre-connect. Those are
82                // not the redirect, so keep waiting instead of failing the flow.
83                Ok(None) => continue,
84                Ok(Some(code)) => return Ok(code),
85                Err(error) => return Err(error),
86            }
87        }
88    }
89
90    /// Returns `Ok(None)` for a request that is not the redirect we are waiting for.
91    async fn handle(
92        &self,
93        mut stream: TcpStream,
94        expected_state: &str,
95    ) -> Result<Option<AuthorizationCode>> {
96        let request_line = read_request_line(&mut stream).await?;
97        let Some(target) = request_line.split_whitespace().nth(1) else {
98            respond(&mut stream, 400, "Bad request").await;
99            return Ok(None);
100        };
101
102        let (path, query) = target.split_once('?').unwrap_or((target, ""));
103        if path != CALLBACK_PATH {
104            respond(&mut stream, 404, "Not found").await;
105            return Ok(None);
106        }
107
108        let parameters = query::parse(query);
109        let get = |name: &str| {
110            parameters
111                .iter()
112                .find(|(key, _)| key == name)
113                .map(|(_, v)| v.clone())
114        };
115
116        // The state check is what makes this listener safe: anything on localhost can
117        // hit this port, but only the flow we started knows the state.
118        match get("state") {
119            Some(state) if state == expected_state => {}
120            _ => {
121                respond(
122                    &mut stream,
123                    400,
124                    "Unexpected request. You can close this window.",
125                )
126                .await;
127                return Err(AppError::Authentication(
128                    "redirect did not carry the expected state — the flow was not started \
129                     by this application"
130                        .to_owned(),
131                ));
132            }
133        }
134
135        if let Some(error) = get("error") {
136            let description = get("error_description").unwrap_or_else(|| error.clone());
137            respond(
138                &mut stream,
139                400,
140                "Authorization was denied. You can close this window.",
141            )
142            .await;
143            return Err(AppError::Authentication(format!(
144                "authorization was denied: {description}"
145            )));
146        }
147
148        let Some(code) = get("code") else {
149            respond(&mut stream, 400, "Missing authorization code.").await;
150            return Err(AppError::Authentication(
151                "redirect carried no authorization code".to_owned(),
152            ));
153        };
154
155        respond(
156            &mut stream,
157            200,
158            "Signed in. You can close this window and return to the app.",
159        )
160        .await;
161        Ok(Some(AuthorizationCode::new(code)))
162    }
163}
164
165#[async_trait]
166impl RedirectListener for LoopbackRedirect {
167    fn redirect_uri(&self) -> String {
168        self.redirect_uri.clone()
169    }
170
171    async fn wait(&self, expected_state: &str) -> Result<AuthorizationCode> {
172        tokio::time::timeout(self.timeout, self.accept_redirect(expected_state))
173            .await
174            .map_err(|_| {
175                AppError::Authentication(
176                    "timed out waiting for the browser to complete authorization".to_owned(),
177                )
178            })?
179    }
180}
181
182/// Read up to the end of the request line.
183///
184/// Only the first line is needed; the headers and body of a redirect carry nothing we
185/// use, and reading them would mean parsing HTTP properly.
186async fn read_request_line(stream: &mut TcpStream) -> Result<String> {
187    let mut buffer = Vec::new();
188    let mut chunk = [0u8; 1024];
189
190    loop {
191        let read = stream.read(&mut chunk).await.map_err(|error| {
192            AppError::internal(format!("cannot read loopback redirect request: {error}"))
193        })?;
194
195        if read == 0 {
196            break;
197        }
198
199        buffer.extend_from_slice(&chunk[..read]);
200
201        if let Some(end) = buffer.iter().position(|byte| *byte == b'\n') {
202            buffer.truncate(end);
203            break;
204        }
205
206        if buffer.len() > MAX_REQUEST_BYTES {
207            return Err(AppError::internal(
208                "loopback redirect request exceeded the size limit".to_owned(),
209            ));
210        }
211    }
212
213    Ok(String::from_utf8_lossy(&buffer).trim_end().to_owned())
214}
215
216/// Best-effort response. The user's browser showing a blank page is a cosmetic problem;
217/// the authorization itself already succeeded or failed by this point.
218async fn respond(stream: &mut TcpStream, status: u16, message: &str) {
219    let reason = match status {
220        200 => "OK",
221        400 => "Bad Request",
222        _ => "Not Found",
223    };
224
225    let body = format!(
226        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
227         <title>Origin</title><style>body{{font:16px system-ui;display:grid;\
228         place-items:center;height:100vh;margin:0;color:#14171c;background:#f6f7f9}}\
229         @media(prefers-color-scheme:dark){{body{{color:#e7eaef;background:#0f1115}}}}\
230         </style></head><body><p>{message}</p></body></html>"
231    );
232
233    let response = format!(
234        "HTTP/1.1 {status} {reason}\r\n\
235         content-type: text/html; charset=utf-8\r\n\
236         content-length: {}\r\n\
237         connection: close\r\n\r\n{body}",
238        body.len()
239    );
240
241    if let Err(error) = stream.write_all(response.as_bytes()).await {
242        tracing::debug!(%error, "cannot write loopback redirect response");
243    }
244    let _ = stream.flush().await;
245    let _ = stream.shutdown().await;
246}