@deque/axe-auth 1.1.0-next.adf1ee93 → 1.1.0-next.bd805a7a

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.
@@ -1,37 +1,18 @@
1
1
  import type { ParseArgsConfig } from "node:util";
2
- /**
3
- * Default axe server URL for `axe-auth` users on Deque's SaaS prod
4
- * deployment. The CLI's `--server` flag (and `AXE_SERVER_URL` env)
5
- * override this; non-prod customers must supply their own walnut URL.
6
- */
2
+ /** Default axe server URL for Deque SaaS prod. */
7
3
  export declare const DEFAULT_WALNUT_URL = "https://axe.deque.com";
8
4
  /** Common configuration the CLI verbs share. */
9
5
  export interface CommonArgs {
10
- /**
11
- * axe server URL (walnut). Used by `login` to fetch
12
- * `/api/sso-config` and derive the OAuth issuer / realm / client
13
- * coordinates. `token` and `logout` operate on the stored entry
14
- * alone and ignore this value.
15
- */
6
+ /** axe server URL (walnut). */
16
7
  walnutURL: string;
17
- /**
18
- * Whether to permit non-loopback http walnut/issuer URLs. Loopback
19
- * hosts (`localhost` / `127.0.0.1` / `[::1]`) are always allowed
20
- * over http; this flag is the opt-in for non-loopback http
21
- * deployments.
22
- */
8
+ /** Whether non-loopback http walnut/issuer URLs are permitted. */
23
9
  allowInsecureIssuer: boolean;
24
10
  }
25
11
  /**
26
- * `parseArgs`-shaped options describing the flags every CLI verb
27
- * accepts (--server, --allow-insecure-issuer, --no-allow-insecure-issuer).
28
- * Subcommands spread this into their own `options` so they can add
29
- * verb-specific flags alongside.
30
- *
31
- * Node's `parseArgs` doesn't support `--no-` boolean negation
32
- * natively, so the opt-out is registered as its own flag. Passing
33
- * both `--allow-insecure-issuer` and `--no-allow-insecure-issuer` is
34
- * treated as user error and rejected.
12
+ * `parseArgs`-shaped options shared by every CLI verb. `parseArgs`
13
+ * doesn't support `--no-` boolean negation natively, so the opt-out
14
+ * is registered as its own flag and `parseCommonArgs` rejects passing
15
+ * both together.
35
16
  */
36
17
  export declare const COMMON_OPTIONS: NonNullable<ParseArgsConfig["options"]>;
37
18
  /** Subset of `parseArgs(...).values` this helper consumes. */
@@ -40,43 +21,15 @@ export interface ParsedCommonValues {
40
21
  "allow-insecure-issuer"?: boolean;
41
22
  "no-allow-insecure-issuer"?: boolean;
42
23
  }
43
- /**
44
- * Subset of a stored entry used as a fallback for
45
- * `allowInsecureIssuer` when the user passes neither
46
- * `--allow-insecure-issuer` nor `--no-allow-insecure-issuer`.
47
- * Sourced from `KeyringTokenStore.load()` by the dispatcher.
48
- *
49
- * `walnutURL` is carried alongside so the fallback only applies when
50
- * the user is targeting the same deployment as the stored entry —
51
- * otherwise a dev-time HTTP-allow setting would silently carry over
52
- * to a prod login.
53
- */
24
+ /** Stored fallback for `allowInsecureIssuer` + the `walnutURL` it was minted against. */
54
25
  export interface StoredCommonDefaults {
55
26
  walnutURL: string;
56
27
  allowInsecureIssuer: boolean;
57
28
  }
58
29
  /**
59
30
  * Resolves common configuration from parsed flag values, falling
60
- * back to `AXE_SERVER_URL` when `--server` is absent and finally to
61
- * the SaaS prod walnut URL when neither is set.
62
- *
63
- * `allowInsecureIssuer` is consumed by `login` (it is forwarded to
64
- * SSO discovery and the OAuth flow). The fallback to a stored value
65
- * exists so an interactive re-login on a private dev instance does
66
- * not need the flag re-passed when the previous login set it. The
67
- * fallback is gated on the resolved walnut URL matching the stored
68
- * one: a user logging in to a different deployment must opt back in
69
- * with `--allow-insecure-issuer` explicitly. The `token` and `logout`
70
- * verbs do **not** consume this resolved value — they read
71
- * `allowInsecureIssuer` directly from the keychain entry's metadata,
72
- * so flag/env input is silently ignored there (and the help text for
73
- * those verbs documents that).
74
- *
75
- * @param values The `values` object returned from `parseArgs`.
76
- * @param env Environment to consult for fallback. Defaults to
77
- * `process.env`; injected for test determinism.
78
- * @param defaults Stored fallback for `allowInsecureIssuer` plus the
79
- * `walnutURL` it was minted against. Pass `null` (or omit) when
80
- * nothing is stored.
31
+ * back to `AXE_SERVER_URL` and finally to `DEFAULT_WALNUT_URL`.
32
+ * `allowInsecureIssuer` falls back to `defaults` only when the
33
+ * resolved walnut URL matches `defaults.walnutURL`.
81
34
  */
82
35
  export declare function parseCommonArgs(values: ParsedCommonValues, env?: NodeJS.ProcessEnv, defaults?: StoredCommonDefaults | null): CommonArgs;
@@ -6,22 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.COMMON_OPTIONS = exports.DEFAULT_WALNUT_URL = void 0;
7
7
  exports.parseCommonArgs = parseCommonArgs;
8
8
  const remove_trailing_slash_1 = __importDefault(require("remove-trailing-slash"));
9
- /**
10
- * Default axe server URL for `axe-auth` users on Deque's SaaS prod
11
- * deployment. The CLI's `--server` flag (and `AXE_SERVER_URL` env)
12
- * override this; non-prod customers must supply their own walnut URL.
13
- */
9
+ /** Default axe server URL for Deque SaaS prod. */
14
10
  exports.DEFAULT_WALNUT_URL = "https://axe.deque.com";
15
11
  /**
16
- * `parseArgs`-shaped options describing the flags every CLI verb
17
- * accepts (--server, --allow-insecure-issuer, --no-allow-insecure-issuer).
18
- * Subcommands spread this into their own `options` so they can add
19
- * verb-specific flags alongside.
20
- *
21
- * Node's `parseArgs` doesn't support `--no-` boolean negation
22
- * natively, so the opt-out is registered as its own flag. Passing
23
- * both `--allow-insecure-issuer` and `--no-allow-insecure-issuer` is
24
- * treated as user error and rejected.
12
+ * `parseArgs`-shaped options shared by every CLI verb. `parseArgs`
13
+ * doesn't support `--no-` boolean negation natively, so the opt-out
14
+ * is registered as its own flag and `parseCommonArgs` rejects passing
15
+ * both together.
25
16
  */
