@blocofy/cli 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import { createServer } from "node:http";
2
+
3
+ /**
4
+ * ADR-0014 §5.1/§5.2 (wave P4) — the browser login's loopback redirect (RFC 8252 §7.3).
5
+ *
6
+ * The CLI listens on `127.0.0.1` (or `[::1]`) on an ephemeral port and registers `http://<host>:<port>/callback`.
7
+ * `localhost` is refused (RFC 8252 §8.3: it may not resolve to the loopback), as the platform refuses it. Exactly one
8
+ * GET `/callback` is accepted; every other path is 404 and does not end the wait. The listener closes after that one
9
+ * callback or after the timeout (5 minutes by default). The page it answers never echoes the code.
10
+ */
11
+
12
+ export const CALLBACK_PATH = "/callback";
13
+ export const LOGIN_TIMEOUT_MS = 5 * 60_000;
14
+ const HOSTS = new Set(["127.0.0.1", "::1"]);
15
+
16
+ export class LoopbackError extends Error {
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.name = "LoopbackError";
20
+ this.code = code;
21
+ this.exitCode = 1;
22
+ }
23
+ }
24
+
25
+ /** The redirect URI for `host` + `port`, or LOGIN_LOOPBACK_REFUSED (anything but 127.0.0.1 / ::1, a port < 1024). */
26
+ export function loopbackRedirectUri(host, port) {
27
+ if (!HOSTS.has(host)) {
28
+ throw new LoopbackError("LOGIN_LOOPBACK_REFUSED", `The login callback must listen on 127.0.0.1 or [::1], not "${host}" (localhost is not accepted: it may not resolve to this machine).`);
29
+ }
30
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
31
+ throw new LoopbackError("LOGIN_LOOPBACK_REFUSED", `The login callback port must be between 1024 and 65535 (got ${port}).`);
32
+ }
33
+ return `http://${host === "::1" ? "[::1]" : host}:${port}${CALLBACK_PATH}`;
34
+ }
35
+
36
+ /** The platform's rule for the first-party client's redirect, mirrored (canonical form, exact path, no query). */
37
+ export function isLoopbackCallback(uri) {
38
+ let url;
39
+ try {
40
+ url = new URL(uri);
41
+ } catch {
42
+ return false;
43
+ }
44
+ if (url.href !== uri || url.protocol !== "http:" || url.username || url.password || url.search || url.hash) return false;
45
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "[::1]") return false;
46
+ const port = Number(url.port);
47
+ return url.pathname === CALLBACK_PATH && url.port !== "" && port >= 1024 && port <= 65535;
48
+ }
49
+
50
+ const PAGE = (ok) =>
51
+ `<!doctype html><html lang="tr"><meta charset="utf-8"><title>Blocofy CLI</title><body style="font-family:system-ui;margin:3rem">` +
52
+ (ok
53
+ ? "<h1>Blocofy CLI</h1><p>Tarayıcı adımı tamamlandı. Bu sekmeyi kapatıp terminale dönebilirsin.</p><p>The browser step is done; you can close this tab and return to the terminal.</p>"
54
+ : "<h1>Blocofy CLI</h1><p>Bu adres bir giriş yanıtı değil.</p>") +
55
+ "</body></html>";
56
+
57
+ /**
58
+ * Listen on `host` (127.0.0.1 | ::1). Returns `{ redirectUri, wait(), close() }`: `wait()` resolves with the
59
+ * callback's URLSearchParams (the first GET /callback) or rejects LOGIN_TIMEOUT; the server is closed either way.
60
+ */
61
+ export async function startLoopbackListener({ host = "127.0.0.1", timeoutMs = LOGIN_TIMEOUT_MS } = {}) {
62
+ if (!HOSTS.has(host)) loopbackRedirectUri(host, 1024); // throws LOGIN_LOOPBACK_REFUSED
63
+ let settle;
64
+ const result = new Promise((resolve, reject) => {
65
+ settle = { resolve, reject };
66
+ });
67
+ result.catch(() => {}); // observed through wait()
68
+ let done = false;
69
+ const server = createServer((req, res) => {
70
+ const url = new URL(req.url ?? "/", "http://loopback");
71
+ if (done || req.method !== "GET" || url.pathname !== CALLBACK_PATH) {
72
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
73
+ res.end(PAGE(false));
74
+ return;
75
+ }
76
+ done = true;
77
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", connection: "close" });
78
+ res.end(PAGE(true));
79
+ finish(() => settle.resolve(url.searchParams));
80
+ });
81
+ let timer = null;
82
+ const finish = (fn) => {
83
+ clearTimeout(timer);
84
+ server.close();
85
+ server.closeAllConnections?.();
86
+ fn();
87
+ };
88
+ await new Promise((resolve, reject) => {
89
+ server.once("error", reject);
90
+ server.listen(0, host, () => {
91
+ server.off("error", reject);
92
+ resolve();
93
+ });
94
+ });
95
+ const redirectUri = loopbackRedirectUri(host, server.address().port);
96
+ timer = setTimeout(() => {
97
+ if (done) return;
98
+ done = true;
99
+ finish(() => settle.reject(new LoopbackError("LOGIN_TIMEOUT", `No answer from the browser within ${Math.round(timeoutMs / 1000)} s; the login was not completed. Nothing was saved. Run \`blocofy login\` again.`)));
100
+ }, timeoutMs);
101
+ timer.unref?.();
102
+ return {
103
+ redirectUri,
104
+ wait: () => result,
105
+ close() {
106
+ if (done) return;
107
+ done = true;
108
+ finish(() => settle.reject(new LoopbackError("LOGIN_CANCELLED", "The login was cancelled.")));
109
+ },
110
+ };
111
+ }
@@ -20,11 +20,13 @@
20
20
  import { fetchWithRetry } from "./http.mjs";
