@deque/axe-auth 1.1.0-next.fb07beab → 1.1.0-next.fea0aa8a

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.
Files changed (70) hide show
  1. package/README.md +59 -12
  2. package/credits.json +53 -0
  3. package/dist/cli/commonArgs.d.ts +35 -0
  4. package/dist/cli/commonArgs.help.d.ts +2 -0
  5. package/dist/cli/commonArgs.help.js +20 -0
  6. package/dist/cli/commonArgs.js +63 -0
  7. package/dist/cli/confirm.d.ts +17 -0
  8. package/dist/cli/confirm.js +53 -0
  9. package/dist/cli/errors.d.ts +13 -0
  10. package/dist/cli/errors.js +30 -0
  11. package/dist/cli/testUtils.d.ts +52 -0
  12. package/dist/cli/testUtils.js +100 -0
  13. package/dist/cli/types.d.ts +39 -0
  14. package/dist/cli/types.js +2 -0
  15. package/dist/commands/login.d.ts +41 -0
  16. package/dist/commands/login.help.d.ts +2 -0
  17. package/dist/commands/login.help.js +41 -0
  18. package/dist/commands/login.js +108 -0
  19. package/dist/commands/logout.d.ts +24 -0
  20. package/dist/commands/logout.help.d.ts +2 -0
  21. package/dist/commands/logout.help.js +38 -0
  22. package/dist/commands/logout.js +68 -0
  23. package/dist/commands/token.d.ts +21 -0
  24. package/dist/commands/token.help.d.ts +2 -0
  25. package/dist/commands/token.help.js +41 -0
  26. package/dist/commands/token.js +40 -0
  27. package/dist/index.js +107 -22
  28. package/dist/oauth/authorizationURL.d.ts +24 -0
  29. package/dist/oauth/authorizationURL.js +48 -0
  30. package/dist/oauth/authorize.d.ts +53 -0
  31. package/dist/oauth/authorize.js +117 -0
  32. package/dist/oauth/discoverOIDC.d.ts +33 -0
  33. package/dist/oauth/discoverOIDC.js +144 -0
  34. package/dist/oauth/discoverSSOConfig.d.ts +37 -0
  35. package/dist/oauth/discoverSSOConfig.js +105 -0
  36. package/dist/oauth/errors.d.ts +57 -2
  37. package/dist/oauth/errors.js +35 -1
  38. package/dist/oauth/getValidAccessToken.d.ts +54 -0
  39. package/dist/oauth/getValidAccessToken.js +131 -0
  40. package/dist/oauth/index.d.ts +14 -2
  41. package/dist/oauth/index.js +13 -1
  42. package/dist/oauth/issuerURL.d.ts +22 -0
  43. package/dist/oauth/issuerURL.js +38 -0
  44. package/dist/oauth/keyringBinding.d.ts +22 -0
  45. package/dist/oauth/keyringBinding.js +41 -0
  46. package/dist/oauth/openBrowser.d.ts +30 -0
  47. package/dist/oauth/openBrowser.js +95 -0
  48. package/dist/oauth/pkce.d.ts +17 -0
  49. package/dist/oauth/pkce.js +43 -0
  50. package/dist/oauth/predicates.d.ts +7 -0
  51. package/dist/oauth/predicates.js +15 -0
  52. package/dist/oauth/refreshTokens.d.ts +30 -0
  53. package/dist/oauth/refreshTokens.js +60 -0
  54. package/dist/oauth/revokeToken.d.ts +28 -0
  55. package/dist/oauth/revokeToken.js +63 -0
  56. package/dist/oauth/testUtils.d.ts +35 -0
  57. package/dist/oauth/testUtils.js +61 -0
  58. package/dist/oauth/tokenExchange.d.ts +26 -0
  59. package/dist/oauth/tokenExchange.js +44 -0
  60. package/dist/oauth/tokenResponse.d.ts +22 -0
  61. package/dist/oauth/tokenResponse.js +101 -0
  62. package/dist/oauth/tokenStore.d.ts +183 -0
  63. package/dist/oauth/tokenStore.js +560 -0
  64. package/dist/userAgent.d.ts +12 -0
  65. package/dist/userAgent.js +18 -0
  66. package/docs/architecture.md +201 -0
  67. package/docs/callback-page.md +24 -0
  68. package/docs/callback-server.md +21 -0
  69. package/docs/oauth-flow.md +15 -0
  70. package/package.json +19 -5
