@deque/axe-auth 1.1.0-next.297e118f → 1.1.0-next.2d96b49c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/credits.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
- "@napi-rs/keyring@1.2.0": {
2
+ "@napi-rs/keyring@1.3.0": {
3
3
  "name": "@napi-rs/keyring",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "licenses": "MIT",
6
- "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring@1.2.0/node_modules/@napi-rs/keyring",
6
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring@1.3.0/node_modules/@napi-rs/keyring",
7
7
  "licenseText": "MIT License\n\nCopyright (c) 2020 N-API for Rust\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
8
- "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring@1.2.0/node_modules/@napi-rs/keyring/LICENSE",
8
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring@1.3.0/node_modules/@napi-rs/keyring/LICENSE",
9
9
  "copyright": "Copyright (c) 2020 N-API for Rust"
10
10
  },
11
- "@napi-rs/keyring-linux-x64-gnu@1.2.0": {
11
+ "@napi-rs/keyring-linux-x64-gnu@1.3.0": {
12
12
  "name": "@napi-rs/keyring-linux-x64-gnu",
13
- "version": "1.2.0",
13
+ "version": "1.3.0",
14
14
  "licenses": "MIT",
15
- "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring-linux-x64-gnu@1.2.0/node_modules/@napi-rs/keyring-linux-x64-gnu",
15
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring-linux-x64-gnu@1.3.0/node_modules/@napi-rs/keyring-linux-x64-gnu",
16
16
  "licenseText": "# `@napi-rs/keyring-linux-x64-gnu`\n\nThis is the **x86_64-unknown-linux-gnu** binary for `@napi-rs/keyring`\n",
17
- "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring-linux-x64-gnu@1.2.0/node_modules/@napi-rs/keyring-linux-x64-gnu/README.md"
17
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring-linux-x64-gnu@1.3.0/node_modules/@napi-rs/keyring-linux-x64-gnu/README.md"
18
18
  },
19
19
  "remove-trailing-slash@0.1.1": {
20
20
  "name": "remove-trailing-slash",
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Force-exit the process to avoid hanging on open connections.
3
+ *
4
+ * On Windows, briefly yield first so undici can finish closing pending sockets;
5
+ * otherwise the process aborts with a libuv UV_HANDLE_CLOSING assertion in
6
+ * `src\win\async.c`. See https://github.com/nodejs/node/issues/56645.
7
+ */
8
+ export declare function safeExit(code: number): Promise<never>;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeExit = safeExit;
4
+ /**
5
+ * Force-exit the process to avoid hanging on open connections.
6
+ *
7
+ * On Windows, briefly yield first so undici can finish closing pending sockets;
8
+ * otherwise the process aborts with a libuv UV_HANDLE_CLOSING assertion in
9
+ * `src\win\async.c`. See https://github.com/nodejs/node/issues/56645.
10
+ */
11
+ async function safeExit(code) {
12
+ if (process.platform === "win32") {
13
+ await new Promise((resolve) => setTimeout(resolve, 100));
14
+ }
15
+ process.exit(code);
16
+ }
@@ -88,7 +88,7 @@ const loginCommand = {
88
88
  scopes: ["offline_access"],
89
89
  allowInsecureIssuer: args.allowInsecureIssuer,
90
90
  tokenStore,
91
- onAuthorizationUrl: (url) => {
91
+ onAuthorizationURL: (url) => {
92
92
  deps.stderr.write(`Authorization URL: ${url}\n`);
93
93
  },
94
94
  onWarning: (msg) => {
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ const login_1 = __importDefault(require("./commands/login"));
13
13
  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
+ const safeExit_1 = require("./cli/safeExit");
16
17
  const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
17
18
  const COMMANDS = [
18
19
  login_1.default,
@@ -126,7 +127,7 @@ async function dispatch(argv) {
126
127
  return 2;
127
128
  }
128
129
  }
129
- dispatch(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
130
+ dispatch(process.argv.slice(2)).then((code) => (0, safeExit_1.safeExit)(code), (err) => {
130
131
  process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
131
- process.exit(1);
132
+ return (0, safeExit_1.safeExit)(1);
132
133
  });
@@ -5,7 +5,7 @@ export interface BuildAuthorizationURLOptions {
5
5
  /** OAuth client identifier registered with the authorization server. */
6
6
  clientId: string;
7
7
  /** Loopback redirect URI the callback server is listening on. */
8
- redirectUri: string;
8
+ redirectURI: string;
9
9
  /** PKCE `code_challenge` derived via S256. */
10
10
  codeChallenge: string;
11
11
  /** CSRF `state` value, echoed by the auth server and validated on callback. */
@@ -39,7 +39,7 @@ function buildAuthorizationURL(options) {
39
39
  // `set` just reads more conventionally.
40
40
  url.searchParams.set("response_type", "code");
41
41
  url.searchParams.set("client_id", options.clientId);
42
- url.searchParams.set("redirect_uri", options.redirectUri);
42
+ url.searchParams.set("redirect_uri", options.redirectURI);
43
43
  url.searchParams.set("code_challenge", options.codeChallenge);
44
44
  url.searchParams.set("code_challenge_method", "S256");
45
45
  url.searchParams.set("state", options.state);
@@ -19,7 +19,7 @@ export interface AuthorizeOptions {
19
19
  /** Override for the system browser launcher. Injected for tests. */
20
20
  openBrowser?: (url: string) => void;
21
21
  /** Called with the authorization URL just before the browser launch. Default prints to stderr only when stderr is a TTY. */
22
- onAuthorizationUrl?: (url: string) => void;
22
+ onAuthorizationURL?: (url: string) => void;
23
23
  /**
24
24
  * Called for soft warnings (e.g. requested `offline_access` but the
25
25
  * server returned no refresh token, or the browser failed to
@@ -9,7 +9,7 @@ const openBrowser_1 = require("./openBrowser");
9
9
  const tokenExchange_1 = require("./tokenExchange");
10
10
  const tokenStore_1 = require("./tokenStore");
11
11
  const errors_1 = require("./errors");
12
- function defaultOnAuthorizationUrl(url) {
12
+ function defaultOnAuthorizationURL(url) {
13
13
  if (process.stderr.isTTY) {
14
14
  console.error(`Authorization URL: ${url}`);
15
15
  }
@@ -38,7 +38,7 @@ function defaultOnWarning(message) {
38
38
  * @throws {OAuthCallbackError} For loopback/callback-server failures.
39
39
  */
40
40
  async function authorize(options) {
41
- const { issuerURL, clientId, walnutURL, scopes, timeoutMs, signal, tokenStore = new tokenStore_1.KeyringTokenStore(), openBrowser = openBrowser_1.openBrowser, onAuthorizationUrl = defaultOnAuthorizationUrl, onWarning = defaultOnWarning, allowInsecureIssuer, } = options;
41
+ const { issuerURL, clientId, walnutURL, scopes, timeoutMs, signal, tokenStore = new tokenStore_1.KeyringTokenStore(), openBrowser = openBrowser_1.openBrowser, onAuthorizationURL = defaultOnAuthorizationURL, onWarning = defaultOnWarning, allowInsecureIssuer, } = options;
42
42
  // Discovery before browser-launch so a bad URL surfaces as a
43
43
  // throw rather than a wrong/unreachable browser tab.
44
44
  const config = await (0, discoverOIDC_1.discoverOIDC)(issuerURL, {
@@ -57,7 +57,7 @@ async function authorize(options) {
57
57
  const authURL = (0, authorizationURL_1.buildAuthorizationURL)({
58
58
  authorizationEndpoint: config.authorizationEndpoint,
59
59
  clientId,
60
- redirectUri: callback.redirectUri,
60
+ redirectURI: callback.redirectURI,
61
61
  codeChallenge,
62
62
  state,
63
63
  scopes,
@@ -65,13 +65,13 @@ async function authorize(options) {
65
65
  // Surface before launch so the URL is always visible even if the
66
66
  // browser spawn fails (or never does anything useful, e.g. on a
67
67
  // headless box).
68
- onAuthorizationUrl(authURL);
68
+ onAuthorizationURL(authURL);
69
69
  try {
70
70
  openBrowser(authURL);
71
71
  }
72
72
  catch (err) {
73
73
  // Only swallow the "could not launch browser" case: the URL was
74
- // already surfaced via onAuthorizationUrl so the user can
74
+ // already surfaced via onAuthorizationURL so the user can
75
75
  // complete the flow manually. Any other error (a bug in an
76
76
  // injected openBrowser, an unexpected throw) must propagate so
77
77
  // callers and tests can see it.
@@ -89,7 +89,7 @@ async function authorize(options) {
89
89
  clientId,
90
90
  code,
91
91
  codeVerifier,
92
- redirectUri: callback.redirectUri,
92
+ redirectURI: callback.redirectURI,
93
93
  signal,
94
94
  });
95
95
  // If the caller requested offline_access but no refresh_token
@@ -1,23 +1,34 @@
1
1
  import { IncomingMessage, Server, ServerResponse } from "node:http";
2
- export type CallbackServerOptions = {
2
+ /** Configures the loopback callback server. */
3
+ export interface CallbackServerOptions {
3
4
  expectedState: string;
4
5
  timeoutMs?: number;
5
6
  signal?: AbortSignal;
6
- };
7
- export type CallbackResult = {
7
+ }
8
+ /** Authorization-code and state pair captured from a successful redirect. */
9
+ export interface CallbackResult {
8
10
  code: string;
9
11
  state: string;
10
- };
11
- export type CallbackServerHandle = {
12
- redirectUri: string;
12
+ }
13
+ /** Live handle to a running callback server. */
14
+ export interface CallbackServerHandle {
15
+ redirectURI: string;
13
16
  result: Promise<CallbackResult>;
14
17
  close: () => Promise<void>;
15
- };
18
+ }
16
19
  type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void;
17
20
  type LoopbackListener = (handler: RequestHandler, host: string) => Promise<Server>;
18
- export declare const bindLoopback: (handler: RequestHandler, listenFn?: LoopbackListener) => Promise<{
21
+ /**
22
+ * Binds an HTTP server to the loopback interface, preferring IPv4.
23
+ *
24
+ * RFC 8252 §7.3: "use whichever is available". Prefer IPv4; fall back to
25
+ * IPv6 only when the IPv4 loopback isn't configured on this host. `listenFn`
26
+ * is an injection seam for tests — production callers use the default.
27
+ */
28
+ export declare function bindLoopback(handler: RequestHandler, listenFn?: LoopbackListener): Promise<{
19
29
  server: Server;
20
30
  host: "127.0.0.1" | "::1";
21
31
  }>;
22
- export declare const startCallbackServer: (opts: CallbackServerOptions) => Promise<CallbackServerHandle>;
32
+ /** Starts a loopback HTTP server that captures the OAuth redirect. */
33
+ export declare function startCallbackServer(opts: CallbackServerOptions): Promise<CallbackServerHandle>;
23
34
  export {};
@@ -1,13 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.startCallbackServer = exports.bindLoopback = void 0;
3
+ exports.bindLoopback = bindLoopback;
4
+ exports.startCallbackServer = startCallbackServer;
4
5
  const node_events_1 = require("node:events");
5
6
  const node_http_1 = require("node:http");
6
7
  const errors_1 = require("./errors");
7
- const renderHtml_1 = require("./renderHtml");
8
+ const renderHTML_1 = require("./renderHTML");
8
9
  const DEFAULT_TIMEOUT_MS = 120_000;
9
10
  const LOOPBACK_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
10
- const isLoopback = (addr) => !!addr && LOOPBACK_ADDRESSES.has(addr);
11
+ function isLoopback(addr) {
12
+ return !!addr && LOOPBACK_ADDRESSES.has(addr);
13
+ }
11
14
  // Errors from bind(2) that mean "this address family isn't configured on this
12
15
  // host" — the signal to fall back to the other loopback family rather than
13
16
  // fail the caller.
@@ -19,10 +22,14 @@ const listen = async (handler, host) => {
19
22
  await (0, node_events_1.once)(server, "listening");
20
23
  return server;
21
24
  };
22
- // RFC 8252 §7.3: "use whichever is available". Prefer IPv4; fall back to
23
- // IPv6 only when the IPv4 loopback isn't configured on this host. `listenFn`
24
- // is an injection seam for tests — production callers use the default.
25
- const bindLoopback = async (handler, listenFn = listen) => {
25
+ /**
26
+ * Binds an HTTP server to the loopback interface, preferring IPv4.
27
+ *
28
+ * RFC 8252 §7.3: "use whichever is available". Prefer IPv4; fall back to
29
+ * IPv6 only when the IPv4 loopback isn't configured on this host. `listenFn`
30
+ * is an injection seam for tests — production callers use the default.
31
+ */
32
+ async function bindLoopback(handler, listenFn = listen) {
26
33
  try {
27
34
  return {
28
35
  server: await listenFn(handler, "127.0.0.1"),
@@ -44,32 +51,32 @@ const bindLoopback = async (handler, listenFn = listen) => {
44
51
  throw new errors_1.OAuthCallbackError("BIND_FAILED", `Failed to bind loopback server on 127.0.0.1 (${code}) and [::1]: ${ipv6Err.message}`, { cause: ipv6Err });
45
52
  }
46
53
  }
47
- };
48
- exports.bindLoopback = bindLoopback;
49
- const closeServer = async (server) => {
54
+ }
55
+ async function closeServer(server) {
50
56
  if (!server.listening)
51
57
  return;
52
58
  server.close();
53
59
  server.closeAllConnections?.();
54
60
  await (0, node_events_1.once)(server, "close");
55
- };
56
- const writeHtml = (res, status, html) => {
61
+ }
62
+ function writeHTML(res, status, html) {
57
63
  res.writeHead(status, {
58
64
  "Content-Type": "text/html; charset=utf-8",
59
- "Content-Security-Policy": renderHtml_1.CSP_HEADER,
65
+ "Content-Security-Policy": renderHTML_1.CSP_HEADER,
60
66
  "X-Content-Type-Options": "nosniff",
61
67
  });
62
68
  res.end(html);
63
- };
64
- const writeText = (res, status, body, extraHeaders = {}) => {
69
+ }
70
+ function writeText(res, status, body, extraHeaders = {}) {
65
71
  res.writeHead(status, {
66
72
  "Content-Type": "text/plain; charset=utf-8",
67
73
  "X-Content-Type-Options": "nosniff",
68
74
  ...extraHeaders,
69
75
  });
70
76
  res.end(body + "\n");
71
- };
72
- const startCallbackServer = async (opts) => {
77
+ }
78
+ /** Starts a loopback HTTP server that captures the OAuth redirect. */
79
+ async function startCallbackServer(opts) {
73
80
  const { expectedState, timeoutMs = DEFAULT_TIMEOUT_MS, signal } = opts;
74
81
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
75
82
  throw new TypeError(`timeoutMs must be a positive finite number (received ${timeoutMs}).`);
@@ -164,7 +171,7 @@ const startCallbackServer = async (opts) => {
164
171
  // up front means once we start composing the response, no concurrent
165
172
  // settlement (timer/abort) can race us into a dropped connection.
166
173
  if (!tryConsume()) {
167
- writeHtml(res, 409, (0, renderHtml_1.renderHtml)({
174
+ writeHTML(res, 409, (0, renderHTML_1.renderHTML)({
168
175
  kind: "error",
169
176
  reason: "This callback server has already handled a response.",
170
177
  }));
@@ -179,7 +186,7 @@ const startCallbackServer = async (opts) => {
179
186
  : { error: providerError },
180
187
  });
181
188
  deferSettleOnClose(res, () => rejectResult(error));
182
- writeHtml(res, 400, (0, renderHtml_1.renderHtml)({
189
+ writeHTML(res, 400, (0, renderHTML_1.renderHTML)({
183
190
  kind: "error",
184
191
  reason: providerError,
185
192
  description,
@@ -191,7 +198,7 @@ const startCallbackServer = async (opts) => {
191
198
  if (!code) {
192
199
  const error = new errors_1.OAuthCallbackError("MISSING_CODE", "Authorization response missing 'code' parameter.");
193
200
  deferSettleOnClose(res, () => rejectResult(error));
194
- writeHtml(res, 400, (0, renderHtml_1.renderHtml)({
201
+ writeHTML(res, 400, (0, renderHTML_1.renderHTML)({
195
202
  kind: "error",
196
203
  reason: "The authorization response was missing the 'code' parameter.",
197
204
  }));
@@ -200,19 +207,19 @@ const startCallbackServer = async (opts) => {
200
207
  if (state !== expectedState) {
201
208
  const error = new errors_1.OAuthCallbackError("STATE_MISMATCH", "Authorization response 'state' did not match expected value.");
202
209
  deferSettleOnClose(res, () => rejectResult(error));
203
- writeHtml(res, 400, (0, renderHtml_1.renderHtml)({
210
+ writeHTML(res, 400, (0, renderHTML_1.renderHTML)({
204
211
  kind: "error",
205
212
  reason: "The authorization response failed state validation.",
206
213
  }));
207
214
  return;
208
215
  }
209
216
  deferSettleOnClose(res, () => resolveResult({ code, state }));
210
- writeHtml(res, 200, (0, renderHtml_1.renderHtml)({ kind: "success" }));
217
+ writeHTML(res, 200, (0, renderHTML_1.renderHTML)({ kind: "success" }));
211
218
  };
212
- const bound = await (0, exports.bindLoopback)(handler);
219
+ const bound = await bindLoopback(handler);
213
220
  server = bound.server;
214
221
  const port = server.address().port;
215
- const redirectUri = bound.host === "::1"
222
+ const redirectURI = bound.host === "::1"
216
223
  ? `http://[::1]:${port}/callback`
217
224
  : `http://127.0.0.1:${port}/callback`;
218
225
  timeoutHandle = setTimeout(() => {
@@ -229,6 +236,5 @@ const startCallbackServer = async (opts) => {
229
236
  signal.addEventListener("abort", abortListener, { once: true });
230
237
  }
231
238
  }
232
- return { redirectUri, result, close };
233
- };
234
- exports.startCallbackServer = startCallbackServer;
239
+ return { redirectURI, result, close };
240
+ }
@@ -45,7 +45,7 @@ function browserCommand(platform, url, browserOverride) {
45
45
  // module deliberately swallows (see `child.once("error", ...)`
46
46
  // below); `BROWSER_LAUNCH_FAILED` is only raised for synchronous
47
47
  // `spawn()` throws. The caller's fallback is the URL already
48
- // surfaced via `onAuthorizationUrl` so the user can finish the
48
+ // surfaced via `onAuthorizationURL` so the user can finish the
49
49
  // flow manually.
50
50
  return { command: "xdg-open", args: [url] };
51
51
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.refreshTokens = refreshTokens;
4
4
  const errors_1 = require("./errors");
5
+ const retry_1 = require("./retry");
5
6
  const tokenResponse_1 = require("./tokenResponse");
6
7
  const userAgent_1 = require("../userAgent");
7
8
  /**
@@ -31,11 +32,11 @@ async function refreshTokens(options) {
31
32
  grant_type: "refresh_token",
32
33
  client_id: options.clientId,
33
34
  refresh_token: options.refreshToken,
34
- });
35
+ }).toString();
35
36
  const issuedAt = now();
36
37
  let response;
37
38
  try {
38
- response = await fetch(options.tokenEndpoint, {
39
+ response = await (0, retry_1.fetchWithRetry)(options.tokenEndpoint, {
39
40
  method: "POST",
40
41
  headers: {
41
42
  "Content-Type": "application/x-www-form-urlencoded",
@@ -0,0 +1,12 @@
1
+ /** Discriminates the success vs. error variant of the callback page. */
2
+ export type HTMLInput = {
3
+ kind: "success";
4
+ } | {
5
+ kind: "error";
6
+ reason: string;
7
+ description?: string;
8
+ };
9
+ /** Content-Security-Policy header value sent with every callback page. */
10
+ export declare const CSP_HEADER: string;
11
+ /** Renders the loopback callback page shown in the user's browser. */
12
+ export declare function renderHTML(input: HTMLInput): string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.renderHtml = exports.CSP_HEADER = void 0;
3
+ exports.CSP_HEADER = void 0;
4
+ exports.renderHTML = renderHTML;
4
5
  const node_crypto_1 = require("node:crypto");
5
6
  const logo_generated_1 = require("./logo.generated");
6
7
  const CSS = `:root{--off-black:#666;--off-white:#fdfdfe;--white:#fff;--pink:#d71ef7;--blue:#3c7aae;--space-small:12px;--space-medium:24px;--space-large:36px;--space-huge:48px;--border-radius:3px}
@@ -19,17 +20,21 @@ main{margin:var(--space-huge) auto;padding-left:var(--space-medium);padding-righ
19
20
  // 'unsafe-inline'. Any drift between this digest and the rendered <style> tag
20
21
  // causes the browser to refuse the stylesheet — see docs/callback-page.md.
21
22
  const STYLE_HASH = (0, node_crypto_1.createHash)("sha256").update(CSS, "utf8").digest("base64");
23
+ /** Content-Security-Policy header value sent with every callback page. */
22
24
  exports.CSP_HEADER = `default-src 'none'; img-src data:; style-src 'sha256-${STYLE_HASH}'; frame-ancestors 'none'`;
23
25
  // OWASP-recommended 5-character set for HTML body/attribute contexts;
24
26
  // & must come first to avoid double-escaping. Not safe for JS/CSS/URL contexts.
25
27
  // https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#output-encoding-for-html-contexts
26
- const escape = (s) => s
27
- .replace(/&/g, "&amp;")
28
- .replace(/</g, "&lt;")
29
- .replace(/>/g, "&gt;")
30
- .replace(/"/g, "&quot;")
31
- .replace(/'/g, "&#39;");
32
- const page = (title, body) => `<!doctype html>
28
+ function escape(s) {
29
+ return s
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&#39;");
35
+ }
36
+ function page(title, body) {
37
+ return `<!doctype html>
33
38
  <html lang="en">
34
39
  <head>
35
40
  <meta charset="UTF-8">
@@ -44,7 +49,9 @@ ${body}
44
49
  </main>
45
50
  </body>
46
51
  </html>`;
47
- const renderHtml = (input) => {
52
+ }
53
+ /** Renders the loopback callback page shown in the user's browser. */
54
+ function renderHTML(input) {
48
55
  if (input.kind === "success") {
49
56
  return page("Authenticated", `<h1>Authenticated</h1>
50
57
  <p class="close">You can close this tab and return to your terminal.</p>`);
@@ -56,5 +63,4 @@ const renderHtml = (input) => {
56
63
  <p class="reason">${escape(input.reason)}</p>
57
64
  ${descriptionBlock}
58
65
  <p class="close">You can close this tab and return to your terminal.</p>`);
59
- };
60
- exports.renderHtml = renderHtml;
66
+ }
@@ -0,0 +1,2 @@
1
+ /** Wraps `fetch` with bounded retries on transient connection errors. */
2
+ export declare function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ // undici's `RetryAgent` cannot retry POSTs through `fetch()` — its retry
3
+ // handler aborts once the fetch ReadableStream body is consumed. Re-invoking
4
+ // `fetch()` per attempt with a string body sidesteps that. POST replay is
5
+ // safe for our OAuth endpoints by spec: single-use codes (RFC 6749 §4.1.2),
6
+ // refresh requests that never reached the server (§6), no-op revocation
7
+ // (RFC 7009 §2.2).
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.fetchWithRetry = fetchWithRetry;
10
+ const promises_1 = require("node:timers/promises");
11
+ const MAX_RETRIES = 3;
12
+ const CONNECTION_ERROR_CODES = new Set([
13
+ "ECONNRESET",
14
+ "ECONNREFUSED",
15
+ "ENOTFOUND",
16
+ "ENETDOWN",
17
+ "ENETUNREACH",
18
+ "EHOSTDOWN",
19
+ "UND_ERR_SOCKET",
20
+ ]);
21
+ function isConnectionError(err) {
22
+ const seen = new Set();
23
+ let current = err;
24
+ while (current && typeof current === "object" && !seen.has(current)) {
25
+ seen.add(current);
26
+ const e = current;
27
+ if (typeof e.code === "string" && CONNECTION_ERROR_CODES.has(e.code)) {
28
+ return true;
29
+ }
30
+ current = e.cause;
31
+ }
32
+ return false;
33
+ }
34
+ /** Wraps `fetch` with bounded retries on transient connection errors. */
35
+ async function fetchWithRetry(input, init) {
36
+ for (let attempt = 0;; attempt++) {
37
+ try {
38
+ return await fetch(input, init);
39
+ }
40
+ catch (err) {
41
+ if (attempt >= MAX_RETRIES || !isConnectionError(err)) {
42
+ throw err;
43
+ }
44
+ // Exponential backoff: 500ms, 1s, 2s, capped at 30s.
45
+ // `sleep` honors `init.signal` so an aborted request interrupts the wait.
46
+ const delay = Math.min(500 * Math.pow(2, attempt), 30_000);
47
+ await (0, promises_1.setTimeout)(delay, undefined, { signal: init?.signal ?? undefined });
48
+ }
49
+ }
50
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.revokeRefreshToken = revokeRefreshToken;
4
+ const retry_1 = require("./retry");
4
5
  const userAgent_1 = require("../userAgent");
5
6
  /**
6
7
  * Revokes a refresh token via RFC 7009. Servers SHOULD return 200
@@ -23,10 +24,10 @@ async function revokeRefreshToken(options) {
23
24
  token: options.refreshToken,
24
25
  token_type_hint: "refresh_token",
25
26
  client_id: options.clientId,
26
- });
27
+ }).toString();
27
28
  let response;
28
29
  try {
29
- response = await fetch(options.revocationEndpoint, {
30
+ response = await (0, retry_1.fetchWithRetry)(options.revocationEndpoint, {
30
31
  method: "POST",
31
32
  headers: {
32
33
  "Content-Type": "application/x-www-form-urlencoded",
@@ -10,7 +10,7 @@ export interface ExchangeCodeForTokensOptions {
10
10
  /** PKCE verifier paired with the `code_challenge` sent at auth time. */
11
11
  codeVerifier: string;
12
12
  /** Redirect URI originally sent to the authorization endpoint. */
13
- redirectUri: string;
13
+ redirectURI: string;
14
14
  /** Source of `now`. Injected for test determinism; defaults to `Date.now`. */
15
15
  now?: () => number;
16
16
  /** Aborts the underlying fetch when fired. */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.exchangeCodeForTokens = exchangeCodeForTokens;
4
4
  const errors_1 = require("./errors");
5
+ const retry_1 = require("./retry");
5
6
  const tokenResponse_1 = require("./tokenResponse");
6
7
  const userAgent_1 = require("../userAgent");
7
8
  /**
@@ -18,12 +19,12 @@ async function exchangeCodeForTokens(options) {
18
19
  client_id: options.clientId,
19
20
  code: options.code,
20
21
  code_verifier: options.codeVerifier,
21
- redirect_uri: options.redirectUri,
22
- });
22
+ redirect_uri: options.redirectURI,
23
+ }).toString();
23
24
  const issuedAt = now();
24
25
  let response;
25
26
  try {
26
- response = await fetch(options.tokenEndpoint, {
27
+ response = await (0, retry_1.fetchWithRetry)(options.tokenEndpoint, {
27
28
  method: "POST",
28
29
  headers: {
29
30
  "Content-Type": "application/x-www-form-urlencoded",
@@ -2,8 +2,11 @@ import { type KeyringEntryFactory } from "./keyringBinding";
2
2
  import type { TokenSet } from "./tokenResponse";
3
3
  /**
4
4
  * Whether `KeyringTokenStore` should split the stored blob across
5
- * multiple keychain entries on this platform. Windows-only because of
6
- * Credential Manager's 2560 UTF-16 character per-entry cap. Exported
5
+ * multiple keychain entries on this platform. Windows-only: Credential
6
+ * Manager has a 2560-byte per-entry cap that large OAuth tokens
7
+ * routinely exceed. macOS Keychain and Linux libsecret have no
8
+ * comparable limit, and on macOS each entry is independently lockable
9
+ * (chunking there would multiply per-entry ACL prompts). Exported
7
10
  * (parameterized for tests) so the chunking path can be exercised
8
11
  * deterministically.
9
12
  */
@@ -109,28 +112,13 @@ export type BlobChainResult = {
109
112
  */
110
113
  export declare function parseAndMigrateBlob(raw: string | null, expectedVersion?: number, migrators?: ReadonlyMap<number, (old: unknown) => unknown | null>): BlobChainResult;
111
114
  /**
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
+ * Builds the user-facing keychain error message: the underlying
116
+ * cause's text plus a per-platform hint. Platform is a parameter
117
+ * (defaulting to `process.platform`) so tests can drive each branch
118
+ * without mocking the runtime; mirrors the pattern in
115
119
  * `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
120
  */
124
121
  export declare function keyringErrorMessage(op: string, cause: unknown, platform?: NodeJS.Platform): string;
125
- /**
126
- * Detects the `@napi-rs/keyring` error string for "value too large".
127
- * In practice only Windows Credential Manager triggers this — its
128
- * stored values are capped at 2560 UTF-16 chars; macOS Keychain and
129
- * Linux libsecret have no comparable limit. Exported (but not
130
- * re-exported from the package index) so tests can exercise the
131
- * detector independently of the wrap path.
132
- */
133
- export declare function isKeyringSizeError(cause: unknown): boolean;
134
122
  /**
135
123
  * Returns a per-platform hint appended to keychain error messages so
136
124
  * users see actionable guidance for their OS instead of generic or
@@ -145,8 +133,8 @@ export declare function platformKeyringHint(platform?: NodeJS.Platform): string;
145
133
  * Secret Service). On macOS and Linux the blob lives in a single entry
146
134
  * keyed by the fixed `credentials` account name. On Windows the blob
147
135
  * is split across `credentials.0`, `credentials.1`, … entries to fit
148
- * under Credential Manager's 2560 UTF-16 character per-entry cap; see
149
- * `shouldChunkForKeyring`.
136
+ * under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
137
+ * cap; see `shouldChunkForKeyring`.
150
138
  *
151
139
  * The blob carries its own issuer/client coordinates so verbs can
152
140
  * recover full config without per-issuer keying.
@@ -4,34 +4,41 @@ exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
4
4
  exports.shouldChunkForKeyring = shouldChunkForKeyring;
5
5
  exports.parseAndMigrateBlob = parseAndMigrateBlob;
6
6
  exports.keyringErrorMessage = keyringErrorMessage;
7
- exports.isKeyringSizeError = isKeyringSizeError;
8
7
  exports.platformKeyringHint = platformKeyringHint;
9
8
  exports.chunkBlobForKeyring = chunkBlobForKeyring;
10
9
  const errors_1 = require("./errors");
11
10
  const keyringBinding_1 = require("./keyringBinding");
12
11
  const SERVICE_NAME = "axe-auth";
13
- // On Windows the blob is base64-encoded and split across
14
- // `credentials.0`, `credentials.1`, entries (see `CHUNK_LIMIT`); a
15
- // Windows dev inspecting Credential Manager will see opaque base64.
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
+ */
16
18
  const ACCOUNT_NAME = "credentials";
17
- // Windows Credential Manager caps stored values at 2560 UTF-16 code
18
- // units, which large OAuth access-token JWTs (many groups/roles
19
- // claims) routinely exceed. On Windows we work around this by
20
- // splitting the JSON blob across multiple entries with account names
21
- // `credentials.0`, `credentials.1`, . `CHUNK_LIMIT` leaves margin
22
- // under the platform cap; `MAX_CHUNKS` is a safety bound — we should
23
- // never get close in practice, even with maximally-claimed tokens.
24
- //
25
- // macOS Keychain and Linux libsecret have no comparable limit, so
26
- // chunking there would just multiply per-entry ACL prompts (each
27
- // keychain entry is independently lockable on macOS) for no gain.
28
- // Chunking is therefore Windows-only, gated by `shouldChunkForKeyring`.
29
- const CHUNK_LIMIT = 2500;
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
+ */
30
34
  const MAX_CHUNKS = 32;
31
35
  /**
32
36
  * Whether `KeyringTokenStore` should split the stored blob across
33
- * multiple keychain entries on this platform. Windows-only because of
34
- * Credential Manager's 2560 UTF-16 character per-entry cap. Exported
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
35
42
  * (parameterized for tests) so the chunking path can be exercised
36
43
  * deterministically.
37
44
  */
@@ -180,38 +187,16 @@ function wrapKeyringError(op, cause) {
180
187
  });
181
188
  }
182
189
  /**
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
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
186
194
  * `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
  */
195
196
  function keyringErrorMessage(op, cause, platform = process.platform) {
196
- if (platform === "win32" && isKeyringSizeError(cause)) {
197
- return `System keychain ${op} failed: Windows Credential Manager limits stored values to 2560 UTF-16 characters. Large OAuth access-token JWTs (many groups/roles claims) commonly exceed this.`;
198
- }
199
197
  const causeMessage = cause instanceof Error ? cause.message : String(cause);
200
198
  return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
201
199
  }
202
- /**
203
- * Detects the `@napi-rs/keyring` error string for "value too large".
204
- * In practice only Windows Credential Manager triggers this — its
205
- * stored values are capped at 2560 UTF-16 chars; macOS Keychain and
206
- * Linux libsecret have no comparable limit. Exported (but not
207
- * re-exported from the package index) so tests can exercise the
208
- * detector independently of the wrap path.
209
- */
210
- function isKeyringSizeError(cause) {
211
- if (!(cause instanceof Error))
212
- return false;
213
- return /longer than the platform limit/.test(cause.message);
214
- }
215
200
  /**
216
201
  * Returns a per-platform hint appended to keychain error messages so
217
202
  * users see actionable guidance for their OS instead of generic or
@@ -259,8 +244,8 @@ function parseChunkHeader(first) {
259
244
  * Secret Service). On macOS and Linux the blob lives in a single entry
260
245
  * keyed by the fixed `credentials` account name. On Windows the blob
261
246
  * is split across `credentials.0`, `credentials.1`, … entries to fit
262
- * under Credential Manager's 2560 UTF-16 character per-entry cap; see
263
- * `shouldChunkForKeyring`.
247
+ * under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
248
+ * cap; see `shouldChunkForKeyring`.
264
249
  *
265
250
  * The blob carries its own issuer/client coordinates so verbs can
266
251
  * recover full config without per-issuer keying.
@@ -1,6 +1,6 @@
1
1
  # Callback response page
2
2
 
3
- The HTML the browser renders after Keycloak redirects to the loopback URL, produced by `src/oauth/renderHtml.ts`.
3
+ The HTML the browser renders after Keycloak redirects to the loopback URL, produced by `src/oauth/renderHTML.ts`.
4
4
 
5
5
  ## Approach
6
6
 
@@ -17,7 +17,7 @@ Content-Security-Policy: default-src 'none'; img-src data:; style-src 'sha256-<d
17
17
  X-Content-Type-Options: nosniff
18
18
  ```
19
19
 
20
- `default-src 'none'` blocks everything by default. `img-src data:` allows only the inlined logo. `style-src 'sha256-...'` allows only a `<style>` block whose contents match a digest computed at module load — stricter than `'unsafe-inline'`, and any drift between the rendered CSS and the committed hash causes the browser to refuse the stylesheet. Inline `style=""` attributes are avoided entirely (they require separate `style-src-attr` / `'unsafe-hashes'` machinery). The `renderHtml` test suite asserts the hash matches the rendered `<style>` contents to prevent silent drift.
20
+ `default-src 'none'` blocks everything by default. `img-src data:` allows only the inlined logo. `style-src 'sha256-...'` allows only a `<style>` block whose contents match a digest computed at module load — stricter than `'unsafe-inline'`, and any drift between the rendered CSS and the committed hash causes the browser to refuse the stylesheet. Inline `style=""` attributes are avoided entirely (they require separate `style-src-attr` / `'unsafe-hashes'` machinery). The `renderHTML` test suite asserts the hash matches the rendered `<style>` contents to prevent silent drift.
21
21
 
22
22
  **Auth code not echoed.** The success page deliberately does not render the received `code` in the HTML (RFC 8252 §8.1 interception mitigation).
23
23
 
@@ -4,7 +4,7 @@ The loopback HTTP listener that receives the OAuth authorization code redirect,
4
4
 
5
5
  ## Loopback bind
6
6
 
7
- RFC 8252 §7.3 says clients SHOULD NOT assume a particular IP version is available and RECOMMENDS trying both. Implementation: bind `127.0.0.1` on an ephemeral port; on `EAFNOSUPPORT` / `EADDRNOTAVAIL` (no IPv4 loopback configured on this host) fall back to `[::1]` on a fresh ephemeral port. The returned `redirectUri` uses whichever literal actually got bound, so the browser connects to exactly what the authorization server redirects it to — no reliance on `localhost` DNS resolution.
7
+ RFC 8252 §7.3 says clients SHOULD NOT assume a particular IP version is available and RECOMMENDS trying both. Implementation: bind `127.0.0.1` on an ephemeral port; on `EAFNOSUPPORT` / `EADDRNOTAVAIL` (no IPv4 loopback configured on this host) fall back to `[::1]` on a fresh ephemeral port. The returned `redirectURI` uses whichever literal actually got bound, so the browser connects to exactly what the authorization server redirects it to — no reliance on `localhost` DNS resolution.
8
8
 
9
9
  Request handling additionally rejects non-loopback `remoteAddress` values with `403` as defense in depth against DNS-rebinding-style pivots — the listener only binds to a loopback interface, but enforcing it at the handler costs nothing.
10
10
 
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@deque/axe-auth",
3
- "version": "1.1.0-next.297e118f",
3
+ "version": "1.1.0-next.2d96b49c",
4
4
  "description": "CLI authentication utility for Deque services",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dequelabs/axe-mcp-server-public.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/dequelabs/axe-mcp-server-public/issues"
12
+ },
6
13
  "type": "commonjs",
7
14
  "main": "dist/index.js",
8
15
  "types": "dist/index.d.ts",
@@ -20,17 +27,17 @@
20
27
  "registry": "https://registry.npmjs.org/"
21
28
  },
22
29
  "engines": {
23
- "node": ">=22.13.0"
30
+ "node": ">=24.11.0"
24
31
  },
25
32
  "dependencies": {
26
- "@napi-rs/keyring": "^1.2.0",
33
+ "@napi-rs/keyring": "^1.3.0",
27
34
  "remove-trailing-slash": "^0.1.1",
28
35
  "shlex": "^3.0.0",
29
36
  "ts-dedent": "^2.2.0"
30
37
  },
31
38
  "devDependencies": {
32
39
  "@hono/node-server": "^1.19.14",
33
- "@types/node": "^22.13.10",
40
+ "@types/node": "^24.0.0",
34
41
  "c8": "^10.1.3",
35
42
  "hono": "^4.12.16",
36
43
  "tsx": "^4.20.6",
@@ -38,7 +45,8 @@
38
45
  },
39
46
  "scripts": {
40
47
  "build": "tsc",
41
- "test": "tsx --test 'src/**/*.test.ts'",
48
+ "typecheck:scripts": "tsc -p tsconfig.scripts.json",
49
+ "test": "tsx --test \"src/**/*.test.ts\"",
42
50
  "coverage": "c8 pnpm test",
43
51
  "register-dev-client": "tsx scripts/registerDevClient.ts",
44
52
  "smoke-authorize": "tsx scripts/smokeAuthorize.ts",
@@ -1,9 +0,0 @@
1
- export type HtmlInput = {
2
- kind: "success";
3
- } | {
4
- kind: "error";
5
- reason: string;
6
- description?: string;
7
- };
8
- export declare const CSP_HEADER: string;
9
- export declare const renderHtml: (input: HtmlInput) => string;