mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
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:
parent
1d4ab65853
commit
12fdc8c8e8
7 changed files with 508 additions and 20 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
393
crates/idx-core/src/api/stockbit/loopback.rs
Normal file
393
crates/idx-core/src/api/stockbit/loopback.rs
Normal 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('&', "&")
|
||||
.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<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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue