@deque/axe-auth 1.1.0-next.73fce274 → 1.1.0-next.7469dec9

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 (50) hide show
  1. package/README.md +13 -17
  2. package/credits.json +53 -0
  3. package/dist/cli/commonArgs.d.ts +20 -51
  4. package/dist/cli/commonArgs.help.d.ts +1 -1
  5. package/dist/cli/commonArgs.help.js +12 -11
  6. package/dist/cli/commonArgs.js +20 -76
  7. package/dist/cli/confirm.js +0 -3
  8. package/dist/cli/errors.d.ts +2 -19
  9. package/dist/cli/errors.js +3 -25
  10. package/dist/cli/testUtils.js +3 -3
  11. package/dist/cli/types.d.ts +10 -53
  12. package/dist/commands/login.d.ts +4 -4
  13. package/dist/commands/login.help.d.ts +1 -1
  14. package/dist/commands/login.help.js +11 -5
  15. package/dist/commands/login.js +33 -18
  16. package/dist/commands/logout.d.ts +1 -1
  17. package/dist/commands/logout.help.d.ts +1 -1
  18. package/dist/commands/logout.help.js +5 -4
  19. package/dist/commands/logout.js +1 -17
  20. package/dist/commands/token.d.ts +2 -7
  21. package/dist/commands/token.help.d.ts +1 -1
  22. package/dist/commands/token.help.js +5 -5
  23. package/dist/commands/token.js +6 -22
  24. package/dist/index.js +17 -52
  25. package/dist/oauth/authorizationURL.d.ts +1 -6
  26. package/dist/oauth/authorizationURL.js +2 -6
  27. package/dist/oauth/authorize.d.ts +13 -44
  28. package/dist/oauth/authorize.js +4 -5
  29. package/dist/oauth/discoverOIDC.d.ts +10 -27
  30. package/dist/oauth/discoverOIDC.js +33 -32
  31. package/dist/oauth/discoverSSOConfig.d.ts +37 -0
  32. package/dist/oauth/discoverSSOConfig.js +105 -0
  33. package/dist/oauth/errors.d.ts +2 -0
  34. package/dist/oauth/getValidAccessToken.d.ts +9 -44
  35. package/dist/oauth/getValidAccessToken.js +8 -16
  36. package/dist/oauth/openBrowser.d.ts +14 -3
  37. package/dist/oauth/openBrowser.js +22 -5
  38. package/dist/oauth/refreshTokens.js +5 -5
  39. package/dist/oauth/retry.d.ts +1 -0
  40. package/dist/oauth/retry.js +49 -0
  41. package/dist/oauth/revokeToken.js +8 -3
  42. package/dist/oauth/tokenExchange.js +5 -2
  43. package/dist/oauth/tokenResponse.d.ts +6 -38
  44. package/dist/oauth/tokenResponse.js +7 -27
  45. package/dist/oauth/tokenStore.d.ts +63 -3
  46. package/dist/oauth/tokenStore.js +379 -32
  47. package/dist/userAgent.d.ts +12 -0
  48. package/dist/userAgent.js +18 -0
  49. package/docs/architecture.md +27 -18
  50. package/package.json +16 -4
@@ -1,24 +1,50 @@
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.platformKeyringHint = platformKeyringHint;
8
+ exports.chunkBlobForKeyring = chunkBlobForKeyring;
5
9
  const errors_1 = require("./errors");
6
10
  const keyringBinding_1 = require("./keyringBinding");
7
- // On macOS: Keychain generic password item with the service name below.
8
- // On Windows: Credential Manager entry. On Linux: Secret Service / libsecret.
9
- // Exposed as a human-readable string because these all surface the service
10
- // name in OS UIs (Keychain Access, credmgr.exe, seahorse).
11
11
  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.
16
- //
17
- // Account name is human-readable so users investigating the entry in
18
- // macOS Keychain Access (or `secret-tool` on Linux, credmgr on
19
- // Windows) can tell what it is. Not versioned: the schema version
20
- // lives inside the blob and migrators handle the upgrade path.
12
+ /**
13
+ * Keychain account identifier. On macOS/Linux the entire blob lives at
14
+ * this single account. On Windows the blob is base64-encoded and split
15
+ * across `credentials.0`, `credentials.1`, entries (see `CHUNK_LIMIT`),
16
+ * so a Windows dev inspecting Credential Manager will see opaque base64.
17
+ */
21
18
  const ACCOUNT_NAME = "credentials";
