I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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, mut stream) =
184 tokio::task::spawn_blocking(move || wait_for_code(listener, &expected_state))
185 .await
186 .map_err(|error| format!("sign-in was interrupted: {error}"))??;
187
188 // The browser is still waiting on its request. Answer it only once the
189 // exchange has settled, so the page never reports a sign-in that failed.
190 let result = exchange(&code, &verifier, &redirect_uri).await;
191 match &result {
192 Ok(_) => respond(
193 &mut stream,
194 "Signed in to siGit Code",
195 "You can close this tab.",
196 ),
197 Err(_) => respond(
198 &mut stream,
199 "Sign-in did not complete",
200 "Return to siGit Code and try again.",
201 ),
202 }
203 result
204 }
205
206 /// Redeem a code the person pasted in. Returns the signed-in email.
207 pub async fn complete_paste(flow: &Flow, code: &str) -> Result<String, String> {
208 let code = code.trim();
209 if code.is_empty() {
210 return Err("no code was entered".to_string());
211 }
212 exchange(code, &flow.verifier, &flow.redirect_uri).await
213 }
214
215 /// Accept connections until one carries our callback. Anything else on the
216 /// port (a browser probe, a stray scan) is answered and ignored rather than
217 /// ending the wait. On success the matching connection is returned unanswered,
218 /// for the caller to respond to once the code has been redeemed.
219 fn wait_for_code(
220 listener: TcpListener,
221 expected_state: &str,
222 ) -> Result<(String, std::net::TcpStream), String> {
223 let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
224 listener
225 .set_nonblocking(true)
226 .map_err(|error| format!("could not watch for the browser: {error}"))?;
227
228 'connections: loop {
229 if std::time::Instant::now() >= deadline {
230 return Err("timed out waiting for the browser".to_string());
231 }
232
233 let mut stream = match listener.accept() {
234 Ok((stream, _)) => stream,
235 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
236 std::thread::sleep(Duration::from_millis(100));
237 continue;
238 }
239 Err(error) => return Err(format!("could not accept the browser: {error}")),
240 };
241 if std::time::Instant::now() >= deadline {
242 return Err("timed out waiting for the browser".to_string());
243 }
244 stream
245 .set_nonblocking(false)
246 .map_err(|error| format!("could not read the browser callback: {error}"))?;
247 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
248 stream
249 .set_read_timeout(Some(remaining.min(Duration::from_secs(5))))
250 .map_err(|error| format!("could not read the browser callback: {error}"))?;
251
252 // Read through the end of the request line; a TCP read may return a
253 // partial line when the request spans multiple packets.
254 let mut buffer = [0_u8; 4096];
255 let mut read = 0;
256 while read < buffer.len() {
257 match stream.read(&mut buffer[read..]) {
258 Ok(0) => break,
259 Ok(count) => {
260 read += count;
261 if buffer[..read].windows(2).any(|window| window == b"\r\n") {
262 break;
263 }
264 }
265 Err(error) => {
266 respond(&mut stream, "Nothing to do here", "");
267 log::debug!("could not read browser callback: {error}");
268 continue 'connections;
269 }
270 }
271 }
272 let request = String::from_utf8_lossy(&buffer[..read]);
273 let Some(target) = request
274 .lines()
275 .next()
276 .and_then(|line| line.split_whitespace().nth(1))
277 else {
278 respond(&mut stream, "Nothing to do here", "");
279 continue;
280 };
281
282 let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
283 let mut code = None;
284 let mut state = None;
285 let mut error = None;
286 for (key, value) in parse_query(query) {
287 match key.as_str() {
288 "code" => code = Some(value),
289 "state" => state = Some(value),
290 "error" => error = Some(value),
291 _ => {}
292 }
293 }
294
295 if let Some(error) = error {
296 respond(&mut stream, "Sign-in cancelled", "You can close this tab.");
297 return Err(format!("sign-in was declined ({error})"));
298 }
299
300 let Some(code) = code else {
301 respond(&mut stream, "Nothing to do here", "");
302 continue;
303 };
304
305 // The state proves this callback belongs to the flow we started, not
306 // to a link someone else sent to this browser.
307 if state.as_deref() != Some(expected_state) {
308 respond(
309 &mut stream,
310 "Sign-in didn't match",
311 "That sign-in did not come from siGit Code.",
312 );
313 return Err("the sign-in response did not match this request".to_string());
314 }
315
316 return Ok((code, stream));
317 }
318 }
319
320 /// The 64x64 app icon, inlined into the page so it needs no second request.
321 const LOGO_PNG: &[u8] = include_bytes!("../assets/sign-in-logo.png");
322
323 /// Renders the page the browser lands on. Self-contained: inline CSS and an
324 /// inline logo, because the page is served from the loopback address and must
325 /// not fetch anything. Title and hint are constants, so nothing is escaped.
326 fn page(title: &str, hint: &str) -> String {
327 let logo = base64::engine::general_purpose::STANDARD.encode(LOGO_PNG);
328 let hint = if hint.is_empty() {
329 String::new()
330 } else {
331 format!("<p>{hint}</p>")
332 };
333 format!(
334 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
335 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
336 <meta name=\"color-scheme\" content=\"dark\"><title>{title}</title><style>\
337 :root{{color-scheme:dark}}\
338 html,body{{margin:0;height:100%}}\
339 body{{display:flex;align-items:center;justify-content:center;background:#15171a;\
340 font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif}}\
341 main{{display:flex;flex-direction:column;align-items:center;text-align:center;\
342 padding:0 24px;transform:translateY(-6%);animation:fade-in 240ms ease-out}}\
343 img{{width:40px;height:40px;border-radius:9px;opacity:.8;\
344 box-shadow:0 0 0 1px rgb(255 255 255/.06)}}\
345 h1{{margin:16px 0 0;font-size:15px;line-height:20px;font-weight:500;\
346 letter-spacing:-.01em;color:#f3f4f6}}\
347 p{{margin:4px 0 0;font-size:13px;line-height:18px;color:#6b7280}}\
348 @keyframes fade-in{{from{{opacity:0}}to{{opacity:1}}}}\
349 @media(prefers-reduced-motion:reduce){{main{{animation:none}}}}\
350 </style></head><body><main>\
351 <img src=\"data:image/png;base64,{logo}\" alt=\"\" width=\"40\" height=\"40\">\
352 <h1>{title}</h1>{hint}</main></body></html>"
353 )
354 }
355
356 fn respond(stream: &mut std::net::TcpStream, title: &str, hint: &str) {
357 let body = page(title, hint);
358 let response = format!(
359 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
360 body.len(),
361 body
362 );
363 let _ = stream.write_all(response.as_bytes());
364 let _ = stream.flush();
365 }
366
367 /// Trade the authorization code for an access token and store it.
368 async fn exchange(code: &str, verifier: &str, redirect_uri: &str) -> Result<String, String> {
369 let url = format!("{}/oauth/token", api_base());
370 let response = reqwest::Client::new()
371 .post(&url)
372 .form(&[
373 ("grant_type", "authorization_code"),
374 ("client_id", OAUTH_CLIENT_ID),
375 ("redirect_uri", redirect_uri),
376 ("code", code),
377 ("code_verifier", verifier),
378 ])
379 .send()
380 .await
381 .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
382
383 let status = response.status();
384 let body: serde_json::Value = response
385 .json()
386 .await
387 .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?;
388
389 if !status.is_success() {
390 // OAuth error bodies are `{"error":…,"error_description":…}`.
391 let detail = body
392 .get("error_description")
393 .or_else(|| body.get("error"))
394 .and_then(|value| value.as_str())
395 .unwrap_or("the code could not be exchanged");
396 return Err(format!("sign-in failed: {detail}"));
397 }
398
399 let token = body
400 .get("access_token")
401 .and_then(|value| value.as_str())
402 .filter(|token| !token.trim().is_empty())
403 .ok_or_else(|| "siGit Code Cloud returned no access token".to_string())?;
404
405 // The token is the thing worth keeping, and the email is only there to
406 // label it. Store once so a profile lookup failure does not leave partial
407 // state on disk.
408 let email = fetch_email(token).await;
409 credentials::store(&Credentials {
410 access_token: token.to_string(),
411 email: email.clone(),
412 })?;
413
414 Ok(email.unwrap_or_else(|| "(unknown)".to_string()))
415 }
416
417 /// Read the account email for display. OAuth tokens are served by
418 /// `/api/v1/user`, not the deprecated `/api/v1/me`.
419 async fn fetch_email(token: &str) -> Option<String> {
420 let url = format!("{}/api/v1/user", api_base());
421 let response = reqwest::Client::new()
422 .get(&url)
423 .bearer_auth(token)
424 .send()
425 .await
426 .ok()?;
427 if !response.status().is_success() {
428 return None;
429 }
430 response
431 .json::<serde_json::Value>()
432 .await
433 .ok()?
434 .get("email")?
435 .as_str()
436 .map(str::to_string)
437 }
438
439 /// 32 random bytes, base64url without padding: a PKCE verifier and a state
440 /// value both want an unguessable ASCII string of exactly this shape.
441 fn random_token() -> String {
442 let mut bytes = [0_u8; 32];
443 rand::fill(&mut bytes);
444 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
445 }
446
447 /// S256: base64url(sha256(verifier)), unpadded.
448 fn code_challenge(verifier: &str) -> String {
449 let digest = Sha256::digest(verifier.as_bytes());
450 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
451 }
452
453 /// Percent-encode a query-string value. Everything outside the unreserved set
454 /// is escaped, which is stricter than necessary and never wrong.
455 fn percent_encode(value: &str) -> String {
456 let mut out = String::with_capacity(value.len());
457 for byte in value.bytes() {
458 match byte {
459 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
460 out.push(byte as char)
461 }
462 _ => out.push_str(&format!("%{byte:02X}")),
463 }
464 }
465 out
466 }
467
468 /// Split a query string into decoded key/value pairs.
469 fn parse_query(query: &str) -> Vec<(String, String)> {
470 query
471 .split('&')
472 .filter(|pair| !pair.is_empty())
473 .map(|pair| {
474 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
475 (percent_decode(key), percent_decode(value))
476 })
477 .collect()
478 }
479
480 fn percent_decode(value: &str) -> String {
481 let bytes = value.as_bytes();
482 let mut out = Vec::with_capacity(bytes.len());
483 let mut index = 0;
484 while index < bytes.len() {
485 match bytes[index] {
486 b'%' if index + 3 <= bytes.len() => {
487 let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
488 match u8::from_str_radix(hex, 16) {
489 Ok(byte) => {
490 out.push(byte);
491 index += 3;
492 }
493 Err(_) => {
494 out.push(bytes[index]);
495 index += 1;
496 }
497 }
498 }
499 b'+' => {
500 out.push(b' ');
501 index += 1;
502 }
503 byte => {
504 out.push(byte);
505 index += 1;
506 }
507 }
508 }
509 String::from_utf8_lossy(&out).into_owned()
510 }
511
512 #[cfg(test)]
513 mod tests {
514 use super::*;
515
516 #[test]
517 fn page_carries_the_title_hint_and_an_inline_logo() {
518 let body = page("Signed in to siGit Code", "You can close this tab.");
519 assert!(body.contains("<h1>Signed in to siGit Code</h1>"));
520 assert!(body.contains("<p>You can close this tab.</p>"));
521 assert!(body.contains("data:image/png;base64,"));
522 }
523
524 #[test]
525 fn page_omits_the_hint_when_empty() {
526 assert!(!page("Nothing to do here", "").contains("<p>"));
527 }
528
529 #[test]
530 fn code_challenge_matches_rfc_7636_example() {
531 // RFC 7636 Appendix B.
532 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
533 assert_eq!(
534 code_challenge(verifier),
535 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
536 );
537 }
538
539 #[test]
540 fn authorize_url_carries_pkce_and_the_bound_port() {
541 let flow = begin();
542 assert!(flow.is_loopback(), "a loopback port should be available");
543 assert!(flow.authorize_url().contains("code_challenge_method=S256"));
544 assert!(flow.authorize_url().contains("response_type=code"));
545 // The redirect URI is encoded, so look for the escaped form.
546 assert!(
547 flow.authorize_url().contains("http%3A%2F%2F127.0.0.1%3A"),
548 "authorize url should carry the loopback redirect: {}",
549 flow.authorize_url()
550 );
551 }
552
553 #[test]
554 fn paste_flow_asks_the_server_to_display_the_code() {
555 let flow = begin_paste();
556 assert!(!flow.is_loopback());
557 assert!(
558 flow.authorize_url()
559 .contains("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob")
560 );
561 }
562
563 #[test]
564 fn verifier_and_state_differ_between_flows() {
565 let first = begin_paste();
566 let second = begin_paste();
567 assert_ne!(first.verifier, second.verifier);
568 assert_ne!(first.state, second.state);
569 assert_eq!(first.verifier.len(), 43, "32 bytes as unpadded base64url");
570 }
571
572 #[test]
573 fn query_parsing_decodes_escapes() {
574 let pairs = parse_query("code=abc%2Fdef&state=x+y");
575 assert_eq!(pairs[0], ("code".to_string(), "abc/def".to_string()));
576 assert_eq!(pairs[1], ("state".to_string(), "x y".to_string()));
577 }
578
579 #[test]
580 fn percent_decode_handles_escape_at_end() {
581 assert_eq!(percent_decode("code%2F"), "code/");
582 }
583 }