@deque/axe-auth 1.1.0-rc.047d31b4 → 1.2.0-next.9573917e

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",
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@deque/axe-auth",
3
- "version": "1.1.0-rc.047d31b4",
3
+ "version": "1.2.0-next.9573917e",
4
4
  "description": "CLI authentication utility for Deque services",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -27,18 +27,18 @@
27
27
  "registry": "https://registry.npmjs.org/"
28
28
  },
29
29
  "engines": {
30
- "node": ">=22.13.0"
30
+ "node": ">=24.11.0"
31
31
  },
32
32
  "dependencies": {
33
- "@napi-rs/keyring": "^1.2.0",
33
+ "@napi-rs/keyring": "^1.3.0",
34
34
  "remove-trailing-slash": "^0.1.1",
35
35
  "shlex": "^3.0.0",
36
36
  "ts-dedent": "^2.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@hono/node-server": "^1.19.14",
40
- "@types/node": "^22.13.10",
41
- "c8": "^10.1.3",
40
+ "@types/node": "^24.0.0",
41
+ "c8": "^11.0.0",
42
42
  "hono": "^4.12.16",
43
43
  "tsx": "^4.20.6",
44
44
  "typescript": "^6.0.3"
@@ -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;