19
+ /**
20
+ * Max JS string length per chunk. The limit applies to the full chunk
21
+ * including chunk 0's `<N>\n` count header, so chunk 0's data slice is
22
+ * `CHUNK_LIMIT - headerLen`. Windows Credential Manager's per-entry
23
+ * cap is `CRED_MAX_CREDENTIAL_BLOB_SIZE = 2560` bytes, and the
24
+ * `@napi-rs/keyring` Windows backend stores strings as UTF-16 (2 bytes
25
+ * per char), so 1250 chars = 2500 bytes stays safely under the cap.
26
+ */
27
+ const CHUNK_LIMIT = 1250;
28
+ /**
29
+ * Cap on chunks per stored blob. A request that would exceed this
30
+ * raises `TOKEN_TOO_LARGE` so an IDP issuing tokens with extraordinary
31
+ * claim counts fails with a clear error instead of silently consuming
32
+ * dozens of keychain entries.
33
+ */
34
+ const MAX_CHUNKS = 32;
35
+ /**
36
+ * Whether `KeyringTokenStore` should split the stored blob across
37
+ * multiple keychain entries on this platform. Windows-only: Credential
38
+ * Manager has a 2560-byte per-entry cap that large OAuth tokens
39
+ * routinely exceed. macOS Keychain and Linux libsecret have no
40
+ * comparable limit, and on macOS each entry is independently lockable
41
+ * (chunking there would multiply per-entry ACL prompts). Exported
42
+ * (parameterized for tests) so the chunking path can be exercised
43
+ * deterministically.
44
+ */
45
+ function shouldChunkForKeyring(platform = process.platform) {
46
+ return platform === "win32";
47
+ }
22
48
  /**
23
49
  * Current on-disk blob schema version. Exported so consumers can
24
50
  * display "stored v:N, expected v:M" diagnostics when `load()` returns
@@ -72,7 +98,9 @@ function isLatestBlob(blob) {
72
98
  (b.refreshToken === undefined || typeof b.refreshToken === "string") &&
73
99
  typeof b.issuerURL === "string" &&
74
100
  typeof b.clientId === "string" &&
75
- typeof b.allowInsecureIssuer === "boolean");
101
+ typeof b.allowInsecureIssuer === "boolean" &&
102
+ typeof b.walnutURL === "string" &&
103
+ b.walnutURL.length > 0);
76
104
  }
77
105
  function blobToEntry(blob) {
78
106
  const tokens = {
@@ -86,6 +114,7 @@ function blobToEntry(blob) {
86
114
  issuerURL: blob.issuerURL,
87
115
  clientId: blob.clientId,
88
116
  allowInsecureIssuer: blob.allowInsecureIssuer,
117
+ walnutURL: blob.walnutURL,
89
118
  };
90
119
  }
91
120
  function entryToBlob(entry) {
@@ -96,6 +125,7 @@ function entryToBlob(entry) {
96
125
  issuerURL: entry.issuerURL,
97
126
  clientId: entry.clientId,
98
127
  allowInsecureIssuer: entry.allowInsecureIssuer,
128
+ walnutURL: entry.walnutURL,
99
129
  };
100
130
  if (entry.tokens.refreshToken)
101
131
  blob.refreshToken = entry.tokens.refreshToken;
@@ -122,10 +152,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
122
152
  const storedVersion = getStoredVersion(parsed);
123
153
  if (storedVersion === null)
124
154
  return { ok: false, reason: "corrupt" };
125
- // Walk the migrator chain until we reach the expected version. A
126
- // missing or null-returning migrator means the old blob cannot be
127
- // upgraded; surface that so callers can prompt re-auth with a
128
- // clear signal instead of silently returning `empty`.
129
155
  let current = parsed;
130
156
  let currentVersion = storedVersion;
131
157
  while (currentVersion !== expectedVersion) {
@@ -139,8 +165,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
139
165
  }
140
166
  const nextVersion = getStoredVersion(next);
141
167
  if (nextVersion === null || nextVersion <= currentVersion) {
142
- // Migrator output is malformed or didn't advance. Treat the
143
- // stored blob as un-migratable rather than loop forever.
144
168
  return { ok: false, reason: "version-mismatch", storedVersion };
145
169
  }
146
170
  current = next;
@@ -149,32 +173,162 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
149
173
  return { ok: true, blob: current };
150
174
  }
151
175
  function wrapKeyringError(op, cause) {
152
- throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", `System keychain ${op} failed. On Linux this usually means no D-Bus Secret Service is running.`, { cause });
176
+ // Pass-through pre-wrapped OAuthFlowErrors so we don't double-wrap
177
+ // our own error type. The most common source today is
178
+ // `defaultEntryFactory` throwing `KEYRING_UNAVAILABLE` when the
179
+ // native binding can't be loaded — relabelling that as another
180
+ // `KEYRING_UNAVAILABLE` with a duplicate message and a possibly
181
+ // misleading platform hint helps nobody.
182
+ if (cause instanceof errors_1.OAuthFlowError) {
183
+ throw cause;
184
+ }
185
+ throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", keyringErrorMessage(op, cause), {
186
+ cause,
187
+ });
188
+ }
189
+ /**
190
+ * Builds the user-facing keychain error message: the underlying
191
+ * cause's text plus a per-platform hint. Platform is a parameter
192
+ * (defaulting to `process.platform`) so tests can drive each branch
193
+ * without mocking the runtime; mirrors the pattern in
194
+ * `platformKeyringHint`.
195
+ */
196
+ function keyringErrorMessage(op, cause, platform = process.platform) {
197
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
198
+ return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
199
+ }
200
+ /**
201
+ * Returns a per-platform hint appended to keychain error messages so
202
+ * users see actionable guidance for their OS instead of generic or
203
+ * Linux-only advice. Exported (but not re-exported from the package
204
+ * index) so tests can exercise each branch without mocking
205
+ * `process.platform`.
206
+ */
207
+ function platformKeyringHint(platform = process.platform) {
208
+ switch (platform) {
209
+ case "darwin":
210
+ return "On macOS this usually means Keychain Access denied or cancelled the prompt.";
211
+ case "win32":
212
+ return "On Windows this usually means Credential Manager rejected the operation.";
213
+ case "linux":
214
+ return "On Linux this usually means no D-Bus Secret Service is running (e.g. GNOME Keyring or KWallet).";
215
+ default:
216
+ return `Underlying platform: ${platform}.`;
217
+ }
218
+ }
219
+ /**
220
+ * Parses chunk 0's `<N>\n<rest>` header. Returns the chunk count and
221
+ * the data part following the newline, or `null` for any malformed /
222
+ * out-of-range / non-canonically-encoded header. Centralised here
223
+ * (rather than open-coded twice in `#loadChunked` and
224
+ * `#previousChunkN`) so the canonical-encoding contract has one
225
+ * authoritative implementation.
226
+ */
227
+ function parseChunkHeader(first) {
228
+ const newlineIdx = first.indexOf("\n");
229
+ if (newlineIdx <= 0)
230
+ return null;
231
+ const nStr = first.slice(0, newlineIdx);
232
+ const n = parseInt(nStr, 10);
233
+ // Reject non-canonical encodings ("01", " 3", "3abc"). parseInt is
234
+ // permissive about those; we want a single canonical encoding so
235
+ // two different headers can't decode to the same N.
236
+ if (!Number.isInteger(n) || n < 1 || n > MAX_CHUNKS || String(n) !== nStr) {
237
+ return null;
238
+ }
239
+ return { n, rest: first.slice(newlineIdx + 1) };
153
240
  }
