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.
This commit is contained in:
hermes 2026-10-23 12:29:01 +00:00
commit 12fdc8c8e8
7 changed files with 508 additions and 20 deletions

View file

@ -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<u16>,
},
#[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<String, IdxError> {
let stdin = std::io::stdin();

View file

@ -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

View file

@ -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<Self, IdxError> {
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::<Vec<_>>()
.join("")
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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<Request> {
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::<usize>().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<usize> {
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#"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Connect Stockbit to idx</title>
<style>
:root{--bg:#fff;--fg:#111;--muted:#666;--line:#e5e5e5;--accent:#111;--ok:#1a7f37;--err:#b42318;--card:#fafafa}
@media (prefers-color-scheme:dark){:root{--bg:#0d0d0d;--fg:#f2f2f2;--muted:#9a9a9a;--line:#2a2a2a;--accent:#f2f2f2;--card:#161616}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:640px;margin:0 auto;padding:48px 24px 64px}
h1{font-size:22px;letter-spacing:-.01em;margin:0 0 6px}
p.lede{color:var(--muted);margin:0 0 28px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:28px 0 10px}
.step{border:1px solid var(--line);border-radius:12px;padding:18px;background:var(--card);margin-bottom:14px}
.step p{margin:0 0 12px}
.btn{display:inline-block;border:1px solid var(--accent);background:var(--accent);color:var(--bg);border-radius:9px;padding:9px 14px;font:inherit;font-weight:600;cursor:pointer;text-decoration:none}
.btn[disabled]{opacity:.5;cursor:default}
.btn.ghost{background:transparent;color:var(--fg);border-color:var(--line);font-weight:500}
code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:1px 5px}
textarea{width:100%;min-height:76px;margin-top:10px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--fg);padding:10px;font:13px ui-monospace,Menlo,monospace;resize:vertical}
#status{margin-top:18px;font-weight:600;min-height:1.4em}
#status.ok{color:var(--ok)}#status.err{color:var(--err)}
.foot{color:var(--muted);font-size:13px;margin-top:28px}
.foot a{color:inherit}
</style></head>
<body><main>
<h1>Connect Stockbit to idx</h1>
<p class="lede">One step. idx stores the session on this machine only, and never sends it anywhere but Stockbit.</p>
<div class="step">
<p><strong>1.</strong> Open <a href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">stockbit.com</a> and make sure you are logged in.</p>
</div>
<div class="step">
<p><strong>2.</strong> While on stockbit.com, click this bookmarklet. It reads your Stockbit session cookie in your own browser and hands it to this page.</p>
<p><a class="btn" href="__BOOKMARKLET__">Send my Stockbit session</a></p>
<p class="lede" style="margin:0">If your browser blocks it, drag the button to your bookmarks bar and click it there.</p>
</div>
<div class="step">
<p><strong>Or copy it by hand.</strong> On stockbit.com: DevTools, Application, Cookies, <code>https://stockbit.com</code>, copy the <code>credentialStorage</code> value, then paste it here.</p>
<textarea id="paste" placeholder="credentialStorage value"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" id="send">Connect</button>
<button class="btn ghost" id="clip">Paste from clipboard</button>
</div>
</div>
<p id="status"></p>
<p class="foot">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.</p>
</main>
<script>
const target = "__TARGET__";
const status = document.getElementById('status');
const setStatus = (text, kind) => { status.textContent = text; status.className = kind || ''; };
async function send(text) {
if (!text || !text.trim()) { setStatus('Nothing to send yet.', 'err'); return; }
setStatus('Checking with Stockbit…');
try {
const response = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: text });
const result = await response.json();
setStatus(result.message, result.ok ? 'ok' : 'err');
} catch (error) { setStatus('Could not reach idx: ' + error, 'err'); }
}
document.getElementById('send').addEventListener('click', () => send(document.getElementById('paste').value));
document.getElementById('clip').addEventListener('click', async () => {
try { send(await navigator.clipboard.readText()); }
catch (error) { setStatus('Clipboard blocked (' + error + '). Paste into the box instead.', 'err'); }
});
if (location.hash.startsWith('#t=')) {
const value = decodeURIComponent(location.hash.slice(3));
history.replaceState(null, '', location.pathname);
send(value);
}
</script>
</body></html>
"#;
#[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));
}
}

View file

@ -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<auth::SessionStatus, IdxError> {
)
})?;
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<Vec<u8>>) {
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<auth::SessionStatus, IdxError> {
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))

View file

@ -195,7 +195,13 @@ WIB as the fallback.
`credentialStorage` cookie (or a bare refresh token) in
`<config dir>/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

View file

@ -1555,7 +1555,7 @@ Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` qu
Usage: idx stockbit [OPTIONS] <COMMAND>
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>
Output format [default: auto = table on a terminal, json when piped]
@ -1612,6 +1615,9 @@ Options:
--no-color
Disable colored output
--port <PORT>
Fixed port for the local page (for `ssh -L`); default: any free one
-q, --quiet
Suppress non-essential output

View file

@ -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