26
17
  exports.COMMON_OPTIONS = {
27
18
  server: { type: "string" },
@@ -30,27 +21,9 @@ exports.COMMON_OPTIONS = {
30
21
  };
31
22
  /**
32
23
  * Resolves common configuration from parsed flag values, falling
33
- * back to `AXE_SERVER_URL` when `--server` is absent and finally to
34
- * the SaaS prod walnut URL when neither is set.
35
- *
36
- * `allowInsecureIssuer` is consumed by `login` (it is forwarded to
37
- * SSO discovery and the OAuth flow). The fallback to a stored value
38
- * exists so an interactive re-login on a private dev instance does
39
- * not need the flag re-passed when the previous login set it. The
40
- * fallback is gated on the resolved walnut URL matching the stored
41
- * one: a user logging in to a different deployment must opt back in
42
- * with `--allow-insecure-issuer` explicitly. The `token` and `logout`
43
- * verbs do **not** consume this resolved value — they read
44
- * `allowInsecureIssuer` directly from the keychain entry's metadata,
45
- * so flag/env input is silently ignored there (and the help text for
46
- * those verbs documents that).
47
- *
48
- * @param values The `values` object returned from `parseArgs`.
49
- * @param env Environment to consult for fallback. Defaults to
50
- * `process.env`; injected for test determinism.
51
- * @param defaults Stored fallback for `allowInsecureIssuer` plus the
52
- * `walnutURL` it was minted against. Pass `null` (or omit) when
53
- * nothing is stored.
24
+ * back to `AXE_SERVER_URL` and finally to `DEFAULT_WALNUT_URL`.
25
+ * `allowInsecureIssuer` falls back to `defaults` only when the
26
+ * resolved walnut URL matches `defaults.walnutURL`.
54
27
  */
55
28
  function parseCommonArgs(values, env = process.env, defaults = null) {
56
29
  const walnutURL = (0, remove_trailing_slash_1.default)(values.server ?? env.AXE_SERVER_URL ?? exports.DEFAULT_WALNUT_URL);
@@ -9,9 +9,6 @@ const node_readline_1 = require("node:readline");
9
9
  * default action stays conservative.
10
10
  */
11
11
  async function confirm(options) {
12
- // Annotate after the ?? fallbacks so the union of `Writable |
13
- // NodeJS.WriteStream` (from `process.stderr`) collapses to the
14
- // base class — otherwise `output.write(...)` is ambiguous.
15
12
  const input = options.input ?? process.stdin;
16
13
  const output = options.output ?? process.stderr;
17
14
  const question = options.question.endsWith(" ")
@@ -2,19 +2,12 @@
2
2
  export type CLIErrorCode = "NOT_AUTHENTICATED" | "USER_CANCELLED" | "ALREADY_AUTHENTICATED" | "OAUTH_FAILED" | "KEYCHAIN_FAILURE";
3
3
  /**
4
4
  * Thrown from a verb's `run` to signal a known failure. The
5
- * dispatcher in `src/index.ts` writes `message` to stderr and exits
6
- * with `exitCode`. The `code` field is the load-bearing
7
- * discriminator; `exitCode` is derived for shell scripts and is
8
- * documented in the README.
5
+ * dispatcher writes `message` to stderr and exits with `exitCode`.
9
6
  */
10
7
  export declare class CLIError extends Error {
11
8
  readonly code: CLIErrorCode;
12
9
  readonly exitCode: number;
13
10
  constructor(code: CLIErrorCode, message: string);
14
11
  }
15
- /**
16
- * Returns the `message` of an `Error`-shaped value, or its `String`
17
- * coercion otherwise. Used in user-facing error templates so
18
- * callers don't inline the `instanceof` ternary every time.
19
- */
12
+ /** Returns the `message` of an `Error`, or its `String` coercion otherwise. */
20
13
  export declare function describeError(err: unknown): string;
@@ -11,10 +11,7 @@ const EXIT_CODE_BY_ERROR_CODE = {
11
11
  };
12
12
  /**
13
13
  * Thrown from a verb's `run` to signal a known failure. The
14
- * dispatcher in `src/index.ts` writes `message` to stderr and exits
15
- * with `exitCode`. The `code` field is the load-bearing
16
- * discriminator; `exitCode` is derived for shell scripts and is
17
- * documented in the README.
14
+ * dispatcher writes `message` to stderr and exits with `exitCode`.
18
15
  */
19
16
  class CLIError extends Error {
20
17
  code;
@@ -27,11 +24,7 @@ class CLIError extends Error {
27
24
  }
28
25
  }
29
26
  exports.CLIError = CLIError;
30
- /**
31
- * Returns the `message` of an `Error`-shaped value, or its `String`
32
- * coercion otherwise. Used in user-facing error templates so
33
- * callers don't inline the `instanceof` ternary every time.
34
- */
27
+ /** Returns the `message` of an `Error`, or its `String` coercion otherwise. */
35
28
  function describeError(err) {
36
29
  return err instanceof Error ? err.message : String(err);
37
30
  }
@@ -4,48 +4,22 @@ import type { LoadResult } from "../oauth/tokenStore";
4
4
  import type { CommonArgs } from "./commonArgs";
5
5
  /** Mapping passed to `parseArgs` for verb-specific flags. */
6
6
  export type VerbOptions = NonNullable<ParseArgsConfig["options"]>;
7
- /**
8
- * The injectables every CLI verb sees. The dispatcher fills these in
9
- * with the real `process.*` streams; tests pass synthetic values.
10
- * Verb-specific overrides (e.g. `getToken`, `authorize`) extend this
11
- * interface in the relevant verb's module — TS allows the dispatcher
12
- * to pass a plain `CommandDeps` because the verb-specific extras are
13
- * declared optional.
14
- */
7
+ /** Injectables every CLI verb sees. Dispatcher fills with `process.*`; tests pass synthetic values. */
15
8
  export interface CommandDeps {
16
- /**
17
- * Standard input. Carries an optional `isTTY` so verbs like
18
- * `login` can branch on interactivity. Real `process.stdin`
19
- * satisfies the shape; test fixtures using `Readable.from([])`
20
- * leave `isTTY` undefined, which coerces to `false`.
21
- */
9
+ /** `isTTY` is optional so `Readable.from([])` test fixtures coerce to non-TTY. */
22
10
  stdin: Readable & {
23
11
  isTTY?: boolean;
24
12
  };
25
13
  stdout: Writable;
26
14
  stderr: Writable;
27
15
  /**
28
- * Pre-loaded result of `KeyringTokenStore.load()`, populated by the
29
- * dispatcher when it ran the keychain read to derive the
30
- * `parseCommonArgs` fallback. Verbs prefer this over loading
31
- * again so a single `axe-auth token` invocation hits the keychain
32
- * once instead of three times. Tests typically leave this
33
- * undefined and inject a fake `tokenStore` instead.
16
+ * Pre-loaded result of `KeyringTokenStore.load()` from the
17
+ * dispatcher's keychain read, so a single CLI invocation hits the
18
+ * keychain once instead of N times.
34
19
  */
35
20
  loadedEntry?: LoadResult;
36
21
  }
37
- /**
38
- * Specification of a single CLI verb. The dispatcher in
39
- * `src/index.ts` consumes one of these per registered command:
40
- * parses argv, resolves common args, prints `helpText` for `--help`,
41
- * runs `run`, and translates thrown `CLIError`s (see `./errors`)
42
- * into exit codes.
43
- *
44
- * Each verb narrows the `run` parameters to its own
45
- * `CommonArgs & <Verb>Flags` and `<Verb>Deps`. Method-shorthand
46
- * bivariance lets that narrower signature satisfy this interface
47
- * without casts at the verb definition.
48
- */
22
+ /** Specification of a single CLI verb consumed by the dispatcher. */
49
23
  export interface CommandSpec {
50
24
  /** Verb name, e.g. `"login"`. */
51
25
  readonly name: string;
@@ -53,27 +27,13 @@ export interface CommandSpec {
53
27
  readonly summary: string;
54
28
  /** Full help text printed for `axe-auth <verb> --help`. */
55
29
  readonly helpText: string;
56
- /**
57
- * Verb-specific `parseArgs` options. Common options
58
- * (`--server` / `--allow-insecure-issuer` / `--no-allow-insecure-issuer`)
59
- * are added by the dispatcher; do not duplicate them here.
60
- */
30
+ /** Verb-specific `parseArgs` options; common options are added by the dispatcher. */
61
31
  readonly options: VerbOptions;
62
- /**
63
- * `true` if this verb cannot run without a usable walnut URL
64
- * (login). `false` for verbs that operate on the stored entry
65
- * alone (token, logout) and have their own "not authenticated" /
66
- * "already logged out" handling for the empty-entry case. With
67
- * the SaaS prod default in `parseCommonArgs`, the walnut URL is
68
- * never strictly missing, but this flag remains for future
69
- * required-config additions.
70
- */
32
+ /** `true` if this verb cannot run without a usable walnut URL (login). */
71
33
  readonly requiresConfig: boolean;
72
34
  /**
73
- * Run the verb with already-resolved args and the dispatcher's
74
- * deps. Throw a `CLIError` to signal a known failure with an
75
- * explicit exit code; throw any other error for a generic exit
76
- * code 2 plus the error message on stderr.
35
+ * Throw `CLIError` for a known failure with an explicit exit code;
36
+ * any other error becomes exit code 2 with the message on stderr.
77
37
  */
78
38
  run(args: CommonArgs, deps: CommandDeps): Promise<void>;
79
39
  }
@@ -17,10 +17,7 @@ export interface LoginDeps extends CommandDeps {
17
17
  * so the pre-check and post-flow save agree on the keychain entry.
18
18
  */
19
19
  tokenStore?: TokenStore;
20
- /**
21
- * Override the confirmation prompt (for tests). Receives the
22
- * issuer URL and returns whether the user wants to proceed.
23
- */
20
+ /** Override the confirmation prompt (for tests). */
24
21
  confirm?: (prompt: string) => Promise<boolean>;
25
22
  }
26
23
  /** Verb-specific flags for `axe-auth login`. */
@@ -32,9 +32,6 @@ const loginCommand = {
32
32
  output: deps.stderr,
33
33
  }));
34
34
  const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
35
- // 1. Ask the axe server where its Keycloak lives and which client to use.
36
- // This is the only step that hits the axe server directly; from here on
37
- // the CLI talks to Keycloak using the discovered coordinates.
38
35
  let ssoConfig;
39
36
  try {
40
37
  ssoConfig = await discoverFn(args.walnutURL, {
@@ -49,16 +46,11 @@ const loginCommand = {
49
46
  }
50
47
  const issuerURL = `${(0, remove_trailing_slash_1.default)(ssoConfig.url)}/auth/realms/${ssoConfig.realm}`;
51
48
  const clientId = ssoConfig.mcpClientId;
52
- // 2. Re-auth confirmation. Same UX as the previous flag-driven
53
- // flow, but the comparison is against the *discovered* issuer
54
- // + client, not user-supplied flags.
55
49
  if (!args.force) {
56
50
  const stored = deps.loadedEntry ?? (await tokenStore.load());
57
51
  if (!stored.ok && stored.reason !== "empty") {
58
- // Existing entry is unreadable (corrupt or stored under a
59
- // schema we can't migrate). authorize() will overwrite it
60
- // either way, but the user deserves a breadcrumb for what
61
- // disappeared.
52
+ // authorize() will overwrite either way, but the user
53
+ // deserves a breadcrumb for what disappeared.
62
54
  deps.stderr.write(`axe-auth: replacing unreadable stored credentials (${stored.reason}).\n`);
63
55
  }
64
56
  if (stored.ok) {
@@ -86,9 +78,8 @@ const loginCommand = {
86
78
  }
87
79
  }
88
80
  }
89
- // 3. Drive the OAuth flow. The originating axe server URL rides along
90
- // so it lands in the StoredEntry and future verbs (re-discovery,
91
- // revoke) can operate without user-supplied flags.
81
+ // `walnutURL` lands in the StoredEntry so future verbs
82
+ // (re-discovery, revoke) operate without user-supplied flags.
92
83
  try {
93
84
  await authorizeFn({
94
85
  issuerURL,
@@ -16,8 +16,6 @@ const logoutCommand = {
16
16
  const discoverFn = deps.discoverOIDC ?? discoverOIDC_1.discoverOIDC;
17
17
  const revokeFn = deps.revokeRefreshToken ?? revokeToken_1.revokeRefreshToken;
18
18
  const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
19
- // Prefer the entry the dispatcher already loaded; only fall back
20
- // to a fresh read when there isn't one (test path).
21
19
  const loaded = deps.loadedEntry ?? (await tokenStore.load());
22
20
  if (!loaded.ok) {
23
21
  if (loaded.reason === "empty") {
@@ -15,10 +15,6 @@ const tokenCommand = {
15
15
  async run(_args, deps) {
16
16
  const getToken = deps.getToken ?? getValidAccessToken_1.getValidAccessToken;
17
17
  const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
18
- // Stored entry is the only source of truth: single-entry-per-machine
19
- // model, the blob carries the issuer/client coordinates the tokens
20
- // were minted against, and there is no longer a flag to override
21
- // them with.
22
18
  const loaded = deps.loadedEntry ?? (await tokenStore.load());
23
19
  let token;
24
20
  try {
package/dist/index.js CHANGED
@@ -14,7 +14,6 @@ const logout_1 = __importDefault(require("./commands/logout"));
14
14
  const token_1 = __importDefault(require("./commands/token"));
15
15
  const errors_1 = require("./cli/errors");
16
16
  const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
17
- // Iteration order is the order verbs appear in `axe-auth --help`.
18
17
  const COMMANDS = [
19
18
  login_1.default,
20
19
  logout_1.default,
@@ -82,14 +81,9 @@ async function dispatch(argv) {
82
81
  process.stdout.write(`${command.helpText}\n`);
83
82
  return 0;
84
83
  }
85
- // Best-effort load of the stored entry. Used to (a) supply the
86
- // `allowInsecureIssuer` fallback on flag-free invocations, and (b)
87
- // hand the entry to the verb via `deps.loadedEntry` so a single
88
- // `axe-auth token` invocation hits the keychain once instead of
89
- // twice. A read failure here is non-fatal — `parseCommonArgs`
90
- // always succeeds (the SaaS prod default fills any unsupplied
91
- // walnut URL), and verbs that need a stored entry have their own
92
- // empty/corrupt-entry handling.
84
+ // Best-effort load: handed to verbs via `deps.loadedEntry` so a
85
+ // single CLI invocation hits the keychain once. Read failure is
86
+ // non-fatal verbs handle their own empty/corrupt cases.
93
87
  let defaults = null;
94
88
  let loadedEntry;
95
89
  try {
@@ -102,9 +96,8 @@ async function dispatch(argv) {
102
96
  }
103
97
  }
104
98
  catch {
105
- // Keychain unavailable: leave defaults null. login will fail at
106
- // `tokenStore.save()` with a clearer error than we can produce
107
- // here; token / logout's own empty-entry path handles it.
99
+ // Keychain unavailable: leave defaults null. Verbs surface their
100
+ // own clearer errors at the actual save / read site.
108
101
  }
109
102
  let common;
110
103
  try {
@@ -10,12 +10,7 @@ export interface BuildAuthorizationURLOptions {
10
10
  codeChallenge: string;
11
11
  /** CSRF `state` value, echoed by the auth server and validated on callback. */
12
12
  state: string;
13
- /**
14
- * OAuth scopes to request. No default — callers must choose explicitly.
15
- * Keycloak-idiomatic value for a refresh-token flow is `["offline_access"]`;
16
- * Google uses `access_type=offline` (a custom parameter) instead; Auth0
17
- * tolerates `offline_access` only with specific audience settings.
18
- */
13
+ /** OAuth scopes to request. No default; callers choose explicitly. */
19
14
  scopes: readonly string[];
20
15
  }
21
16
  /**
@@ -2,12 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildAuthorizationURL = buildAuthorizationURL;
4
4
  const errors_1 = require("./errors");
5
- // Names of OAuth params we always set. If any of these are already
6
- // present on the authorization endpoint URL returned by discovery,
7
- // something is wrong on the server side (or with the caller's
8
- // endpoint override) and silently keeping both values would be a
9
- // security trap: the authorization server's disambiguation is
10
- // unspecified and varies by implementation.
5
+ // Pre-existing values for these on the authorization endpoint are a
6
+ // security trap: the auth server's disambiguation is unspecified.
11
7
  const OAUTH_REQUIRED_PARAMS = [
12
8
  "response_type",
13
9
  "client_id",
@@ -2,72 +2,34 @@ import type { TokenSet } from "./tokenResponse";
2
2
  import { type TokenStore } from "./tokenStore";
3
3
  /** Options for `authorize`. */
4
4
  export interface AuthorizeOptions {
5
- /**
6
- * Authorization-server URL the discovery document claims as its
7
- * `issuer`. For Keycloak, callers build this as
8
- * `${serverURL}/realms/${realm}`. For other providers it is the
9
- * hostname (or issuer path) advertised in their discovery document.
10
- */
5
+ /** Issuer URL the OIDC discovery document advertises (e.g. `${serverURL}/realms/${realm}` for Keycloak). */
11
6
  issuerURL: string;
12
7
  /** OAuth client ID registered with the authorization server. */
13
8
  clientId: string;
14
- /**
15
- * Originating walnut (axe server) URL the user supplied (or the
16
- * SaaS prod default) at login. Persisted in the stored entry
17
- * alongside the OAuth coordinates so future verbs can re-discover
18
- * `/api/sso-config` without user-supplied flags.
19
- */
9
+ /** Persisted alongside the tokens so future verbs can re-discover `/api/sso-config` without flags. */
20
10
  walnutURL: string;
21
- /**
22
- * OAuth scopes to request. Required — this library has no opinion
23
- * about which scopes your provider expects. Keycloak callers who
24
- * want a refresh token typically pass `["offline_access"]`; Google
25
- * uses `access_type=offline` as a separate query param and
26
- * therefore needs an empty scope list plus that param threaded
27
- * through elsewhere.
28
- */
11
+ /** OAuth scopes to request. Keycloak callers typically pass `["offline_access"]` for a refresh token. */
29
12
  scopes: readonly string[];
30
13
  /** Max time to wait for the loopback callback, in milliseconds. */
31
14
  timeoutMs?: number;
32
15
  /** Aborts the in-flight discovery, callback wait, and token exchange. */
33
16
  signal?: AbortSignal;
34
- /**
35
- * Override for the token persistence layer. Defaults to a fresh
36
- * `KeyringTokenStore()` (single keychain entry per machine; the
37
- * blob carries its own issuer/client coordinates).
38
- */
17
+ /** Override for the token persistence layer. */
39
18
  tokenStore?: TokenStore;
40
19
  /** Override for the system browser launcher. Injected for tests. */
41
20
  openBrowser?: (url: string) => void;
42
- /**
43
- * Called with the authorization URL just before the browser launch.
44
- * The default prints to stderr only when stderr is a TTY, so a
45
- * parent CLI consuming this library as a dependency does not
46
- * double-print. Pass a custom handler to route the URL through your
47
- * own UI, or `() => {}` to suppress entirely.
48
- */
21
+ /** Called with the authorization URL just before the browser launch. Default prints to stderr only when stderr is a TTY. */
49
22
  onAuthorizationUrl?: (url: string) => void;
50
23
  /**
51
- * Called for soft warnings that are not errors but warrant user
52
- * attention (e.g. `offline_access` was requested but the server did
53
- * not return a `refresh_token`, or the browser failed to launch).
54
- * The default prints to stderr only when stderr is a TTY. Pass a
55
- * custom handler to route warnings through your own UI, or `() =>
56
- * {}` to suppress entirely.
57
- *
58
- * Non-TTY callers who want warning visibility (log files, parent
59
- * CLIs, background workers) should pass an explicit handler.
60
- * Dropped warnings have no visible symptom at the time they fire —
61
- * users only discover the consequence later (e.g. being prompted to
62
- * re-authenticate at the next session).
24
+ * Called for soft warnings (e.g. requested `offline_access` but the
25
+ * server returned no refresh token, or the browser failed to
26
+ * launch). Default prints to stderr only when stderr is a TTY.
27
+ * Non-TTY callers who want warning visibility should pass an
28
+ * explicit handler dropped warnings have no symptom at the time
29
+ * they fire; users discover the consequence later.
63
30
  */
64
31
  onWarning?: (message: string) => void;
65
- /**
66
- * Forwarded to the discovery step. Loopback hosts (`localhost` /
67
- * `127.0.0.1` / `[::1]`) are always permitted over http; this flag
68
- * is the opt-in for non-loopback http issuers and for non-loopback
69
- * http endpoints returned by discovery. Default `false`.
70
- */
32
+ /** Forwarded to discovery; permits non-loopback http issuers + endpoints. */
71
33
  allowInsecureIssuer?: boolean;
72
34
  }
73
35
  /**
@@ -39,10 +39,8 @@ function defaultOnWarning(message) {
39
39
  */
40
40
  async function authorize(options) {
41
41
  const { issuerURL, clientId, walnutURL, scopes, timeoutMs, signal, tokenStore = new tokenStore_1.KeyringTokenStore(), openBrowser = openBrowser_1.openBrowser, onAuthorizationUrl = defaultOnAuthorizationUrl, onWarning = defaultOnWarning, allowInsecureIssuer, } = options;
42
- // Discovery first. If the auth server is unreachable we want to fail
43
- // *before* opening a browser — a rejected discovery throw is
44
- // strictly more useful than a browser tab pointing at a
45
- // wrong/unreachable URL.
42
+ // Discovery before browser-launch so a bad URL surfaces as a
43
+ // throw rather than a wrong/unreachable browser tab.
46
44
  const config = await (0, discoverOIDC_1.discoverOIDC)(issuerURL, {
47
45
  signal,
48
46
  allowInsecureIssuer,
@@ -15,36 +15,19 @@ export interface OIDCConfiguration {
15
15
  export interface DiscoverOIDCOptions {
16
16
  /** Aborts the underlying fetch when fired. */
17
17
  signal?: AbortSignal;
18
- /**
19
- * Permit non-HTTPS issuer URLs whose host is not a loopback literal.
20
- * Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
21
- * allowed over http since they cannot be intercepted remotely; this
22
- * flag is for corporate dev setups or reverse-proxy scenarios where
23
- * http is the only available path. Default `false`.
24
- */
18
+ /** Permit non-HTTPS issuer URLs whose host is not a loopback literal. Default `false`. */
25
19
  allowInsecureIssuer?: boolean;
26
20
  }
27
21
  /**
28
- * Fetches and parses the OpenID Connect discovery document for a given
29
- * issuer. Fails fast (no retry) so the caller does not open a browser
30
- * against an unreachable authorization server.
22
+ * Fetches and parses the OIDC discovery document. Fails fast (no
23
+ * retry) so the caller does not open a browser against an unreachable
24
+ * authorization server. Verifies the server's claimed `issuer` matches
25
+ * the input URL per OIDC Discovery §3 — without this, a hostile
26
+ * discovery response could redirect the authorization and token
27
+ * endpoints to attacker hosts.
31
28
  *
32
- * This function uses the OIDC discovery well-known path as a
33
- * convention most OAuth 2.0 providers expose it regardless of
34
- * whether you intend to perform identity validation. This library
35
- * itself does not perform OIDC identity validation (no id_token /
36
- * nonce / signature checks); callers needing OIDC-strength identity
37
- * assurance should layer that on top.
38
- *
39
- * Verifies that the server's claimed `issuer` matches the URL the
40
- * caller passed in, per OIDC Discovery §3 / defence against a hostile
41
- * discovery response redirecting `authorization_endpoint` and
42
- * `token_endpoint` to attacker-controlled hosts.
43
- *
44
- * @param issuerURL Authorization-server URL the discovery document
45
- * claims as its `issuer`. For Keycloak, callers build this as
46
- * `${serverURL}/realms/${realm}`. For other providers it is the
47
- * hostname (or issuer path) advertised in their discovery document.
48
- * Trailing slashes tolerated.
29
+ * Uses the OIDC well-known path as a convention; does not perform
30
+ * OIDC-strength identity validation (no id_token / nonce / signature
31
+ * checks). Callers needing identity assurance should layer that on top.
49
32
  */
50
33
  export declare function discoverOIDC(issuerURL: string, options?: DiscoverOIDCOptions): Promise<OIDCConfiguration>;
@@ -9,13 +9,7 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
9
9
  function optionalString(v) {
10
10
  return (0, predicates_1.isNonEmptyString)(v) ? v : undefined;
11
11
  }
12
- /**
13
- * Throws `DISCOVERY_FAILED` if `url` is not safe to transmit OAuth
14
- * secrets over. `https:` is always fine; `http:` is only fine for
15
- * loopback hosts, or for any host when `allowInsecurePermitted` is
16
- * `true`. `label` describes the URL being checked ("issuer URL",
17
- * "token_endpoint", etc.) and appears in the error message.
18
- */
12
+ /** Throws `DISCOVERY_FAILED` if `url` is not safe to transmit OAuth secrets over. */
19
13
  function assertSecureURL(url, label, allowInsecurePermitted) {
20
14
  let parsed;
21
15
  try {
@@ -40,10 +34,9 @@ function assertSecureURL(url, label, allowInsecurePermitted) {
40
34
  throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Unsupported ${label} scheme '${parsed.protocol}'; expected https: or http: (loopback only).`);
41
35
  }
42
36
  function buildDiscoveryURL(issuerURL) {
43
- // Use URL parsing (rather than string concat) so the discovery path
44
- // lands on the URL's pathname, not accidentally after a query string
45
- // or fragment. `normalizeIssuerURL` already strips those, but
46
- // defense in depth keeps the contract obvious from the code.
37
+ // URL parsing (rather than concat) so the path lands on `pathname`
38
+ // even if the input has a query string or fragment. `normalizeIssuerURL`
39
+ // strips those, but defense in depth.
47
40
  const normalized = new URL((0, issuerURL_1.normalizeIssuerURL)(issuerURL));
48
41
  normalized.search = "";
49
42
  normalized.hash = "";
@@ -71,21 +64,10 @@ function parseConfiguration(body, url) {
71
64
  };
72
65
  }
73
66
  /**
74
- * `code_challenge_methods_supported` is OPTIONAL in OIDC discovery, so its
75
- * absence proves nothing older providers may support PKCE without
76
- * advertising it. But when the list IS present and does not include
77
- * `S256` (the only method this CLI uses, per RFC 7636), the server has
78
- * explicitly declared it does not support the flow we need. Fail fast
79
- * with an actionable message instead of letting the user hit a generic
80
- * OAuth error several steps deeper into the flow.
81
- *
82
- * An empty list (`[]`) is treated the same as a populated list missing
83
- * `S256`: the server has explicitly advertised zero supported methods,
84
- * which is incompatible.
85
- *
86
- * Called from `discoverOIDC` after issuer verification so that a
87
- * tampered discovery doc surfaces the more security-critical issuer
88
- * mismatch first.
67
+ * `code_challenge_methods_supported` is OPTIONAL in OIDC discovery
68
+ * absence is fine (older providers don't advertise). But when the
69
+ * list is present and excludes `S256` (the only method this CLI
70
+ * uses, per RFC 7636), fail fast with an actionable message.
89
71
  */
90
72
  function assertPKCESupport(body, url) {
91
73
  const methods = body.code_challenge_methods_supported;
@@ -96,27 +78,16 @@ function assertPKCESupport(body, url) {
96
78
  throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `OpenID configuration at ${url} advertises code_challenge_methods_supported = ${JSON.stringify(methods)}, but axe-auth requires S256 (PKCE per RFC 7636). The OAuth client used by axe-auth needs PKCE enabled, or you may be on an axe server version that predates OAuth-based MCP authentication.`);
97
79
  }
98
80
  /**
99
- * Fetches and parses the OpenID Connect discovery document for a given
100
- * issuer. Fails fast (no retry) so the caller does not open a browser
101
- * against an unreachable authorization server.
102
- *
103
- * This function uses the OIDC discovery well-known path as a
104
- * convention most OAuth 2.0 providers expose it regardless of
105
- * whether you intend to perform identity validation. This library
106
- * itself does not perform OIDC identity validation (no id_token /
107
- * nonce / signature checks); callers needing OIDC-strength identity
108
- * assurance should layer that on top.
109
- *
110
- * Verifies that the server's claimed `issuer` matches the URL the
111
- * caller passed in, per OIDC Discovery §3 / defence against a hostile
112
- * discovery response redirecting `authorization_endpoint` and
113
- * `token_endpoint` to attacker-controlled hosts.
81
+ * Fetches and parses the OIDC discovery document. Fails fast (no
82
+ * retry) so the caller does not open a browser against an unreachable
83
+ * authorization server. Verifies the server's claimed `issuer` matches
84
+ * the input URL per OIDC Discovery §3 — without this, a hostile
85
+ * discovery response could redirect the authorization and token
86
+ * endpoints to attacker hosts.
114
87
  *
115
- * @param issuerURL Authorization-server URL the discovery document
116
- * claims as its `issuer`. For Keycloak, callers build this as
117
- * `${serverURL}/realms/${realm}`. For other providers it is the
118
- * hostname (or issuer path) advertised in their discovery document.
119
- * Trailing slashes tolerated.
88
+ * Uses the OIDC well-known path as a convention; does not perform
89
+ * OIDC-strength identity validation (no id_token / nonce / signature
90
+ * checks). Callers needing identity assurance should layer that on top.
120
91
  */
121
92
  async function discoverOIDC(issuerURL, options = {}) {
122
93
  const allowInsecure = options.allowInsecureIssuer ?? false;
@@ -1,9 +1,4 @@
1
- /**
2
- * Subset of the axe server's `/api/sso-config` response that this
3
- * package consumes. The full response may carry additional fields
4
- * (e.g. `publicClientId` for the SPA frontend); we ignore everything
5
- * except what the CLI needs to drive its OAuth flow.
6
- */
1
+ /** Subset of `/api/sso-config` this package consumes. */
7
2
  export interface SSOConfig {
8
3
  /** Keycloak base URL, e.g. `https://auth.example.com`. */
9
4
  url: string;
@@ -16,12 +11,7 @@ export interface SSOConfig {
16
11
  export interface DiscoverSSOConfigOptions {
17
12
  /** Aborts the underlying fetch when fired. */
18
13
  signal?: AbortSignal;
19
- /**
20
- * Permit non-HTTPS axe server URLs whose host is not a loopback
21
- * literal. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are
22
- * always allowed over http; this flag is the opt-in for non-loopback
23
- * http (corporate dev / reverse-proxy setups). Default `false`.
24
- */
14
+ /** Permit non-HTTPS axe server URLs whose host is not a loopback literal. Default `false`. */
25
15
  allowInsecure?: boolean;
26
16
  }
27
17
  /**
@@ -1,60 +1,25 @@
1
1
  import { type LoadResult, type TokenStore } from "./tokenStore";
2
2
  /** Options for `getValidAccessToken`. */
3
3
  export interface GetValidAccessTokenOptions {
4
- /**
5
- * OIDC issuer URL (same value passed to `authorize`). Must match
6
- * the stored entry's `issuerURL`; mismatch throws
7
- * `OAuthFlowError("NOT_AUTHENTICATED", ...)` rather than refreshing
8
- * the wrong issuer's tokens at the requested endpoint.
9
- */
4
+ /** OIDC issuer URL. Must match the stored entry's `issuerURL`; mismatch throws NOT_AUTHENTICATED. */
10
5
  issuerURL: string;
11
- /**
12
- * OAuth client identifier. Must match the stored entry's
13
- * `clientId`; see the note on `issuerURL` for the mismatch
14
- * behavior.
15
- */
6
+ /** OAuth client identifier. Must match the stored entry's `clientId`; same mismatch behavior as `issuerURL`. */
16
7
  clientId: string;
17
8
  /**
18
- * How close to expiry we start preemptively refreshing, in
19
- * milliseconds. Defaults to 60_000 (60s). The buffer gives headroom
20
- * between our "still fresh enough" check and the server's view of
21
- * expiry (which may differ by a few seconds of clock skew) and
22
- * prevents a token from expiring mid-request after we hand it out.
23
- *
24
- * Assumes the access-token TTL is much larger than the buffer. With
25
- * TTLs ≤ `expiryBufferMs`, every call will trigger a refresh.
9
+ * How close to expiry preemptive refresh kicks in, in ms. Default
10
+ * 60_000. Buffer covers clock skew vs. the server. Assumes
11
+ * access-token TTL this; otherwise every call refreshes.
26
12
  */
27
13
  expiryBufferMs?: number;
28
- /**
29
- * Override for the token store. Defaults to a fresh
30
- * `KeyringTokenStore()` (single keychain entry per machine).
31
- */
14
+ /** Override for the token store. */
32
15
  tokenStore?: TokenStore;
33
- /**
34
- * Pre-loaded result of `tokenStore.load()`. When provided, the
35
- * function skips its own keychain read and uses this value
36
- * instead — lets a caller that already loaded the entry (the CLI
37
- * dispatcher does, to derive `parseCommonArgs` defaults) avoid a
38
- * redundant second read on the hot path. The same `tokenStore` is
39
- * still used for the post-refresh `save()` and the
40
- * `invalid_grant` `clear()`.
41
- */
16
+ /** Pre-loaded `tokenStore.load()` result so the dispatcher's keychain read isn't repeated. */
42
17
  loadedEntry?: LoadResult;
43
18
  /** Aborts discovery + the refresh POST when fired. */
44
19
  signal?: AbortSignal;
45
- /**
46
- * Forwarded to discovery. Loopback issuers are always permitted
47
- * over http; this flag is the opt-in for non-loopback http.
48
- */
20
+ /** Forwarded to discovery; permits non-loopback http. */
49
21
  allowInsecureIssuer?: boolean;
50
- /**
51
- * Called for soft warnings that are not errors but warrant user
52
- * attention (e.g. a fresh `TokenSet` could not be written to the
53
- * keychain, stranding the rotated refresh token — see the hazard
54
- * note in the body of `getValidAccessToken`). The default prints
55
- * to stderr only when stderr is a TTY. Pass a custom handler to
56
- * route warnings through your own UI, or `() => {}` to suppress.
57
- */
22
+ /** Called for soft warnings (e.g. rotated tokens couldn't be persisted — see HAZARD note in the body). Default prints to stderr only when stderr is a TTY. */
58
23
  onWarning?: (message: string) => void;
59
24
  /** Source of `now`. Defaults to `Date.now`. Injected for test determinism. */
60
25
  now?: () => number;
@@ -59,21 +59,15 @@ async function getValidAccessToken(options) {
59
59
  throw notAuthenticated(`Stored credentials are from an unsupported schema version (v:${loaded.storedVersion}). Run \`axe-auth login\` to re-authenticate.`);
60
60
  }
61
61
  }
62
- // Guard against a mismatch between the requested issuer/client and
63
- // the stored entry's. Under single-entry storage, the keychain
64
- // holds one set of tokens minted against one (issuer, client)
65
- // pair. Refreshing those tokens against a different
66
- // discovery/token endpoint would land an unrelated refresh token
67
- // at the wrong server and leak it. Refuse rather than silently
68
- // proceed so direct library callers (the CLI's verbs warn + route
69
- // before getting here) get a clear signal.
62
+ // Refuse on issuer/client mismatch: refreshing tokens at a
63
+ // different endpoint would leak the refresh token to the wrong
64
+ // server.
70
65
  if (loaded.entry.issuerURL !== issuerURL ||
71
66
  loaded.entry.clientId !== clientId) {
72
67
  throw notAuthenticated(`Stored credentials are for issuer ${loaded.entry.issuerURL} (client ${loaded.entry.clientId}), but the requested issuer is ${issuerURL} (client ${clientId}). Run \`axe-auth login\` to re-authenticate.`);
73
68
  }
74
69
  const tokens = loaded.entry.tokens;
75
70
  if (now() + expiryBufferMs < tokens.expiresAt) {
76
- // Still fresh — no network call, no store write.
77
71
  return tokens.accessToken;
78
72
  }
79
73
  if (!tokens.refreshToken) {
@@ -95,13 +89,10 @@ async function getValidAccessToken(options) {
95
89
  }
96
90
  catch (err) {
97
91
  if (isInvalidGrant(err)) {
98
- // Refresh token revoked / expired server-side. Best-effort
99
- // clear of the stored tokens so the next run starts clean —
100
- // but if the clear itself fails (e.g. KEYRING_UNAVAILABLE),
101
- // prefer surfacing NOT_AUTHENTICATED so the user still gets
102
- // the actionable "please run login" signal. Note the clear
103
- // failure via onWarning; the next run will see the stale
104
- // tokens, try to refresh, and land back here.
92
+ // Best-effort clear: if the clear itself fails, still surface
93
+ // NOT_AUTHENTICATED so the user gets the "please run login"
94
+ // signal the next run will refresh, land back here, and
95
+ // retry the clear.
105
96
  try {
106
97
  await tokenStore.clear();
107
98
  }
@@ -53,9 +53,6 @@ async function refreshTokens(options) {
53
53
  await (0, tokenResponse_1.throwTokenEndpointError)(response, "Token refresh");
54
54
  }
55
55
  const fresh = await (0, tokenResponse_1.parseTokenResponse)(response, issuedAt, options.tokenEndpoint);
56
- // Preserve the input refresh token if the server didn't rotate.
57
- // Keycloak rotates by default; others (e.g. Okta with some
58
- // configs) don't.
59
56
  return {
60
57
  ...fresh,
61
58
  refreshToken: fresh.refreshToken ?? options.refreshToken,
@@ -1,18 +1,4 @@
1
- /**
2
- * Tokens returned by a successful token-endpoint call (authorization
3
- * code exchange, refresh-token grant, etc.).
4
- *
5
- * `refreshToken` is optional because not all flows return one. On
6
- * authorization-code exchange it's absent if the caller did not
7
- * request `offline_access` (or the provider equivalent); on refresh
8
- * some providers rotate tokens (return a new one) while others don't
9
- * (the caller should keep the existing refresh token).
10
- *
11
- * `grantedScope` reflects the authorization server's `scope` response
12
- * field when present. RFC 6749 §5.1 says `scope` is required in the
13
- * response when the granted set differs from the requested set; many
14
- * servers send it unconditionally.
15
- */
1
+ /** Tokens returned by a successful token-endpoint call. */
16
2
  export interface TokenSet {
17
3
  /** Access token for authenticated API calls. */
18
4
  accessToken: string;
@@ -24,31 +10,13 @@ export interface TokenSet {
24
10
  grantedScope?: string;
25
11
  }
26
12
  /**
27
- * Reads a non-2xx response body and throws
28
- * `OAuthFlowError("TOKEN_EXCHANGE_FAILED", …)` with the OAuth
29
- * `error` / `error_description` surfaced in both message and details
30
- * when present. Shared by both the authorization-code exchange and
31
- * refresh-token paths since the error contract is identical.
32
- *
33
- * @param context Short human-readable description of which call
34
- * failed ("Token exchange", "Token refresh", etc.). Appears in the
35
- * error message.
13
+ * Reads a non-2xx response body and throws `TOKEN_EXCHANGE_FAILED`
14
+ * with the OAuth `error` / `error_description` surfaced when present.
36
15
  */
37
16
  export declare function throwTokenEndpointError(response: Response, context: string): Promise<never>;
38
17
  /**
39
- * Parses a 2xx response body from an RFC 6749 §5.1 token endpoint
40
- * (authorization-code exchange, refresh-token grant, etc.) into a
41
- * `TokenSet`. Validates the required shape (`access_token`,
42
- * `expires_in`, Bearer `token_type`) and converts the relative
43
- * `expires_in` into an absolute `expiresAt` using `issuedAt`.
44
- *
45
- * @param response The HTTP response (must be 2xx; caller handles
46
- * error statuses via `throwTokenEndpointError`).
47
- * @param issuedAt The timestamp captured just before the network
48
- * call. Slightly conservative — the token actually expires
49
- * `expires_in` seconds from when the server issued it, so the
50
- * effective usable window is `expires_in - RTT`, which errs toward
51
- * "expires sooner" rather than "expires later."
52
- * @param endpointURL URL used for error messages.
18
+ * Parses a 2xx response from an RFC 6749 §5.1 token endpoint into a
19
+ * `TokenSet`. `issuedAt` is the timestamp captured just before the
20
+ * network call; the resulting `expiresAt` is conservative by ~RTT.
53
21
  */
54
22
  export declare function parseTokenResponse(response: Response, issuedAt: number, endpointURL: string): Promise<TokenSet>;
@@ -4,10 +4,8 @@ exports.throwTokenEndpointError = throwTokenEndpointError;
4
4
  exports.parseTokenResponse = parseTokenResponse;
5
5
  const errors_1 = require("./errors");
6
6
  const predicates_1 = require("./predicates");
7
- // RFC 6749 §5.1 describes `expires_in` as "the lifetime in seconds"
8
- // without pinning the JSON type, and some providers historically send
9
- // numeric strings. Accept both; reject anything non-positive or
10
- // non-finite.
7
+ // RFC 6749 §5.1 doesn't pin the JSON type and some providers send
8
+ // numeric strings; accept both, reject non-positive or non-finite.
11
9
  function parseExpiresIn(v) {
12
10
  if (typeof v === "number" && Number.isFinite(v) && v > 0)
13
11
  return v;
@@ -37,15 +35,8 @@ function parseErrorBody(body) {
37
35
  };
38
36
  }
39
37
  /**
40
- * Reads a non-2xx response body and throws
41
- * `OAuthFlowError("TOKEN_EXCHANGE_FAILED", …)` with the OAuth
42
- * `error` / `error_description` surfaced in both message and details
43
- * when present. Shared by both the authorization-code exchange and
44
- * refresh-token paths since the error contract is identical.
45
- *
46
- * @param context Short human-readable description of which call
47
- * failed ("Token exchange", "Token refresh", etc.). Appears in the
48
- * error message.
38
+ * Reads a non-2xx response body and throws `TOKEN_EXCHANGE_FAILED`
39
+ * with the OAuth `error` / `error_description` surfaced when present.
49
40
  */
50
41
  async function throwTokenEndpointError(response, context) {
51
42
  const body = await response.text().catch(() => "");
@@ -63,20 +54,9 @@ async function throwTokenEndpointError(response, context) {
63
54
  throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `${context} failed with HTTP ${response.status}${suffix}`, Object.keys(details).length > 0 ? { details } : undefined);
64
55
  }
65
56
  /**
66
- * Parses a 2xx response body from an RFC 6749 §5.1 token endpoint
67
- * (authorization-code exchange, refresh-token grant, etc.) into a
68
- * `TokenSet`. Validates the required shape (`access_token`,
69
- * `expires_in`, Bearer `token_type`) and converts the relative
70
- * `expires_in` into an absolute `expiresAt` using `issuedAt`.
71
- *
72
- * @param response The HTTP response (must be 2xx; caller handles
73
- * error statuses via `throwTokenEndpointError`).
74
- * @param issuedAt The timestamp captured just before the network
75
- * call. Slightly conservative — the token actually expires
76
- * `expires_in` seconds from when the server issued it, so the
77
- * effective usable window is `expires_in - RTT`, which errs toward
78
- * "expires sooner" rather than "expires later."
79
- * @param endpointURL URL used for error messages.
57
+ * Parses a 2xx response from an RFC 6749 §5.1 token endpoint into a
58
+ * `TokenSet`. `issuedAt` is the timestamp captured just before the
59
+ * network call; the resulting `expiresAt` is conservative by ~RTT.
80
60
  */
81
61
  async function parseTokenResponse(response, issuedAt, endpointURL) {
82
62
  let parsed;
@@ -108,6 +108,29 @@ export type BlobChainResult = {
108
108
  * and `MIGRATORS` and applies the latest-shape check on top.
109
109
  */
110
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;
111
134
  /**
112
135
  * Returns a per-platform hint appended to keychain error messages so
113
136
  * users see actionable guidance for their OS instead of generic or
@@ -3,29 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
4
4
  exports.shouldChunkForKeyring = shouldChunkForKeyring;
5
5
  exports.parseAndMigrateBlob = parseAndMigrateBlob;
6
+ exports.keyringErrorMessage = keyringErrorMessage;
7
+ exports.isKeyringSizeError = isKeyringSizeError;
6
8
  exports.platformKeyringHint = platformKeyringHint;
7
9
  exports.chunkBlobForKeyring = chunkBlobForKeyring;
8
10
  const errors_1 = require("./errors");
9
11
  const keyringBinding_1 = require("./keyringBinding");
10
- // On macOS: Keychain generic password item with the service name below.
11
- // On Windows: Credential Manager entry. On Linux: Secret Service / libsecret.
12
- // Exposed as a human-readable string because these all surface the service
13
- // name in OS UIs (Keychain Access, credmgr.exe, seahorse).
14
12
  const SERVICE_NAME = "axe-auth";
15
- // Single keychain entry per machine on macOS / Linux. (Windows splits
16
- // across `credentials.0`, `credentials.1`, … see `CHUNK_LIMIT`
17
- // below.) The blob it holds is fully self-describing (issuerURL,
18
- // clientId, allowInsecureIssuer, plus the tokens), so verbs that
19
- // don't pass `--server` / `--realm` / `--client-id` can resolve their
20
- // config from the entry.
21
- //
22
- // Account name is human-readable so users investigating the entry in
23
- // macOS Keychain Access (or `secret-tool` on Linux, credmgr on
24
- // Windows) can tell what it is. Not versioned: the schema version
25
- // lives inside the blob and migrators handle the upgrade path. Note:
26
- // Windows entries hold base64-encoded JSON rather than the raw JSON
27
- // macOS / Linux store, so a Windows user inspecting their Credential
28
- // Manager will see opaque base64; that's a side effect of chunking.
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.
29
16
  const ACCOUNT_NAME = "credentials";
30
17
  // Windows Credential Manager caps stored values at 2560 UTF-16 code
31
18
  // units, which large OAuth access-token JWTs (many groups/roles
@@ -158,10 +145,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
158
145
  const storedVersion = getStoredVersion(parsed);
159
146
  if (storedVersion === null)
160
147
  return { ok: false, reason: "corrupt" };
161
- // Walk the migrator chain until we reach the expected version. A
162
- // missing or null-returning migrator means the old blob cannot be
163
- // upgraded; surface that so callers can prompt re-auth with a
164
- // clear signal instead of silently returning `empty`.
165
148
  let current = parsed;
166
149
  let currentVersion = storedVersion;
167
150
  while (currentVersion !== expectedVersion) {
@@ -175,8 +158,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
175
158
  }
176
159
  const nextVersion = getStoredVersion(next);
177
160
  if (nextVersion === null || nextVersion <= currentVersion) {
178
- // Migrator output is malformed or didn't advance. Treat the
179
- // stored blob as un-migratable rather than loop forever.
180
161
  return { ok: false, reason: "version-mismatch", storedVersion };
181
162
  }
182
163
  current = next;
@@ -194,8 +175,42 @@ function wrapKeyringError(op, cause) {
194
175
  if (cause instanceof errors_1.OAuthFlowError) {
195
176
  throw cause;
196
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
+ }
197
199
  const causeMessage = cause instanceof Error ? cause.message : String(cause);
198
- throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint()}`, { 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);
199
214
  }
200
215
  /**
201
216
  * Returns a per-platform hint appended to keychain error messages so
@@ -388,10 +403,6 @@ class KeyringTokenStore {
388
403
  * could trip it.
389
404
  */
390
405
  #saveChunked(parts) {
391
- // Read previous N before any writes so the cleanup sweep is
392
- // bounded. If the previous chunk 0 is missing or its header is
393
- // unparseable we have no upper bound, so fall back to the full
394
- // safety range as a one-time defensive recovery.
395
406
  const previousN = this.#previousChunkN();
396
407
  for (let i = parts.length - 1; i >= 1; i--) {
397
408
  this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deque/axe-auth",
3
- "version": "1.1.0-next.adf1ee93",
3
+ "version": "1.1.0-next.bd805a7a",
4
4
  "description": "CLI authentication utility for Deque services",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",