1 //! Browser sign-in for siGit Code Cloud.
2 //!
3 //! The alternative to typing a password into a chat box. sigit is a public
4 //! OAuth client: it opens the browser at sigit.si, the person signs in and
5 //! approves there, and the authorization code comes back here. PKCE (RFC 7636)
6 //! is what proves the code belongs to this process, since a shipped CLI can
7 //! hold no client secret.
8 //!
9 //! The code comes home one of two ways:
10 //!
11 //! - **Loopback** (RFC 8252 §7.3). We bind `127.0.0.1:0`, let the OS pick the
12 //! port, and hand that URL to the server as the redirect URI. The server
13 //! registration lists `http://127.0.0.1/callback` with no port, which the
14 //! provider matches port-agnostically, so no per-run registration is needed.
15 //! Used by the editor's "Sign in" button, where there is nowhere to type.
16 //! - **Paste** (the out-of-band URN). No listener, no open port. The browser
17 //! lands on a page showing the code and the person carries it back by hand.
18 //! This is the path that survives ssh, a remote editor, or a locked-down box
19 //! where binding a port fails.
20 //!
21 //! The token that comes out is stored by [`crate::credentials`] exactly like
22 //! the one `/login` produces, so everything downstream is unchanged.
23
24 use std::io::{Read, Write};
25 use std::net::TcpListener;
26 use std::time::Duration;
27
28 use base64::Engine as _;
29 use sha2::{Digest, Sha256};
30
31 use crate::credentials::{self, Credentials};
32
33 /// Client id of the siGit Code registration on sigit.si. Public clients have
34 /// no secret and their id is not one either, so it ships as a constant rather
35 /// than being configured per install. Pinned server-side by
36 /// `rake oauth:register_cli`.
37 const OAUTH_CLIENT_ID: &str = "sigit-code-cli";
38
39 /// `user:read` for whoami, `code:agent` for inference and the MCP tools.
40 const OAUTH_SCOPE: &str = "user:read code:agent";
41
42 /// Non-standard redirect URI that asks the server to display the code instead
43 /// of redirecting anywhere.
44 const OOB_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
45
46 /// How long to hold the loopback listener open waiting for the browser. The
47 /// authorization code itself expires server-side in 10 minutes; this is the
48 /// shorter local patience for someone who walked away.
49 const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
50
51 /// Default account host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`).
52 const DEFAULT_API_URL: &str = "https://sigit.si";
53
54 fn api_base() -> String {
55 std::env::var("SIGIT_API_URL")
56 .unwrap_or_else(|_| DEFAULT_API_URL.to_string())
57 .trim_end_matches('/')
58 .to_string()
59 }
60
61 /// An authorization in flight: the URL to send the browser to, and the secrets
62 /// needed to redeem whatever code comes back.
63 pub struct Flow {
64 authorize_url: String,
65 redirect_uri: String,
66 verifier: String,
67 state: String,
68 /// Present only for the loopback variant.
69 listener: Option<TcpListener>,
70 }
71
72 impl Flow {
73 /// The URL the person needs to visit. Worth showing even when we opened it
74 /// ourselves: the browser we launch is not always the one they're signed
75 /// in to.
76 pub fn authorize_url(&self) -> &str {
77 &self.authorize_url
78 }
79
80 /// True when a code will arrive on its own, false when it has to be pasted.
81 pub fn is_loopback(&self) -> bool {
82 self.listener.is_some()
83 }
84 }
85
86 /// Start a loopback authorization, falling back to the paste variant when no
87 /// local port can be bound.
88 pub fn begin() -> Flow {
89 match TcpListener::bind(("127.0.0.1", 0)).and_then(|listener| {
90 let port = listener.local_addr()?.port();
91 listener.set_nonblocking(false)?;
92 Ok((listener, port))
93 }) {
94 Ok((listener, port)) => {
95 let redirect_uri = format!("http://127.0.0.1:{port}/callback");
96 build_flow(redirect_uri, Some(listener))
97 }
98 Err(error) => {
99 log::warn!("no loopback port for browser sign-in ({error}); using the paste flow");
100 build_flow(OOB_REDIRECT_URI.to_string(), None)
101 }
102 }
103 }
104
105 /// Start a paste authorization outright, without touching the network stack.
106 pub fn begin_paste() -> Flow {
107 build_flow(OOB_REDIRECT_URI.to_string(), None)
108 }
109
110 fn build_flow(redirect_uri: String, listener: Option<TcpListener>) -> Flow {
111 let verifier = random_token();
112 let state = random_token();
113 let challenge = code_challenge(&verifier);
114
115 let authorize_url = format!(
116 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
117 api_base(),
118 percent_encode(OAUTH_CLIENT_ID),
119 percent_encode(&redirect_uri),
120 percent_encode(OAUTH_SCOPE),
121 percent_encode(&state),
122 percent_encode(&challenge),
123 );
124
125 Flow {
126 authorize_url,
127 redirect_uri,
128 verifier,
129 state,
130 listener,
131 }
132 }
133
134 /// Hand the authorize URL to the platform's browser. Best-effort: a headless
135 /// box has nothing to open, which is not a reason to abandon the flow, since
136 /// the URL can still be carried to a browser elsewhere.
137 pub fn open_browser(url: &str) -> bool {
138 #[cfg(target_os = "macos")]
139 let mut command = {
140 let mut c = std::process::Command::new("open");
141 c.arg(url);
142 c
143 };
144 #[cfg(target_os = "windows")]
145 let mut command = {
146 // `start` is a cmd builtin, and its first quoted argument is taken as
147 // the window title, hence the empty one.
148 let mut c = std::process::Command::new("cmd");
149 c.args(["/C", "start", "", url]);
150 c
151 };
152 #[cfg(all(unix, not(target_os = "macos")))]
153 let mut command = {
154 let mut c = std::process::Command::new("xdg-open");
155 c.arg(url);
156 c
157 };
158
159 command
160 .stdout(std::process::Stdio::null())
161 .stderr(std::process::Stdio::null())
162 .status()
163 .map(|status| status.success())
164 .unwrap_or(false)
165 }
166
167 /// Block on the loopback listener until the browser redirects back, then
168 /// redeem the code and store the session. Returns the signed-in email.
169 ///
170 /// Runs the socket work on a blocking thread: the wait is minutes long and
171 /// must not sit on an async worker.
172 pub async fn complete_loopback(flow: Flow) -> Result<String, String> {
173 let Flow {
174 redirect_uri,
175 verifier,
176 state,
177 listener,
178 ..
179 } = flow;
180 let listener = listener.ok_or_else(|| "this sign-in has no local listener".to_string())?;
181
182 let expected_state = state.clone();
183 let code = tokio::task::spawn_blocking(move || wait_for_code(listener, &expected_state))
184 .await
185 .map_err(|error| format!("sign-in was interrupted: {error}"))??;
186
187 exchange(&code, &verifier, &redirect_uri).await
188 }
189
190 /// Redeem a code the person pasted in. Returns the signed-in email.
191 pub async fn complete_paste(flow: &Flow, code: &str) -> Result<String, String> {
192 let code = code.trim();
193 if code.is_empty() {
194 return Err("no code was entered".to_string());
195 }
196 exchange(code, &flow.verifier, &flow.redirect_uri).await
197 }
198
199 /// Accept connections until one carries our callback. Anything else on the
200 /// port (a browser probe, a stray scan) is answered and ignored rather than
201 /// ending the wait.
202 fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String, String> {
203 let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
204 listener
205 .set_nonblocking(true)
206 .map_err(|error| format!("could not watch for the browser: {error}"))?;
207
208 'connections: loop {
209 if std::time::Instant::now() >= deadline {
210 return Err("timed out waiting for the browser".to_string());
211 }
212
213 let mut stream = match listener.accept() {
214 Ok((stream, _)) => stream,
215 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
216 std::thread::sleep(Duration::from_millis(100));
217 continue;
218 }
219 Err(error) => return Err(format!("could not accept the browser: {error}")),
220 };
221 if std::time::Instant::now() >= deadline {
222 return Err("timed out waiting for the browser".to_string());
223 }
224 stream
225 .set_nonblocking(false)
226 .map_err(|error| format!("could not read the browser callback: {error}"))?;
227 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
228 stream
229 .set_read_timeout(Some(remaining.min(Duration::from_secs(5))))
230 .map_err(|error| format!("could not read the browser callback: {error}"))?;
231
232 // Read through the end of the request line; a TCP read may return a
233 // partial line when the request spans multiple packets.
234 let mut buffer = [0_u8; 4096];
235 let mut read = 0;
236 while read < buffer.len() {
237 match stream.read(&mut buffer[read..]) {
238 Ok(0) => break,
239 Ok(count) => {
240 read += count;
241 if buffer[..read].windows(2).any(|window| window == b"\r\n") {
242 break;
243 }
244 }
245 Err(error) => {
246 respond(&mut stream, "Could not read the sign-in callback.");
247 log::debug!("could not read browser callback: {error}");
248 continue 'connections;
249 }
250 }
251 }
252 let request = String::from_utf8_lossy(&buffer[..read]);
253 let Some(target) = request
254 .lines()
255 .next()
256 .and_then(|line| line.split_whitespace().nth(1))
257 else {
258 respond(&mut stream, "Bad request.");
259 continue;
260 };
261
262 let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
263 let mut code = None;
264 let mut state = None;
265 let mut error = None;
266 for (key, value) in parse_query(query) {
267 match key.as_str() {
268 "code" => code = Some(value),
269 "state" => state = Some(value),
270 "error" => error = Some(value),
271 _ => {}
272 }
273 }
274
275 if let Some(error) = error {
276 respond(
277 &mut stream,
278 "Sign-in was cancelled. You can close this tab.",
279 );
280 return Err(format!("sign-in was declined ({error})"));
281 }
282
283 let Some(code) = code else {
284 respond(&mut stream, "Nothing to do here.");
285 continue;
286 };
287
288 // The state proves this callback belongs to the flow we started, not
289 // to a link someone else sent to this browser.
290 if state.as_deref() != Some(expected_state) {
291 respond(&mut stream, "That sign-in did not come from siGit Code.");
292 return Err("the sign-in response did not match this request".to_string());
293 }
294
295 respond(
296 &mut stream,
297 "Signed in to siGit Code. You can close this tab.",
298 );
299 return Ok(code);
300 }
301 }
302
303 fn respond(stream: &mut std::net::TcpStream, message: &str) {
304 let body = format!(
305 "<!doctype html><meta charset=\"utf-8\"><title>siGit Code</title>\
306 <body style=\"font:16px system-ui;display:grid;place-items:center;height:90vh\">\
307 <p>{message}</p></body>"
308 );
309 let response = format!(
310 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
311 body.len(),
312 body
313 );
314 let _ = stream.write_all(response.as_bytes());
315 let _ = stream.flush();
316 }
317
318 /// Trade the authorization code for an access token and store it.
319 async fn exchange(code: &str, verifier: &str, redirect_uri: &str) -> Result<String, String> {
320 let url = format!("{}/oauth/token", api_base());
321 let response = reqwest::Client::new()
322 .post(&url)
323 .form(&[
324 ("grant_type", "authorization_code"),
325 ("client_id", OAUTH_CLIENT_ID),
326 ("redirect_uri", redirect_uri),
327 ("code", code),
328 ("code_verifier", verifier),
329 ])
330 .send()
331 .await
332 .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
333
334 let status = response.status();
335 let body: serde_json::Value = response
336 .json()
337 .await
338 .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?;
339
340 if !status.is_success() {
341 // OAuth error bodies are `{"error":…,"error_description":…}`.
342 let detail = body
343 .get("error_description")
344 .or_else(|| body.get("error"))
345 .and_then(|value| value.as_str())
346 .unwrap_or("the code could not be exchanged");
347 return Err(format!("sign-in failed: {detail}"));
348 }
349
350 let token = body
351 .get("access_token")
352 .and_then(|value| value.as_str())
353 .filter(|token| !token.trim().is_empty())
354 .ok_or_else(|| "siGit Code Cloud returned no access token".to_string())?;
355
356 // The token is the thing worth keeping, and the email is only there to
357 // label it. Store once so a profile lookup failure does not leave partial
358 // state on disk.
359 let email = fetch_email(token).await;
360 credentials::store(&Credentials {
361 access_token: token.to_string(),
362 email: email.clone(),
363 })?;
364
365 Ok(email.unwrap_or_else(|| "(unknown)".to_string()))
366 }
367
368 /// Read the account email for display. OAuth tokens are served by
369 /// `/api/v1/user`, not the deprecated `/api/v1/me`.
370 async fn fetch_email(token: &str) -> Option<String> {
371 let url = format!("{}/api/v1/user", api_base());
372 let response = reqwest::Client::new()
373 .get(&url)
374 .bearer_auth(token)
375 .send()
376 .await
377 .ok()?;
378 if !response.status().is_success() {
379 return None;
380 }
381 response
382 .json::<serde_json::Value>()
383 .await
384 .ok()?
385 .get("email")?
386 .as_str()
387 .map(str::to_string)
388 }
389
390 /// 32 random bytes, base64url without padding: a PKCE verifier and a state
391 /// value both want an unguessable ASCII string of exactly this shape.
392 fn random_token() -> String {
393 let mut bytes = [0_u8; 32];
394 rand::fill(&mut bytes);
395 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
396 }
397
398 /// S256: base64url(sha256(verifier)), unpadded.
399 fn code_challenge(verifier: &str) -> String {
400 let digest = Sha256::digest(verifier.as_bytes());
401 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
402 }
403
404 /// Percent-encode a query-string value. Everything outside the unreserved set
405 /// is escaped, which is stricter than necessary and never wrong.
406 fn percent_encode(value: &str) -> String {
407 let mut out = String::with_capacity(value.len());
408 for byte in value.bytes() {
409 match byte {
410 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
411 out.push(byte as char)
412 }
413 _ => out.push_str(&format!("%{byte:02X}")),
414 }
415 }
416 out
417 }
418
419 /// Split a query string into decoded key/value pairs.
420 fn parse_query(query: &str) -> Vec<(String, String)> {
421 query
422 .split('&')
423 .filter(|pair| !pair.is_empty())
424 .map(|pair| {
425 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
426 (percent_decode(key), percent_decode(value))
427 })
428 .collect()
429 }
430
431 fn percent_decode(value: &str) -> String {
432 let bytes = value.as_bytes();
433 let mut out = Vec::with_capacity(bytes.len());
434 let mut index = 0;
435 while index < bytes.len() {
436 match bytes[index] {
437 b'%' if index + 3 <= bytes.len() => {
438 let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
439 match u8::from_str_radix(hex, 16) {
440 Ok(byte) => {
441 out.push(byte);
442 index += 3;
443 }
444 Err(_) => {
445 out.push(bytes[index]);
446 index += 1;
447 }
448 }
449 }
450 b'+' => {
451 out.push(b' ');
452 index += 1;
453 }
454 byte => {
455 out.push(byte);
456 index += 1;
457 }
458 }
459 }
460 String::from_utf8_lossy(&out).into_owned()
461 }
462
463 #[cfg(test)]
464 mod tests {
465 use super::*;
466
467 #[test]
468 fn code_challenge_matches_rfc_7636_example() {
469 // RFC 7636 Appendix B.
470 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
471 assert_eq!(
472 code_challenge(verifier),
473 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
474 );
475 }
476
477 #[test]
478 fn authorize_url_carries_pkce_and_the_bound_port() {
479 let flow = begin();
480 assert!(flow.is_loopback(), "a loopback port should be available");
481 assert!(flow.authorize_url().contains("code_challenge_method=S256"));
482 assert!(flow.authorize_url().contains("response_type=code"));
483 // The redirect URI is encoded, so look for the escaped form.
484 assert!(
485 flow.authorize_url().contains("http%3A%2F%2F127.0.0.1%3A"),
486 "authorize url should carry the loopback redirect: {}",
487 flow.authorize_url()
488 );
489 }
490
491 #[test]
492 fn paste_flow_asks_the_server_to_display_the_code() {
493 let flow = begin_paste();
494 assert!(!flow.is_loopback());
495 assert!(
496 flow.authorize_url()
497 .contains("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob")
498 );
499 }
500
501 #[test]
502 fn verifier_and_state_differ_between_flows() {
503 let first = begin_paste();
504 let second = begin_paste();
505 assert_ne!(first.verifier, second.verifier);
506 assert_ne!(first.state, second.state);
507 assert_eq!(first.verifier.len(), 43, "32 bytes as unpadded base64url");
508 }
509
510 #[test]
511 fn query_parsing_decodes_escapes() {
512 let pairs = parse_query("code=abc%2Fdef&state=x+y");
513 assert_eq!(pairs[0], ("code".to_string(), "abc/def".to_string()));
514 assert_eq!(pairs[1], ("state".to_string(), "x y".to_string()));
515 }
516
517 #[test]
518 fn percent_decode_handles_escape_at_end() {
519 assert_eq!(percent_decode("code%2F"), "code/");
520 }
521 }