154
241
  /**
155
242
  * `TokenStore` backed by the operating system's native keychain via
156
243
  * `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
157
- * Secret Service). One entry per machine, keyed by a fixed account
158
- * name; the blob carries its own issuer/client coordinates so verbs
159
- * can recover full config without per-issuer keying.
244
+ * Secret Service). On macOS and Linux the blob lives in a single entry
245
+ * keyed by the fixed `credentials` account name. On Windows the blob
246
+ * is split across `credentials.0`, `credentials.1`, entries to fit
247
+ * under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
248
+ * cap; see `shouldChunkForKeyring`.
249
+ *
250
+ * The blob carries its own issuer/client coordinates so verbs can
251
+ * recover full config without per-issuer keying.
160
252
  */
161
253
  class KeyringTokenStore {
162
- #entry;
254
+ #entryFactory;
255
+ #chunked;
256
+ /**
257
+ * @param entryFactory Injection seam for `@napi-rs/keyring` entries.
258
+ * Defaults to the production lazy-resolved factory; tests pass a
259
+ * recording / faking variant.
260
+ */
163
261
  constructor(entryFactory = keyringBinding_1.defaultEntryFactory) {
164
- this.#entry = entryFactory(SERVICE_NAME, ACCOUNT_NAME);
262
+ this.#entryFactory = entryFactory;
263
+ this.#chunked = shouldChunkForKeyring();
264
+ }
265
+ /**
266
+ * @internal Test seam. Constructs a store with an explicit chunking
267
+ * decision instead of the platform-determined default, so the
268
+ * chunked path can be exercised on macOS/Linux CI and the unchunked
269
+ * path on Windows CI. Production code must use the regular
270
+ * constructor and let `shouldChunkForKeyring()` decide — passing
271
+ * `chunked: true` on macOS would write data that the regular
272
+ * constructor wouldn't be able to read.
273
+ */
274
+ static forTesting(entryFactory, chunked) {
275
+ const store = new KeyringTokenStore(entryFactory);
276
+ store.#chunked = chunked;
277
+ return store;
278
+ }
279
+ #entry(account) {
280
+ return this.#entryFactory(SERVICE_NAME, account);
165
281
  }
