1use 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
14pub const LOOPBACK_HOST: &str = "127.0.0.1";
16pub const CALLBACK_PATH: &str = "/cb";
18
19#[must_use]
21pub fn loopback_redirect_uri(port: u16) -> String {
22 format!("http://{LOOPBACK_HOST}:{port}{CALLBACK_PATH}")
23}
24
25#[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#[derive(Debug, thiserror::Error)]
54pub enum LoopbackError {
55 #[error("loopback bind: {0}")]
57 Bind(String),
58 #[error("loopback capture timed out")]
60 Timeout,
61 #[error("state mismatch (possible CSRF)")]
63 StateMismatch,
64 #[error("provider error: {0}")]
66 Provider(String),
67 #[error("redirect missing code")]
69 MissingCode,
70 #[error("loopback io: {0}")]
72 Io(String),
73}
74
75pub struct LoopbackFlow {
78 listener: TcpListener,
79 auth_url: String,
80 state: String,
81 verifier: String,
82 port: u16,
83 timeout: Duration,
84}
85
86#[derive(Clone, Debug)]
88pub struct CapturedCode {
89 pub code: String,
91 pub verifier: String,
93 pub port: u16,
95}
96
97impl LoopbackFlow {
98 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 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 #[must_use]
157 pub fn port(&self) -> u16 {
158 self.port
159 }
160
161 #[must_use]
163 pub fn authorization_url(&self) -> &str {
164 &self.auth_url
165 }
166
167 #[must_use]
169 pub fn state(&self) -> &str {
170 &self.state
171 }
172
173 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 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 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 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}