| 101 |
101 |
|
static CALLBACK_CANCEL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
| 102 |
102 |
|
|
| 103 |
103 |
|
/// Stored callback state for the /result polling endpoint.
|
|
104 |
+ |
///
|
|
105 |
+ |
/// `page_delivered` records whether the browser tab actually received its
|
|
106 |
+ |
/// confirmation page. The handshake itself does not depend on that write (the
|
|
107 |
+ |
/// code reaches the app through `/result`), so a failed write must not fail the
|
|
108 |
+ |
/// auth, but it does leave the user staring at a tab that never finishes
|
|
109 |
+ |
/// loading. Carrying the flag through to the poll result is what lets the app
|
|
110 |
+ |
/// say so instead of leaving the two cases indistinguishable.
|
| 104 |
111 |
|
#[derive(Clone)]
|
| 105 |
112 |
|
enum StoredCallback {
|
| 106 |
113 |
|
Pending,
|
| 107 |
|
- |
Success { code: String, state: String },
|
| 108 |
|
- |
Error { error: String },
|
|
114 |
+ |
Success {
|
|
115 |
+ |
code: String,
|
|
116 |
+ |
state: String,
|
|
117 |
+ |
page_delivered: bool,
|
|
118 |
+ |
},
|
|
119 |
+ |
Error {
|
|
120 |
+ |
error: String,
|
|
121 |
+ |
page_delivered: bool,
|
|
122 |
+ |
},
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
/// Render the `/result` polling payload for the current callback state.
|
|
126 |
+ |
fn result_json(stored: &StoredCallback) -> String {
|
|
127 |
+ |
match stored {
|
|
128 |
+ |
StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
|
|
129 |
+ |
StoredCallback::Success {
|
|
130 |
+ |
code,
|
|
131 |
+ |
state,
|
|
132 |
+ |
page_delivered,
|
|
133 |
+ |
} => format!(
|
|
134 |
+ |
r#"{{"status":"success","code":"{}","state":"{}","page_delivered":{}}}"#,
|
|
135 |
+ |
code.replace('"', "\\\""),
|
|
136 |
+ |
state.replace('"', "\\\""),
|
|
137 |
+ |
page_delivered
|
|
138 |
+ |
),
|
|
139 |
+ |
StoredCallback::Error {
|
|
140 |
+ |
error,
|
|
141 |
+ |
page_delivered,
|
|
142 |
+ |
} => format!(
|
|
143 |
+ |
r#"{{"status":"error","error":"{}","page_delivered":{}}}"#,
|
|
144 |
+ |
error.replace('"', "\\\""),
|
|
145 |
+ |
page_delivered
|
|
146 |
+ |
),
|
|
147 |
+ |
}
|
|
148 |
+ |
}
|
|
149 |
+ |
|
|
150 |
+ |
/// Write one loopback response and flush it, naming the site in the log if the
|
|
151 |
+ |
/// socket is already gone.
|
|
152 |
+ |
///
|
|
153 |
+ |
/// Every caller is writing to a browser tab that may have been closed, so the
|
|
154 |
+ |
/// failure is not recoverable here; what it must not be is invisible. `site`
|
|
155 |
+ |
/// says which response was lost, which is the difference between "the app never
|
|
156 |
+ |
/// picked up the code" and "the user's tab looks stuck".
|
|
157 |
+ |
fn write_loopback_response(stream: &mut std::net::TcpStream, site: &str, response: &str) -> bool {
|
|
158 |
+ |
use std::io::Write;
|
|
159 |
+ |
match stream
|
|
160 |
+ |
.write_all(response.as_bytes())
|
|
161 |
+ |
.and_then(|()| stream.flush())
|
|
162 |
+ |
{
|
|
163 |
+ |
Ok(()) => true,
|
|
164 |
+ |
Err(e) => {
|
|
165 |
+ |
tracing::warn!(site, error = %e, "OAuth loopback response not delivered");
|
|
166 |
+ |
false
|
|
167 |
+ |
}
|
|
168 |
+ |
}
|
| 109 |
169 |
|
}
|
| 110 |
170 |
|
|
| 111 |
171 |
|
/// Start a minimal HTTP server on a random port that waits for the OAuth redirect.
|
| 129 |
189 |
|
let generation = CALLBACK_CANCEL.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
| 130 |
190 |
|
|
| 131 |
191 |
|
std::thread::spawn(move || {
|
| 132 |
|
- |
use std::io::{Read, Write};
|
|
192 |
+ |
use std::io::Read;
|
| 133 |
193 |
|
use std::sync::{Arc, Mutex};
|
| 134 |
194 |
|
|
| 135 |
195 |
|
let stored = Arc::new(Mutex::new(StoredCallback::Pending));
|
| 156 |
216 |
|
|
| 157 |
217 |
|
// Handle /result polling endpoint
|
| 158 |
218 |
|
if path_only == "/result" {
|
| 159 |
|
- |
let json = match &*stored.lock().unwrap() {
|
| 160 |
|
- |
StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
|
| 161 |
|
- |
StoredCallback::Success { code, state } => {
|
| 162 |
|
- |
format!(
|
| 163 |
|
- |
r#"{{"status":"success","code":"{}","state":"{}"}}"#,
|
| 164 |
|
- |
code.replace('"', "\\\""),
|
| 165 |
|
- |
state.replace('"', "\\\"")
|
| 166 |
|
- |
)
|
| 167 |
|
- |
}
|
| 168 |
|
- |
StoredCallback::Error { error } => {
|
| 169 |
|
- |
format!(
|
| 170 |
|
- |
r#"{{"status":"error","error":"{}"}}"#,
|
| 171 |
|
- |
error.replace('"', "\\\"")
|
| 172 |
|
- |
)
|
| 173 |
|
- |
}
|
| 174 |
|
- |
};
|
|
219 |
+ |
let json = result_json(&stored.lock().unwrap());
|
| 175 |
220 |
|
let response = format!(
|
| 176 |
221 |
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
|
| 177 |
222 |
|
json.len(),
|
| 178 |
223 |
|
json
|
| 179 |
224 |
|
);
|
| 180 |
|
- |
let _ = stream.write_all(response.as_bytes());
|
| 181 |
|
- |
let _ = stream.flush();
|
|
225 |
+ |
// A dropped poll response is recoverable on its own: the
|
|
226 |
+ |
// app polls once a second and the stored state is
|
|
227 |
+ |
// unchanged, so the next one carries the same answer.
|
|
228 |
+ |
write_loopback_response(&mut stream, "/result", &response);
|
| 182 |
229 |
|
continue;
|
| 183 |
230 |
|
}
|
| 184 |
231 |
|
|
| 200 |
247 |
|
}
|
| 201 |
248 |
|
|
| 202 |
249 |
|
if let Some(err) = error {
|
| 203 |
|
- |
*stored.lock().unwrap() = StoredCallback::Error { error: err };
|
| 204 |
250 |
|
let body = "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>";
|
| 205 |
251 |
|
let response = format!(
|
| 206 |
252 |
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
|
| 207 |
253 |
|
body.len(),
|
| 208 |
254 |
|
body
|
| 209 |
255 |
|
);
|
| 210 |
|
- |
let _ = stream.write_all(response.as_bytes());
|
| 211 |
|
- |
let _ = stream.flush();
|
|
256 |
+ |
let page_delivered =
|
|
257 |
+ |
write_loopback_response(&mut stream, "callback/error", &response);
|
|
258 |
+ |
*stored.lock().unwrap() = StoredCallback::Error {
|
|
259 |
+ |
error: err,
|
|
260 |
+ |
page_delivered,
|
|
261 |
+ |
};
|
| 212 |
262 |
|
callback_received = true;
|
| 213 |
263 |
|
} else if let (Some(code), Some(state)) = (code, cb_state) {
|
| 214 |
|
- |
*stored.lock().unwrap() = StoredCallback::Success {
|
| 215 |
|
- |
code: code.clone(),
|
| 216 |
|
- |
state: state.clone(),
|
| 217 |
|
- |
};
|
| 218 |
264 |
|
let body = "<html><body><h1>Authenticated</h1><p>You can close this tab and return to Balanced Breakfast.</p></body></html>";
|
| 219 |
265 |
|
let response = format!(
|
| 220 |
266 |
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
|
| 221 |
267 |
|
body.len(),
|
| 222 |
268 |
|
body
|
| 223 |
269 |
|
);
|
| 224 |
|
- |
let _ = stream.write_all(response.as_bytes());
|
| 225 |
|
- |
let _ = stream.flush();
|
|
270 |
+ |
let page_delivered =
|
|
271 |
+ |
write_loopback_response(&mut stream, "callback/success", &response);
|
|
272 |
+ |
*stored.lock().unwrap() = StoredCallback::Success {
|
|
273 |
+ |
code: code.clone(),
|
|
274 |
+ |
state: state.clone(),
|
|
275 |
+ |
page_delivered,
|
|
276 |
+ |
};
|
| 226 |
277 |
|
callback_received = true;
|
| 227 |
278 |
|
}
|
| 228 |
279 |
|
}
|
| 252 |
303 |
|
.unwrap_or("/");
|
| 253 |
304 |
|
|
| 254 |
305 |
|
if path.starts_with("/result") {
|
| 255 |
|
- |
let json = match &*stored.lock().unwrap() {
|
| 256 |
|
- |
StoredCallback::Pending => {
|
| 257 |
|
- |
r#"{"status":"pending"}"#.to_string()
|
| 258 |
|
- |
}
|
| 259 |
|
- |
StoredCallback::Success { code, state } => {
|
| 260 |
|
- |
format!(
|
| 261 |
|
- |
r#"{{"status":"success","code":"{}","state":"{}"}}"#,
|
| 262 |
|
- |
code.replace('"', "\\\""),
|
| 263 |
|
- |
state.replace('"', "\\\"")
|
| 264 |
|
- |
)
|
| 265 |
|
- |
}
|
| 266 |
|
- |
StoredCallback::Error { error } => {
|
| 267 |
|
- |
format!(
|
| 268 |
|
- |
r#"{{"status":"error","error":"{}"}}"#,
|
| 269 |
|
- |
error.replace('"', "\\\"")
|
| 270 |
|
- |
)
|
| 271 |
|
- |
}
|
| 272 |
|
- |
};
|
|
306 |
+ |
let json = result_json(&stored.lock().unwrap());
|
| 273 |
307 |
|
let response = format!(
|
| 274 |
308 |
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
|
| 275 |
309 |
|
json.len(),
|
| 276 |
310 |
|
json
|
| 277 |
311 |
|
);
|
| 278 |
|
- |
let _ = stream.write_all(response.as_bytes());
|
| 279 |
|
- |
let _ = stream.flush();
|
|
312 |
+ |
write_loopback_response(&mut stream, "/result", &response);
|
| 280 |
313 |
|
}
|
| 281 |
314 |
|
}
|
| 282 |
315 |
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|