origin_auth_loopback/
lib.rs1mod query;
8
9use async_trait::async_trait;
10use origin_auth::{AuthorizationCode, RedirectListener};
11use origin_domain::{AppError, Result};
12use std::net::{Ipv4Addr, SocketAddr};
13use std::time::Duration;
14use tokio::io::{AsyncReadExt, AsyncWriteExt};
15use tokio::net::{TcpListener, TcpStream};
16
17const CALLBACK_PATH: &str = "/callback";
19
20const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
25
26const MAX_REQUEST_BYTES: usize = 8 * 1024;
29
30#[derive(Debug)]
31pub struct LoopbackRedirect {
32 listener: TcpListener,
33 redirect_uri: String,
34 timeout: Duration,
35}
36
37impl LoopbackRedirect {
38 pub async fn bind() -> Result<Self> {
43 Self::bind_port(0).await
44 }
45
46 pub async fn bind_port(port: u16) -> Result<Self> {
49 let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
50 .await
51 .map_err(|error| {
52 AppError::configuration(format!("cannot bind loopback redirect port: {error}"))
53 })?;
54
55 let address = listener.local_addr().map_err(|error| {
56 AppError::internal(format!("cannot read loopback redirect address: {error}"))
57 })?;
58
59 let redirect_uri = format!("http://127.0.0.1:{}{CALLBACK_PATH}", address.port());
60 tracing::debug!(%redirect_uri, "loopback redirect listening");
61
62 Ok(Self {
63 listener,
64 redirect_uri,
65 timeout: DEFAULT_TIMEOUT,
66 })
67 }
68
69 pub fn with_timeout(mut self, timeout: Duration) -> Self {
70 self.timeout = timeout;
71 self
72 }
73
74 async fn accept_redirect(&self, expected_state: &str) -> Result<AuthorizationCode> {
75 loop {
76 let (stream, _) = self.listener.accept().await.map_err(|error| {
77 AppError::internal(format!("loopback redirect accept failed: {error}"))
78 })?;
79
80 match self.handle(stream, expected_state).await {
81 Ok(None) => continue,
84 Ok(Some(code)) => return Ok(code),
85 Err(error) => return Err(error),
86 }
87 }
88 }
89
90 async fn handle(
92 &self,
93 mut stream: TcpStream,
94 expected_state: &str,
95 ) -> Result<Option<AuthorizationCode>> {
96 let request_line = read_request_line(&mut stream).await?;
97 let Some(target) = request_line.split_whitespace().nth(1) else {
98 respond(&mut stream, 400, "Bad request").await;
99 return Ok(None);
100 };
101
102 let (path, query) = target.split_once('?').unwrap_or((target, ""));
103 if path != CALLBACK_PATH {
104 respond(&mut stream, 404, "Not found").await;
105 return Ok(None);
106 }
107
108 let parameters = query::parse(query);
109 let get = |name: &str| {
110 parameters
111 .iter()
112 .find(|(key, _)| key == name)
113 .map(|(_, v)| v.clone())
114 };
115
116 match get("state") {
119 Some(state) if state == expected_state => {}
120 _ => {
121 respond(
122 &mut stream,
123 400,
124 "Unexpected request. You can close this window.",
125 )
126 .await;
127 return Err(AppError::Authentication(
128 "redirect did not carry the expected state — the flow was not started \
129 by this application"
130 .to_owned(),
131 ));
132 }
133 }
134
135 if let Some(error) = get("error") {
136 let description = get("error_description").unwrap_or_else(|| error.clone());
137 respond(
138 &mut stream,
139 400,
140 "Authorization was denied. You can close this window.",
141 )
142 .await;
143 return Err(AppError::Authentication(format!(
144 "authorization was denied: {description}"
145 )));
146 }
147
148 let Some(code) = get("code") else {
149 respond(&mut stream, 400, "Missing authorization code.").await;
150 return Err(AppError::Authentication(
151 "redirect carried no authorization code".to_owned(),
152 ));
153 };
154
155 respond(
156 &mut stream,
157 200,
158 "Signed in. You can close this window and return to the app.",
159 )
160 .await;
161 Ok(Some(AuthorizationCode::new(code)))
162 }
163}
164
165#[async_trait]
166impl RedirectListener for LoopbackRedirect {
167 fn redirect_uri(&self) -> String {
168 self.redirect_uri.clone()
169 }
170
171 async fn wait(&self, expected_state: &str) -> Result<AuthorizationCode> {
172 tokio::time::timeout(self.timeout, self.accept_redirect(expected_state))
173 .await
174 .map_err(|_| {
175 AppError::Authentication(
176 "timed out waiting for the browser to complete authorization".to_owned(),
177 )
178 })?
179 }
180}
181
182async fn read_request_line(stream: &mut TcpStream) -> Result<String> {
187 let mut buffer = Vec::new();
188 let mut chunk = [0u8; 1024];
189
190 loop {
191 let read = stream.read(&mut chunk).await.map_err(|error| {
192 AppError::internal(format!("cannot read loopback redirect request: {error}"))
193 })?;
194
195 if read == 0 {
196 break;
197 }
198
199 buffer.extend_from_slice(&chunk[..read]);
200
201 if let Some(end) = buffer.iter().position(|byte| *byte == b'\n') {
202 buffer.truncate(end);
203 break;
204 }
205
206 if buffer.len() > MAX_REQUEST_BYTES {
207 return Err(AppError::internal(
208 "loopback redirect request exceeded the size limit".to_owned(),
209 ));
210 }
211 }
212
213 Ok(String::from_utf8_lossy(&buffer).trim_end().to_owned())
214}
215
216async fn respond(stream: &mut TcpStream, status: u16, message: &str) {
219 let reason = match status {
220 200 => "OK",
221 400 => "Bad Request",
222 _ => "Not Found",
223 };
224
225 let body = format!(
226 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
227 <title>Origin</title><style>body{{font:16px system-ui;display:grid;\
228 place-items:center;height:100vh;margin:0;color:#14171c;background:#f6f7f9}}\
229 @media(prefers-color-scheme:dark){{body{{color:#e7eaef;background:#0f1115}}}}\
230 </style></head><body><p>{message}</p></body></html>"
231 );
232
233 let response = format!(
234 "HTTP/1.1 {status} {reason}\r\n\
235 content-type: text/html; charset=utf-8\r\n\
236 content-length: {}\r\n\
237 connection: close\r\n\r\n{body}",
238 body.len()
239 );
240
241 if let Err(error) = stream.write_all(response.as_bytes()).await {
242 tracing::debug!(%error, "cannot write loopback redirect response");
243 }
244 let _ = stream.flush().await;
245 let _ = stream.shutdown().await;
246}