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(
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 #[must_use]
156 pub fn port(&self) -> u16 {
157 self.port
158 }
159
160 #[must_use]
162 pub fn authorization_url(&self) -> &str {
163 &self.auth_url
164 }
165
166 #[must_use]
168 pub fn state(&self) -> &str {
169 &self.state
170 }
171
172 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 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 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 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}