From 12fdc8c8e8af8f967a267ce725fb9174b0b6e62a Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 12:29:01 +0000 Subject: [PATCH] feat(stockbit): connect a session from a local browser page idx stockbit login in a terminal now serves a page on 127.0.0.1: a bookmarklet that reads stockbit.com's session cookie in the user's own browser and opens the page with it in the URL fragment, or a read-the-clipboard button, with the manual paste as the fallback. A per-run secret guards the path, the page is no-store with a strict CSP, and it stops on success or after five minutes. --no-browser prints the URL (--port pins it for ssh -L); piped stdin keeps the paste path. A paste Stockbit refuses now restores the previous session instead of losing it. --- crates/idx-cli/src/cli/stockbit.rs | 71 +++- .../tests/golden/completions-bash.golden | 6 +- crates/idx-core/src/api/stockbit/loopback.rs | 393 ++++++++++++++++++ crates/idx-core/src/api/stockbit/mod.rs | 16 +- docs/ARCHITECTURE.md | 8 +- web/src/content/docs/cli/reference.md | 10 +- web/src/content/docs/cli/stockbit.md | 24 +- 7 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 crates/idx-core/src/api/stockbit/loopback.rs diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs index 7b9147f..a483adc 100644 --- a/crates/idx-cli/src/cli/stockbit.rs +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -27,23 +27,46 @@ pub struct StockbitCmd { #[derive(Debug, Subcommand)] pub enum StockbitSubcommand { #[command( - about = "Store a session pasted on stdin and check it with Stockbit", - long_about = "Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie \ -(in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh \ -token, stores it, and checks it with one request. Servers can set \ + about = "Connect a session in your browser, or from stdin", + long_about = "In a terminal, opens a local page on 127.0.0.1 with two one-click ways to \ +hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own \ +browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page \ +URL is printed instead. With stdin piped, it reads the value of stockbit.com's \ +`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, \ +as before. Either way idx checks the session with one request. Servers can set \ IDX_STOCKBIT_REFRESH_TOKEN instead." )] - Login, + Login { + /// Print the page URL instead of opening a browser + #[arg(long)] + no_browser: bool, + /// Fixed port for the local page (for `ssh -L`); default: any free one + #[arg(long)] + port: Option, + }, #[command(about = "Show whether a session is stored and when its tokens expire")] Status, #[command(about = "Delete the stored session")] Logout, } +/// How long the local page waits for the session. +const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> { match &cmd.command { - StockbitSubcommand::Login => { - let status = stockbit::login(&read_paste()?)?; + StockbitSubcommand::Login { no_browser, port } => { + let status = if std::io::stdin().is_terminal() { + match login_in_browser(port.unwrap_or(0), !no_browser) { + Ok(()) => stockbit::session_status()?, + Err(err) => { + runtime::warn(format!("browser login unavailable ({err}); paste instead")); + stockbit::login(&read_paste()?)? + } + } + } else { + stockbit::login(&read_paste()?)? + }; if format.is_json() { return json::print_json(&status); } @@ -81,6 +104,40 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> Ok(()) } +/// Serves the local login page and waits for a session it can verify. +fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> { + let page = stockbit::loopback::LoginPage::bind(port)?; + println!("Open this page to connect Stockbit: {}", page.url()); + if open { + open_in_browser(&page.url()); + } + page.wait(LOGIN_TIMEOUT, &|pasted| { + stockbit::login(pasted) + .map(|_| ()) + .map_err(|err| err.to_string()) + }) +} + +/// Opens `url` with the desktop's default browser, best effort. +fn open_in_browser(url: &str) { + #[cfg(target_os = "macos")] + let (program, args) = ("open", vec![url]); + #[cfg(target_os = "windows")] + let (program, args) = ("cmd", vec!["/C", "start", "", url]); + #[cfg(all(unix, not(target_os = "macos")))] + let (program, args) = ("xdg-open", vec![url]); + let opened = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok(); + if !opened { + runtime::warn(format!("could not open a browser; open {url} yourself")); + } +} + /// The pasted session: one line from a terminal, else all of stdin. fn read_paste() -> Result { let stdin = std::io::stdin(); diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 0c79a03..bd541ff 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -2607,12 +2607,16 @@ _idx() { return 0 ;; idx__stockbit__login) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + opts="-o -q -v -h --no-browser --port --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --port) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --output) COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs new file mode 100644 index 0000000..b8315ed --- /dev/null +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -0,0 +1,393 @@ +//! The local page `idx stockbit login` opens: a loopback server on +//! 127.0.0.1 that takes the pasted session, checks it, and reports back. +//! +//! Stockbit has no OAuth redirect that could hand idx a session, and the +//! browser will not give one site another site's cookie, so the page takes +//! the value two ways, both one click: +//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools +//! once and clicks the button (`navigator.clipboard`, allowed on localhost). +//! - **Bookmarklet**: a link rendered with this run's port and secret that +//! reads `document.cookie` on stockbit.com and opens the page with the value +//! in the URL *fragment*, which browsers never send to the server. +//! +//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no +//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP. +//! The session value is held in memory only on the way to `stockbit::login`; +//! it is never written to a log or the response. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::time::{Duration, Instant}; + +use crate::error::IdxError; + +/// Largest paste accepted (the cookie is a few KB). +const MAX_BODY: usize = 16 * 1024; +/// Requests time out instead of pinning a thread. +const IO_TIMEOUT: Duration = Duration::from_secs(10); + +/// A running login page. +pub struct LoginPage { + listener: TcpListener, + port: u16, + secret: String, +} + +impl LoginPage { + /// Binds 127.0.0.1 on `port` (0 picks a free one). + pub fn bind(port: u16) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| { + IdxError::Io(format!( + "cannot open the Stockbit login page on 127.0.0.1:{port}: {e}" + )) + })?; + let port = listener + .local_addr() + .map_err(|e| IdxError::Io(e.to_string()))? + .port(); + listener + .set_nonblocking(true) + .map_err(|e| IdxError::Io(e.to_string()))?; + Ok(Self { + listener, + port, + secret: random_secret(), + }) + } + + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}/login/{}", self.port, self.secret) + } + + /// Serves until `verify` accepts a paste or `timeout` passes. `verify` + /// returns the error text to show on the page when the paste is refused. + pub fn wait( + &self, + timeout: Duration, + verify: &dyn Fn(&str) -> Result<(), String>, + ) -> Result<(), IdxError> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match self.listener.accept() { + Ok((stream, _)) => { + if self.serve(stream, verify) { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(40)); + } + Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))), + } + } + Err(IdxError::Io(format!( + "the Stockbit login page timed out after {}s", + timeout.as_secs() + ))) + } + + /// Answers one request; true when a paste was accepted. + fn serve(&self, mut stream: TcpStream, verify: &dyn Fn(&str) -> Result<(), String>) -> bool { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let Some(request) = read_request(&mut stream) else { + respond(&mut stream, 400, "text/plain", b"bad request"); + return false; + }; + let page = format!("/login/{}", self.secret); + match (request.method.as_str(), request.path.as_str()) { + ("GET", p) if p == page => { + respond( + &mut stream, + 200, + "text/html; charset=utf-8", + self.page().as_bytes(), + ); + } + ("POST", p) if p == page => match verify(&request.body) { + Ok(()) => { + respond( + &mut stream, + 200, + "application/json", + br#"{"ok":true,"message":"Connected. You can close this tab."}"#, + ); + return true; + } + Err(message) => { + let body = serde_json::json!({ "ok": false, "message": message }); + respond( + &mut stream, + 200, + "application/json", + body.to_string().as_bytes(), + ); + } + }, + ("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""), + _ => respond(&mut stream, 404, "text/plain", b"not found"), + } + false + } + + /// The page, with this run's port and secret baked into the bookmarklet. + fn page(&self) -> String { + let target = self.url(); + // Reads stockbit.com's session cookie and opens the page with it in + // the fragment (never sent to a server, never in a referrer). + let bookmarklet = format!( + "javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\ +if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\ +window.open('{target}#t='+encodeURIComponent(m[1]))}})()" + ); + PAGE.replace("__TARGET__", &target) + .replace("__BOOKMARKLET__", &html_escape(&bookmarklet)) + } +} + +fn random_secret() -> String { + (0..4) + .map(|_| format!("{:08x}", fastrand::u32(..))) + .collect::>() + .join("") +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +struct Request { + method: String, + path: String, + body: String, +} + +/// A minimal HTTP/1.1 request: request line, headers, and a bounded body. +fn read_request(stream: &mut TcpStream) -> Option { + let mut raw = Vec::new(); + let mut chunk = [0u8; 2048]; + let header_end = loop { + let n = stream.read(&mut chunk).ok()?; + if n == 0 { + return None; + } + raw.extend_from_slice(&chunk[..n]); + if let Some(at) = find(&raw, b"\r\n\r\n") { + break at + 4; + } + if raw.len() > MAX_BODY { + return None; + } + }; + let head = String::from_utf8_lossy(&raw[..header_end]).into_owned(); + let mut lines = head.lines(); + let mut request_line = lines.next()?.split_whitespace(); + let method = request_line.next()?.to_string(); + let path = request_line.next()?.split('?').next()?.to_string(); + let length = lines + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0) + .min(MAX_BODY); + let mut body = raw[header_end..].to_vec(); + while body.len() < length { + let n = stream.read(&mut chunk).ok()?; + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + body.truncate(length); + Some(Request { + method, + path, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + _ => "Not Found", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\ + X-Content-Type-Options: nosniff\r\n\ + Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \ + script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); +} + +const PAGE: &str = r#" + + +Connect Stockbit to idx + +
+

Connect Stockbit to idx

+

One step. idx stores the session on this machine only, and never sends it anywhere but Stockbit.

+ +
+

1. Open stockbit.com and make sure you are logged in.

+
+ +
+

2. While on stockbit.com, click this bookmarklet. It reads your Stockbit session cookie in your own browser and hands it to this page.

+

Send my Stockbit session

+

If your browser blocks it, drag the button to your bookmarks bar and click it there.

+
+ +
+

Or copy it by hand. On stockbit.com: DevTools, Application, Cookies, https://stockbit.com, copy the credentialStorage value, then paste it here.

+ +
+ + +
+
+ +

+

idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser sharing this session will be signed out once idx refreshes it. This page closes when you finish, or after a few minutes.

+
+ + +"#; + +#[cfg(test)] +mod tests { + use std::io::{Read as _, Write as _}; + use std::net::TcpStream; + + use super::*; + + fn request(url: &str, method: &str, body: &str) -> (u16, String) { + let rest = url.trim_start_matches("http://"); + let (authority, path) = rest.split_once('/').expect("path"); + let mut stream = TcpStream::connect(authority).expect("connect"); + // A server that never answers must fail the test, not hang it. + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let status = raw + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("status"); + let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(); + (status, body) + } + + #[test] + fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() { + let page = LoginPage::bind(0).unwrap(); + let url = page.url(); + let bad = url.replace(&page.secret, "not-the-secret"); + + let handle = std::thread::spawn(move || { + let verify = |paste: &str| match paste.trim() { + "good" => Ok(()), + _ => Err("not a Stockbit session".to_string()), + }; + page.wait(Duration::from_secs(10), &verify) + }); + std::thread::sleep(Duration::from_millis(100)); + + assert_eq!(request(&bad, "GET", "").0, 404); + let (status, html) = request(&url, "GET", ""); + assert_eq!(status, 200); + assert!(html.contains("Connect Stockbit to idx")); + assert!(html.contains("javascript:"), "bookmarklet rendered"); + // The bookmarklet carries this run's page and a fragment, not the + // secret in a query string. + assert!(html.contains(&format!("{url}#t=")), "bookmarklet target"); + + let (status, body) = request(&url, "POST", "nonsense"); + assert_eq!(status, 200); + assert!(body.contains("\"ok\":false"), "{body}"); + + let (status, body) = request(&url, "POST", "good"); + assert_eq!(status, 200); + assert!(body.contains("\"ok\":true"), "{body}"); + handle.join().unwrap().expect("accepted"); + } + + #[test] + fn times_out_without_a_paste() { + let page = LoginPage::bind(0).unwrap(); + let started = Instant::now(); + let err = page + .wait(Duration::from_millis(150), &|_| Err("no".to_string())) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index d0b0175..e27147d 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -17,6 +17,7 @@ pub mod auth; pub mod exodus; +pub mod loopback; mod parse; use std::fs::{File, OpenOptions}; @@ -234,18 +235,31 @@ pub fn login(pasted: &str) -> Result { ) })?; let store = SessionStore::open()?; + // A refused paste must not lose a session that already worked. + let previous = std::fs::read(store.path()).ok(); store.save(&session)?; #[cfg(feature = "testing")] let check = !crate::api::mock::enabled(); #[cfg(not(feature = "testing"))] let check = true; if check && let Err(err) = Exodus::new(store.clone()).verify() { - let _ = store.clear(); + restore(&store, previous); return Err(err); } Ok(store.status(chrono::Utc::now().timestamp())) } +fn restore(store: &SessionStore, previous: Option>) { + match previous { + Some(bytes) => { + let _ = std::fs::write(store.path(), bytes); + } + None => { + let _ = store.clear(); + } + } +} + /// The stored session's state, without a request and without any token. pub fn session_status() -> Result { Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp())) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f66b7cc..ac45218 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,7 +195,13 @@ WIB as the fallback. `credentialStorage` cookie (or a bare refresh token) in `/stockbit-session.json`, written atomically with mode 0600; `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the - file lacks. `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, + file lacks. In a terminal it first serves a page on 127.0.0.1 + (`api/stockbit/loopback.rs`): a bookmarklet that reads the cookie in the + user's own browser and opens the page with it in the URL fragment, or a + clipboard button; a per-run secret guards the path, the page is `no-store` + with a strict CSP, and the server stops on success or after five minutes. + Piped stdin keeps the paste path. `api/stockbit/auth.rs` reads expiries + from the JWTs (`exp`, unverified) and refreshes an access token within an hour of expiry with `POST /login/refresh` (bearer = refresh token), which rotates both tokens. Because a refresh token is spent once, refreshes run under an exclusive diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index d02708f..1796b35 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -1555,7 +1555,7 @@ Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` qu Usage: idx stockbit [OPTIONS] Commands: - login Store a session pasted on stdin and check it with Stockbit + login Connect a session in your browser, or from stdin status Show whether a session is stored and when its tokens expire logout Delete the stored session help Print this message or the help of the given subcommand(s) @@ -1599,11 +1599,14 @@ Examples: ### `idx stockbit login` ```text -Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie (in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh token, stores it, and checks it with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead. +In a terminal, opens a local page on 127.0.0.1 with two one-click ways to hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page URL is printed instead. With stdin piped, it reads the value of stockbit.com's `credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, as before. Either way idx checks the session with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead. Usage: idx stockbit login [OPTIONS] Options: + --no-browser + Print the page URL instead of opening a browser + -o, --output Output format [default: auto = table on a terminal, json when piped] @@ -1612,6 +1615,9 @@ Options: --no-color Disable colored output + --port + Fixed port for the local page (for `ssh -L`); default: any free one + -q, --quiet Suppress non-essential output diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index a372423..37d74b7 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -80,14 +80,22 @@ idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily idx stockbit logout ``` -**Logging in.** In a browser logged in to stockbit.com, open DevTools, -Application, Cookies, `https://stockbit.com`, and copy the value of -`credentialStorage`. Paste it into `idx stockbit login`, or pipe it -(`pbpaste | idx stockbit login`). A refresh token on its own works too. idx -checks the session with one request and stores it in -`~/.config/idx/stockbit-session.json`, readable only by you. Tokens never -appear in output, logs or errors. On a server, set -`IDX_STOCKBIT_REFRESH_TOKEN` instead. +**Logging in.** `idx stockbit login` opens a small page on your own machine +(127.0.0.1, a fresh port each run). With stockbit.com open and logged in, +click the page's **Send my Stockbit session** bookmarklet: it reads +Stockbit's `credentialStorage` cookie in your browser and hands it to the +page, which checks it with one request and says "Connected". If bookmarklets +are blocked, click **Paste from clipboard** after copying the cookie, or +paste it into the box. Without a browser, `--no-browser` prints the page URL +instead, and `--port 7878` pins the port for `ssh -L 7878:127.0.0.1:7878`. +Piping the value in still works (`pbpaste | idx stockbit login`), as does a +refresh token on its own. On a server, set `IDX_STOCKBIT_REFRESH_TOKEN`. + +The session lands in `~/.config/idx/stockbit-session.json`, readable only by +you. Tokens never appear in output, logs or errors. The page takes only the +session value, keeps it in memory, and closes when it is done (or after five +minutes). A session Stockbit refuses leaves a previously stored one in +place. **One refresher per session.** Stockbit replaces the refresh token every time it is used. Once idx refreshes your session, a browser holding the same