166
282
  async save(entry) {
167
- try {
168
- this.#entry.setPassword(JSON.stringify(entryToBlob(entry)));
283
+ const jsonBlob = JSON.stringify(entryToBlob(entry));
284
+ if (this.#chunked) {
285
+ // Encode + chunk OUTSIDE the try/catch so a TOKEN_TOO_LARGE from
286
+ // `chunkBlobForKeyring` surfaces unchanged. The keychain
287
+ // operations stay inside the try and get wrapped as
288
+ // KEYRING_UNAVAILABLE if they fail.
289
+ const encoded = Buffer.from(jsonBlob, "utf8").toString("base64");
290
+ const parts = chunkBlobForKeyring(encoded);
291
+ try {
292
+ this.#saveChunked(parts);
293
+ }
294
+ catch (cause) {
295
+ wrapKeyringError("write", cause);
296
+ }
169
297
  }
170
- catch (cause) {
171
- wrapKeyringError("write", cause);
298
+ else {
299
+ try {
300
+ this.#entry(ACCOUNT_NAME).setPassword(jsonBlob);
301
+ }
302
+ catch (cause) {
303
+ wrapKeyringError("write", cause);
304
+ }
172
305
  }
173
306
  }
174
307
  async load() {
175
308
  let raw;
176
309
  try {
177
- raw = this.#entry.getPassword();
310
+ if (this.#chunked) {
311
+ const result = this.#loadChunked();
312
+ if (result.kind === "present") {
313
+ raw = result.blob;
314
+ }
315
+ else if (result.kind === "empty") {
316
+ // First-time-upgrade fallback: a Windows dev who upgraded
317
+ // across the chunking change has data at the bare
318
+ // `credentials` account but no chunks yet. Read that legacy
319
+ // entry; the next save() migrates it. Note we only fall
320
+ // back when chunked data is *empty* — when chunked data is
321
+ // *corrupt* we surface that directly rather than restoring
322
+ // potentially stale legacy data underneath the corruption.
323
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
324
+ }
325
+ else {
326
+ return { ok: false, reason: "corrupt" };
327
+ }
328
+ }
329
+ else {
330
+ raw = this.#entry(ACCOUNT_NAME).getPassword();
331
+ }
178
332
  }
179
333
  catch (cause) {
180
334
  wrapKeyringError("read", cause);
@@ -188,11 +342,204 @@ class KeyringTokenStore {
188
342
  }
189
343
  async clear() {
190
344
  try {
191
- this.#entry.deletePassword();
345
+ if (this.#chunked) {
346
+ this.#clearChunked();
347
+ }
348
+ else {
349
+ this.#entry(ACCOUNT_NAME).deletePassword();
350
+ }
192
351
  }
193
352
  catch (cause) {
194
353
  wrapKeyringError("delete", cause);
195
354
  }
196
355
  }
356
+ /**
357
+ * Writes `parts` (the output of `chunkBlobForKeyring`) to entries
358
+ * `credentials.0..N-1`.
359
+ *
360
+ * Writes are in **reverse index order** — chunks N-1..1, then chunk
361
+ * 0 with the new header last. Chunk 0's header is what reads use to
362
+ * learn N, so until it's overwritten the previous chunk 0 still
363
+ * references the previous N chunks.
364
+ *
365
+ * Crash recovery is partial, not total. Reverse order helps in one
366
+ * case: when N_new > N_old and the crash happens before chunk 0 is
367
+ * rewritten — writes to indices >= N_old don't disturb old data,
368
+ * the previous chunk 0 still references the previous N chunks, and
369
+ * the prior session survives. The typical refresh case (N_new ==
370
+ * N_old) overwrites chunks 1..N-1 with new data while chunk 0 is
371
+ * still old, so a crash there reads as corrupt and the user
372
+ * re-auths. Reverse order is therefore a marginal improvement over
373
+ * forward order, not a guarantee.
374
+ *
375
+ * Cleanup sweeps `[N_new, N_old)` (bounded by the previous chunk
376
+ * count read from the old chunk 0 header before we overwrite it).
377
+ * For a typical token refresh (same N) this is zero deletes; the
378
+ * full safety sweep up to MAX_CHUNKS only runs as a defensive
379
+ * recovery when the previous N can't be determined. Orphans at
380
+ * indices >= max(N_new, N_old) from interrupted resize-up writes
381
+ * persist until the next `clear()` does the full sweep.
382
+ *
383
+ * Concurrency: this method is not safe to run concurrently against
384
+ * the same OS keychain. Two writers can interleave at chunk
385
+ * boundaries and produce a Frankenstein blob. axe-auth runs as a
386
+ * short-lived CLI so this is unlikely in practice, but a long-lived
387
+ * process refreshing in the background while the CLI is invoked
388
+ * could trip it.
389
+ */
390
+ #saveChunked(parts) {
391
+ const previousN = this.#previousChunkN();
392
+ for (let i = parts.length - 1; i >= 1; i--) {
393
+ this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
394
+ }
395
+ this.#entry(`${ACCOUNT_NAME}.0`).setPassword(parts[0]);
396
+ // Best-effort sweep: writes have already succeeded, so a sweep
397
+ // failure shouldn't roll back the save. The next save's bounded
398
+ // sweep cleans up anything we miss here. Same reasoning for the
399
+ // legacy delete below.
400
+ const sweepEnd = previousN ?? MAX_CHUNKS;
401
+ for (let i = parts.length; i < sweepEnd; i++) {
402
+ try {
403
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
404
+ }
405
+ catch {
406
+ // Sweep is best-effort; the next save handles leftovers.
407
+ }
408
+ }
409
+ // Clear any pre-chunking single-entry blob from a previous
410
+ // axe-auth release. This is a forever-tax (one extra
411
+ // deletePassword per save even after the migration is done)
412
+ // because we have no per-machine "migration completed" flag;
413
+ // adding one would mean another keychain entry to manage. The
414
+ // cost is one Credential Manager call per refresh — negligible
415
+ // relative to the OAuth round-trip.
416
+ try {
417
+ this.#entry(ACCOUNT_NAME).deletePassword();
418
+ }
419
+ catch {
420
+ // Best-effort; the next save attempts again.
421
+ }
422
+ }
423
+ /**
424
+ * Reads the chunk-count header from `credentials.0` so `#saveChunked`
425
+ * can bound its cleanup sweep. Returns `null` when chunk 0 is
426
+ * missing, when the header is malformed, or when the encoded N is
427
+ * out of range — every "I don't know the previous count" case
428
+ * collapses to a full safety sweep at the call site.
429
+ */
430
+ #previousChunkN() {
431
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
432
+ if (first === null)
433
+ return null;
434
+ return parseChunkHeader(first)?.n ?? null;
435
+ }
436
+ /**
437
+ * Reverse of `#saveChunked`. Returns a discriminated result so the
438
+ * caller can distinguish "no data" from "data is malformed" without
439
+ * reaching for sentinel strings.
440
+ */
441
+ #loadChunked() {
442
+ const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
443
+ if (first === null)
444
+ return { kind: "empty" };
445
+ const header = parseChunkHeader(first);
446
+ if (!header)
447
+ return { kind: "corrupt" };
448
+ const parts = [header.rest];
449
+ for (let i = 1; i < header.n; i++) {
450
+ const part = this.#entry(`${ACCOUNT_NAME}.${i}`).getPassword();
451
+ if (part === null)
452
+ return { kind: "corrupt" };
453
+ parts.push(part);
454
+ }
455
+ // `Buffer.from(_, 'base64')` is permissive — invalid characters
456
+ // are silently dropped rather than throwing. Garbage base64
457
+ // produces garbage UTF-8, which falls through to the upstream
458
+ // JSON.parse and surfaces as `corrupt` from
459
+ // `parseAndMigrateBlob`. So no try/catch is needed here.
460
+ const blob = Buffer.from(parts.join(""), "base64").toString("utf8");
461
+ return { kind: "present", blob };
462
+ }
463
+ #clearChunked() {
464
+ // Sweep the whole safety range rather than break-on-first-missing
465
+ // so chunk holes (from interrupted writes or manual tampering)
466
+ // still get cleaned up. Logout is rare enough that the
467
+ // unconditional sweep cost is irrelevant.
468
+ //
469
+ // Per-entry errors are caught locally so a single throw doesn't
470
+ // strand the remaining chunks (or the legacy entry) in the
471
+ // keychain. After all attempts, we surface the first failure so
472
+ // the user still sees that logout didn't fully complete.
473
+ let firstError = null;
474
+ for (let i = 0; i < MAX_CHUNKS; i++) {
475
+ try {
476
+ this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
477
+ }
478
+ catch (cause) {
479
+ firstError ??= cause;
480
+ }
481
+ }
482
+ // And the pre-chunking single-entry blob, in case a Windows dev
483
+ // had axe-auth installed before chunking shipped.
484
+ try {
485
+ this.#entry(ACCOUNT_NAME).deletePassword();
486
+ }
487
+ catch (cause) {
488
+ firstError ??= cause;
489
+ }
490
+ if (firstError !== null) {
491
+ throw firstError;
492
+ }
493
+ }
197
494
  }
