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 [`MailProvider`] preset.
137    ///
138    /// # Errors
139    /// [`LoopbackError::Bind`] if the loopback listener cannot bind.
140    pub fn start_for_provider(
141        provider: &crate::providers::MailProvider,
142        login_hint: Option<&str>,
143        timeout: Duration,
144    ) -> Result<Self, LoopbackError> {
145        Self::start(
146            &provider.auth_url,
147            &provider.client_id,
148            &provider.authorization_scopes(),
149            login_hint,
150            timeout,
151        )
152    }
153
154    /// The ephemeral port the capture server is listening on.
155    #[must_use]
156    pub fn port(&self) -> u16 {
157        self.port
158    }
159
160    /// The full authorization URL for the user's browser.
161    #[must_use]
162    pub fn authorization_url(&self) -> &str {
163        &self.auth_url
164    }
165
166    /// The single-use state token (exposed for tests and diagnostics).
167    #[must_use]
168    pub fn state(&self) -> &str {
169        &self.state
170    }
171
172    /// Waits for exactly one redirect, validates the single-use `state`,
173    /// serves a confirmation page, and shuts the listener down.
174    ///
175    /// Consumes `self`: after this returns (ok or err) the port is closed
176    /// and a second redirect is refused.
177    ///
178    /// # Errors
179    /// [`LoopbackError`] variants for timeout, provider error, state
180    /// mismatch, missing code, or I/O failure.
181    pub fn wait_for_code(self) -> Result<CapturedCode, LoopbackError> {
182        let Self {
183            listener,
184            state,
185            verifier,
186            port,
187            timeout,
188            ..
189        } = self;
190        let deadline = Instant::now() + timeout;
191
192        listener
193            .set_nonblocking(true)
194            .map_err(|e| LoopbackError::Io(e.to_string()))?;
195        let (mut stream, _) = loop {
196            match listener.accept() {
197                Ok(pair) => break pair,
198                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
199                    if Instant::now() >= deadline {
200                        return Err(LoopbackError::Timeout);
201                    }
202                    std::thread::sleep(Duration::from_millis(10));
203                }
204                Err(e) => return Err(LoopbackError::Io(e.to_string())),
205            }
206        };
207
208        let remaining = deadline.saturating_duration_since(Instant::now());
209        stream
210            .set_read_timeout(Some(remaining.max(Duration::from_millis(50))))
211            .map_err(|e| LoopbackError::Io(e.to_string()))?;
212
213        let request = read_request(&mut stream)?;
214        let query = request_line_query(&request);
215        let (code, got_state, oauth_error) = parse_query(&query);
216
217        let body = "<html><body><h3>Signed in</h3>You may close this tab and return to the application.</body></html>";
218        let response = format!(
219            "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
220            body.len(),
221            body
222        );
223        let _ = stream.write_all(response.as_bytes());
224        let _ = stream.flush();
225        drop(stream);
226        drop(listener);
227
228        if !oauth_error.is_empty() {
229            return Err(LoopbackError::Provider(oauth_error));
230        }
231        if got_state != state {
232            return Err(LoopbackError::StateMismatch);
233        }
234        code.map(|code| CapturedCode {
235            code,
236            verifier,
237            port,
238        })
239        .ok_or(LoopbackError::MissingCode)
240    }
241}
242
243fn read_request(stream: &mut TcpStream) -> Result<String, LoopbackError> {
244    let mut buf = Vec::with_capacity(4096);
245    let mut chunk = [0u8; 1024];
246    loop {
247        match stream.read(&mut chunk) {
248            Ok(0) => break,
249            Ok(n) => {
250                buf.extend_from_slice(&chunk[..n]);
251                if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 8192 {
252                    break;
253                }
254            }
255            Err(e)
256                if matches!(
257                    e.kind(),
258                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
259                ) =>
260            {
261                return Err(LoopbackError::Timeout);
262            }
263            Err(e) => return Err(LoopbackError::Io(e.to_string())),
264        }
265    }
266    Ok(String::from_utf8_lossy(&buf).into_owned())
267}
268
269fn request_line_query(request: &str) -> String {
270    let request_line = request.lines().next().unwrap_or_default();
271    let path = request_line.split_whitespace().nth(1).unwrap_or_default();
272    path.split_once('?')
273        .map_or(String::new(), |(_, q)| q.to_owned())
274}
275
276fn parse_query(query: &str) -> (Option<String>, String, String) {
277    let mut code = None;
278    let mut state = String::new();
279    let mut error = String::new();
280    for kv in query.split('&') {
281        let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
282        let decoded = urlencoding::decode(v)
283            .map(|c| c.into_owned())
284            .unwrap_or_default();
285        match k {
286            "code" => code = Some(decoded),
287            "state" => state = decoded,
288            "error" => error = decoded,
289            _ => {}
290        }
291    }
292    (code, state, error)
293}
294
295#[cfg(test)]
296mod tests {
297    #![allow(clippy::unwrap_used, clippy::expect_used)]
298
299    use super::*;
300
301    const FIVE_SECS: Duration = Duration::from_secs(5);
302
303    fn start_test_flow(timeout: Duration) -> LoopbackFlow {
304        LoopbackFlow::start(
305            "https://accounts.google.com/o/oauth2/v2/auth",
306            "cid-123",
307            &["https://mail.google.com/".to_string()],
308            Some("a@b.c"),
309            timeout,
310        )
311        .unwrap()
312    }
313
314    /// Sends a forged redirect request and returns the raw HTTP response.
315    fn send_redirect(port: u16, query: &str) -> String {
316        let mut sock = TcpStream::connect((LOOPBACK_HOST, port)).expect("connect");
317        let request =
318            format!("GET /cb?{query} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
319        sock.write_all(request.as_bytes()).expect("write");
320        let mut buf = String::new();
321        let _ = sock.read_to_string(&mut buf);
322        buf
323    }
324
325    #[test]
326    fn binds_ephemeral_loopback_port() {
327        let flow = start_test_flow(FIVE_SECS);
328        assert!(flow.port() > 0, "must bind an ephemeral port, not :0");
329        assert!(
330            flow.authorization_url()
331                .contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A")
332        );
333    }
334
335    #[test]
336    fn authorization_url_contains_required_params() {
337        let flow = start_test_flow(FIVE_SECS);
338        let url = flow.authorization_url();
339        assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
340        assert!(url.contains("client_id=cid-123"));
341        assert!(url.contains("response_type=code"));
342        assert!(url.contains("code_challenge_method=S256"));
343        assert!(url.contains("code_challenge="));
344        assert!(url.contains(&format!("state={}", flow.state())));
345        assert!(url.contains("scope=https%3A%2F%2Fmail.google.com%2F"));
346        assert!(url.contains("login_hint=a%40b.c"));
347    }
348
349    #[test]
350    fn loopback_redirect_uri_shape() {
351        assert_eq!(loopback_redirect_uri(53123), "http://127.0.0.1:53123/cb");
352    }
353
354    #[test]
355    fn capture_accepts_valid_redirect() {
356        let flow = start_test_flow(FIVE_SECS);
357        let port = flow.port();
358        let state = flow.state().to_owned();
359        let handle = std::thread::spawn(move || flow.wait_for_code());
360        let response = send_redirect(port, &format!("code=AC123&state={state}"));
361        assert!(response.contains("200 OK"), "{response}");
362        assert!(response.contains("Signed in"), "{response}");
363        let captured = handle.join().unwrap().unwrap();
364        assert_eq!(captured.code, "AC123");
365        assert_eq!(captured.port, port);
366        assert!(!captured.verifier.is_empty(), "PKCE verifier returned");
367    }
368
369    #[test]
370    fn capture_rejects_state_mismatch() {
371        let flow = start_test_flow(FIVE_SECS);
372        let port = flow.port();
373        let handle = std::thread::spawn(move || flow.wait_for_code());
374        send_redirect(port, "code=X&state=evil");
375        match handle.join().unwrap() {
376            Err(LoopbackError::StateMismatch) => {}
377            other => panic!("expected StateMismatch, got {other:?}"),
378        }
379    }
380
381    #[test]
382    fn state_is_single_use_and_listener_shuts_down() {
383        let flow = start_test_flow(FIVE_SECS);
384        let port = flow.port();
385        // First (mismatched) redirect consumes the single capture.
386        let handle = std::thread::spawn(move || flow.wait_for_code());
387        let _ = send_redirect(port, "code=X&state=evil");
388        assert!(handle.join().unwrap().is_err());
389        // The listener is gone: a second redirect is refused.
390        assert!(
391            TcpStream::connect((LOOPBACK_HOST, port)).is_err(),
392            "port {port} must be shut down after the first redirect"
393        );
394    }
395
396    #[test]
397    fn capture_times_out_when_no_redirect() {
398        let flow = LoopbackFlow::start(
399            "https://accounts.google.com/o/oauth2/v2/auth",
400            "cid",
401            &[],
402            None,
403            Duration::from_millis(150),
404        )
405        .unwrap();
406        std::thread::sleep(Duration::from_millis(300));
407        match flow.wait_for_code() {
408            Err(LoopbackError::Timeout) => {}
409            other => panic!("expected Timeout, got {other:?}"),
410        }
411    }
412
413    #[test]
414    fn capture_surfaces_provider_error() {
415        let flow = start_test_flow(FIVE_SECS);
416        let port = flow.port();
417        let state = flow.state().to_owned();
418        let handle = std::thread::spawn(move || flow.wait_for_code());
419        send_redirect(port, &format!("error=access_denied&state={state}"));
420        match handle.join().unwrap() {
421            Err(LoopbackError::Provider(e)) => assert_eq!(e, "access_denied"),
422            other => panic!("expected Provider error, got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn capture_rejects_missing_code() {
428        let flow = start_test_flow(FIVE_SECS);
429        let port = flow.port();
430        let state = flow.state().to_owned();
431        let handle = std::thread::spawn(move || flow.wait_for_code());
432        send_redirect(port, &format!("state={state}"));
433        match handle.join().unwrap() {
434            Err(LoopbackError::MissingCode) => {}
435            other => panic!("expected MissingCode, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn percent_decoded_query_values() {
441        assert_eq!(
442            parse_query("code=a%20b%2Fc&state=s%2Bt").0.as_deref(),
443            Some("a b/c")
444        );
445        assert_eq!(parse_query("state=s%2Bt").1, "s+t");
446        assert_eq!(parse_query("").0, None);
447    }
448
449    #[test]
450    fn start_for_provider_bakes_gmail_urls() {
451        let provider = crate::providers::MailProvider::fastmail("fm-cid");
452        let flow = LoopbackFlow::start_for_provider(&provider, None, FIVE_SECS).unwrap();
453        let url = flow.authorization_url();
454        assert!(url.starts_with("https://app.fastmail.com/oauth/authorize?"));
455        assert!(url.contains("client_id=fm-cid"));
456        assert!(url.contains("protocolIMAP"));
457        assert!(url.contains("protocolSMTP"));
458    }
459}