Skip to content

Commit e5b356d

Browse files
committed
fix(login): tolerate stray loopback callbacks instead of bailing
A single stray request to the CLI's loopback /callback endpoint with a missing or stale state would close the server and abort the login, even when the legitimate browser redirect was about to arrive. This made login flaky in the presence of browser prefetchers, restored tabs from earlier login attempts, or any background loopback probe. Now empty/unrecognised payloads and state mismatches respond 400 to the offending request but keep listening; the login only fails on a state-matching request that's missing a token, or on a thrown error. Add an opt-in --debug flag on \`timebook login\` that prints every request hitting /callback (method, url, ua, referer) so the source of stray hits can be identified when needed.
1 parent 9bd28e7 commit e5b356d

3 files changed

Lines changed: 52 additions & 16 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ timebook login
111111

112112
You can also pass `--api-url` and `--web-url` to `timebook login` once; subsequent commands re-use the saved values.
113113

114+
If `timebook login` errors with `State mismatch` or you want to see exactly which requests reach the loopback callback, run with `--debug`:
115+
116+
```bash
117+
timebook login --debug
118+
```
119+
114120
## Develop
115121

116122
```bash

src/commands/login.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface LoginOptions {
2222
port?: number;
2323
/** Open the browser automatically. Default true. */
2424
openBrowser?: boolean;
25+
/** Log every request that hits the loopback /callback. */
26+
debug?: boolean;
2527
}
2628

2729
const SUCCESS_HTML = `<!doctype html>
@@ -97,18 +99,36 @@ export async function loginCommand(opts: LoginOptions = {}): Promise<void> {
9799
return;
98100
}
99101

102+
if (opts.debug) {
103+
console.error(
104+
c.dim(
105+
`[callback] ${req.method ?? '?'} ${req.url ?? '/'} ` +
106+
`ua=${(req.headers['user-agent'] ?? '').slice(0, 60)} ` +
107+
`referer=${req.headers.referer ?? '-'}`,
108+
),
109+
);
110+
}
111+
100112
try {
101113
const payload = await extractPayload(req, url);
102114
if (!payload) {
115+
// Empty / unrecognised — could be a probe. Reply 400 but keep
116+
// listening; the legit callback may still arrive.
117+
if (opts.debug) {
118+
console.error(c.dim('[callback] ignored: empty/unrecognized payload'));
119+
}
103120
fail(res, 'Empty or unrecognized callback payload');
104-
reject(new Error('Empty callback — missing token/secret or state in redirect'));
105-
server.close();
106121
return;
107122
}
108123
if (payload.state !== state) {
124+
// Stale state — almost always a stray request (prefetch, old tab).
125+
// Don't bail the whole login on it; just refuse this one.
126+
if (opts.debug) {
127+
console.error(
128+
c.dim(`[callback] ignored: state mismatch (got ${payload.state ?? '∅'})`),
129+
);
130+
}
109131
fail(res, 'Invalid or missing state');
110-
reject(new Error('State mismatch — login was cancelled or replayed'));
111-
server.close();
112132
return;
113133
}
114134
if (!payload.token || typeof payload.token !== 'string') {

src/index.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,28 @@ async function main(): Promise<void> {
4848
.option('--no-open', 'Do not auto-open the browser; print the URL instead')
4949
.option('--web-url <url>', 'Override the Timebook web URL')
5050
.option('--api-url <url>', 'Override the Timebook API URL')
51-
.action(async (opts: { port?: number; open: boolean; webUrl?: string; apiUrl?: string }) => {
52-
try {
53-
await loginCommand({
54-
port: opts.port,
55-
openBrowser: opts.open,
56-
webUrl: opts.webUrl,
57-
apiUrl: opts.apiUrl,
58-
});
59-
} catch (err) {
60-
fail(err);
61-
}
62-
});
51+
.option('--debug', 'Print every loopback request hitting /callback (diagnostic)')
52+
.action(
53+
async (opts: {
54+
port?: number;
55+
open: boolean;
56+
webUrl?: string;
57+
apiUrl?: string;
58+
debug?: boolean;
59+
}) => {
60+
try {
61+
await loginCommand({
62+
port: opts.port,
63+
openBrowser: opts.open,
64+
webUrl: opts.webUrl,
65+
apiUrl: opts.apiUrl,
66+
debug: opts.debug,
67+
});
68+
} catch (err) {
69+
fail(err);
70+
}
71+
},
72+
);
6373

6474
program
6575
.command('logout')

0 commit comments

Comments
 (0)