-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcliAuthPoll.ts
More file actions
117 lines (104 loc) · 4.77 KB
/
Copy pathcliAuthPoll.ts
File metadata and controls
117 lines (104 loc) · 4.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// cliAuthPoll.ts — the CLI's poll loop for the loopback-free login (ARP-773).
//
// After opening the browser to /cli-auth?session=&k=, `backthread login` polls the public
// cli-auth-poll endpoint (consume mode) until the browser has stashed the encrypted token.
// On 'ready' we decrypt locally with our ephemeral private key and return the token. The
// browser stays on app.backthread.dev the whole time — no 127.0.0.1, no localhost landing,
// and because delivery is via polling the browser can even be on ANOTHER device (SSH /
// containers work with no flags).
//
// The network + timing are injectable (fetch / sleep / now) so the loop is unit-testable
// without a real server or real waits.
import type { ECDH } from 'node:crypto';
import { decryptToken, type EphemeralKeypair, type EncryptedPayload } from './cliAuthCrypto.js';
import { buildCliAuthPollUrl } from './urls.js';
import { versionHeaders } from './version.js';
const TOKEN_RE = /^backthread_pat_[A-Za-z0-9_-]+$/;
export type PollResult =
| { ok: true; token: string }
| { ok: false; reason: 'expired' | 'timeout' | 'error'; message: string };
export interface PollOptions {
env?: NodeJS.ProcessEnv;
/** Test seam: inject a fetch. Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Poll cadence (ms). Default 1500. */
intervalMs?: number;
/** Overall budget (ms) before giving up. Default 5 min (the CLI's wait window). */
timeoutMs?: number;
/** Test seams for deterministic timing. */
sleep?: (ms: number) => Promise<void>;
now?: () => number;
}
// Poll until the browser delivers the encrypted token (then decrypt + return it), the
// session expires, or the timeout elapses. Never throws — every failure path returns a
// typed reason the caller turns into a clear message.
export async function pollForToken(
sessionId: string,
keypair: EphemeralKeypair,
opts: PollOptions = {},
): Promise<PollResult> {
const env = opts.env ?? process.env;
const doFetch = opts.fetchImpl ?? fetch;
const interval = opts.intervalMs ?? 1500;
const timeout = opts.timeoutMs ?? 5 * 60_000;
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const now = opts.now ?? (() => Date.now());
const url = buildCliAuthPollUrl(env);
const deadline = now() + timeout;
while (now() < deadline) {
let res: Response;
try {
res = await doFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...versionHeaders() },
// The CLI is the CONSUMING poller (default mode) — the browser peeks separately.
body: JSON.stringify({ session_id: sessionId }),
});
} catch {
// Transient network error — back off and retry within the budget.
await sleep(interval);
continue;
}
// 429 rate_limited (or any 5xx): back off and retry; never fatal on its own.
if (res.status === 429 || res.status >= 500) {
await sleep(interval);
continue;
}
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const status = typeof body?.status === 'string' ? body.status : null;
if (status === 'ready') {
const enc = extractPayload(body);
if (!enc) return { ok: false, reason: 'error', message: 'incomplete token payload from the server' };
let token: string;
try {
token = decryptToken(enc, keypair.ecdh as ECDH);
} catch {
return { ok: false, reason: 'error', message: 'could not decrypt the token (key mismatch)' };
}
// Bound the decrypted plaintext to the exact token shape before it's stored/used.
if (!TOKEN_RE.test(token)) {
return { ok: false, reason: 'error', message: 'the decrypted token was malformed' };
}
return { ok: true, token };
}
if (status === 'expired') {
return { ok: false, reason: 'expired', message: 'the login session expired before you authorized' };
}
if (status === 'consumed') {
// Someone (or a duplicate poll) already claimed this session's one-time fetch.
return { ok: false, reason: 'error', message: 'this login was already used — start a fresh `backthread login`' };
}
// 'pending' (or an unknown transient) → keep waiting.
await sleep(interval);
}
return { ok: false, reason: 'timeout', message: 'timed out waiting for the browser to authorize this device' };
}
// Pull the three ciphertext fields out of a 'ready' response, or null if any is missing.
function extractPayload(body: Record<string, unknown> | null): EncryptedPayload | null {
if (!body) return null;
const { page_ephemeral_pubkey, iv, ciphertext } = body;
if (typeof page_ephemeral_pubkey === 'string' && typeof iv === 'string' && typeof ciphertext === 'string') {
return { page_ephemeral_pubkey, iv, ciphertext };
}
return null;
}