Skip to main content

oauth_toolkit/
loopback.rs

1//! Desktop loopback redirect capture (RFC 8252 ยง7.3).
2//!
3//! Binds `127.0.0.1:<ephemeral>`, builds the authorization URL with PKCE
4//! (S256) and a single-use `state`, then captures exactly one redirect:
5//! the listener serves one response and shuts down.
6
7use std::fmt::Write as _;
8use std::io::{Read, Write};
9use std::net::{SocketAddr, TcpListener, TcpStream};
10use std::time::{Duration, Instant};
11
12use crate::pkce::generate_pkce_pair;
13
14/// Loopback host the capture server binds (never a routable interface).
15pub const LOOPBACK_HOST: &str = "127.0.0.1";
16/// Fixed callback path for the redirect URI.
17pub const CALLBACK_PATH: &str = "/cb";
18
19/// The redirect URI for a loopback port: `http://127.0.0.1:<port>/cb`.
20#[must_use]
21pub fn loopback_redirect_uri(port: u16) -> String {
22    format!("http://{LOOPBACK_HOST}:{port}{CALLBACK_PATH}")
23}
24
25/// Build the browser authorization URL (RFC 8252 style; the user opens it
26/// externally) with PKCE S256, state, scopes, and an optional login hint.
27#[must_use]
28pub fn build_authorization_url(
29    authorize_url: &str,
30    client_id: &str,
31    redirect_uri: &str,
32    state: &str,
33    scopes: &[String],
34    code_challenge: &str,
35    login_hint: Option<&str>,
36) -> String {
37    let mut url = format!(
38        "{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope={}&code_challenge={}&code_challenge_method=S256",
39        authorize_url,
40        urlencoding::encode(client_id),
41        urlencoding::encode(redirect_uri),
42        urlencoding::encode(state),
43        urlencoding::encode(&scopes.join(" ")),
44        urlencoding::encode(code_challenge),
45    );
46    if let Some(hint) = login_hint {
47        let _ = write!(url, "&login_hint={}", urlencoding::encode(hint));
48    }
49    url
50}
51
52/// Errors from the loopback capture flow.
53#[derive(Debug, thiserror::Error)]
54pub enum LoopbackError {
55    /// Binding the loopback listener failed.
56    #[error("loopback bind: {0}")]
57    Bind(String),
58    /// The user did not complete the redirect within the timeout.
59    #[error("loopback capture timed out")]
60    Timeout,
61    /// The redirect's `state` did not match (possible CSRF).
62    #[error("state mismatch (possible CSRF)")]
63    StateMismatch,
64    /// The provider redirected with an `error` parameter.
65    #[error("provider error: {0}")]
66    Provider(String),
67    /// The redirect carried no authorization `code`.
68    #[error("redirect missing code")]
69    MissingCode,
70    /// Socket I/O failure during capture.
71    #[error("loopback io: {0}")]
72    Io(String),
73}
74
75/// A started loopback flow: the authorization URL to open plus the capture
76/// server waiting on an ephemeral loopback port.
77pub struct LoopbackFlow {
78    listener: TcpListener,
79    auth_url: String,
80    state: String,
81    verifier: String,
82    port: u16,
83    timeout: Duration,
84}
85
86/// Result of a captured redirect.
87#[derive(Clone, Debug)]
88pub struct CapturedCode {
89    /// Authorization code from the provider.
90    pub code: String,
91    /// PKCE verifier matching the challenge sent in the auth URL.
92    pub verifier: String,
93    /// The ephemeral loopback port (needed to reconstruct `redirect_uri`).
94    pub port: u16,
95}
96
97impl LoopbackFlow {
98    /// Binds an ephemeral loopback port and builds the authorization URL.
99    ///
100    /// # Errors
101    /// [`LoopbackError::Bind`] if the loopback listener cannot bind.
102    pub fn start(
103        authorize_url: &str,
104        client_id: &str,
105        scopes: &[String],
106        login_hint: Option<&str>,
107        timeout: Duration,
108    ) -> Result<Self, LoopbackError> {
109        let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
110            .map_err(|e| LoopbackError::Bind(e.to_string()))?;
111        let port = listener
112            .local_addr()
113            .map_err(|e| LoopbackError::Bind(e.to_string()))?
114            .port();
115        let (verifier, challenge) = generate_pkce_pair();
116        let state = crate::crypto::generate_state_nonce(16);
117        let auth_url = build_authorization_url(
118            authorize_url,
119            client_id,
120            &loopback_redirect_uri(port),
121            &state,
122            scopes,
123            &challenge,
124            login_hint,
125        );
126        Ok(Self {
127            listener,
128            auth_url,
129            state,
130            verifier,
131            port,
132            timeout,
133        })
134    }
135
136    /// Convenience wrapper binding a flow for a
137    /// [`MailProvider`](crate::providers::MailProvider) preset.
138    ///
139    /// # Errors
140    /// [`LoopbackError::Bind`] if the loopback listener cannot bind.
141    pub fn start_for_provider(
142        provider: &crate::providers::MailProvider,
143        login_hint: Option<&str>,
144        timeout: Duration,
145    ) -> Result<Self, LoopbackError> {
146        Self::start(
147            &provider.auth_url,
148            &provider.client_id,
149            &provider.authorization_scopes(),
150            login_hint,
151            timeout,
152        )
153    }
154
155    /// The ephemeral port the capture server is listening on.
156    #[must_use]
157    pub fn port(&self) -> u16 {
158        self.port
159    }
160
161    /// The full authorization URL for the user's browser.
162    #[must_use]
163    pub fn authorization_url(&self) -> &str {
164        &self.auth_url
165    }
166
167    /// The single-use state token (exposed for tests and diagnostics).
168    #[must_use]
169    pub fn state(&self) -> &str {
170        &self.state
171    }
172
173    /// Waits for exactly one redirect, validates the single-use `state`,
174    /// serves a confirmation page, and shuts the listener down.
175    ///
176    /// Consumes `self`: after this returns (ok or err) the port is closed
177    /// and a second redirect is refused.
178    ///
179    /// # Errors
180    /// [`LoopbackError`] variants for timeout, provider error, state
181    /// mismatch, missing code, or I/O failure.
182    pub fn wait_for_code(self) -> Result<CapturedCode, LoopbackError> {
183        let Self {
184            listener,
185            state,
186            verifier,
187            port,
188            timeout,
189            ..
190        } = self;
191        let deadline = Instant::now() + timeout;
192
193        listener
194            .set_nonblocking(true)
195            .map_err(|e| LoopbackError::Io(e.to_string()))?;
196        let (mut stream, _) = loop {
197            match listener.accept() {
198                Ok(pair) => break pair,
199                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
200                    if Instant::now() >= deadline {
201                        return Err(LoopbackError::Timeout);
202                    }
203                    std::thread::sleep(Duration::from_millis(10));
204                }
205                Err(e) => return Err(LoopbackError::Io(e.to_string())),
206            }
207        };
208
209        let remaining = deadline.saturating_duration_since(Instant::now());
210        stream
211            .set_read_timeout(Some(remaining.max(Duration::from_millis(50))))
212            .map_err(|e| LoopbackError::Io(e.to_string()))?;
213
214        let request = read_request(&mut stream)?;
215        let query = request_line_query(&request);
216        let (code, got_state, oauth_error) = parse_query(&query);
217
218        let body = "<html><body><h3>Signed in</h3>You may close this tab and return to the application.</body></html>";
219        let response = format!(
220            "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
221            body.len(),
222            body
223        );
224        let _ = stream.write_all(response.as_bytes());
225        let _ = stream.flush();
226        drop(stream);
227        drop(listener);
228
229        if !oauth_error.is_empty() {
230            return Err(LoopbackError::Provider(oauth_error));
231        }
232        if got_state != state {
233            return Err(LoopbackError::StateMismatch);
234        }
235        code.map(|code| CapturedCode {
236            code,
237            verifier,
238            port,
239        })
240        .ok_or(LoopbackError::MissingCode)
241    }
242}
243
244fn read_request(stream: &mut TcpStream) -> Result<String, LoopbackError> {
245    let mut buf = Vec::with_capacity(4096);
246    let mut chunk = [0u8; 1024];
247    loop {
248        match stream.read(&mut chunk) {
249            Ok(0) => break,
250            Ok(n) => {
251                buf.extend_from_slice(&chunk[..n]);
252                if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 8192 {
253                    break;
254                }
255            }
256            Err(e)
257                if matches!(
258                    e.kind(),
259                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
260                ) =>
261            {
262                return Err(LoopbackError::Timeout);
263            }
264            Err(e) => return Err(LoopbackError::Io(e.to_string())),
265        }
266    }
267    Ok(String::from_utf8_lossy(&buf).into_owned())
268}
269
270fn request_line_query(request: &str) -> String {
271    let request_line = request.lines().next().unwrap_or_default();
272    let path = request_line.split_whitespace().nth(1).unwrap_or_default();
273    path.split_once('?')
274        .map_or(String::new(), |(_, q)| q.to_owned())
275}
276
277fn parse_query(query: &str) -> (Option<String>, String, String) {
278    let mut code = None;
279    let mut state = String::new();
280    let mut error = String::new();
281    for kv in query.split('&') {
282        let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
283        let decoded = urlencoding::decode(v)
284            .map(|c| c.into_owned())
285            .unwrap_or_default();
286        match k {
287            "code" => code = Some(decoded),
288            "state" => state = decoded,
289            "error" => error = decoded,
290            _ => {}
291        }
292    }
293    (code, state, error)
294}
295
296#[cfg(test)]
297mod tests {
298    #![allow(clippy::unwrap_used, clippy::expect_used)]
299
300    use super::*;
301
302    const FIVE_SECS: Duration = Duration::from_secs(5);
303
304    fn start_test_flow(timeout: Duration) -> LoopbackFlow {
305        LoopbackFlow::start(
306            "https://accounts.google.com/o/oauth2/v2/auth",
307            "cid-123",
308            &["https://mail.google.com/".to_string()],
309            Some("a@b.c"),
310            timeout,
311        )
312        .unwrap()
313    }
314
315    /// Sends a forged redirect request and returns the raw HTTP response.
316    fn send_redirect(port: u16, query: &str) -> String {
317        let mut sock = TcpStream::connect((LOOPBACK_HOST, port)).expect("connect");
318        let request =
319            format!("GET /cb?{query} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
320        sock.write_all(request.as_bytes()).expect("write");
321        let mut buf = String::new();
322        let _ = sock.read_to_string(&mut buf);
323        buf
324    }
325
326    #[test]
327    fn binds_ephemeral_loopback_port() {
328        let flow = start_test_flow(FIVE_SECS);
329        assert!(flow.port() > 0, "must bind an ephemeral port, not :0");
330        assert!(
331            flow.authorization_url()
332                .contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A")
333        );
334    }
335
336    #[test]
337    fn authorization_url_contains_required_params() {
338        let flow = start_test_flow(FIVE_SECS);
339        let url = flow.authorization_url();
340        assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
341        assert!(url.contains("client_id=cid-123"));
342        assert!(url.contains("response_type=code"));
343        assert!(url.contains("code_challenge_method=S256"));
344        assert!(url.contains("code_challenge="));
345        assert!(url.contains(&format!("state={}", flow.state())));
346        assert!(url.contains("scope=https%3A%2F%2Fmail.google.com%2F"));
347        assert!(url.contains("login_hint=a%40b.c"));
348    }
349
350    #[test]
351    fn loopback_redirect_uri_shape() {
352        assert_eq!(loopback_redirect_uri(53123), "http://127.0.0.1:53123/cb");
353    }
354
355    #[test]
356    fn capture_accepts_valid_redirect() {
357        let flow = start_test_flow(FIVE_SECS);
358        let port = flow.port();
359        let state = flow.state().to_owned();
360        let handle = std::thread::spawn(move || flow.wait_for_code());
361        let response = send_redirect(port, &format!("code=AC123&state={state}"));
362        assert!(response.contains("200 OK"), "{response}");
363        assert!(response.contains("Signed in"), "{response}");
364        let captured = handle.join().unwrap().unwrap();
365        assert_eq!(captured.code, "AC123");
366        assert_eq!(captured.port, port);
367        assert!(!captured.verifier.is_empty(), "PKCE verifier returned");
368    }
369
370    #[test]
371    fn capture_rejects_state_mismatch() {
372        let flow = start_test_flow(FIVE_SECS);
373        let port = flow.port();
374        let handle = std::thread::spawn(move || flow.wait_for_code());
375        send_redirect(port, "code=X&state=evil");
376        match handle.join().unwrap() {
377            Err(LoopbackError::StateMismatch) => {}
378            other => panic!("expected StateMismatch, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn state_is_single_use_and_listener_shuts_down() {
384        let flow = start_test_flow(FIVE_SECS);
385        let port = flow.port();
386        // First (mismatched) redirect consumes the single capture.
387        let handle = std::thread::spawn(move || flow.wait_for_code());
388        let _ = send_redirect(port, "code=X&state=evil");
389        assert!(handle.join().unwrap().is_err());
390        // The listener is gone: a second redirect is refused.
391        assert!(
392            TcpStream::connect((LOOPBACK_HOST, port)).is_err(),
393            "port {port} must be shut down after the first redirect"
394        );
395    }
396
397    #[test]
398    fn capture_times_out_when_no_redirect() {
399        let flow = LoopbackFlow::start(
400            "https://accounts.google.com/o/oauth2/v2/auth",
401            "cid",
402            &[],
403            None,
404            Duration::from_millis(150),
405        )
406        .unwrap();
407        std::thread::sleep(Duration::from_millis(300));
408        match flow.wait_for_code() {
409            Err(LoopbackError::Timeout) => {}
410            other => panic!("expected Timeout, got {other:?}"),
411        }
412    }
413
414    #[test]
415    fn capture_surfaces_provider_error() {
416        let flow = start_test_flow(FIVE_SECS);
417        let port = flow.port();
418        let state = flow.state().to_owned();
419        let handle = std::thread::spawn(move || flow.wait_for_code());
420        send_redirect(port, &format!("error=access_denied&state={state}"));
421        match handle.join().unwrap() {
422            Err(LoopbackError::Provider(e)) => assert_eq!(e, "access_denied"),
423            other => panic!("expected Provider error, got {other:?}"),
424        }
425    }
426
427    #[test]
428    fn capture_rejects_missing_code() {
429        let flow = start_test_flow(FIVE_SECS);
430        let port = flow.port();
431        let state = flow.state().to_owned();
432        let handle = std::thread::spawn(move || flow.wait_for_code());
433        send_redirect(port, &format!("state={state}"));
434        match handle.join().unwrap() {
435            Err(LoopbackError::MissingCode) => {}
436            other => panic!("expected MissingCode, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn percent_decoded_query_values() {
442        assert_eq!(
443            parse_query("code=a%20b%2Fc&state=s%2Bt").0.as_deref(),
444            Some("a b/c")
445        );
446        assert_eq!(parse_query("state=s%2Bt").1, "s+t");
447        assert_eq!(parse_query("").0, None);
448    }
449
450    #[test]
451    fn start_for_provider_bakes_gmail_urls() {
452        let provider = crate::providers::MailProvider::fastmail("fm-cid");
453        let flow = LoopbackFlow::start_for_provider(&provider, None, FIVE_SECS).unwrap();
454        let url = flow.authorization_url();
455        assert!(url.starts_with("https://app.fastmail.com/oauth/authorize?"));
456        assert!(url.contains("client_id=fm-cid"));
457        assert!(url.contains("protocolIMAP"));
458        assert!(url.contains("protocolSMTP"));
459    }
460}