@deque/axe-auth 1.2.0-rc.cd2af31c → 1.2.1-next.0b5b0847
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 +8 -8
- package/dist/cli/safeExit.d.ts +8 -0
- package/dist/cli/safeExit.js +16 -0
- package/dist/commands/login.js +1 -1
- package/dist/index.js +3 -2
- package/dist/oauth/authorizationURL.d.ts +1 -1
- package/dist/oauth/authorizationURL.js +1 -1
- package/dist/oauth/authorize.d.ts +1 -1
- package/dist/oauth/authorize.js +6 -6
- package/dist/oauth/callbackServer.d.ts +20 -9
- package/dist/oauth/callbackServer.js +33 -27
- package/dist/oauth/openBrowser.js +1 -1
- package/dist/oauth/refreshTokens.js +3 -2
- package/dist/oauth/renderHTML.d.ts +12 -0
- package/dist/oauth/{renderHtml.js → renderHTML.js} +17 -11
- package/dist/oauth/retry.d.ts +2 -0
- package/dist/oauth/retry.js +50 -0
- package/dist/oauth/revokeToken.js +3 -2
- package/dist/oauth/tokenExchange.d.ts +1 -1
- package/dist/oauth/tokenExchange.js +4 -3
- package/docs/callback-page.md +2 -2
- package/docs/callback-server.md +1 -1
- package/package.json +6 -6
- package/dist/oauth/renderHtml.d.ts +0 -9
package/credits.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@napi-rs/keyring@1.
|
|
2
|
+
"@napi-rs/keyring@1.3.0": {
|
|
3
3
|
"name": "@napi-rs/keyring",
|
|
4
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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.
|
|
11
|
+
"@napi-rs/keyring-linux-x64-gnu@1.3.0": {
|
|
12
12
|
"name": "@napi-rs/keyring-linux-x64-gnu",
|
|
13
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/commands/login.js
CHANGED
|
@@ -88,7 +88,7 @@ const loginCommand = {
|
|
|
88
88
|
scopes: ["offline_access"],
|
|
89
89
|
allowInsecureIssuer: args.allowInsecureIssuer,
|
|
90
90
|
tokenStore,
|
|
91
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
package/dist/oauth/authorize.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2
|
+
/** Configures the loopback callback server. */
|
|
3
|
+
export interface CallbackServerOptions {
|
|
3
4
|
expectedState: string;
|
|
4
5
|
timeoutMs?: number;
|
|
5
6
|
signal?: AbortSignal;
|
|
6
|
-
}
|
|
7
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
65
|
+
"Content-Security-Policy": renderHTML_1.CSP_HEADER,
|
|
60
66
|
"X-Content-Type-Options": "nosniff",
|
|
61
67
|
});
|
|
62
68
|
res.end(html);
|
|
63
|
-
}
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
writeHTML(res, 200, (0, renderHTML_1.renderHTML)({ kind: "success" }));
|
|
211
218
|
};
|
|
212
|
-
const bound = await
|
|
219
|
+
const bound = await bindLoopback(handler);
|
|
213
220
|
server = bound.server;
|
|
214
221
|
const port = server.address().port;
|
|
215
|
-
const
|
|
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 {
|
|
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 `
|
|
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
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
function escape(s) {
|
|
29
|
+
return s
|
|
30
|
+
.replace(/&/g, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """)
|
|
34
|
+
.replace(/'/g, "'");
|
|
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
|
-
|
|
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,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
|
|
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
|
-
|
|
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.
|
|
22
|
-
});
|
|
22
|
+
redirect_uri: options.redirectURI,
|
|
23
|
+
}).toString();
|
|
23
24
|
const issuedAt = now();
|
|
24
25
|
let response;
|
|
25
26
|
try {
|
|
26
|
-
response = await
|
|
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",
|
package/docs/callback-page.md
CHANGED
|
@@ -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/
|
|
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 `
|
|
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
|
|
package/docs/callback-server.md
CHANGED
|
@@ -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 `
|
|
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.2.
|
|
3
|
+
"version": "1.2.1-next.0b5b0847",
|
|
4
4
|
"description": "CLI authentication utility for Deque services",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
|
@@ -27,19 +27,19 @@
|
|
|
27
27
|
"registry": "https://registry.npmjs.org/"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
30
|
-
"node": ">=
|
|
30
|
+
"node": ">=24.11.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@napi-rs/keyring": "^1.
|
|
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": "^
|
|
41
|
-
"c8": "^
|
|
42
|
-
"hono": "^4.12.
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"c8": "^11.0.0",
|
|
42
|
+
"hono": "^4.12.21",
|
|
43
43
|
"tsx": "^4.20.6",
|
|
44
44
|
"typescript": "^6.0.3"
|
|
45
45
|
},
|