@@ -0,0 +1,560 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
4
+ exports.shouldChunkForKeyring = shouldChunkForKeyring;
5
+ exports.parseAndMigrateBlob = parseAndMigrateBlob;
6
+ exports.keyringErrorMessage = keyringErrorMessage;
7
+ exports.isKeyringSizeError = isKeyringSizeError;
8
+ exports.platformKeyringHint = platformKeyringHint;
9
+ exports.chunkBlobForKeyring = chunkBlobForKeyring;
10
+ const errors_1 = require("./errors");
11
+ const keyringBinding_1 = require("./keyringBinding");
12
+ const SERVICE_NAME = "axe-auth";
13
+ // On Windows the blob is base64-encoded and split across
14
+ // `credentials.0`, `credentials.1`, … entries (see `CHUNK_LIMIT`); a
15
+ // Windows dev inspecting Credential Manager will see opaque base64.
16
+ const ACCOUNT_NAME = "credentials";
17
+ // Windows Credential Manager caps stored values at 2560 UTF-16 code
18
+ // units, which large OAuth access-token JWTs (many groups/roles
19
+ // claims) routinely exceed. On Windows we work around this by
20
+ // splitting the JSON blob across multiple entries with account names
21
+ // `credentials.0`, `credentials.1`, … . `CHUNK_LIMIT` leaves margin
22
+ // under the platform cap; `MAX_CHUNKS` is a safety bound — we should
23
+ // never get close in practice, even with maximally-claimed tokens.
24
+ //
25
+ // macOS Keychain and Linux libsecret have no comparable limit, so
26
+ // chunking there would just multiply per-entry ACL prompts (each
27
+ // keychain entry is independently lockable on macOS) for no gain.
28
+ // Chunking is therefore Windows-only, gated by `shouldChunkForKeyring`.
29
+ const CHUNK_LIMIT = 2500;
30
+ const MAX_CHUNKS = 32;
31
+ /**
32
+ * Whether `KeyringTokenStore` should split the stored blob across
33
+ * multiple keychain entries on this platform. Windows-only because of
34
+ * Credential Manager's 2560 UTF-16 character per-entry cap. Exported
35
+ * (parameterized for tests) so the chunking path can be exercised
36
+ * deterministically.
37
+ */
38
+ function shouldChunkForKeyring(platform = process.platform) {
39
+ return platform === "win32";
40
+ }
41
+ /**
42
+ * Current on-disk blob schema version. Exported so consumers can
43
+ * display "stored v:N, expected v:M" diagnostics when `load()` returns
44
+ * a `version-mismatch` result.
45
+ */
46
+ exports.STORED_BLOB_VERSION = 1;
47
+ /**
48
+ * Migrators upgrade an older blob to the next version up. Walked by
49
+ * `load()` until the stored blob reaches `STORED_BLOB_VERSION`.
50
+ *
51
+ * A migrator returns `null` when the bump cannot be inferred from the
52
+ * old shape (e.g. a new required field with no derivable default); the
53
+ * caller then sees `{ ok: false, reason: "version-mismatch" }` and
54
+ * decides whether to re-auth, prompt, or preserve the old blob.
55
+ *
56
+ * Each migrator is responsible for taking `vN` → `vN+1`. To skip a
57
+ * version deliberately, register a migrator that returns `null` for
58
+ * that `fromVersion`.
59
+ */
60
+ const MIGRATORS = new Map([
61
+ // [1, (v1) => migrateV1ToV2(v1 as StoredBlobV1)],
62
+ ]);
63
+ // Sanity-check the migrator map at module load. Every key must be
64
+ // strictly less than `STORED_BLOB_VERSION` — the chain only walks
65
+ // forward, so a leftover migrator at the current (or future) version
66
+ // would either be unreachable or confuse the loop. Fail-fast so a
67
+ // dev forgetting to remove a stale entry during a version bump
68
+ // notices before shipping.
69
+ for (const fromVersion of MIGRATORS.keys()) {
70
+ if (fromVersion >= exports.STORED_BLOB_VERSION) {
71
+ throw new Error(`MIGRATORS contains a key (v${fromVersion}) that is not strictly less than STORED_BLOB_VERSION (${exports.STORED_BLOB_VERSION}). The chain only walks forward; remove stale migrators when bumping the schema version.`);
72
+ }
73
+ }
74
+ function getStoredVersion(blob) {
75
+ if (blob === null || typeof blob !== "object")
76
+ return null;
77
+ const v = blob.v;
78
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : null;
79
+ }
80
+ function isLatestBlob(blob) {
81
+ if (blob === null || typeof blob !== "object")
82
+ return false;
83
+ const b = blob;
84
+ return (b.v === exports.STORED_BLOB_VERSION &&
85
+ // Empty access token is treated as corrupt rather than a usable
86
+ // credential. `axe-auth token` printing an empty line and exiting
87
+ // 0 would look like success and silently break downstream.
88
+ typeof b.accessToken === "string" &&
89
+ b.accessToken.length > 0 &&
90
+ typeof b.expiresAt === "number" &&
91
+ (b.refreshToken === undefined || typeof b.refreshToken === "string") &&
92
+ typeof b.issuerURL === "string" &&
93
+ typeof b.clientId === "string" &&
94
+ typeof b.allowInsecureIssuer === "boolean" &&
95
+ typeof b.walnutURL === "string" &&
96
+ b.walnutURL.length > 0);
97
+ }
98
+ function blobToEntry(blob) {
99
+ const tokens = {
100
+ accessToken: blob.accessToken,
101
+ expiresAt: blob.expiresAt,
102
+ };
103
+ if (blob.refreshToken)
104
+ tokens.refreshToken = blob.refreshToken;
105
+ return {
106
+ tokens,
107
+ issuerURL: blob.issuerURL,
108
+ clientId: blob.clientId,
109
+ allowInsecureIssuer: blob.allowInsecureIssuer,
110
+ walnutURL: blob.walnutURL,
111
+ };
112
+ }
113
+ function entryToBlob(entry) {
114
+ const blob = {
115
+ v: exports.STORED_BLOB_VERSION,
116
+ accessToken: entry.tokens.accessToken,
117
+ expiresAt: entry.tokens.expiresAt,
118
+ issuerURL: entry.issuerURL,
119
+ clientId: entry.clientId,
120
+ allowInsecureIssuer: entry.allowInsecureIssuer,
121
+ walnutURL: entry.walnutURL,
122
+ };
123
+ if (entry.tokens.refreshToken)
124
+ blob.refreshToken = entry.tokens.refreshToken;
125
+ return blob;
126
+ }
127
+ /**
128
+ * JSON-parses the raw keychain password and walks the migrator chain
129
+ * until it reaches `expectedVersion`. Exported with `expectedVersion`
130
+ * and `migrators` parameters only for testing the chain mechanics
131
+ * against synthetic versions / migrators; production callers use
132
+ * `KeyringTokenStore.load()`, which feeds in `STORED_BLOB_VERSION`
133
+ * and `MIGRATORS` and applies the latest-shape check on top.
134
+ */
135
+ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION, migrators = MIGRATORS) {
136
+ if (raw === null)
137
+ return { ok: false, reason: "empty" };
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return { ok: false, reason: "corrupt" };
144
+ }
145
+ const storedVersion = getStoredVersion(parsed);
146
+ if (storedVersion === null)
147
+ return { ok: false, reason: "corrupt" };
148
+ let current = parsed;
149
+ let currentVersion = storedVersion;
150
+ while (currentVersion !== expectedVersion) {
151
+ const migrator = migrators.get(currentVersion);
152
+ if (!migrator) {
153
+ return { ok: false, reason: "version-mismatch", storedVersion };
154
+ }
155
+ const next = migrator(current);
156
+ if (next === null) {
157
+ return { ok: false, reason: "version-mismatch", storedVersion };
158
+ }
159
+ const nextVersion = getStoredVersion(next);
160
+ if (nextVersion === null || nextVersion <= currentVersion) {
161
+ return { ok: false, reason: "version-mismatch", storedVersion };
162
+ }
163
+ current = next;
164
+ currentVersion = nextVersion;
165
+ }
166
+ return { ok: true, blob: current };
167
+ }
168
+ function wrapKeyringError(op, cause) {
169
+ // Pass-through pre-wrapped OAuthFlowErrors so we don't double-wrap
170
+ // our own error type. The most common source today is
171
+ // `defaultEntryFactory` throwing `KEYRING_UNAVAILABLE` when the
172
+ // native binding can't be loaded — relabelling that as another
173
+ // `KEYRING_UNAVAILABLE` with a duplicate message and a possibly
174
+ // misleading platform hint helps nobody.
175
+ if (cause instanceof errors_1.OAuthFlowError) {
176
+ throw cause;
177
+ }
178
+ throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", keyringErrorMessage(op, cause), {
179
+ cause,
180
+ });
181
+ }
182
+ /**
183
+ * Builds the user-facing keychain error message. Platform is a
184
+ * parameter (defaulting to `process.platform`) so tests can drive each
185
+ * branch without mocking the runtime; mirrors the pattern in
186
+ * `platformKeyringHint`.
187
+ *
188
+ * The Windows-specific size-limit message is only used when the
189
+ * underlying error matches the binding's "longer than the platform
190
+ * limit" wording AND the runtime is win32 — that combination is the
191
+ * only way the size cap actually manifests in practice. On other
192
+ * platforms (or for any other binding error) we fall back to the
193
+ * generic per-platform hint.
194
+ */
195
+ function keyringErrorMessage(op, cause, platform = process.platform) {
196
+ if (platform === "win32" && isKeyringSizeError(cause)) {
197
+ 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.`;
198
+ }
199
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
200
+ return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
201
+ }
202
+ /**
203
+ * Detects the `@napi-rs/keyring` error string for "value too large".
204
+ * In practice only Windows Credential Manager triggers this — its
205
+ * stored values are capped at 2560 UTF-16 chars; macOS Keychain and
206
+ * Linux libsecret have no comparable limit. Exported (but not
207
+ * re-exported from the package index) so tests can exercise the
208
+ * detector independently of the wrap path.
209
+ */
210
+ function isKeyringSizeError(cause) {
211
+ if (!(cause instanceof Error))
212
+ return false;
213
+ return /longer than the platform limit/.test(cause.message);
214
+ }
215
+ /**
216
+ * Returns a per-platform hint appended to keychain error messages so
217
+ * users see actionable guidance for their OS instead of generic or
218
+ * Linux-only advice. Exported (but not re-exported from the package
219
+ * index) so tests can exercise each branch without mocking
220
+ * `process.platform`.
221
+ */
222
+ function platformKeyringHint(platform = process.platform) {
223
+ switch (platform) {
224
+ case "darwin":
225
+ return "On macOS this usually means Keychain Access denied or cancelled the prompt.";
226
+ case "win32":
227
+ return "On Windows this usually means Credential Manager rejected the operation.";
228
+ case "linux":
229
+ return "On Linux this usually means no D-Bus Secret Service is running (e.g. GNOME Keyring or KWallet).";
230
+ default:
231
+ return `Underlying platform: ${platform}.`;
232
+ }
233
+ }
234
+ /**
235
+ * Parses chunk 0's `<N>\n<rest>` header. Returns the chunk count and
236
+ * the data part following the newline, or `null` for any malformed /
237
+ * out-of-range / non-canonically-encoded header. Centralised here
238
+ * (rather than open-coded twice in `#loadChunked` and
239
+ * `#previousChunkN`) so the canonical-encoding contract has one
240
+ * authoritative implementation.
241
+ */
242
+ function parseChunkHeader(first) {
243
+ const newlineIdx = first.indexOf("\n");
244
+ if (newlineIdx <= 0)
245
+ return null;
246
+ const nStr = first.slice(0, newlineIdx);
247
+ const n = parseInt(nStr, 10);
248
+ // Reject non-canonical encodings ("01", " 3", "3abc"). parseInt is
249
+ // permissive about those; we want a single canonical encoding so
250
+ // two different headers can't decode to the same N.
251
+ if (!Number.isInteger(n) || n < 1 || n > MAX_CHUNKS || String(n) !== nStr) {
252
+ return null;
253
+ }
254
+ return { n, rest: first.slice(newlineIdx + 1) };
255
+ }
256
+ /**
257
+ * `TokenStore` backed by the operating system's native keychain via
258
+ * `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
259
+ * Secret Service). On macOS and Linux the blob lives in a single entry
260
+ * keyed by the fixed `credentials` account name. On Windows the blob
261
+ * is split across `credentials.0`, `credentials.1`, … entries to fit
262
+ * under Credential Manager's 2560 UTF-16 character per-entry cap; see
263
+ * `shouldChunkForKeyring`.
264
+ *
265
+ * The blob carries its own issuer/client coordinates so verbs can
266
+ * recover full config without per-issuer keying.
267
+ */
268
+ class KeyringTokenStore {
269
+ #entryFactory;
270
+ #chunked;
271
+ /**
272
+ * @param entryFactory Injection seam for `@napi-rs/keyring` entries.
273
+ * Defaults to the production lazy-resolved factory; tests pass a
274
+ * recording / faking variant.
275
+ */
276
+ constructor(entryFactory = keyringBinding_1.defaultEntryFactory) {
277
+ this.#entryFactory = entryFactory;
278
+ this.#chunked = shouldChunkForKeyring();
279
+ }
280
+ /**
281
+ * @internal Test seam. Constructs a store with an explicit chunking
282
+ * decision instead of the platform-determined default, so the
283
+ * chunked path can be exercised on macOS/Linux CI and the unchunked
284
+ * path on Windows CI. Production code must use the regular
285
+ * constructor and let `shouldChunkForKeyring()` decide — passing
286
+ * `chunked: true` on macOS would write data that the regular
287
+ * constructor wouldn't be able to read.
288
+ */
289
+ static forTesting(entryFactory, chunked) {
290
+ const store = new KeyringTokenStore(entryFactory);
291
+ store.#chunked = chunked;
292
+ return store;
293
+ }
294
+ #entry(account) {
295
+ return this.#entryFactory(SERVICE_NAME, account);
296
+ }
297
+ async save(entry) {
298
+ const jsonBlob = JSON.stringify(entryToBlob(entry));
299
+ if (this.#chunked) {
300
+ // Encode + chunk OUTSIDE the try/catch so a TOKEN_TOO_LARGE from
301
+ // `chunkBlobForKeyring` surfaces unchanged. The keychain
302
+ // operations stay inside the try and get wrapped as
303
+ // KEYRING_UNAVAILABLE if they fail.
304
+ const encoded = Buffer.from(jsonBlob, "utf8").toString("base64");
305
+ const parts = chunkBlobForKeyring(encoded);
306
+ try {
307
+ this.#saveChunked(parts);
308
+ }
309
+ catch (cause) {
310
+ wrapKeyringError("write", cause);
311
+ }
312
+ }
313
+ else {
314
+ try {
315
+ this.#entry(ACCOUNT_NAME).setPassword(jsonBlob);
316
+ }
317
+ catch (cause) {
318
+ wrapKeyringError("write", cause);
319
+ }
320
+ }
321
+ }
322
+ async load() {
323
+ let raw;
324
+ try {
325
+ if (this.#chunked) {
326
+ const result = this.#loadChunked();
327
+ if (result.kind === "present") {
328
+ raw = result.blob;
329
+ }
330
+ else if (result.kind === "empty") {
331
+ // First-time-upgrade fallback: a Windows dev who upgraded
332
+ // across the chunking change has data at the bare
333
+ // `credentials` account but no chunks yet. Read that legacy
334
+ // entry; the next save() migrates it. Note we only fall
335
+ // back when chunked data is *empty* — when chunked data is
336
+ // *corrupt* we surface that directly rather than restoring
337
+ // potentially stale legacy data underneath the corruption.
338
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
339
+ }
340
+ else {
341
+ return { ok: false, reason: "corrupt" };
342
+ }
343
+ }
344
+ else {
345
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
346
+ }
347
+ }
348
+ catch (cause) {
349
+ wrapKeyringError("read", cause);
350
+ }
351
+ const chain = parseAndMigrateBlob(raw);
352
+ if (!chain.ok)
353
+ return chain;
354
+ if (!isLatestBlob(chain.blob))
355
+ return { ok: false, reason: "corrupt" };
356
+ return { ok: true, entry: blobToEntry(chain.blob) };
357
+ }
358
+ async clear() {
359
+ try {
360
+ if (this.#chunked) {
361
+ this.#clearChunked();
362
+ }
363
+ else {
364
+ this.#entry(ACCOUNT_NAME).deletePassword();
365
+ }
366
+ }
367
+ catch (cause) {
368
+ wrapKeyringError("delete", cause);
369
+ }
370
+ }
371
+ /**
372
+ * Writes `parts` (the output of `chunkBlobForKeyring`) to entries
373
+ * `credentials.0..N-1`.
374
+ *
375
+ * Writes are in **reverse index order** — chunks N-1..1, then chunk
376
+ * 0 with the new header last. Chunk 0's header is what reads use to
377
+ * learn N, so until it's overwritten the previous chunk 0 still
378
+ * references the previous N chunks.
379
+ *
380
+ * Crash recovery is partial, not total. Reverse order helps in one
381
+ * case: when N_new > N_old and the crash happens before chunk 0 is
382
+ * rewritten — writes to indices >= N_old don't disturb old data,
383
+ * the previous chunk 0 still references the previous N chunks, and
384
+ * the prior session survives. The typical refresh case (N_new ==
385
+ * N_old) overwrites chunks 1..N-1 with new data while chunk 0 is
386
+ * still old, so a crash there reads as corrupt and the user
387
+ * re-auths. Reverse order is therefore a marginal improvement over
388
+ * forward order, not a guarantee.
389
+ *
390
+ * Cleanup sweeps `[N_new, N_old)` (bounded by the previous chunk
391
+ * count read from the old chunk 0 header before we overwrite it).
392
+ * For a typical token refresh (same N) this is zero deletes; the
393
+ * full safety sweep up to MAX_CHUNKS only runs as a defensive
394
+ * recovery when the previous N can't be determined. Orphans at
395
+ * indices >= max(N_new, N_old) from interrupted resize-up writes
396
+ * persist until the next `clear()` does the full sweep.
397
+ *
398
+ * Concurrency: this method is not safe to run concurrently against
399
+ * the same OS keychain. Two writers can interleave at chunk
400
+ * boundaries and produce a Frankenstein blob. axe-auth runs as a
401
+ * short-lived CLI so this is unlikely in practice, but a long-lived
402
+ * process refreshing in the background while the CLI is invoked
403
+ * could trip it.
404
+ */
405
+ #saveChunked(parts) {
406
+ const previousN = this.#previousChunkN();
407
+ for (let i = parts.length - 1; i >= 1; i--) {
408
+ this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
409
+ }
410
+ this.#entry(`${ACCOUNT_NAME}.0`).setPassword(parts[0]);
411
+ // Best-effort sweep: writes have already succeeded, so a sweep
412
+ // failure shouldn't roll back the save. The next save's bounded
413
+ // sweep cleans up anything we miss here. Same reasoning for the
414
+ // legacy delete below.
415
+ const sweepEnd = previousN ?? MAX_CHUNKS;
416
+ for (let i = parts.length; i < sweepEnd; i++) {
417
+ try {
418
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
419
+ }
420
+ catch {
421
+ // Sweep is best-effort; the next save handles leftovers.
422
+ }
423
+ }
424
+ // Clear any pre-chunking single-entry blob from a previous
425
+ // axe-auth release. This is a forever-tax (one extra
426
+ // deletePassword per save even after the migration is done)
427
+ // because we have no per-machine "migration completed" flag;
428
+ // adding one would mean another keychain entry to manage. The
429
+ // cost is one Credential Manager call per refresh — negligible
430
+ // relative to the OAuth round-trip.
431
+ try {
432
+ this.#entry(ACCOUNT_NAME).deletePassword();
433
+ }
434
+ catch {
435
+ // Best-effort; the next save attempts again.
436
+ }
437
+ }
438
+ /**
439
+ * Reads the chunk-count header from `credentials.0` so `#saveChunked`
440
+ * can bound its cleanup sweep. Returns `null` when chunk 0 is
441
+ * missing, when the header is malformed, or when the encoded N is
442
+ * out of range — every "I don't know the previous count" case
443
+ * collapses to a full safety sweep at the call site.
444
+ */
445
+ #previousChunkN() {
446
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
447
+ if (first === null)
448
+ return null;
449
+ return parseChunkHeader(first)?.n ?? null;
450
+ }
451
+ /**
452
+ * Reverse of `#saveChunked`. Returns a discriminated result so the
453
+ * caller can distinguish "no data" from "data is malformed" without
454
+ * reaching for sentinel strings.
455
+ */
456
+ #loadChunked() {
457
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
458
+ if (first === null)
459
+ return { kind: "empty" };
460
+ const header = parseChunkHeader(first);
461
+ if (!header)
462
+ return { kind: "corrupt" };
463
+ const parts = [header.rest];
464
+ for (let i = 1; i < header.n; i++) {
465
+ const part = this.#entry(`${ACCOUNT_NAME}.${i}`).getPassword();
466
+ if (part === null)
467
+ return { kind: "corrupt" };
468
+ parts.push(part);
469
+ }
470
+ // `Buffer.from(_, 'base64')` is permissive — invalid characters
471
+ // are silently dropped rather than throwing. Garbage base64
472
+ // produces garbage UTF-8, which falls through to the upstream
473
+ // JSON.parse and surfaces as `corrupt` from
474
+ // `parseAndMigrateBlob`. So no try/catch is needed here.
475
+ const blob = Buffer.from(parts.join(""), "base64").toString("utf8");
476
+ return { kind: "present", blob };
477
+ }
478
+ #clearChunked() {
479
+ // Sweep the whole safety range rather than break-on-first-missing
480
+ // so chunk holes (from interrupted writes or manual tampering)
481
+ // still get cleaned up. Logout is rare enough that the
482
+ // unconditional sweep cost is irrelevant.
483
+ //
484
+ // Per-entry errors are caught locally so a single throw doesn't
485
+ // strand the remaining chunks (or the legacy entry) in the
486
+ // keychain. After all attempts, we surface the first failure so
487
+ // the user still sees that logout didn't fully complete.
488
+ let firstError = null;
489
+ for (let i = 0; i < MAX_CHUNKS; i++) {
490
+ try {
491
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
492
+ }
493
+ catch (cause) {
494
+ firstError ??= cause;
495
+ }
496
+ }
497
+ // And the pre-chunking single-entry blob, in case a Windows dev
498
+ // had axe-auth installed before chunking shipped.
499
+ try {
500
+ this.#entry(ACCOUNT_NAME).deletePassword();
501
+ }
502
+ catch (cause) {
503
+ firstError ??= cause;
504
+ }
505
+ if (firstError !== null) {
506
+ throw firstError;
507
+ }
508
+ }
509
+ }
510
+ exports.KeyringTokenStore = KeyringTokenStore;
511
+ /**
512
+ * Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
513
+ * writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
514
+ * the reader can learn N from a single getPassword call. Each chunk
515
+ * stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
516
+ * require more than `MAX_CHUNKS` chunks. Exported for tests.
517
+ */
518
+ function chunkBlobForKeyring(blob) {
519
+ // N depends on the header length, which depends on N. Solve by
520
+ // iterating until the chunk count stabilises (converges in <= a
521
+ // couple of steps for any realistic blob). The safety counter is
522
+ // belt-and-suspenders against a future tweak (different
523
+ // CHUNK_LIMIT, different header format) accidentally introducing
524
+ // oscillation; an unbounded loop here would hang `axe-auth login`
525
+ // with no error.
526
+ let n = Math.max(1, Math.ceil(blob.length / CHUNK_LIMIT));
527
+ let safety = 0;
528
+ while (true) {
529
+ if (++safety > 8) {
530
+ throw new Error(`chunkBlobForKeyring: chunk count failed to converge after ${safety} iterations (blob length ${blob.length})`);
531
+ }
532
+ const headerLen = String(n).length + 1; // "<N>\n"
533
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
534
+ if (chunk0Capacity <= 0) {
535
+ throw new Error(`chunkBlobForKeyring: chunk count ${n} leaves no room for data`);
536
+ }
537
+ const remaining = Math.max(0, blob.length - chunk0Capacity);
538
+ const next = 1 + Math.ceil(remaining / CHUNK_LIMIT);
539
+ if (next === n)
540
+ break;
541
+ n = next;
542
+ }
543
+ if (n > MAX_CHUNKS) {
544
+ // Surfaced as a distinct error code (rather than KEYRING_UNAVAILABLE)
545
+ // because the keystore is healthy — the failure is that the IDP's
546
+ // token has too many claims to fit. Wrapping this as a keychain
547
+ // error would attach a misleading "Credential Manager rejected"
548
+ // platform hint via `wrapKeyringError`'s default path.
549
+ 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.`);
550
+ }
551
+ const headerLen = String(n).length + 1;
552
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
553
+ const parts = [`${n}\n${blob.slice(0, chunk0Capacity)}`];
554
+ let pos = chunk0Capacity;
555
+ while (pos < blob.length) {
556
+ parts.push(blob.slice(pos, pos + CHUNK_LIMIT));
557
+ pos += CHUNK_LIMIT;
558
+ }
559
+ return parts;
560
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `User-Agent` header value sent on all outbound requests, per
3
+ * Service Development Standards §4.4.
4
+ *
5
+ * Format: `axe-auth/v<package-version>` (e.g. `axe-auth/v1.0.2`).
6
+ *
7
+ * The npm scope (`@deque/`) is deliberately omitted from the wire format:
8
+ * `@` and `/` are not valid `tchar` per RFC 9110 §5.6.2, so a token like
9
+ * `@deque/axe-auth` would make the User-Agent malformed and risk WAF
10
+ * rejection (e.g. OWASP CRS rule 920330).
11
+ */
12
+ export declare const USER_AGENT: string;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.USER_AGENT = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
7
+ /**
8
+ * `User-Agent` header value sent on all outbound requests, per
9
+ * Service Development Standards §4.4.
10
+ *
11
+ * Format: `axe-auth/v<package-version>` (e.g. `axe-auth/v1.0.2`).
12
+ *
13
+ * The npm scope (`@deque/`) is deliberately omitted from the wire format:
14
+ * `@` and `/` are not valid `tchar` per RFC 9110 §5.6.2, so a token like
15
+ * `@deque/axe-auth` would make the User-Agent malformed and risk WAF
16
+ * rejection (e.g. OWASP CRS rule 920330).
17
+ */
18
+ exports.USER_AGENT = `axe-auth/v${pkg.version}`;