21
21
 
22
22
  export const API_KEY_PREFIX = "blcf_live_";
23
+ /** ADR-0014 §3.2: a profiled API key (`theme-dev` / `review` / `service-ci` / `full`) starts with `blcf_k2_`. */
24
+ export const API_KEY_PREFIXES = [API_KEY_PREFIX, "blcf_k2_"];
23
25
  export const DEFAULT_API_URL = "https://app.blocofy.com";
24
26
 
25
- /** v1 accepts ONLY a live API key. Never echo the key back in a message. */
27
+ /** A pasted v1 API key (`blcf_live_` or `blcf_k2_`; never a CLI login token `blcf_ct_`). Never echo the key back. */
26
28
  export function isValidApiKey(key) {
27
- return typeof key === "string" && key.startsWith(API_KEY_PREFIX) && key.length > API_KEY_PREFIX.length;
29
+ return typeof key === "string" && API_KEY_PREFIXES.some((p) => key.startsWith(p) && key.length > p.length);
28
30
  }
29
31
 
30
32
  /** A 4xx response from v1: `status` + the server's `error` object ({ code, message, details? }). */
package/lib/oauth.mjs ADDED
@@ -0,0 +1,295 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ import { fetchWithRetry } from "./http.mjs";
4
+ import { isLoopbackCallback, startLoopbackListener } from "./loopback.mjs";
5
+
6
+ /**
7
+ * ADR-0014 §5 (wave P4) — `blocofy login`: the system browser, Authorization Code + PKCE S256, loopback redirect.
8
+ *
9
+ * 1. Discovery: `GET <origin>/.well-known/oauth-authorization-server`. The issuer must be the origin itself, the
10
+ * token/revocation endpoints on it; S256 and RFC 9207 `iss` must be supported (an older platform is refused).
11
+ * 2. Loopback listener (127.0.0.1 or [::1], ephemeral port, exact /callback), `state` and the PKCE verifier are 32
12
+ * random bytes. The browser opens `/authorize?…&resource=<origin>/api/v1` (no scope: the platform pre-checks the
13
+ * draft profile's). The URL carries no secret.
14
+ * 3. The callback must carry `iss` = the metadata issuer and our `state`; only then is its code looked at.
15
+ * 4. `/token` (authorization_code): the verifier + the same `resource`. Never retried (a code is single-use). The
16
+ * answer must be a CLI token (`blcf_ct_`, audience `cli`).
17
+ * 5. Success = the canonical identity probe `GET /api/v1/ping` (site + profile + audience `cli`), not a config write.
18
+ * Anything that fails after the code exchange revokes the new login (best effort), so no half-made grant is left.
19
+ */
20
+
21
+ export const CLI_CLIENT_ID = "blocofy-cli";
22
+ export const CLI_TOKEN_PREFIX = "blcf_ct_";
23
+ const REQUEST_TIMEOUT_MS = 10_000;
24
+
25
+ export class LoginError extends Error {
26
+ constructor(code, message, details = {}, exitCode = 1) {
27
+ super(message);
28
+ this.name = "LoginError";
29
+ this.code = code;
30
+ this.details = details;
31
+ this.exitCode = exitCode;
32
+ }
33
+ }
34
+
35
+ const b64url = (buf) => Buffer.from(buf).toString("base64url");
36
+ const stripSlash = (u) => String(u).replace(/\/+$/, "");
37
+
38
+ export function createPkce() {
39
+ const verifier = b64url(randomBytes(32));
40
+ return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()), method: "S256" };
41
+ }
42
+
43
+ export const newState = () => b64url(randomBytes(32));
44
+
45
+ const sameString = (a, b) => {
46
+ const x = Buffer.from(String(a ?? ""));
47
+ const y = Buffer.from(String(b ?? ""));
48
+ return x.length === y.length && timingSafeEqual(x, y);
49
+ };
50
+
51
+ /** https, or http on a loopback address (a local development platform). */
52
+ function acceptableOrigin(origin) {
53
+ let u;
54
+ try {
55
+ u = new URL(origin);
56
+ } catch {
57
+ return false;
58
+ }
59
+ if (u.protocol === "https:") return true;
60
+ return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "localhost");
61
+ }
62
+
63
+ /** The resource indicator (RFC 8707) of the CLI audience. */
64
+ export const cliResource = (origin) => `${stripSlash(origin)}/api/v1`;
65
+
66
+ /** RFC 8414 discovery with the checks above. */
67
+ export async function discoverMetadata(origin, { fetchImpl = globalThis.fetch } = {}) {
68
+ const base = stripSlash(origin);
69
+ if (!acceptableOrigin(base)) throw new LoginError("LOGIN_DISCOVERY_FAILED", `The platform address must be https:// (got ${base}). Nothing was sent.`);
70
+ let res;
71
+ try {
72
+ res = await fetchWithRetry(`${base}/.well-known/oauth-authorization-server`, { headers: { accept: "application/json" } }, { fetchImpl, timeoutMs: REQUEST_TIMEOUT_MS, retries: 1 });
73
+ } catch (error) {
74
+ throw new LoginError("LOGIN_DISCOVERY_FAILED", `Could not reach ${base} (${error?.name === "TimeoutError" ? "timeout" : "network error"}). Nothing was saved.`);
75
+ }
76
+ let meta = null;
77
+ try {
78
+ meta = res.ok ? await res.json() : null;
79
+ } catch {
80
+ meta = null;
81
+ }
82
+ if (!meta || typeof meta !== "object") throw new LoginError("LOGIN_DISCOVERY_FAILED", `${base} did not answer the login discovery (HTTP ${res.status}). Nothing was saved.`);
83
+ if (meta.issuer !== base) {
84
+ throw new LoginError("LOGIN_ISSUER_MISMATCH", `The platform at ${base} names another issuer (${meta.issuer}); the login was refused. Nothing was saved.`, { issuer: meta.issuer ?? null });
85
+ }
86
+ for (const key of ["authorization_endpoint", "token_endpoint", "revocation_endpoint"]) {
87
+ const v = meta[key];
88
+ if (v === undefined && key === "revocation_endpoint") continue;
89
+ let u;
90
+ try {
91
+ u = new URL(v);
92
+ } catch {
93
+ u = null;
94
+ }
95
+ if (!u || u.origin !== new URL(base).origin) {
96
+ throw new LoginError("LOGIN_ISSUER_MISMATCH", `The platform's ${key} is not on ${base}; the login was refused. Nothing was saved.`, { [key]: v ?? null });
97
+ }
98
+ }
99
+ if (!Array.isArray(meta.code_challenge_methods_supported) || !meta.code_challenge_methods_supported.includes("S256") || meta.authorization_response_iss_parameter_supported !== true) {
100
+ throw new LoginError(
101
+ "LOGIN_UNSUPPORTED_PLATFORM",
102
+ `The platform at ${base} does not support the CLI browser login yet (PKCE S256 + RFC 9207 iss). Nothing was saved. Use the advanced login: blocofy login --token (dev token) or blocofy login --api-key.`,
103
+ );
104
+ }
105
+ return { issuer: meta.issuer, authorization_endpoint: meta.authorization_endpoint, token_endpoint: meta.token_endpoint, revocation_endpoint: meta.revocation_endpoint ?? null };
106
+ }
107
+
108
+ export function authorizeUrl(meta, { redirectUri, challenge, state, resource, clientId = CLI_CLIENT_ID }) {
109
+ const u = new URL(meta.authorization_endpoint);
110
+ u.searchParams.set("response_type", "code");
111
+ u.searchParams.set("client_id", clientId);
112
+ u.searchParams.set("redirect_uri", redirectUri);
113
+ u.searchParams.set("code_challenge", challenge);
114
+ u.searchParams.set("code_challenge_method", "S256");
115
+ u.searchParams.set("state", state);
116
+ u.searchParams.set("resource", resource);
117
+ return u.toString();
118
+ }
119
+
120
+ /** RFC 9207 first (also on error answers), then state, then the answer itself. Returns the code. */
121
+ export function validateCallback(params, { issuer, state }) {
122
+ if (!params.has("iss") || !sameString(params.get("iss"), issuer)) {
123
+ throw new LoginError("LOGIN_ISS_MISMATCH", "The browser answer did not come from this platform (iss mismatch); the login was refused. Nothing was saved.");
124
+ }
125
+ if (!sameString(params.get("state"), state)) {
126
+ throw new LoginError("LOGIN_STATE_MISMATCH", "The browser answer does not belong to this login (state mismatch); the login was refused. Nothing was saved.");
127
+ }
128
+ const error = params.get("error");
129
+ if (error) {
130
+ throw new LoginError(
131
+ "LOGIN_DENIED",
132
+ error === "access_denied" ? "The login was not approved in the browser. Nothing was saved." : `The platform refused the login (${error}). Nothing was saved.`,
133
+ { error },
134
+ );
135
+ }
136
+ const code = params.get("code");
137
+ if (!code) throw new LoginError("LOGIN_DENIED", "The browser answer carried no authorization code. Nothing was saved.");
138
+ return code;
139
+ }
140
+
141
+ async function postForm(url, fields, { fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS }) {
142
+ const res = await fetchImpl(url, {
143
+ method: "POST",
144
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
145
+ body: new URLSearchParams(fields).toString(),
146
+ signal: AbortSignal.timeout(timeoutMs),
147
+ });
148
+ const text = await res.text().catch(() => "");
149
+ let json = null;
150
+ try {
151
+ json = JSON.parse(text);
152
+ } catch {
153
+ json = null;
154
+ }
155
+ return { res, json };
156
+ }
157
+
158
+ /** The code exchange. Never retried (a resend of a spent code makes the platform revoke what it issued). */
159
+ export async function exchangeCode(meta, { code, verifier, redirectUri, resource, clientId = CLI_CLIENT_ID, fetchImpl = globalThis.fetch, now = Date.now }) {
160
+ let out;
161
+ try {
162
+ out = await postForm(meta.token_endpoint, { grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, resource }, { fetchImpl });
163
+ } catch (error) {
164
+ throw new LoginError("LOGIN_TOKEN_FAILED", `The platform did not answer the login's code exchange (${error?.name === "TimeoutError" ? "timeout" : "network error"}). Nothing was saved; run \`blocofy login\` again.`);
165
+ }
166
+ const { res, json } = out;
167
+ if (!res.ok || !json) {
168
+ throw new LoginError("LOGIN_TOKEN_FAILED", `The platform refused the login's code exchange (${json?.error ?? `HTTP ${res.status}`}). Nothing was saved; run \`blocofy login\` again.`, { error: json?.error ?? null, status: res.status });
169
+ }
170
+ const tokens = {
171
+ access_token: typeof json.access_token === "string" ? json.access_token : null,
172
+ refresh_token: typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : null,
173
+ expires_at: now() + Number(json.expires_in) * 1000,
174
+ };
175
+ return { tokens, tokenType: json.token_type ?? null };
176
+ }
177
+
178
+ /**
179
+ * RFC 7009 revocation: `revoked` (HTTP 200 — the platform answers 200 for an unknown or already revoked token too),
180
+ * `refused` (another 4xx/5xx answer: NOT revoked), `unreachable` (network/timeout: NOT revoked).
181
+ */
182
+ export async function revokeToken({ endpoint, token, clientId = CLI_CLIENT_ID, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS }) {
183
+ try {
184
+ const { res } = await postForm(endpoint, { token, client_id: clientId }, { fetchImpl, timeoutMs });
185
+ if (res.status === 200) return { outcome: "revoked", status: 200 };
186
+ return { outcome: res.status >= 500 ? "unreachable" : "refused", status: res.status };
187
+ } catch {
188
+ return { outcome: "unreachable", status: null };
189
+ }
190
+ }
191
+
192
+ /** The canonical identity probe (`GET /api/v1/ping`) of a CLI token: site + profile + audience `cli`. */
193
+ export async function probeIdentity(origin, accessToken, { fetchImpl = globalThis.fetch, onRetry = null } = {}) {
194
+ let res;
195
+ try {
196
+ res = await fetchWithRetry(`${stripSlash(origin)}/api/v1/ping`, { headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" } }, { fetchImpl, timeoutMs: 15_000, onRetry });
197
+ } catch (error) {
198
+ throw new LoginError("LOGIN_IDENTITY_UNVERIFIED", `Could not confirm the new login with the platform (${error?.name === "TimeoutError" ? "timeout" : "network error"}). Nothing was saved.`);
199
+ }
200
+ let body = null;
201
+ try {
202
+ body = await res.json();
203
+ } catch {
204
+ body = null;
205
+ }
206
+ if (!res.ok || !body || typeof body !== "object") {
207
+ const code = body?.error?.code ?? null;
208
+ throw new LoginError(code === "audience_mismatch" ? "LOGIN_AUDIENCE_MISMATCH" : "LOGIN_IDENTITY_UNVERIFIED", `The platform did not confirm the new login (${code ?? `HTTP ${res.status}`}). Nothing was saved.`, { server_code: code, status: res.status });
209
+ }
210
+ const site = body.site;
211
+ if (!site || typeof site !== "object" || !(typeof site.id === "string" || Number.isFinite(site.id))) {
212
+ throw new LoginError("LOGIN_IDENTITY_UNVERIFIED", "The platform's identity answer names no site. Nothing was saved.");
213
+ }
214
+ if (body.audience !== "cli") {
215
+ throw new LoginError("LOGIN_AUDIENCE_MISMATCH", `The new login is not a CLI login (audience ${body.audience ?? "missing"}); it was refused. Nothing was saved.`, { audience: body.audience ?? null });
216
+ }
217
+ const p = body.profile;
218
+ if (!p || typeof p !== "object" || typeof p.id !== "string") {
219
+ throw new LoginError("LOGIN_UNSUPPORTED_PLATFORM", "The platform does not report the login's profile (it is older than the CLI login). Nothing was saved. Use the advanced login: blocofy login --token.");
220
+ }
221
+ let devUrl = null;
222
+ if (typeof body.dev_endpoint === "string") {
223
+ try {
224
+ const u = new URL(body.dev_endpoint);
225
+ if ((u.protocol === "https:" || u.protocol === "http:") && /\/api\/dev\/?$/.test(u.pathname)) devUrl = `${u.origin}${u.pathname.replace(/\/api\/dev\/?$/, "")}`;
226
+ } catch {
227
+ devUrl = null;
228
+ }
229
+ }
230
+ return {
231
+ site: { id: site.id, slug: typeof site.slug === "string" ? site.slug : null, name: typeof site.name === "string" ? site.name : null, domain: typeof site.domain === "string" ? site.domain : null },
232
+ platformOrigin: typeof body.platform_origin === "string" && body.platform_origin ? body.platform_origin : null,
233
+ profile: { id: p.id, version: Number.isInteger(p.version) ? p.version : null, label: typeof p.label === "string" ? p.label : null },
234
+ audience: body.audience,
235
+ devUrl,
236
+ policyVersion: body.policy_version ?? null,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * The whole browser login. `openBrowser(url)` opens the system browser (may throw: the URL is printed anyway);
242
+ * `print(line)` writes a progress line (stderr). Returns `{ tokens, identity, metadata }`.
243
+ */
244
+ export async function runBrowserLogin({ origin, openBrowser, print = () => {}, fetchImpl = globalThis.fetch, callbackHost = "127.0.0.1", timeoutMs, onRetry = null }) {
245
+ const base = stripSlash(origin);
246
+ // The loopback rule is checked before any request (localhost refused).
247
+ const listenerOpts = { host: callbackHost, ...(timeoutMs ? { timeoutMs } : {}) };
248
+ if (callbackHost !== "127.0.0.1" && callbackHost !== "::1") await startLoopbackListener(listenerOpts); // throws
249
+ const metadata = await discoverMetadata(base, { fetchImpl });
250
+ const pkce = createPkce();
251
+ const state = newState();
252
+ const resource = cliResource(base);
253
+ let listener;
254
+ try {
255
+ listener = await startLoopbackListener(listenerOpts);
256
+ } catch (error) {
257
+ if (error?.code === "LOGIN_LOOPBACK_REFUSED") throw error;
258
+ if (callbackHost !== "127.0.0.1") throw new LoginError("LOGIN_LOOPBACK_UNAVAILABLE", `Could not listen on ${callbackHost} for the login callback. Nothing was saved.`);
259
+ try {
260
+ listener = await startLoopbackListener({ ...listenerOpts, host: "::1" }); // 127.0.0.1 unavailable: the IPv6 loopback
261
+ } catch {
262
+ throw new LoginError("LOGIN_LOOPBACK_UNAVAILABLE", "Could not listen on the loopback address for the login callback. Nothing was saved.");
263
+ }
264
+ }
265
+ if (!isLoopbackCallback(listener.redirectUri)) {
266
+ listener.close();
267
+ throw new LoginError("LOGIN_LOOPBACK_REFUSED", "The login callback address is not a loopback address. Nothing was saved.");
268
+ }
269
+ const url = authorizeUrl(metadata, { redirectUri: listener.redirectUri, challenge: pkce.challenge, state, resource });
270
+ print(url);
271
+ try {
272
+ await openBrowser(url);
273
+ } catch {
274
+ /* the URL is printed: the user opens it by hand */
275
+ }
276
+ const params = await listener.wait();
277
+ const code = validateCallback(params, { issuer: metadata.issuer, state });
278
+ const { tokens } = await exchangeCode(metadata, { code, verifier: pkce.verifier, redirectUri: listener.redirectUri, resource, fetchImpl });
279
+
280
+ const abandon = async (error) => {
281
+ const token = tokens.refresh_token ?? tokens.access_token;
282
+ if (token && metadata.revocation_endpoint) await revokeToken({ endpoint: metadata.revocation_endpoint, token, fetchImpl });
283
+ throw error;
284
+ };
285
+ if (!tokens.access_token || !tokens.access_token.startsWith(CLI_TOKEN_PREFIX) || !Number.isFinite(tokens.expires_at)) {
286
+ return abandon(new LoginError("LOGIN_AUDIENCE_MISMATCH", "The platform answered with a token that is not a CLI login token; it was not used and the new connection was revoked. Nothing was saved."));
287
+ }
288
+ let identity;
289
+ try {
290
+ identity = await probeIdentity(base, tokens.access_token, { fetchImpl, onRetry });
291
+ } catch (error) {
292
+ return abandon(error);
293
+ }
294
+ return { tokens, identity, metadata, abandon };
295
+ }
@@ -0,0 +1,244 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
2
+ import { homedir, hostname as osHostname } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { CredentialsError, loadStore, markReauthRequired, readOAuthTokens, writeOAuthTokens } from "./credentials.mjs";
6
+
7
+ /**
8
+ * ADR-0014 §5.4 (wave P4) — the CLI login's access token, refreshed single-flight.
9
+ *
10
+ * The platform's refresh tokens are single-use: a second presentation of the same token is reuse and closes the whole
11
+ * login (054). So a refresh is never sent twice and never retried:
12
+ * - in one process: one promise per context;
13
+ * - across processes: `~/.blocofy/.refresh-<context>.lock` (O_EXCL) holding `{ pid, hostname, acquired_at }`. It is
14
+ * stale only when its owner on THIS host is gone (`kill(pid, 0)` → ESRCH) or it is older than 30 s; a live
15
+ * holder's lock is never broken before that. The /token request times out after 10 s, far below the staleness,
16
+ * so no second process can present the token while the first request is still open.
17
+ * Under the lock the store is read again: when another process already refreshed (≥ 60 s left) no request is made.
18
+ * Otherwise ONE /token request; the rotated pair is written atomically before the lock is released.
19
+ *
20
+ * error_description "refresh_token_reused" → the context is marked `reauth_required`; no retry (the platform's
21
+ * (any status) explicit "this refresh token is spent" signal; checked first)
22
+ * invalid_grant (reuse, revoked, expired) → the same
23
+ * 503 without Retry-After (presentation → the same (fallback heuristic for platforms without the signal)
24
+ * recorded, nothing minted)
25
+ * 503 with Retry-After, network, timeout → REFRESH_FAILED (temporary); the login is kept; no retry here
26
+ */
27
+
28
+ export const TOKEN_REQUEST_TIMEOUT_MS = 10_000;
29
+ export const REFRESH_LOCK_STALE_MS = 30_000;
30
+ export const REFRESH_MIN_VALIDITY_MS = 60_000;
31
+ const DEFAULT_WAIT_MS = 45_000;
32
+ const POLL_MS = 50;
33
+
34
+ export class RefreshError extends CredentialsError {
35
+ constructor(code, message, details = {}) {
36
+ super(code, message, details);
37
+ this.name = "RefreshError";
38
+ }
39
+ }
40
+
41
+ const reauth = (name, why, reason = why) =>
42
+ new RefreshError(
43
+ "REAUTH_REQUIRED",
44
+ `The CLI login of context "${name}" can no longer be renewed (${why}). Nothing was sent. Log in again: blocofy login --context ${name}`,
45
+ { context: name, reason },
46
+ );
47
+
48
+ /** The platform's explicit signal that a presented refresh token is spent (single-use, 054). */
49
+ export const REFRESH_REUSED = "refresh_token_reused";
50
+
51
+ export const refreshLockPath = (name, home = homedir()) => join(home, ".blocofy", `.refresh-${name}.lock`);
52
+
53
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
54
+
55
+ function alive(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch (error) {
60
+ return error?.code !== "ESRCH"; // EPERM: it exists, owned by someone else
61
+ }
62
+ }
63
+
64
+ /** Is the lock at `path` stale? Unreadable content falls back to the file's age. */
65
+ function lockIsStale(path, now, host) {
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(path, "utf8");
69
+ } catch {
70
+ return { stale: false, raw: null }; // released meanwhile: just try again
71
+ }
72
+ let holder = null;
73
+ try {
74
+ holder = JSON.parse(raw);
75
+ } catch {
76
+ holder = null;
77
+ }
78
+ const acquired = holder ? Date.parse(holder.acquired_at) : NaN;
79
+ let age;
80
+ if (Number.isFinite(acquired)) age = now - acquired;
81
+ else {
82
+ try {
83
+ age = now - statSync(path).mtimeMs;
84
+ } catch {
85
+ return { stale: false, raw: null };
86
+ }
87
+ }
88
+ if (age > REFRESH_LOCK_STALE_MS) return { stale: true, raw };
89
+ if (holder && holder.hostname === host && Number.isInteger(holder.pid) && !alive(holder.pid)) return { stale: true, raw };
90
+ return { stale: false, raw };
91
+ }
92
+
93
+ /** Take the cross-process lock or throw REFRESH_BUSY after `waitMs`. Returns a release() that only removes OUR lock. */
94
+ async function acquireLock(path, { waitMs, host, pid }) {
95
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
96
+ const deadline = Date.now() + waitMs;
97
+ for (;;) {
98
+ const mine = JSON.stringify({ pid, hostname: host, acquired_at: new Date().toISOString() });
99
+ try {
100
+ const fd = openSync(path, "wx", 0o600);
101
+ writeSync(fd, mine);
102
+ closeSync(fd);
103
+ return () => {
104
+ try {
105
+ if (readFileSync(path, "utf8") === mine) rmSync(path, { force: true });
106
+ } catch {
107
+ /* already gone */
108
+ }
109
+ };
110
+ } catch (error) {
111
+ if (error?.code !== "EEXIST") throw error;
112
+ }
113
+ const { stale, raw } = lockIsStale(path, Date.now(), host);
114
+ if (stale) {
115
+ // Remove only the lock we judged stale (a new holder that replaced it meanwhile is left alone).
116
+ try {
117
+ if (readFileSync(path, "utf8") === raw) rmSync(path, { force: true });
118
+ } catch {
119
+ /* gone */
120
+ }
121
+ continue;
122
+ }
123
+ if (Date.now() > deadline) {
124
+ throw new RefreshError("REFRESH_BUSY", `Another blocofy command is renewing this login and has not finished. Nothing was sent; try again in a moment (the lock frees itself after ${REFRESH_LOCK_STALE_MS / 1000} s).`, { lock: path });
125
+ }
126
+ await sleep(POLL_MS);
127
+ }
128
+ }
129
+
130
+ const fresh = (tokens, now, min) => tokens && Number.isFinite(tokens.expires_at) && tokens.expires_at - now >= min;
131
+
132
+ /** One /token request (refresh_token grant). Never retried. */
133
+ async function presentRefresh({ endpoint, refreshToken, clientId, resource, fetchImpl, timeoutMs }) {
134
+ const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, resource });
135
+ let res;
136
+ try {
137
+ res = await fetchImpl(endpoint, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
140
+ body: body.toString(),
141
+ signal: AbortSignal.timeout(timeoutMs),
142
+ });
143
+ } catch (error) {
144
+ return { kind: "unknown", reason: error?.name === "TimeoutError" ? "timeout" : "network error" };
145
+ }
146
+ const text = await res.text().catch(() => "");
147
+ let json = null;
148
+ try {
149
+ json = JSON.parse(text);
150
+ } catch {
151
+ json = null;
152
+ }
153
+ if (res.ok) return { kind: "ok", json };
154
+ if (json?.error_description === REFRESH_REUSED) return { kind: "reused" };
155
+ if (res.status === 400 && (json?.error === "invalid_grant" || json?.error === "invalid_target" || json?.error === "unauthorized_client")) return { kind: "refused", error: json.error };
156
+ if (res.status === 401 && json?.error === "invalid_client") return { kind: "refused", error: "invalid_client" };
157
+ if (res.status === 503 && !res.headers.get("retry-after")) return { kind: "spent" };
158
+ return { kind: "temporary", status: res.status };
159
+ }
160
+
161
+ const inflight = new Map();
162
+
163
+ /**
164
+ * The DI core. `source`: `{ read() → tokens|null, write(tokens), needsReauth() → bool, markReauth() }`;
165
+ * `endpoint`: `{ tokenEndpoint, clientId, resource }`.
166
+ */
167
+ export async function freshAccess({ name, source, endpoint, lockPath, fetchImpl = globalThis.fetch, now = Date.now, requestTimeoutMs = TOKEN_REQUEST_TIMEOUT_MS, waitMs = DEFAULT_WAIT_MS, minValidityMs = REFRESH_MIN_VALIDITY_MS, host = osHostname(), pid = process.pid }) {
168
+ if (source.needsReauth()) throw reauth(name, "a previous renewal was refused");
169
+ const first = source.read();
170
+ if (!first) throw reauth(name, "no saved login");
171
+ if (fresh(first, now(), minValidityMs)) return first.access_token;
172
+
173
+ const key = lockPath;
174
+ if (inflight.has(key)) return inflight.get(key);
175
+ const run = (async () => {
176
+ const release = await acquireLock(lockPath, { waitMs, host, pid });
177
+ try {
178
+ if (source.needsReauth()) throw reauth(name, "a previous renewal was refused");
179
+ const tokens = source.read();
180
+ if (!tokens) throw reauth(name, "no saved login");
181
+ if (fresh(tokens, now(), minValidityMs)) return tokens.access_token; // another process renewed it
182
+ if (!tokens.refresh_token) {
183
+ source.markReauth();
184
+ throw reauth(name, "the login has no renewal token left");
185
+ }
186
+ const r = await presentRefresh({ endpoint: endpoint.tokenEndpoint, refreshToken: tokens.refresh_token, clientId: endpoint.clientId, resource: endpoint.resource, fetchImpl, timeoutMs: requestTimeoutMs });
187
+ if (r.kind === "ok") {
188
+ const access = r.json?.access_token;
189
+ const expiresIn = Number(r.json?.expires_in);
190
+ if (typeof access !== "string" || !access.startsWith("blcf_ct_") || !Number.isFinite(expiresIn)) {
191
+ // The presented token is spent; what came back is not a CLI token. Never used, never retried.
192
+ source.markReauth();
193
+ throw reauth(name, "the platform answered with an unexpected token");
194
+ }
195
+ // Rotation: the new refresh token replaces the spent one. Without one the login ends at this access token.
196
+ const next = { access_token: access, refresh_token: typeof r.json.refresh_token === "string" && r.json.refresh_token ? r.json.refresh_token : null, expires_at: now() + expiresIn * 1000 };
197
+ source.write(next);
198
+ return access;
199
+ }
200
+ if (r.kind === "reused") {
201
+ source.markReauth();
202
+ throw reauth(name, "its renewal token was already used (refresh_token_reused)", REFRESH_REUSED);
203
+ }
204
+ if (r.kind === "refused") {
205
+ source.markReauth();
206
+ throw reauth(name, r.error === "invalid_grant" ? "it was revoked, expired, or its renewal token was used twice" : r.error);
207
+ }
208
+ if (r.kind === "spent") {
209
+ source.markReauth();
210
+ throw reauth(name, "the platform could not complete the renewal and the renewal token is spent");
211
+ }
212
+ throw new RefreshError(
213
+ "REFRESH_FAILED",
214
+ r.kind === "unknown"
215
+ ? `Could not renew the CLI login of context "${name}" (${r.reason}). Nothing else was sent. Try again; if the platform reports the login closed, log in again (blocofy login --context ${name}).`
216
+ : `The platform could not renew the CLI login of context "${name}" right now (HTTP ${r.status}). Nothing else was sent; try again in a moment.`,
217
+ { context: name, ...(r.status ? { status: r.status } : { reason: r.reason }) },
218
+ );
219
+ } finally {
220
+ release();
221
+ }
222
+ })();
223
+ inflight.set(key, run);
224
+ try {
225
+ return await run;
226
+ } finally {
227
+ inflight.delete(key);
228
+ }
229
+ }
230
+
231
+ /** The access token of a saved CLI-login context (refreshed when needed). `opts` override the DI defaults (tests). */
232
+ export function accessTokenFor(name, opts = {}) {
233
+ const ctxNow = () => loadStore().contexts[name];
234
+ const ctx = ctxNow();
235
+ if (!ctx?.oauth) throw new CredentialsError("LOGIN_REQUIRED", `Context "${name}" is not a CLI login. Run \`blocofy login --context ${name}\`.`, { context: name });
236
+ const source = {
237
+ read: () => readOAuthTokens(name, ctxNow() ?? ctx),
238
+ write: (tokens) => writeOAuthTokens(name, ctx.oauth.secret.store, tokens),
239
+ needsReauth: () => (ctxNow()?.oauth?.state ?? null) === "reauth_required",
240
+ markReauth: () => markReauthRequired(name),
241
+ };
242
+ const endpoint = { tokenEndpoint: ctx.oauth.token_endpoint, clientId: ctx.oauth.client_id, resource: `${String(ctx.oauth.url).replace(/\/+$/, "")}/api/v1` };
243
+ return freshAccess({ name, source, endpoint, lockPath: refreshLockPath(name), ...opts });
244
+ }