198
495
  exports.KeyringTokenStore = KeyringTokenStore;
496
+ /**
497
+ * Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
498
+ * writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
499
+ * the reader can learn N from a single getPassword call. Each chunk
500
+ * stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
501
+ * require more than `MAX_CHUNKS` chunks. Exported for tests.
502
+ */
503
+ function chunkBlobForKeyring(blob) {
504
+ // N depends on the header length, which depends on N. Solve by
505
+ // iterating until the chunk count stabilises (converges in <= a
506
+ // couple of steps for any realistic blob). The safety counter is
507
+ // belt-and-suspenders against a future tweak (different
508
+ // CHUNK_LIMIT, different header format) accidentally introducing
509
+ // oscillation; an unbounded loop here would hang `axe-auth login`
510
+ // with no error.
511
+ let n = Math.max(1, Math.ceil(blob.length / CHUNK_LIMIT));
512
+ let safety = 0;
513
+ while (true) {
514
+ if (++safety > 8) {
515
+ throw new Error(`chunkBlobForKeyring: chunk count failed to converge after ${safety} iterations (blob length ${blob.length})`);
516
+ }
517
+ const headerLen = String(n).length + 1; // "<N>\n"
518
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
519
+ if (chunk0Capacity <= 0) {
520
+ throw new Error(`chunkBlobForKeyring: chunk count ${n} leaves no room for data`);
521
+ }
522
+ const remaining = Math.max(0, blob.length - chunk0Capacity);
523
+ const next = 1 + Math.ceil(remaining / CHUNK_LIMIT);
524
+ if (next === n)
525
+ break;
526
+ n = next;
527
+ }
528
+ if (n > MAX_CHUNKS) {
529
+ // Surfaced as a distinct error code (rather than KEYRING_UNAVAILABLE)
530
+ // because the keystore is healthy — the failure is that the IDP's
531
+ // token has too many claims to fit. Wrapping this as a keychain
532
+ // error would attach a misleading "Credential Manager rejected"
533
+ // platform hint via `wrapKeyringError`'s default path.
534
+ 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.`);
535
+ }
536
+ const headerLen = String(n).length + 1;
537
+ const chunk0Capacity = CHUNK_LIMIT - headerLen;
538
+ const parts = [`${n}\n${blob.slice(0, chunk0Capacity)}`];
539
+ let pos = chunk0Capacity;
540
+ while (pos < blob.length) {
541
+ parts.push(blob.slice(pos, pos + CHUNK_LIMIT));
542
+ pos += CHUNK_LIMIT;
543
+ }
544
+ return parts;
545
+ }
@@ -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}`;
@@ -13,9 +13,11 @@ flowchart TB
13
13
  browser[System browser]
14
14
  callback[Loopback callback server<br/>127.0.0.1:ephemeral]
15
15
  keychain[(OS keychain)]
16
+ axe[axe server]
16
17
  keycloak[Customer Keycloak]
17
18
 
18
19
  user -- "axe-auth login / token / logout" --> cli
20
+ cli -- "GET /api/sso-config (login only)" --> axe
19
21
  cli -- "spawns" --> callback
20
22
  cli -- "opens" --> browser
21
23
  browser -- "authorize redirect<br/>(state, PKCE challenge)" --> keycloak
@@ -23,7 +25,7 @@ flowchart TB
23
25
  browser -- "GET /callback?code=...&state=..." --> callback
24
26
  callback -- "code, state" --> cli
25
27
  cli <-- "OIDC discovery, token exchange,<br/>refresh, revoke (HTTPS)" --> keycloak
26
- cli <-- "tokens + issuer/client<br/>(versioned blob)" --> keychain
28
+ cli <-- "tokens + issuer/client/walnutURL<br/>(versioned blob)" --> keychain
27
29
  cli -- "access token (stdout)" --> user
28
30
  ```
29
31
 
@@ -34,7 +36,8 @@ flowchart TB
34
36
  3. **System browser**: the developer's default OS browser (Chrome, Safari, Firefox, etc.). Used only for the user-interactive part of the OAuth Authorization Code flow. Runs on the host, never in a container or sandbox controlled by `axe-auth`.
35
37
  4. **Loopback callback server**: an HTTP listener bound to `127.0.0.1` on an OS-assigned ephemeral port. Spawned by the CLI at the start of `login` and torn down as soon as the OAuth callback fires. Per RFC 8252 §7.3, this is the standard pattern for native-app OAuth.
36
38
  5. **OS keychain**: the platform-native credential store accessed through [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring) — macOS Keychain, Windows Credential Manager, or Linux Secret Service (GNOME Keyring, KWallet). The CLI writes one entry per machine.
37
- 6. **Customer Keycloak**: the OAuth authorization server for the customer's deployment. Issues access and refresh tokens. Federation between Keycloak and any upstream enterprise IdP (Okta, AAD, etc.) is the customer's concern and out of scope for this document.
39
+ 6. **axe server**: the customer's deployment of the axe API. The CLI hits its `/api/sso-config` endpoint at the start of `login` to discover the Keycloak URL, realm, and OAuth client ID; no other CLI traffic flows through the axe server.
40
+ 7. **Customer Keycloak**: the OAuth authorization server for the customer's deployment. Issues access and refresh tokens. Federation between Keycloak and any upstream enterprise IdP (Okta, AAD, etc.) is the customer's concern and out of scope for this document.
38
41
 
39
42
  `axe-auth` itself does **not** communicate with Deque's API services directly. The access tokens it produces are consumed by downstream tools, most notably the axe MCP server, which presents them to Deque's services as `Authorization: Bearer ...`.
40
43
 
@@ -48,9 +51,12 @@ sequenceDiagram
48
51
  participant CLI as axe-auth CLI
49
52
  participant Browser as System browser
50
53
  participant CB as Loopback callback<br/>(127.0.0.1:port)
54
+ participant Axe as axe server
51
55
  participant KC as Customer Keycloak
52
56
 
53
- User->>CLI: axe-auth login [...]
57
+ User->>CLI: axe-auth login [--server <axe-url>]
58
+ CLI->>Axe: GET /api/sso-config
59
+ Axe-->>CLI: { url, realm, mcpClientId }
54
60
  CLI->>KC: GET /.well-known/openid-configuration
55
61
  KC-->>CLI: { authorization_endpoint, token_endpoint, ... }
56
62
  CLI->>CB: spawn on 127.0.0.1:<ephemeral>
@@ -63,21 +69,22 @@ sequenceDiagram
63
69
  CB-->>CLI: { code, state }
64
70
  CLI->>KC: POST /token (code, code_verifier)
65
71
  KC-->>CLI: { access_token, refresh_token, expires_in }
66
- Note over CLI: save tokens + issuer/client to OS keychain
72
+ Note over CLI: save tokens + issuer/client/walnutURL to OS keychain
67
73
  CLI-->>User: ✓ Authenticated.
68
74
  ```
69
75
 
70
- 1. The developer invokes `axe-auth login` with the authorization-server coordinates (or after a prior login, with no flags the stored entry supplies them).
71
- 2. The CLI fetches the OIDC discovery document at `<issuer>/.well-known/openid-configuration` to learn the authorization, token, and revocation endpoint URLs.
72
- 3. The CLI generates a PKCE `code_verifier` + `code_challenge` (S256) and a random `state` value.
73
- 4. The CLI starts a loopback HTTP listener on `127.0.0.1` at an OS-assigned port.
74
- 5. The CLI opens the developer's system browser to the authorization endpoint with the PKCE challenge, the state, and the loopback `redirect_uri`.
75
- 6. The developer authenticates with Keycloak (typically via the customer's federated SSO).
76
- 7. Keycloak redirects the browser to the loopback `redirect_uri` with an authorization `code` and the original `state`.
77
- 8. The loopback listener validates `state`, captures the `code`, and renders a small success page so the developer knows they can close the tab.
78
- 9. The CLI POSTs `code` + `code_verifier` to Keycloak's token endpoint and receives an `access_token`, `refresh_token`, and `expires_in`.
79
- 10. The CLI persists the resulting `StoredEntry` (tokens plus the issuer/client coordinates that minted them) into the OS keychain.
80
- 11. The CLI prints `✓ Authenticated.` on stdout and exits 0.
76
+ 1. The developer invokes `axe-auth login`, optionally with `--server <axe-url>` (or `AXE_SERVER_URL`); when neither is set the CLI defaults to Deque's SaaS prod axe server.
77
+ 2. The CLI fetches `<axe-server>/api/sso-config` to learn the Keycloak base URL, realm, and OAuth client ID. The axe server returns `mcpClientId: null` when the deployment has not been configured for OAuth-based MCP authentication, and the field is absent on older axe server versions; both cases surface as a clear error before any browser is opened.
78
+ 3. With the discovered coordinates the CLI fetches the OIDC discovery document at `<issuer>/.well-known/openid-configuration` to learn the authorization, token, and revocation endpoint URLs.
79
+ 4. The CLI generates a PKCE `code_verifier` + `code_challenge` (S256) and a random `state` value.
80
+ 5. The CLI starts a loopback HTTP listener on `127.0.0.1` at an OS-assigned port.
81
+ 6. The CLI opens the developer's system browser to the authorization endpoint with the PKCE challenge, the state, and the loopback `redirect_uri`.
82
+ 7. The developer authenticates with Keycloak (typically via the customer's federated SSO).
83
+ 8. Keycloak redirects the browser to the loopback `redirect_uri` with an authorization `code` and the original `state`.
84
+ 9. The loopback listener validates `state`, captures the `code`, and renders a small success page so the developer knows they can close the tab.
85
+ 10. The CLI POSTs `code` + `code_verifier` to Keycloak's token endpoint and receives an `access_token`, `refresh_token`, and `expires_in`.
86
+ 11. The CLI persists the resulting `StoredEntry` (tokens plus the issuer/client coordinates that minted them, plus the originating axe server URL) into the OS keychain.
87
+ 12. The CLI prints `✓ Authenticated.` on stdout and exits 0.
81
88
 
82
89
  ### `axe-auth token`
83
90
 
@@ -173,14 +180,16 @@ sequenceDiagram
173
180
  "accessToken": "...",
174
181
  "refreshToken": "...",
175
182
  "expiresAt": 1714426800000,
176
- "issuerURL": "https://auth.customer.example.com/realms/customer",
177
- "clientId": "axe-auth",
178
- "allowInsecureIssuer": false
183
+ "issuerURL": "https://auth.customer.example.com/auth/realms/customer",
184
+ "clientId": "axe-auth-cli",
185
+ "allowInsecureIssuer": false,
186
+ "walnutURL": "https://axe.customer.example.com"
179
187
  }
180
188
  ```
181
189
 
182
190
  - **Tokens** (`accessToken`, `refreshToken`, `expiresAt`): the OAuth token set returned by Keycloak. `refreshToken` is omitted if the granted scopes did not include `offline_access`.
183
191
  - **Issuer / client coordinates** (`issuerURL`, `clientId`, `allowInsecureIssuer`): the values the tokens were minted against. Persisting them lets `token` and `logout` operate flag-free after first login: the CLI resolves the right discovery URL, token endpoint, and revocation endpoint from the stored values, with no separate "default issuer" pointer to drift out of sync with the tokens themselves.
192
+ - **`walnutURL`**: the originating axe server URL that the SSO discovery used to resolve the OAuth coordinates. Persisted so future verbs can re-discover `/api/sso-config` without user-supplied flags.
184
193
  - **Schema version** (`v`): incremented when the blob shape changes incompatibly. A mismatch surfaces as `version-mismatch` from `KeyringTokenStore.load()`, and the CLI prompts re-authentication rather than guessing at unknown shapes.
185
194
 
186
195
  No other persistent state exists. There is no filesystem cache of OIDC discovery documents (each `login` and `logout` re-fetches), no separate config file, and no logs written to disk by default.