@deque/axe-auth 1.1.0-next.487d00fa → 1.1.0-next.49c17a1d

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.
package/credits.json CHANGED
@@ -27,6 +27,17 @@
27
27
  "publisher": "Stephen Mathieson",
28
28
  "email": "me@stephenmathieson.com"
29
29
  },
30
+ "shlex@3.0.0": {
31
+ "name": "shlex",
32
+ "version": "3.0.0",
33
+ "licenses": "MIT",
34
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shlex@3.0.0/node_modules/shlex",
35
+ "licenseText": "MIT License\n\nCopyright (c) 2018 Ryan Govostes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
36
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shlex@3.0.0/node_modules/shlex/LICENSE",
37
+ "repository": "https://github.com/rgov/node-shlex",
38
+ "publisher": "Ryan Govostes",
39
+ "copyright": "Copyright (c) 2018 Ryan Govostes"
40
+ },
30
41
  "ts-dedent@2.2.0": {
31
42
  "name": "ts-dedent",
32
43
  "version": "2.2.0",
@@ -41,6 +41,8 @@ export type OAuthFlowErrorCode =
41
41
  | "TOKEN_EXCHANGE_FAILED"
42
42
  /** System keychain is unavailable (e.g. no D-Bus secret service on Linux). */
43
43
  | "KEYRING_UNAVAILABLE"
44
+ /** OAuth blob is too large for the OS keystore (Windows Credential Manager: 2560 UTF-16 chars per entry, MAX_CHUNKS chunks max). The keystore itself is healthy; the IDP is issuing tokens with too many claims. */
45
+ | "TOKEN_TOO_LARGE"
44
46
  /** Authorization endpoint returned by discovery cannot be used (e.g. already carries an OAuth-required param). Server misconfiguration. */
45
47
  | "INVALID_AUTHORIZATION_ENDPOINT"
46
48
  /** No usable stored credentials; the user needs to run `login` to re-authenticate. Covers empty / corrupt / version-mismatched store and refresh tokens the authorization server has revoked. */
@@ -7,13 +7,24 @@ export interface OpenBrowserOptions {
7
7
  platform?: NodeJS.Platform;
8
8
  /** Override for `child_process.spawn`. Used by tests. */
9
9
  spawnFn?: SpawnFn;
10
+ /**
11
+ * Override for `process.env.BROWSER`. The de-facto convention shared
12
+ * with `xdg-open` and Python's `webbrowser`: when set, it names the
13
+ * command to invoke instead of the platform default. Parsed shlex-style
14
+ * (POSIX shell tokenization) so values like `firefox --new-window` or
15
+ * `/path/with\ spaces/firefox` work as expected. An empty or missing
16
+ * value falls back to the platform default.
17
+ */
18
+ browserEnv?: string;
10
19
  }
11
20
  /**
12
21
  * Launches the system browser at `url` in a detached child process.
13
- * Returns synchronously once the child is spawned completion of the
14
- * browser load is intentionally not awaited.
22
+ * Honors `$BROWSER` when set, falling back to the platform default
23
+ * (`open` / `xdg-open` / `cmd start`). Returns synchronously once the
24
+ * child is spawned — completion of the browser load is intentionally
25
+ * not awaited.
15
26
  *
16
27
  * @param url Absolute URL to open.
17
- * @param options Platform/spawn overrides; only exposed for tests.
28
+ * @param options Platform/spawn/env overrides; only exposed for tests.
18
29
  */
19
30
  export declare function openBrowser(url: string, options?: OpenBrowserOptions): void;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.openBrowser = openBrowser;
4
4
  const node_child_process_1 = require("node:child_process");
5
+ const shlex_1 = require("shlex");
5
6
  const errors_1 = require("./errors");
6
7
  // On Windows `start` is a cmd.exe builtin, not a standalone binary.
7
8
  // The empty `""` pair is a positional placeholder for the window
@@ -26,7 +27,11 @@ function windowsCommand(url) {
26
27
  args: ["/c", "start", '""', url.replace(/[&|^<>"%\r\n]/g, (c) => `^${c}`)],
27
28
  };
28
29
  }
29
- function browserCommand(platform, url) {
30
+ function browserCommand(platform, url, browserOverride) {
31
+ if (browserOverride && browserOverride.length > 0) {
32
+ const [command, ...extraArgs] = browserOverride;
33
+ return { command, args: [...extraArgs, url] };
34
+ }
30
35
  switch (platform) {
31
36
  case "darwin":
32
37
  return { command: "open", args: [url] };
@@ -47,16 +52,28 @@ function browserCommand(platform, url) {
47
52
  }
48
53
  /**
49
54
  * Launches the system browser at `url` in a detached child process.
50
- * Returns synchronously once the child is spawned completion of the
51
- * browser load is intentionally not awaited.
55
+ * Honors `$BROWSER` when set, falling back to the platform default
56
+ * (`open` / `xdg-open` / `cmd start`). Returns synchronously once the
57
+ * child is spawned — completion of the browser load is intentionally
58
+ * not awaited.
52
59
  *
53
60
  * @param url Absolute URL to open.
54
- * @param options Platform/spawn overrides; only exposed for tests.
61
+ * @param options Platform/spawn/env overrides; only exposed for tests.
55
62
  */
56
63
  function openBrowser(url, options = {}) {
57
64
  const platform = options.platform ?? process.platform;
58
65
  const spawnFn = options.spawnFn ?? node_child_process_1.spawn;
59
- const { command, args } = browserCommand(platform, url);
66
+ const browserEnv = options.browserEnv ?? process.env.BROWSER;
67
+ let browserOverride;
68
+ if (browserEnv && browserEnv.length > 0) {
69
+ try {
70
+ browserOverride = (0, shlex_1.split)(browserEnv);
71
+ }
72
+ catch (cause) {
73
+ throw new errors_1.OAuthFlowError("BROWSER_LAUNCH_FAILED", `Failed to parse $BROWSER (${browserEnv}). Open this URL manually:\n${url}`, { cause });
74
+ }
75
+ }
76
+ const { command, args } = browserCommand(platform, url, browserOverride);
60
77
  let child;
61
78
  try {
62
79
  child = spawnFn(command, args, {
@@ -1,5 +1,13 @@
1
1
  import { type KeyringEntryFactory } from "./keyringBinding";
2
2
  import type { TokenSet } from "./tokenResponse";
3
+ /**
4
+ * Whether `KeyringTokenStore` should split the stored blob across
5
+ * multiple keychain entries on this platform. Windows-only because of
6
+ * Credential Manager's 2560 UTF-16 character per-entry cap. Exported
7
+ * (parameterized for tests) so the chunking path can be exercised
8
+ * deterministically.
9
+ */
10
+ export declare function shouldChunkForKeyring(platform?: NodeJS.Platform): boolean;
3
11
  /**
4
12
  * Current on-disk blob schema version. Exported so consumers can
5
13
  * display "stored v:N, expected v:M" diagnostics when `load()` returns
@@ -100,17 +108,76 @@ export type BlobChainResult = {
100
108
  * and `MIGRATORS` and applies the latest-shape check on top.
101
109
  */
102
110
  export declare function parseAndMigrateBlob(raw: string | null, expectedVersion?: number, migrators?: ReadonlyMap<number, (old: unknown) => unknown | null>): BlobChainResult;
111
+ /**
112
+ * Builds the user-facing keychain error message. Platform is a
113
+ * parameter (defaulting to `process.platform`) so tests can drive each
114
+ * branch without mocking the runtime; mirrors the pattern in
115
+ * `platformKeyringHint`.
116
+ *
117
+ * The Windows-specific size-limit message is only used when the
118
+ * underlying error matches the binding's "longer than the platform
119
+ * limit" wording AND the runtime is win32 — that combination is the
120
+ * only way the size cap actually manifests in practice. On other
121
+ * platforms (or for any other binding error) we fall back to the
122
+ * generic per-platform hint.
123
+ */
124
+ export declare function keyringErrorMessage(op: string, cause: unknown, platform?: NodeJS.Platform): string;
125
+ /**
126
+ * Detects the `@napi-rs/keyring` error string for "value too large".
127
+ * In practice only Windows Credential Manager triggers this — its
128
+ * stored values are capped at 2560 UTF-16 chars; macOS Keychain and
129
+ * Linux libsecret have no comparable limit. Exported (but not
130
+ * re-exported from the package index) so tests can exercise the
131
+ * detector independently of the wrap path.
132
+ */
133
+ export declare function isKeyringSizeError(cause: unknown): boolean;
134
+ /**
135
+ * Returns a per-platform hint appended to keychain error messages so
136
+ * users see actionable guidance for their OS instead of generic or
137
+ * Linux-only advice. Exported (but not re-exported from the package
138
+ * index) so tests can exercise each branch without mocking
139
+ * `process.platform`.
140
+ */
141
+ export declare function platformKeyringHint(platform?: NodeJS.Platform): string;
103
142
  /**
104
143
  * `TokenStore` backed by the operating system's native keychain via
105
144
  * `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
106
- * Secret Service). One entry per machine, keyed by a fixed account
107
- * name; the blob carries its own issuer/client coordinates so verbs
108
- * can recover full config without per-issuer keying.
145
+ * Secret Service). On macOS and Linux the blob lives in a single entry
146
+ * keyed by the fixed `credentials` account name. On Windows the blob
147
+ * is split across `credentials.0`, `credentials.1`, entries to fit
148
+ * under Credential Manager's 2560 UTF-16 character per-entry cap; see
149
+ * `shouldChunkForKeyring`.
150
+ *
151
+ * The blob carries its own issuer/client coordinates so verbs can
152
+ * recover full config without per-issuer keying.
109
153
  */
110
154
  export declare class KeyringTokenStore implements TokenStore {
111
155
  #private;
156
+ /**
157
+ * @param entryFactory Injection seam for `@napi-rs/keyring` entries.
158
+ * Defaults to the production lazy-resolved factory; tests pass a
159
+ * recording / faking variant.
160
+ */
112
161
  constructor(entryFactory?: KeyringEntryFactory);
162
+ /**
163
+ * @internal Test seam. Constructs a store with an explicit chunking
164
+ * decision instead of the platform-determined default, so the
165
+ * chunked path can be exercised on macOS/Linux CI and the unchunked
166
+ * path on Windows CI. Production code must use the regular
167
+ * constructor and let `shouldChunkForKeyring()` decide — passing
168
+ * `chunked: true` on macOS would write data that the regular
169
+ * constructor wouldn't be able to read.
170
+ */
171
+ static forTesting(entryFactory: KeyringEntryFactory, chunked: boolean): KeyringTokenStore;
113
172
  save(entry: StoredEntry): Promise<void>;
114
173
  load(): Promise<LoadResult>;
115
174
  clear(): Promise<void>;
116
175
  }
176
+ /**
177
+ * Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
178
+ * writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
179
+ * the reader can learn N from a single getPassword call. Each chunk
180
+ * stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
181
+ * require more than `MAX_CHUNKS` chunks. Exported for tests.
182
+ */
183
+ export declare function chunkBlobForKeyring(blob: string): string[];
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
4
+ exports.shouldChunkForKeyring = shouldChunkForKeyring;
4
5
  exports.parseAndMigrateBlob = parseAndMigrateBlob;
6
+ exports.keyringErrorMessage = keyringErrorMessage;
7
+ exports.isKeyringSizeError = isKeyringSizeError;
8
+ exports.platformKeyringHint = platformKeyringHint;
9
+ exports.chunkBlobForKeyring = chunkBlobForKeyring;
5
10
  const errors_1 = require("./errors");
6
11
  const keyringBinding_1 = require("./keyringBinding");
7
12
  // On macOS: Keychain generic password item with the service name below.
@@ -9,16 +14,45 @@ const keyringBinding_1 = require("./keyringBinding");
9
14
  // Exposed as a human-readable string because these all surface the service
10
15
  // name in OS UIs (Keychain Access, credmgr.exe, seahorse).
11
16
  const SERVICE_NAME = "axe-auth";
12
- // Single keychain entry per machine. The blob it holds is fully
13
- // self-describing (issuerURL, clientId, allowInsecureIssuer, plus the
14
- // tokens), so verbs that don't pass `--server` / `--realm` /
15
- // `--client-id` can resolve their config from the entry.
17
+ // Single keychain entry per machine on macOS / Linux. (Windows splits
18
+ // across `credentials.0`, `credentials.1`, see `CHUNK_LIMIT`
19
+ // below.) The blob it holds is fully self-describing (issuerURL,
20
+ // clientId, allowInsecureIssuer, plus the tokens), so verbs that
21
+ // don't pass `--server` / `--realm` / `--client-id` can resolve their
22
+ // config from the entry.
16
23
  //
17
24
  // Account name is human-readable so users investigating the entry in
18
25
  // macOS Keychain Access (or `secret-tool` on Linux, credmgr on
19
26
  // Windows) can tell what it is. Not versioned: the schema version
20
- // lives inside the blob and migrators handle the upgrade path.
27
+ // lives inside the blob and migrators handle the upgrade path. Note:
28
+ // Windows entries hold base64-encoded JSON rather than the raw JSON
29
+ // macOS / Linux store, so a Windows user inspecting their Credential
30
+ // Manager will see opaque base64; that's a side effect of chunking.
21
31
  const ACCOUNT_NAME = "credentials";
32
+ // Windows Credential Manager caps stored values at 2560 UTF-16 code
33
+ // units, which large OAuth access-token JWTs (many groups/roles
34
+ // claims) routinely exceed. On Windows we work around this by
35
+ // splitting the JSON blob across multiple entries with account names
36
+ // `credentials.0`, `credentials.1`, … . `CHUNK_LIMIT` leaves margin
37
+ // under the platform cap; `MAX_CHUNKS` is a safety bound — we should
38
+ // never get close in practice, even with maximally-claimed tokens.
39
+ //
40
+ // macOS Keychain and Linux libsecret have no comparable limit, so
41
+ // chunking there would just multiply per-entry ACL prompts (each
42
+ // keychain entry is independently lockable on macOS) for no gain.
43
+ // Chunking is therefore Windows-only, gated by `shouldChunkForKeyring`.
44
+ const CHUNK_LIMIT = 2500;
45
+ const MAX_CHUNKS = 32;
46
+ /**
47
+ * Whether `KeyringTokenStore` should split the stored blob across
48
+ * multiple keychain entries on this platform. Windows-only because of
49
+ * Credential Manager's 2560 UTF-16 character per-entry cap. Exported
50
+ * (parameterized for tests) so the chunking path can be exercised
51
+ * deterministically.
52
+ */
53
+ function shouldChunkForKeyring(platform = process.platform) {
54
+ return platform === "win32";
55
+ }
22
56
  /**
23
57
  * Current on-disk blob schema version. Exported so consumers can
24
58
  * display "stored v:N, expected v:M" diagnostics when `load()` returns
@@ -153,32 +187,184 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
153
187
  return { ok: true, blob: current };
154
188
  }
155
189
  function wrapKeyringError(op, cause) {
156
- throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", `System keychain ${op} failed. On Linux this usually means no D-Bus Secret Service is running.`, { cause });
190
+ // Pass-through pre-wrapped OAuthFlowErrors so we don't double-wrap
191
+ // our own error type. The most common source today is
192
+ // `defaultEntryFactory` throwing `KEYRING_UNAVAILABLE` when the
193
+ // native binding can't be loaded — relabelling that as another
194
+ // `KEYRING_UNAVAILABLE` with a duplicate message and a possibly
195
+ // misleading platform hint helps nobody.
196
+ if (cause instanceof errors_1.OAuthFlowError) {
197
+ throw cause;
198
+ }
199
+ throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", keyringErrorMessage(op, cause), {
200
+ cause,
201
+ });
202
+ }
203
+ /**
204
+ * Builds the user-facing keychain error message. Platform is a
205
+ * parameter (defaulting to `process.platform`) so tests can drive each
206
+ * branch without mocking the runtime; mirrors the pattern in
207
+ * `platformKeyringHint`.
208
+ *
209
+ * The Windows-specific size-limit message is only used when the
210
+ * underlying error matches the binding's "longer than the platform
211
+ * limit" wording AND the runtime is win32 — that combination is the
212
+ * only way the size cap actually manifests in practice. On other
213
+ * platforms (or for any other binding error) we fall back to the
214
+ * generic per-platform hint.
215
+ */
216
+ function keyringErrorMessage(op, cause, platform = process.platform) {
217
+ if (platform === "win32" && isKeyringSizeError(cause)) {
218
+ return `System keychain ${op} failed: Windows Credential Manager limits stored values to 2560 UTF-16 characters. Large OAuth access-token JWTs (many groups/roles claims) commonly exceed this.`;
219
+ }
220
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
221
+ return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
222
+ }
223
+ /**
224
+ * Detects the `@napi-rs/keyring` error string for "value too large".
225
+ * In practice only Windows Credential Manager triggers this — its
226
+ * stored values are capped at 2560 UTF-16 chars; macOS Keychain and
227
+ * Linux libsecret have no comparable limit. Exported (but not
228
+ * re-exported from the package index) so tests can exercise the
229
+ * detector independently of the wrap path.
230
+ */
231
+ function isKeyringSizeError(cause) {
232
+ if (!(cause instanceof Error))
233
+ return false;
234
+ return /longer than the platform limit/.test(cause.message);
235
+ }
236
+ /**
237
+ * Returns a per-platform hint appended to keychain error messages so
238
+ * users see actionable guidance for their OS instead of generic or
239
+ * Linux-only advice. Exported (but not re-exported from the package
240
+ * index) so tests can exercise each branch without mocking
241
+ * `process.platform`.
242
+ */
243
+ function platformKeyringHint(platform = process.platform) {
244
+ switch (platform) {
245
+ case "darwin":
246
+ return "On macOS this usually means Keychain Access denied or cancelled the prompt.";
247
+ case "win32":
248
+ return "On Windows this usually means Credential Manager rejected the operation.";
249
+ case "linux":
250
+ return "On Linux this usually means no D-Bus Secret Service is running (e.g. GNOME Keyring or KWallet).";
251
+ default:
252
+ return `Underlying platform: ${platform}.`;
253
+ }
254
+ }
255
+ /**
256
+ * Parses chunk 0's `<N>\n<rest>` header. Returns the chunk count and
257
+ * the data part following the newline, or `null` for any malformed /
258
+ * out-of-range / non-canonically-encoded header. Centralised here
259
+ * (rather than open-coded twice in `#loadChunked` and
260
+ * `#previousChunkN`) so the canonical-encoding contract has one
261
+ * authoritative implementation.
262
+ */
263
+ function parseChunkHeader(first) {
264
+ const newlineIdx = first.indexOf("\n");
265
+ if (newlineIdx <= 0)
266
+ return null;
267
+ const nStr = first.slice(0, newlineIdx);
268
+ const n = parseInt(nStr, 10);
269
+ // Reject non-canonical encodings ("01", " 3", "3abc"). parseInt is
270
+ // permissive about those; we want a single canonical encoding so
271
+ // two different headers can't decode to the same N.
272
+ if (!Number.isInteger(n) || n < 1 || n > MAX_CHUNKS || String(n) !== nStr) {
273
+ return null;
274
+ }
275
+ return { n, rest: first.slice(newlineIdx + 1) };
157
276
  }
158
277
  /**
159
278
  * `TokenStore` backed by the operating system's native keychain via
160
279
  * `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
161
- * Secret Service). One entry per machine, keyed by a fixed account
162
- * name; the blob carries its own issuer/client coordinates so verbs
163
- * can recover full config without per-issuer keying.
280
+ * Secret Service). On macOS and Linux the blob lives in a single entry
281
+ * keyed by the fixed `credentials` account name. On Windows the blob
282
+ * is split across `credentials.0`, `credentials.1`, entries to fit
283
+ * under Credential Manager's 2560 UTF-16 character per-entry cap; see
284
+ * `shouldChunkForKeyring`.
285
+ *
286
+ * The blob carries its own issuer/client coordinates so verbs can
287
+ * recover full config without per-issuer keying.
164
288
  */
165
289
  class KeyringTokenStore {
166
- #entry;
290
+ #entryFactory;
291
+ #chunked;
292
+ /**
293
+ * @param entryFactory Injection seam for `@napi-rs/keyring` entries.
294
+ * Defaults to the production lazy-resolved factory; tests pass a
295
+ * recording / faking variant.
296
+ */
167
297
  constructor(entryFactory = keyringBinding_1.defaultEntryFactory) {
168
- this.#entry = entryFactory(SERVICE_NAME, ACCOUNT_NAME);
298
+ this.#entryFactory = entryFactory;
299
+ this.#chunked = shouldChunkForKeyring();
300
+ }
301
+ /**
302
+ * @internal Test seam. Constructs a store with an explicit chunking
303
+ * decision instead of the platform-determined default, so the
304
+ * chunked path can be exercised on macOS/Linux CI and the unchunked
305
+ * path on Windows CI. Production code must use the regular
306
+ * constructor and let `shouldChunkForKeyring()` decide — passing
307
+ * `chunked: true` on macOS would write data that the regular
308
+ * constructor wouldn't be able to read.
309
+ */
310
+ static forTesting(entryFactory, chunked) {
311
+ const store = new KeyringTokenStore(entryFactory);
312
+ store.#chunked = chunked;
313
+ return store;
314
+ }
315
+ #entry(account) {
316
+ return this.#entryFactory(SERVICE_NAME, account);
169
317
  }
170
318
  async save(entry) {
171
- try {
172
- this.#entry.setPassword(JSON.stringify(entryToBlob(entry)));
319
+ const jsonBlob = JSON.stringify(entryToBlob(entry));
320
+ if (this.#chunked) {
321
+ // Encode + chunk OUTSIDE the try/catch so a TOKEN_TOO_LARGE from
322
+ // `chunkBlobForKeyring` surfaces unchanged. The keychain
323
+ // operations stay inside the try and get wrapped as
324
+ // KEYRING_UNAVAILABLE if they fail.
325
+ const encoded = Buffer.from(jsonBlob, "utf8").toString("base64");
326
+ const parts = chunkBlobForKeyring(encoded);
327
+ try {
328
+ this.#saveChunked(parts);
329
+ }
330
+ catch (cause) {
331
+ wrapKeyringError("write", cause);
332
+ }
173
333
  }
174
- catch (cause) {
175
- wrapKeyringError("write", cause);
334
+ else {
335
+ try {
336
+ this.#entry(ACCOUNT_NAME).setPassword(jsonBlob);
337
+ }
338
+ catch (cause) {
339
+ wrapKeyringError("write", cause);
340
+ }
176
341
  }
177
342
  }
178
343
  async load() {
179
344
  let raw;
180
345
  try {
181
- raw = this.#entry.getPassword();
346
+ if (this.#chunked) {
347
+ const result = this.#loadChunked();
348
+ if (result.kind === "present") {
349
+ raw = result.blob;
350
+ }
351
+ else if (result.kind === "empty") {
352
+ // First-time-upgrade fallback: a Windows dev who upgraded
353
+ // across the chunking change has data at the bare
354
+ // `credentials` account but no chunks yet. Read that legacy
355
+ // entry; the next save() migrates it. Note we only fall
356
+ // back when chunked data is *empty* — when chunked data is
357
+ // *corrupt* we surface that directly rather than restoring
358
+ // potentially stale legacy data underneath the corruption.
359
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
360
+ }
361
+ else {
362
+ return { ok: false, reason: "corrupt" };
363
+ }
364
+ }
365
+ else {
366
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
367
+ }
182
368
  }
183
369
  catch (cause) {
184
370
  wrapKeyringError("read", cause);
@@ -192,11 +378,208 @@ class KeyringTokenStore {
192
378
  }
193
379
  async clear() {
194
380
  try {
195
- this.#entry.deletePassword();
381
+ if (this.#chunked) {
382
+ this.#clearChunked();
383
+ }
384
+ else {
385
+ this.#entry(ACCOUNT_NAME).deletePassword();
386
+ }
196
387
  }
197
388
  catch (cause) {
198
389
  wrapKeyringError("delete", cause);
199
390
  }
200
391
  }
392
+ /**
393
+ * Writes `parts` (the output of `chunkBlobForKeyring`) to entries
394
+ * `credentials.0..N-1`.
395
+ *
396
+ * Writes are in **reverse index order** — chunks N-1..1, then chunk
397
+ * 0 with the new header last. Chunk 0's header is what reads use to
398
+ * learn N, so until it's overwritten the previous chunk 0 still
399
+ * references the previous N chunks.
400
+ *
401
+ * Crash recovery is partial, not total. Reverse order helps in one
402
+ * case: when N_new > N_old and the crash happens before chunk 0 is
403
+ * rewritten — writes to indices >= N_old don't disturb old data,
404
+ * the previous chunk 0 still references the previous N chunks, and
405
+ * the prior session survives. The typical refresh case (N_new ==
406
+ * N_old) overwrites chunks 1..N-1 with new data while chunk 0 is
407
+ * still old, so a crash there reads as corrupt and the user
408
+ * re-auths. Reverse order is therefore a marginal improvement over
409
+ * forward order, not a guarantee.
410
+ *
411
+ * Cleanup sweeps `[N_new, N_old)` (bounded by the previous chunk
412
+ * count read from the old chunk 0 header before we overwrite it).
413
+ * For a typical token refresh (same N) this is zero deletes; the
414
+ * full safety sweep up to MAX_CHUNKS only runs as a defensive
415
+ * recovery when the previous N can't be determined. Orphans at
416
+ * indices >= max(N_new, N_old) from interrupted resize-up writes
417
+ * persist until the next `clear()` does the full sweep.
418
+ *
419
+ * Concurrency: this method is not safe to run concurrently against
420
+ * the same OS keychain. Two writers can interleave at chunk
421
+ * boundaries and produce a Frankenstein blob. axe-auth runs as a
422
+ * short-lived CLI so this is unlikely in practice, but a long-lived
423
+ * process refreshing in the background while the CLI is invoked
424
+ * could trip it.
425
+ */
426
+ #saveChunked(parts) {
427
+ // Read previous N before any writes so the cleanup sweep is
428
+ // bounded. If the previous chunk 0 is missing or its header is
429
+ // unparseable we have no upper bound, so fall back to the full
430
+ // safety range as a one-time defensive recovery.
431
+ const previousN = this.#previousChunkN();
432
+ for (let i = parts.length - 1; i >= 1; i--) {
433
+ this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
434
+ }
435
+ this.#entry(`${ACCOUNT_NAME}.0`).setPassword(parts[0]);
436
+ // Best-effort sweep: writes have already succeeded, so a sweep
437
+ // failure shouldn't roll back the save. The next save's bounded
438
+ // sweep cleans up anything we miss here. Same reasoning for the
439
+ // legacy delete below.
440
+ const sweepEnd = previousN ?? MAX_CHUNKS;
441
+ for (let i = parts.length; i < sweepEnd; i++) {
442
+ try {
443
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
444
+ }
445
+ catch {
446
+ // Sweep is best-effort; the next save handles leftovers.
447
+ }
448
+ }
449
+ // Clear any pre-chunking single-entry blob from a previous
450
+ // axe-auth release. This is a forever-tax (one extra
451
+ // deletePassword per save even after the migration is done)
452
+ // because we have no per-machine "migration completed" flag;
453
+ // adding one would mean another keychain entry to manage. The
454
+ // cost is one Credential Manager call per refresh — negligible
455
+ // relative to the OAuth round-trip.
456
+ try {
457
+ this.#entry(ACCOUNT_NAME).deletePassword();
458
+ }
459
+ catch {
460
+ // Best-effort; the next save attempts again.
461
+ }
462
+ }
463
+ /**
464
+ * Reads the chunk-count header from `credentials.0` so `#saveChunked`
465
+ * can bound its cleanup sweep. Returns `null` when chunk 0 is
466
+ * missing, when the header is malformed, or when the encoded N is
467
+ * out of range — every "I don't know the previous count" case
468
+ * collapses to a full safety sweep at the call site.
469
+ */
470
+ #previousChunkN() {
471
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
472
+ if (first === null)
473
+ return null;
474
+ return parseChunkHeader(first)?.n ?? null;
475
+ }
476
+ /**
477
+ * Reverse of `#saveChunked`. Returns a discriminated result so the
478
+ * caller can distinguish "no data" from "data is malformed" without
479
+ * reaching for sentinel strings.
480
+ */
481
+ #loadChunked() {
482
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
483
+ if (first === null)
484
+ return { kind: "empty" };
485
+ const header = parseChunkHeader(first);
486
+ if (!header)
487
+ return { kind: "corrupt" };
488
+ const parts = [header.rest];
489
+ for (let i = 1; i < header.n; i++) {
490
+ const part = this.#entry(`${ACCOUNT_NAME}.${i}`).getPassword();
491
+ if (part === null)
492
+ return { kind: "corrupt" };
493
+ parts.push(part);
494
+ }
495
+ // `Buffer.from(_, 'base64')` is permissive — invalid characters
496
+ // are silently dropped rather than throwing. Garbage base64
497
+ // produces garbage UTF-8, which falls through to the upstream
498
+ // JSON.parse and surfaces as `corrupt` from
499
+ // `parseAndMigrateBlob`. So no try/catch is needed here.
500
+ const blob = Buffer.from(parts.join(""), "base64").toString("utf8");
501
+ return { kind: "present", blob };
502
+ }
503
+ #clearChunked() {
504
+ // Sweep the whole safety range rather than break-on-first-missing
505
+ // so chunk holes (from interrupted writes or manual tampering)
506
+ // still get cleaned up. Logout is rare enough that the
507
+ // unconditional sweep cost is irrelevant.
508
+ //
509
+ // Per-entry errors are caught locally so a single throw doesn't
510
+ // strand the remaining chunks (or the legacy entry) in the
511
+ // keychain. After all attempts, we surface the first failure so
512
+ // the user still sees that logout didn't fully complete.
513
+ let firstError = null;
514
+ for (let i = 0; i < MAX_CHUNKS; i++) {
515
+ try {
516
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
517
+ }
518
+ catch (cause) {
519
+ firstError ??= cause;
520
+ }
521
+ }
522
+ // And the pre-chunking single-entry blob, in case a Windows dev
523
+ // had axe-auth installed before chunking shipped.
524
+ try {
525
+ this.#entry(ACCOUNT_NAME).deletePassword();
526
+ }
527
+ catch (cause) {
528
+ firstError ??= cause;
529
+ }
530
+ if (firstError !== null) {
531
+ throw firstError;
532
+ }
533
+ }
201
534
  }
202
535
  exports.KeyringTokenStore = KeyringTokenStore;
536
+ /**
537
+ * Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
538
+ * writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
539
+ * the reader can learn N from a single getPassword call. Each chunk
540
+ * stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
541
+ * require more than `MAX_CHUNKS` chunks. Exported for tests.
542
+ */
543
+ function chunkBlobForKeyring(blob) {
544
+ // N depends on the header length, which depends on N. Solve by
545
+ // iterating until the chunk count stabilises (converges in <= a
546
+ // couple of steps for any realistic blob). The safety counter is
547
+ // belt-and-suspenders against a future tweak (different
548
+ // CHUNK_LIMIT, different header format) accidentally introducing
549
+ // oscillation; an unbounded loop here would hang `axe-auth login`
550
+ // with no error.
551
+ let n = Math.max(1, Math.ceil(blob.length / CHUNK_LIMIT));
552
+ let safety = 0;
553
+ while (true) {
554
+ if (++safety > 8) {
555
+ throw new Error(`chunkBlobForKeyring: chunk count failed to converge after ${safety} iterations (blob length ${blob.length})`);
556
+ }
557
+ const headerLen = String(n).length + 1; // "<N>\n"
558
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
559
+ if (chunk0Capacity <= 0) {
560
+ throw new Error(`chunkBlobForKeyring: chunk count ${n} leaves no room for data`);
561
+ }
562
+ const remaining = Math.max(0, blob.length - chunk0Capacity);
563
+ const next = 1 + Math.ceil(remaining / CHUNK_LIMIT);
564
+ if (next === n)
565
+ break;
566
+ n = next;
567
+ }
568
+ if (n > MAX_CHUNKS) {
569
+ // Surfaced as a distinct error code (rather than KEYRING_UNAVAILABLE)
570
+ // because the keystore is healthy — the failure is that the IDP's
571
+ // token has too many claims to fit. Wrapping this as a keychain
572
+ // error would attach a misleading "Credential Manager rejected"
573
+ // platform hint via `wrapKeyringError`'s default path.
574
+ throw new errors_1.OAuthFlowError("TOKEN_TOO_LARGE", `OAuth token blob would require ${n} keyring entries (max ${MAX_CHUNKS}). The IDP may be issuing tokens with unusually many claims; talk to the realm administrator.`);
575
+ }
576
+ const headerLen = String(n).length + 1;
577
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
578
+ const parts = [`${n}\n${blob.slice(0, chunk0Capacity)}`];
579
+ let pos = chunk0Capacity;
580
+ while (pos < blob.length) {
581
+ parts.push(blob.slice(pos, pos + CHUNK_LIMIT));
582
+ pos += CHUNK_LIMIT;
583
+ }
584
+ return parts;
585
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deque/axe-auth",
3
- "version": "1.1.0-next.487d00fa",
3
+ "version": "1.1.0-next.49c17a1d",
4
4
  "description": "CLI authentication utility for Deque services",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -25,11 +25,14 @@
25
25
  "dependencies": {
26
26
  "@napi-rs/keyring": "^1.2.0",
27
27
  "remove-trailing-slash": "^0.1.1",
28
+ "shlex": "^3.0.0",
28
29
  "ts-dedent": "^2.2.0"
29
30
  },
30
31
  "devDependencies": {
32
+ "@hono/node-server": "^1.19.14",
31
33
  "@types/node": "^22.13.10",
32
34
  "c8": "^10.1.3",
35
+ "hono": "^4.12.16",
33
36
  "tsx": "^4.20.6",
34
37
  "typescript": "^5.9.3"
35
38
  },