126 lines
4.1 KiB
Rust
126 lines
4.1 KiB
Rust
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
|
|
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
|
/// `?code=...` redirect and serves back a static confirmation page.
|
|
pub struct LoopbackServer {
|
|
listener: TcpListener,
|
|
port: u16,
|
|
}
|
|
|
|
impl LoopbackServer {
|
|
pub fn bind() -> std::io::Result<Self> {
|
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
|
let port = listener.local_addr()?.port();
|
|
Ok(LoopbackServer { listener, port })
|
|
}
|
|
|
|
pub fn redirect_uri(&self) -> String {
|
|
format!("http://127.0.0.1:{}/callback", self.port)
|
|
}
|
|
|
|
pub fn wait_for_code(
|
|
&self,
|
|
timeout_ms: u64,
|
|
expected_state: &str,
|
|
) -> std::io::Result<String> {
|
|
let (mut stream, _) = self.listener.accept()?;
|
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
|
Self::read_callback(&mut stream, expected_state)
|
|
}
|
|
|
|
fn read_callback(
|
|
stream: &mut TcpStream,
|
|
expected_state: &str,
|
|
) -> std::io::Result<String> {
|
|
let mut buf = [0u8; 4096];
|
|
let n = stream.read(&mut buf)?;
|
|
let request = String::from_utf8_lossy(&buf[..n]);
|
|
let code = Self::extract_code(&request);
|
|
let state = Self::extract_state(&request);
|
|
let state_ok = state.as_deref() == Some(expected_state);
|
|
let response = match (code.as_ref(), state_ok) {
|
|
(Some(_), true) => {
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
|
Authorization complete. You may close this tab."
|
|
}
|
|
(Some(_), false) => {
|
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
|
State mismatch — possible CSRF attack."
|
|
}
|
|
(None, _) => {
|
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
|
Missing authorization code."
|
|
}
|
|
};
|
|
if let Err(e) = stream.write_all(response.as_bytes()) {
|
|
tracing::warn!("OAuth loopback write error: {e}");
|
|
}
|
|
if let Err(e) = stream.flush() {
|
|
tracing::warn!("OAuth loopback flush error: {e}");
|
|
}
|
|
if !state_ok {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
"state mismatch",
|
|
));
|
|
}
|
|
code.ok_or_else(|| {
|
|
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
|
})
|
|
}
|
|
|
|
fn extract_code(request: &str) -> Option<String> {
|
|
let line = request.lines().next()?;
|
|
let path = line.split(' ').nth(1)?;
|
|
let query = path.split('?').nth(1)?;
|
|
for pair in query.split('&') {
|
|
let mut parts = pair.splitn(2, '=');
|
|
if parts.next()? == "code" {
|
|
return parts.next().map(urlencoding);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn extract_state(request: &str) -> Option<String> {
|
|
let line = request.lines().next()?;
|
|
let path = line.split(' ').nth(1)?;
|
|
let query = path.split('?').nth(1)?;
|
|
for pair in query.split('&') {
|
|
let mut parts = pair.splitn(2, '=');
|
|
if parts.next()? == "state" {
|
|
return parts.next().map(urlencoding);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Percent-decode a string (e.g. `%20` -> space).
|
|
fn urlencoding(s: &str) -> String {
|
|
let mut result = String::with_capacity(s.len());
|
|
let mut chars = s.chars();
|
|
while let Some(c) = chars.next() {
|
|
if c == '%' {
|
|
match (
|
|
chars.next().and_then(|c| c.to_digit(16)),
|
|
chars.next().and_then(|c| c.to_digit(16)),
|
|
) {
|
|
(Some(hi), Some(lo)) => {
|
|
let byte: u8 = (hi as u8) * 16 + lo as u8;
|
|
result.push(char::from(byte));
|
|
}
|
|
_ => {
|
|
result.push('%');
|
|
}
|
|
}
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
result
|
|
}
|