@deque/axe-auth 1.1.0-next.b1986c00 → 1.1.0-next.b7ba541c
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/dist/cli/commonArgs.d.ts +11 -58
- package/dist/cli/commonArgs.js +8 -35
- package/dist/cli/confirm.js +0 -3
- package/dist/cli/errors.d.ts +2 -9
- package/dist/cli/errors.js +2 -9
- package/dist/cli/safeExit.d.ts +8 -0
- package/dist/cli/safeExit.js +16 -0
- package/dist/cli/types.d.ts +10 -50
- package/dist/commands/login.d.ts +1 -4
- package/dist/commands/login.js +5 -14
- package/dist/commands/logout.js +0 -2
- package/dist/commands/token.js +0 -4
- package/dist/index.js +8 -14
- package/dist/oauth/authorizationURL.d.ts +2 -7
- package/dist/oauth/authorizationURL.js +3 -7
- package/dist/oauth/authorize.d.ts +13 -51
- package/dist/oauth/authorize.js +8 -10
- package/dist/oauth/callbackServer.d.ts +20 -9
- package/dist/oauth/callbackServer.js +33 -27
- package/dist/oauth/discoverOIDC.d.ts +10 -27
- package/dist/oauth/discoverOIDC.js +17 -46
- package/dist/oauth/discoverSSOConfig.d.ts +2 -12
- package/dist/oauth/getValidAccessToken.d.ts +9 -44
- package/dist/oauth/getValidAccessToken.js +7 -16
- package/dist/oauth/openBrowser.js +1 -1
- package/dist/oauth/refreshTokens.js +3 -5
- 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/dist/oauth/tokenResponse.d.ts +6 -38
- package/dist/oauth/tokenResponse.js +7 -27
- package/dist/oauth/tokenStore.d.ts +11 -23
- package/dist/oauth/tokenStore.js +32 -72
- package/docs/callback-page.md +2 -2
- package/docs/callback-server.md +1 -1
- package/package.json +13 -5
- package/dist/oauth/renderHtml.d.ts +0 -9
|
@@ -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",
|
|
@@ -1,18 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tokens returned by a successful token-endpoint call (authorization
|
|
3
|
-
* code exchange, refresh-token grant, etc.).
|
|
4
|
-
*
|
|
5
|
-
* `refreshToken` is optional because not all flows return one. On
|
|
6
|
-
* authorization-code exchange it's absent if the caller did not
|
|
7
|
-
* request `offline_access` (or the provider equivalent); on refresh
|
|
8
|
-
* some providers rotate tokens (return a new one) while others don't
|
|
9
|
-
* (the caller should keep the existing refresh token).
|
|
10
|
-
*
|
|
11
|
-
* `grantedScope` reflects the authorization server's `scope` response
|
|
12
|
-
* field when present. RFC 6749 §5.1 says `scope` is required in the
|
|
13
|
-
* response when the granted set differs from the requested set; many
|
|
14
|
-
* servers send it unconditionally.
|
|
15
|
-
*/
|
|
1
|
+
/** Tokens returned by a successful token-endpoint call. */
|
|
16
2
|
export interface TokenSet {
|
|
17
3
|
/** Access token for authenticated API calls. */
|
|
18
4
|
accessToken: string;
|
|
@@ -24,31 +10,13 @@ export interface TokenSet {
|
|
|
24
10
|
grantedScope?: string;
|
|
25
11
|
}
|
|
26
12
|
/**
|
|
27
|
-
* Reads a non-2xx response body and throws
|
|
28
|
-
* `
|
|
29
|
-
* `error` / `error_description` surfaced in both message and details
|
|
30
|
-
* when present. Shared by both the authorization-code exchange and
|
|
31
|
-
* refresh-token paths since the error contract is identical.
|
|
32
|
-
*
|
|
33
|
-
* @param context Short human-readable description of which call
|
|
34
|
-
* failed ("Token exchange", "Token refresh", etc.). Appears in the
|
|
35
|
-
* error message.
|
|
13
|
+
* Reads a non-2xx response body and throws `TOKEN_EXCHANGE_FAILED`
|
|
14
|
+
* with the OAuth `error` / `error_description` surfaced when present.
|
|
36
15
|
*/
|
|
37
16
|
export declare function throwTokenEndpointError(response: Response, context: string): Promise<never>;
|
|
38
17
|
/**
|
|
39
|
-
* Parses a 2xx response
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* `expires_in`, Bearer `token_type`) and converts the relative
|
|
43
|
-
* `expires_in` into an absolute `expiresAt` using `issuedAt`.
|
|
44
|
-
*
|
|
45
|
-
* @param response The HTTP response (must be 2xx; caller handles
|
|
46
|
-
* error statuses via `throwTokenEndpointError`).
|
|
47
|
-
* @param issuedAt The timestamp captured just before the network
|
|
48
|
-
* call. Slightly conservative — the token actually expires
|
|
49
|
-
* `expires_in` seconds from when the server issued it, so the
|
|
50
|
-
* effective usable window is `expires_in - RTT`, which errs toward
|
|
51
|
-
* "expires sooner" rather than "expires later."
|
|
52
|
-
* @param endpointURL URL used for error messages.
|
|
18
|
+
* Parses a 2xx response from an RFC 6749 §5.1 token endpoint into a
|
|
19
|
+
* `TokenSet`. `issuedAt` is the timestamp captured just before the
|
|
20
|
+
* network call; the resulting `expiresAt` is conservative by ~RTT.
|
|
53
21
|
*/
|
|
54
22
|
export declare function parseTokenResponse(response: Response, issuedAt: number, endpointURL: string): Promise<TokenSet>;
|
|
@@ -4,10 +4,8 @@ exports.throwTokenEndpointError = throwTokenEndpointError;
|
|
|
4
4
|
exports.parseTokenResponse = parseTokenResponse;
|
|
5
5
|
const errors_1 = require("./errors");
|
|
6
6
|
const predicates_1 = require("./predicates");
|
|
7
|
-
// RFC 6749 §5.1
|
|
8
|
-
//
|
|
9
|
-
// numeric strings. Accept both; reject anything non-positive or
|
|
10
|
-
// non-finite.
|
|
7
|
+
// RFC 6749 §5.1 doesn't pin the JSON type and some providers send
|
|
8
|
+
// numeric strings; accept both, reject non-positive or non-finite.
|
|
11
9
|
function parseExpiresIn(v) {
|
|
12
10
|
if (typeof v === "number" && Number.isFinite(v) && v > 0)
|
|
13
11
|
return v;
|
|
@@ -37,15 +35,8 @@ function parseErrorBody(body) {
|
|
|
37
35
|
};
|
|
38
36
|
}
|
|
39
37
|
/**
|
|
40
|
-
* Reads a non-2xx response body and throws
|
|
41
|
-
* `
|
|
42
|
-
* `error` / `error_description` surfaced in both message and details
|
|
43
|
-
* when present. Shared by both the authorization-code exchange and
|
|
44
|
-
* refresh-token paths since the error contract is identical.
|
|
45
|
-
*
|
|
46
|
-
* @param context Short human-readable description of which call
|
|
47
|
-
* failed ("Token exchange", "Token refresh", etc.). Appears in the
|
|
48
|
-
* error message.
|
|
38
|
+
* Reads a non-2xx response body and throws `TOKEN_EXCHANGE_FAILED`
|
|
39
|
+
* with the OAuth `error` / `error_description` surfaced when present.
|
|
49
40
|
*/
|
|
50
41
|
async function throwTokenEndpointError(response, context) {
|
|
51
42
|
const body = await response.text().catch(() => "");
|
|
@@ -63,20 +54,9 @@ async function throwTokenEndpointError(response, context) {
|
|
|
63
54
|
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `${context} failed with HTTP ${response.status}${suffix}`, Object.keys(details).length > 0 ? { details } : undefined);
|
|
64
55
|
}
|
|
65
56
|
/**
|
|
66
|
-
* Parses a 2xx response
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `expires_in`, Bearer `token_type`) and converts the relative
|
|
70
|
-
* `expires_in` into an absolute `expiresAt` using `issuedAt`.
|
|
71
|
-
*
|
|
72
|
-
* @param response The HTTP response (must be 2xx; caller handles
|
|
73
|
-
* error statuses via `throwTokenEndpointError`).
|
|
74
|
-
* @param issuedAt The timestamp captured just before the network
|
|
75
|
-
* call. Slightly conservative — the token actually expires
|
|
76
|
-
* `expires_in` seconds from when the server issued it, so the
|
|
77
|
-
* effective usable window is `expires_in - RTT`, which errs toward
|
|
78
|
-
* "expires sooner" rather than "expires later."
|
|
79
|
-
* @param endpointURL URL used for error messages.
|
|
57
|
+
* Parses a 2xx response from an RFC 6749 §5.1 token endpoint into a
|
|
58
|
+
* `TokenSet`. `issuedAt` is the timestamp captured just before the
|
|
59
|
+
* network call; the resulting `expiresAt` is conservative by ~RTT.
|
|
80
60
|
*/
|
|
81
61
|
async function parseTokenResponse(response, issuedAt, endpointURL) {
|
|
82
62
|
let parsed;
|
|
@@ -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
|
|
6
|
-
*
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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.
|
package/dist/oauth/tokenStore.js
CHANGED
|
@@ -4,49 +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
|
-
// On macOS: Keychain generic password item with the service name below.
|
|
13
|
-
// On Windows: Credential Manager entry. On Linux: Secret Service / libsecret.
|
|
14
|
-
// Exposed as a human-readable string because these all surface the service
|
|
15
|
-
// name in OS UIs (Keychain Access, credmgr.exe, seahorse).
|
|
16
11
|
const SERVICE_NAME = "axe-auth";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
// Account name is human-readable so users investigating the entry in
|
|
25
|
-
// macOS Keychain Access (or `secret-tool` on Linux, credmgr on
|
|
26
|
-
// Windows) can tell what it is. Not versioned: the schema version
|
|
27
|
-
// lives inside the blob and migrators handle the upgrade path. Note:
|
|
28
|
-
// Windows entries hold base64-encoded JSON rather than the raw JSON
|
|
29
|
-
// macOS / Linux store, so a Windows user inspecting their Credential
|
|
30
|
-
// Manager will see opaque base64; that's a side effect of chunking.
|
|
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
|
+
*/
|
|
31
18
|
const ACCOUNT_NAME = "credentials";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
*/
|
|
45
34
|
const MAX_CHUNKS = 32;
|
|
46
35
|
/**
|
|
47
36
|
* Whether `KeyringTokenStore` should split the stored blob across
|
|
48
|
-
* multiple keychain entries on this platform. Windows-only
|
|
49
|
-
*
|
|
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
|
|
50
42
|
* (parameterized for tests) so the chunking path can be exercised
|
|
51
43
|
* deterministically.
|
|
52
44
|
*/
|
|
@@ -160,10 +152,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
160
152
|
const storedVersion = getStoredVersion(parsed);
|
|
161
153
|
if (storedVersion === null)
|
|
162
154
|
return { ok: false, reason: "corrupt" };
|
|
163
|
-
// Walk the migrator chain until we reach the expected version. A
|
|
164
|
-
// missing or null-returning migrator means the old blob cannot be
|
|
165
|
-
// upgraded; surface that so callers can prompt re-auth with a
|
|
166
|
-
// clear signal instead of silently returning `empty`.
|
|
167
155
|
let current = parsed;
|
|
168
156
|
let currentVersion = storedVersion;
|
|
169
157
|
while (currentVersion !== expectedVersion) {
|
|
@@ -177,8 +165,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
177
165
|
}
|
|
178
166
|
const nextVersion = getStoredVersion(next);
|
|
179
167
|
if (nextVersion === null || nextVersion <= currentVersion) {
|
|
180
|
-
// Migrator output is malformed or didn't advance. Treat the
|
|
181
|
-
// stored blob as un-migratable rather than loop forever.
|
|
182
168
|
return { ok: false, reason: "version-mismatch", storedVersion };
|
|
183
169
|
}
|
|
184
170
|
current = next;
|
|
@@ -201,38 +187,16 @@ function wrapKeyringError(op, cause) {
|
|
|
201
187
|
});
|
|
202
188
|
}
|
|
203
189
|
/**
|
|
204
|
-
* Builds the user-facing keychain error message
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
207
194
|
* `platformKeyringHint`.
|
|
208
|
-
*
|
|
209
|
-
* The Windows-specific size-limit message is only used when the
|
|
210
|
-
* underlying error matches the binding's "longer than the platform
|
|
211
|
-
* limit" wording AND the runtime is win32 — that combination is the
|
|
212
|
-
* only way the size cap actually manifests in practice. On other
|
|
213
|
-
* platforms (or for any other binding error) we fall back to the
|
|
214
|
-
* generic per-platform hint.
|
|
215
195
|
*/
|
|
216
196
|
function keyringErrorMessage(op, cause, platform = process.platform) {
|
|
217
|
-
if (platform === "win32" && isKeyringSizeError(cause)) {
|
|
218
|
-
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.`;
|
|
219
|
-
}
|
|
220
197
|
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
221
198
|
return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
|
|
222
199
|
}
|
|
223
|
-
/**
|
|
224
|
-
* Detects the `@napi-rs/keyring` error string for "value too large".
|
|
225
|
-
* In practice only Windows Credential Manager triggers this — its
|
|
226
|
-
* stored values are capped at 2560 UTF-16 chars; macOS Keychain and
|
|
227
|
-
* Linux libsecret have no comparable limit. Exported (but not
|
|
228
|
-
* re-exported from the package index) so tests can exercise the
|
|
229
|
-
* detector independently of the wrap path.
|
|
230
|
-
*/
|
|
231
|
-
function isKeyringSizeError(cause) {
|
|
232
|
-
if (!(cause instanceof Error))
|
|
233
|
-
return false;
|
|
234
|
-
return /longer than the platform limit/.test(cause.message);
|
|
235
|
-
}
|
|
236
200
|
/**
|
|
237
201
|
* Returns a per-platform hint appended to keychain error messages so
|
|
238
202
|
* users see actionable guidance for their OS instead of generic or
|
|
@@ -280,8 +244,8 @@ function parseChunkHeader(first) {
|
|
|
280
244
|
* Secret Service). On macOS and Linux the blob lives in a single entry
|
|
281
245
|
* keyed by the fixed `credentials` account name. On Windows the blob
|
|
282
246
|
* is split across `credentials.0`, `credentials.1`, … entries to fit
|
|
283
|
-
* under Credential Manager's 2560 UTF-16
|
|
284
|
-
* `shouldChunkForKeyring`.
|
|
247
|
+
* under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
|
|
248
|
+
* cap; see `shouldChunkForKeyring`.
|
|
285
249
|
*
|
|
286
250
|
* The blob carries its own issuer/client coordinates so verbs can
|
|
287
251
|
* recover full config without per-issuer keying.
|
|
@@ -424,10 +388,6 @@ class KeyringTokenStore {
|
|
|
424
388
|
* could trip it.
|
|
425
389
|
*/
|
|
426
390
|
#saveChunked(parts) {
|
|
427
|
-
// Read previous N before any writes so the cleanup sweep is
|
|
428
|
-
// bounded. If the previous chunk 0 is missing or its header is
|
|
429
|
-
// unparseable we have no upper bound, so fall back to the full
|
|
430
|
-
// safety range as a one-time defensive recovery.
|
|
431
391
|
const previousN = this.#previousChunkN();
|
|
432
392
|
for (let i = parts.length - 1; i >= 1; i--) {
|
|
433
393
|
this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
|
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,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deque/axe-auth",
|
|
3
|
-
"version": "1.1.0-next.
|
|
3
|
+
"version": "1.1.0-next.b7ba541c",
|
|
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,7 +27,7 @@
|
|
|
20
27
|
"registry": "https://registry.npmjs.org/"
|
|
21
28
|
},
|
|
22
29
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
30
|
+
"node": ">=24.11.0"
|
|
24
31
|
},
|
|
25
32
|
"dependencies": {
|
|
26
33
|
"@napi-rs/keyring": "^1.2.0",
|
|
@@ -30,15 +37,16 @@
|
|
|
30
37
|
},
|
|
31
38
|
"devDependencies": {
|
|
32
39
|
"@hono/node-server": "^1.19.14",
|
|
33
|
-
"@types/node": "^
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
34
41
|
"c8": "^10.1.3",
|
|
35
42
|
"hono": "^4.12.16",
|
|
36
43
|
"tsx": "^4.20.6",
|
|
37
|
-
"typescript": "^
|
|
44
|
+
"typescript": "^6.0.3"
|
|
38
45
|
},
|
|
39
46
|
"scripts": {
|
|
40
47
|
"build": "tsc",
|
|
41
|
-
"
|
|
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",
|