@deque/axe-auth 1.1.0-next.ac35e028 → 1.1.0-next.ace85bdc
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/README.md +20 -26
- package/credits.json +53 -0
- package/dist/cli/commonArgs.d.ts +20 -51
- package/dist/cli/commonArgs.help.d.ts +1 -1
- package/dist/cli/commonArgs.help.js +12 -11
- package/dist/cli/commonArgs.js +20 -76
- package/dist/cli/confirm.js +0 -3
- package/dist/cli/errors.d.ts +2 -19
- package/dist/cli/errors.js +3 -25
- package/dist/cli/testUtils.js +3 -3
- package/dist/cli/types.d.ts +10 -53
- package/dist/commands/login.d.ts +4 -4
- package/dist/commands/login.help.d.ts +1 -1
- package/dist/commands/login.help.js +11 -5
- package/dist/commands/login.js +33 -18
- package/dist/commands/logout.d.ts +1 -1
- package/dist/commands/logout.help.d.ts +1 -1
- package/dist/commands/logout.help.js +5 -4
- package/dist/commands/logout.js +1 -17
- package/dist/commands/token.d.ts +2 -7
- package/dist/commands/token.help.d.ts +1 -1
- package/dist/commands/token.help.js +5 -5
- package/dist/commands/token.js +6 -22
- package/dist/index.js +17 -52
- package/dist/oauth/authorizationURL.d.ts +1 -6
- package/dist/oauth/authorizationURL.js +2 -6
- package/dist/oauth/authorize.d.ts +13 -44
- package/dist/oauth/authorize.js +4 -5
- package/dist/oauth/discoverOIDC.d.ts +10 -27
- package/dist/oauth/discoverOIDC.js +33 -32
- package/dist/oauth/discoverSSOConfig.d.ts +37 -0
- package/dist/oauth/discoverSSOConfig.js +105 -0
- package/dist/oauth/errors.d.ts +2 -0
- package/dist/oauth/getValidAccessToken.d.ts +9 -44
- package/dist/oauth/getValidAccessToken.js +8 -16
- package/dist/oauth/openBrowser.d.ts +14 -3
- package/dist/oauth/openBrowser.js +22 -5
- package/dist/oauth/refreshTokens.js +2 -3
- package/dist/oauth/revokeToken.js +5 -1
- package/dist/oauth/tokenExchange.js +2 -0
- package/dist/oauth/tokenResponse.d.ts +6 -38
- package/dist/oauth/tokenResponse.js +7 -27
- package/dist/oauth/tokenStore.d.ts +63 -3
- package/dist/oauth/tokenStore.js +379 -32
- package/dist/userAgent.d.ts +12 -0
- package/dist/userAgent.js +18 -0
- package/docs/architecture.md +201 -0
- package/docs/callback-page.md +24 -0
- package/docs/callback-server.md +21 -0
- package/docs/oauth-flow.md +15 -0
- package/package.json +17 -4
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { type KeyringEntryFactory } from "./keyringBinding";
|
|
2
2
|
import type { TokenSet } from "./tokenResponse";
|
|
3
|
+
/**
|
|
4
|
+
* Whether `KeyringTokenStore` should split the stored blob across
|
|
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
|
|
10
|
+
* (parameterized for tests) so the chunking path can be exercised
|
|
11
|
+
* deterministically.
|
|
12
|
+
*/
|
|
13
|
+
export declare function shouldChunkForKeyring(platform?: NodeJS.Platform): boolean;
|
|
3
14
|
/**
|
|
4
15
|
* Current on-disk blob schema version. Exported so consumers can
|
|
5
16
|
* display "stored v:N, expected v:M" diagnostics when `load()` returns
|
|
@@ -21,6 +32,11 @@ export interface StoredEntry {
|
|
|
21
32
|
clientId: string;
|
|
22
33
|
/** Whether the original login allowed a non-loopback http issuer. */
|
|
23
34
|
allowInsecureIssuer: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Originating axe server (walnut) URL the user supplied (or the
|
|
37
|
+
* SaaS prod default) at login.
|
|
38
|
+
*/
|
|
39
|
+
walnutURL: string;
|
|
24
40
|
}
|
|
25
41
|
/**
|
|
26
42
|
* Outcome of a `TokenStore.load()` call.
|
|
@@ -95,17 +111,61 @@ export type BlobChainResult = {
|
|
|
95
111
|
* and `MIGRATORS` and applies the latest-shape check on top.
|
|
96
112
|
*/
|
|
97
113
|
export declare function parseAndMigrateBlob(raw: string | null, expectedVersion?: number, migrators?: ReadonlyMap<number, (old: unknown) => unknown | null>): BlobChainResult;
|
|
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
|
|
119
|
+
* `platformKeyringHint`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function keyringErrorMessage(op: string, cause: unknown, platform?: NodeJS.Platform): string;
|
|
122
|
+
/**
|
|
123
|
+
* Returns a per-platform hint appended to keychain error messages so
|
|
124
|
+
* users see actionable guidance for their OS instead of generic or
|
|
125
|
+
* Linux-only advice. Exported (but not re-exported from the package
|
|
126
|
+
* index) so tests can exercise each branch without mocking
|
|
127
|
+
* `process.platform`.
|
|
128
|
+
*/
|
|
129
|
+
export declare function platformKeyringHint(platform?: NodeJS.Platform): string;
|
|
98
130
|
/**
|
|
99
131
|
* `TokenStore` backed by the operating system's native keychain via
|
|
100
132
|
* `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
|
|
101
|
-
* Secret Service).
|
|
102
|
-
*
|
|
103
|
-
*
|
|
133
|
+
* Secret Service). On macOS and Linux the blob lives in a single entry
|
|
134
|
+
* keyed by the fixed `credentials` account name. On Windows the blob
|
|
135
|
+
* is split across `credentials.0`, `credentials.1`, … entries to fit
|
|
136
|
+
* under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
|
|
137
|
+
* cap; see `shouldChunkForKeyring`.
|
|
138
|
+
*
|
|
139
|
+
* The blob carries its own issuer/client coordinates so verbs can
|
|
140
|
+
* recover full config without per-issuer keying.
|
|
104
141
|
*/
|
|
105
142
|
export declare class KeyringTokenStore implements TokenStore {
|
|
106
143
|
#private;
|
|
144
|
+
/**
|
|
145
|
+
* @param entryFactory Injection seam for `@napi-rs/keyring` entries.
|
|
146
|
+
* Defaults to the production lazy-resolved factory; tests pass a
|
|
147
|
+
* recording / faking variant.
|
|
148
|
+
*/
|
|
107
149
|
constructor(entryFactory?: KeyringEntryFactory);
|
|
150
|
+
/**
|
|
151
|
+
* @internal Test seam. Constructs a store with an explicit chunking
|
|
152
|
+
* decision instead of the platform-determined default, so the
|
|
153
|
+
* chunked path can be exercised on macOS/Linux CI and the unchunked
|
|
154
|
+
* path on Windows CI. Production code must use the regular
|
|
155
|
+
* constructor and let `shouldChunkForKeyring()` decide — passing
|
|
156
|
+
* `chunked: true` on macOS would write data that the regular
|
|
157
|
+
* constructor wouldn't be able to read.
|
|
158
|
+
*/
|
|
159
|
+
static forTesting(entryFactory: KeyringEntryFactory, chunked: boolean): KeyringTokenStore;
|
|
108
160
|
save(entry: StoredEntry): Promise<void>;
|
|
109
161
|
load(): Promise<LoadResult>;
|
|
110
162
|
clear(): Promise<void>;
|
|
111
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
|
|
166
|
+
* writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
|
|
167
|
+
* the reader can learn N from a single getPassword call. Each chunk
|
|
168
|
+
* stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
|
|
169
|
+
* require more than `MAX_CHUNKS` chunks. Exported for tests.
|
|
170
|
+
*/
|
|
171
|
+
export declare function chunkBlobForKeyring(blob: string): string[];
|
package/dist/oauth/tokenStore.js
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
|
|
4
|
+
exports.shouldChunkForKeyring = shouldChunkForKeyring;
|
|
4
5
|
exports.parseAndMigrateBlob = parseAndMigrateBlob;
|
|
6
|
+
exports.keyringErrorMessage = keyringErrorMessage;
|
|
7
|
+
exports.platformKeyringHint = platformKeyringHint;
|
|
8
|
+
exports.chunkBlobForKeyring = chunkBlobForKeyring;
|
|
5
9
|
const errors_1 = require("./errors");
|
|
6
10
|
const keyringBinding_1 = require("./keyringBinding");
|
|
7
|
-
// On macOS: Keychain generic password item with the service name below.
|
|
8
|
-
// On Windows: Credential Manager entry. On Linux: Secret Service / libsecret.
|
|
9
|
-
// Exposed as a human-readable string because these all surface the service
|
|
10
|
-
// name in OS UIs (Keychain Access, credmgr.exe, seahorse).
|
|
11
11
|
const SERVICE_NAME = "axe-auth";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// macOS Keychain Access (or `secret-tool` on Linux, credmgr on
|
|
19
|
-
// Windows) can tell what it is. Not versioned: the schema version
|
|
20
|
-
// lives inside the blob and migrators handle the upgrade path.
|
|
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
|
+
*/
|
|
21
18
|
const ACCOUNT_NAME = "credentials";
|
|
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
|
+
*/
|
|
34
|
+
const MAX_CHUNKS = 32;
|
|
35
|
+
/**
|
|
36
|
+
* Whether `KeyringTokenStore` should split the stored blob across
|
|
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
|
|
42
|
+
* (parameterized for tests) so the chunking path can be exercised
|
|
43
|
+
* deterministically.
|
|
44
|
+
*/
|
|
45
|
+
function shouldChunkForKeyring(platform = process.platform) {
|
|
46
|
+
return platform === "win32";
|
|
47
|
+
}
|
|
22
48
|
/**
|
|
23
49
|
* Current on-disk blob schema version. Exported so consumers can
|
|
24
50
|
* display "stored v:N, expected v:M" diagnostics when `load()` returns
|
|
@@ -72,7 +98,9 @@ function isLatestBlob(blob) {
|
|
|
72
98
|
(b.refreshToken === undefined || typeof b.refreshToken === "string") &&
|
|
73
99
|
typeof b.issuerURL === "string" &&
|
|
74
100
|
typeof b.clientId === "string" &&
|
|
75
|
-
typeof b.allowInsecureIssuer === "boolean"
|
|
101
|
+
typeof b.allowInsecureIssuer === "boolean" &&
|
|
102
|
+
typeof b.walnutURL === "string" &&
|
|
103
|
+
b.walnutURL.length > 0);
|
|
76
104
|
}
|
|
77
105
|
function blobToEntry(blob) {
|
|
78
106
|
const tokens = {
|
|
@@ -86,6 +114,7 @@ function blobToEntry(blob) {
|
|
|
86
114
|
issuerURL: blob.issuerURL,
|
|
87
115
|
clientId: blob.clientId,
|
|
88
116
|
allowInsecureIssuer: blob.allowInsecureIssuer,
|
|
117
|
+
walnutURL: blob.walnutURL,
|
|
89
118
|
};
|
|
90
119
|
}
|
|
91
120
|
function entryToBlob(entry) {
|
|
@@ -96,6 +125,7 @@ function entryToBlob(entry) {
|
|
|
96
125
|
issuerURL: entry.issuerURL,
|
|
97
126
|
clientId: entry.clientId,
|
|
98
127
|
allowInsecureIssuer: entry.allowInsecureIssuer,
|
|
128
|
+
walnutURL: entry.walnutURL,
|
|
99
129
|
};
|
|
100
130
|
if (entry.tokens.refreshToken)
|
|
101
131
|
blob.refreshToken = entry.tokens.refreshToken;
|
|
@@ -122,10 +152,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
122
152
|
const storedVersion = getStoredVersion(parsed);
|
|
123
153
|
if (storedVersion === null)
|
|
124
154
|
return { ok: false, reason: "corrupt" };
|
|
125
|
-
// Walk the migrator chain until we reach the expected version. A
|
|
126
|
-
// missing or null-returning migrator means the old blob cannot be
|
|
127
|
-
// upgraded; surface that so callers can prompt re-auth with a
|
|
128
|
-
// clear signal instead of silently returning `empty`.
|
|
129
155
|
let current = parsed;
|
|
130
156
|
let currentVersion = storedVersion;
|
|
131
157
|
while (currentVersion !== expectedVersion) {
|
|
@@ -139,8 +165,6 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
139
165
|
}
|
|
140
166
|
const nextVersion = getStoredVersion(next);
|
|
141
167
|
if (nextVersion === null || nextVersion <= currentVersion) {
|
|
142
|
-
// Migrator output is malformed or didn't advance. Treat the
|
|
143
|
-
// stored blob as un-migratable rather than loop forever.
|
|
144
168
|
return { ok: false, reason: "version-mismatch", storedVersion };
|
|
145
169
|
}
|
|
146
170
|
current = next;
|
|
@@ -149,32 +173,162 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
149
173
|
return { ok: true, blob: current };
|
|
150
174
|
}
|
|
151
175
|
function wrapKeyringError(op, cause) {
|
|
152
|
-
|
|
176
|
+
// Pass-through pre-wrapped OAuthFlowErrors so we don't double-wrap
|
|
177
|
+
// our own error type. The most common source today is
|
|
178
|
+
// `defaultEntryFactory` throwing `KEYRING_UNAVAILABLE` when the
|
|
179
|
+
// native binding can't be loaded — relabelling that as another
|
|
180
|
+
// `KEYRING_UNAVAILABLE` with a duplicate message and a possibly
|
|
181
|
+
// misleading platform hint helps nobody.
|
|
182
|
+
if (cause instanceof errors_1.OAuthFlowError) {
|
|
183
|
+
throw cause;
|
|
184
|
+
}
|
|
185
|
+
throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", keyringErrorMessage(op, cause), {
|
|
186
|
+
cause,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
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
|
|
194
|
+
* `platformKeyringHint`.
|
|
195
|
+
*/
|
|
196
|
+
function keyringErrorMessage(op, cause, platform = process.platform) {
|
|
197
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
198
|
+
return `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint(platform)}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Returns a per-platform hint appended to keychain error messages so
|
|
202
|
+
* users see actionable guidance for their OS instead of generic or
|
|
203
|
+
* Linux-only advice. Exported (but not re-exported from the package
|
|
204
|
+
* index) so tests can exercise each branch without mocking
|
|
205
|
+
* `process.platform`.
|
|
206
|
+
*/
|
|
207
|
+
function platformKeyringHint(platform = process.platform) {
|
|
208
|
+
switch (platform) {
|
|
209
|
+
case "darwin":
|
|
210
|
+
return "On macOS this usually means Keychain Access denied or cancelled the prompt.";
|
|
211
|
+
case "win32":
|
|
212
|
+
return "On Windows this usually means Credential Manager rejected the operation.";
|
|
213
|
+
case "linux":
|
|
214
|
+
return "On Linux this usually means no D-Bus Secret Service is running (e.g. GNOME Keyring or KWallet).";
|
|
215
|
+
default:
|
|
216
|
+
return `Underlying platform: ${platform}.`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Parses chunk 0's `<N>\n<rest>` header. Returns the chunk count and
|
|
221
|
+
* the data part following the newline, or `null` for any malformed /
|
|
222
|
+
* out-of-range / non-canonically-encoded header. Centralised here
|
|
223
|
+
* (rather than open-coded twice in `#loadChunked` and
|
|
224
|
+
* `#previousChunkN`) so the canonical-encoding contract has one
|
|
225
|
+
* authoritative implementation.
|
|
226
|
+
*/
|
|
227
|
+
function parseChunkHeader(first) {
|
|
228
|
+
const newlineIdx = first.indexOf("\n");
|
|
229
|
+
if (newlineIdx <= 0)
|
|
230
|
+
return null;
|
|
231
|
+
const nStr = first.slice(0, newlineIdx);
|
|
232
|
+
const n = parseInt(nStr, 10);
|
|
233
|
+
// Reject non-canonical encodings ("01", " 3", "3abc"). parseInt is
|
|
234
|
+
// permissive about those; we want a single canonical encoding so
|
|
235
|
+
// two different headers can't decode to the same N.
|
|
236
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_CHUNKS || String(n) !== nStr) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return { n, rest: first.slice(newlineIdx + 1) };
|
|
153
240
|
}
|
|
154
241
|
/**
|
|
155
242
|
* `TokenStore` backed by the operating system's native keychain via
|
|
156
243
|
* `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
|
|
157
|
-
* Secret Service).
|
|
158
|
-
*
|
|
159
|
-
*
|
|
244
|
+
* Secret Service). On macOS and Linux the blob lives in a single entry
|
|
245
|
+
* keyed by the fixed `credentials` account name. On Windows the blob
|
|
246
|
+
* is split across `credentials.0`, `credentials.1`, … entries to fit
|
|
247
|
+
* under Credential Manager's 2560-byte (1280 UTF-16 char) per-entry
|
|
248
|
+
* cap; see `shouldChunkForKeyring`.
|
|
249
|
+
*
|
|
250
|
+
* The blob carries its own issuer/client coordinates so verbs can
|
|
251
|
+
* recover full config without per-issuer keying.
|
|
160
252
|
*/
|
|
161
253
|
class KeyringTokenStore {
|
|
162
|
-
#
|
|
254
|
+
#entryFactory;
|
|
255
|
+
#chunked;
|
|
256
|
+
/**
|
|
257
|
+
* @param entryFactory Injection seam for `@napi-rs/keyring` entries.
|
|
258
|
+
* Defaults to the production lazy-resolved factory; tests pass a
|
|
259
|
+
* recording / faking variant.
|
|
260
|
+
*/
|
|
163
261
|
constructor(entryFactory = keyringBinding_1.defaultEntryFactory) {
|
|
164
|
-
this.#
|
|
262
|
+
this.#entryFactory = entryFactory;
|
|
263
|
+
this.#chunked = shouldChunkForKeyring();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* @internal Test seam. Constructs a store with an explicit chunking
|
|
267
|
+
* decision instead of the platform-determined default, so the
|
|
268
|
+
* chunked path can be exercised on macOS/Linux CI and the unchunked
|
|
269
|
+
* path on Windows CI. Production code must use the regular
|
|
270
|
+
* constructor and let `shouldChunkForKeyring()` decide — passing
|
|
271
|
+
* `chunked: true` on macOS would write data that the regular
|
|
272
|
+
* constructor wouldn't be able to read.
|
|
273
|
+
*/
|
|
274
|
+
static forTesting(entryFactory, chunked) {
|
|
275
|
+
const store = new KeyringTokenStore(entryFactory);
|
|
276
|
+
store.#chunked = chunked;
|
|
277
|
+
return store;
|
|
278
|
+
}
|
|
279
|
+
#entry(account) {
|
|
280
|
+
return this.#entryFactory(SERVICE_NAME, account);
|
|
165
281
|
}
|
|
166
282
|
async save(entry) {
|
|
167
|
-
|
|
168
|
-
|
|
283
|
+
const jsonBlob = JSON.stringify(entryToBlob(entry));
|
|
284
|
+
if (this.#chunked) {
|
|
285
|
+
// Encode + chunk OUTSIDE the try/catch so a TOKEN_TOO_LARGE from
|
|
286
|
+
// `chunkBlobForKeyring` surfaces unchanged. The keychain
|
|
287
|
+
// operations stay inside the try and get wrapped as
|
|
288
|
+
// KEYRING_UNAVAILABLE if they fail.
|
|
289
|
+
const encoded = Buffer.from(jsonBlob, "utf8").toString("base64");
|
|
290
|
+
const parts = chunkBlobForKeyring(encoded);
|
|
291
|
+
try {
|
|
292
|
+
this.#saveChunked(parts);
|
|
293
|
+
}
|
|
294
|
+
catch (cause) {
|
|
295
|
+
wrapKeyringError("write", cause);
|
|
296
|
+
}
|
|
169
297
|
}
|
|
170
|
-
|
|
171
|
-
|
|
298
|
+
else {
|
|
299
|
+
try {
|
|
300
|
+
this.#entry(ACCOUNT_NAME).setPassword(jsonBlob);
|
|
301
|
+
}
|
|
302
|
+
catch (cause) {
|
|
303
|
+
wrapKeyringError("write", cause);
|
|
304
|
+
}
|
|
172
305
|
}
|
|
173
306
|
}
|
|
174
307
|
async load() {
|
|
175
308
|
let raw;
|
|
176
309
|
try {
|
|
177
|
-
|
|
310
|
+
if (this.#chunked) {
|
|
311
|
+
const result = this.#loadChunked();
|
|
312
|
+
if (result.kind === "present") {
|
|
313
|
+
raw = result.blob;
|
|
314
|
+
}
|
|
315
|
+
else if (result.kind === "empty") {
|
|
316
|
+
// First-time-upgrade fallback: a Windows dev who upgraded
|
|
317
|
+
// across the chunking change has data at the bare
|
|
318
|
+
// `credentials` account but no chunks yet. Read that legacy
|
|
319
|
+
// entry; the next save() migrates it. Note we only fall
|
|
320
|
+
// back when chunked data is *empty* — when chunked data is
|
|
321
|
+
// *corrupt* we surface that directly rather than restoring
|
|
322
|
+
// potentially stale legacy data underneath the corruption.
|
|
323
|
+
raw = this.#entry(ACCOUNT_NAME).getPassword();
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
return { ok: false, reason: "corrupt" };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
raw = this.#entry(ACCOUNT_NAME).getPassword();
|
|
331
|
+
}
|
|
178
332
|
}
|
|
179
333
|
catch (cause) {
|
|
180
334
|
wrapKeyringError("read", cause);
|
|
@@ -188,11 +342,204 @@ class KeyringTokenStore {
|
|
|
188
342
|
}
|
|
189
343
|
async clear() {
|
|
190
344
|
try {
|
|
191
|
-
this.#
|
|
345
|
+
if (this.#chunked) {
|
|
346
|
+
this.#clearChunked();
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
this.#entry(ACCOUNT_NAME).deletePassword();
|
|
350
|
+
}
|
|
192
351
|
}
|
|
193
352
|
catch (cause) {
|
|
194
353
|
wrapKeyringError("delete", cause);
|
|
195
354
|
}
|
|
196
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Writes `parts` (the output of `chunkBlobForKeyring`) to entries
|
|
358
|
+
* `credentials.0..N-1`.
|
|
359
|
+
*
|
|
360
|
+
* Writes are in **reverse index order** — chunks N-1..1, then chunk
|
|
361
|
+
* 0 with the new header last. Chunk 0's header is what reads use to
|
|
362
|
+
* learn N, so until it's overwritten the previous chunk 0 still
|
|
363
|
+
* references the previous N chunks.
|
|
364
|
+
*
|
|
365
|
+
* Crash recovery is partial, not total. Reverse order helps in one
|
|
366
|
+
* case: when N_new > N_old and the crash happens before chunk 0 is
|
|
367
|
+
* rewritten — writes to indices >= N_old don't disturb old data,
|
|
368
|
+
* the previous chunk 0 still references the previous N chunks, and
|
|
369
|
+
* the prior session survives. The typical refresh case (N_new ==
|
|
370
|
+
* N_old) overwrites chunks 1..N-1 with new data while chunk 0 is
|
|
371
|
+
* still old, so a crash there reads as corrupt and the user
|
|
372
|
+
* re-auths. Reverse order is therefore a marginal improvement over
|
|
373
|
+
* forward order, not a guarantee.
|
|
374
|
+
*
|
|
375
|
+
* Cleanup sweeps `[N_new, N_old)` (bounded by the previous chunk
|
|
376
|
+
* count read from the old chunk 0 header before we overwrite it).
|
|
377
|
+
* For a typical token refresh (same N) this is zero deletes; the
|
|
378
|
+
* full safety sweep up to MAX_CHUNKS only runs as a defensive
|
|
379
|
+
* recovery when the previous N can't be determined. Orphans at
|
|
380
|
+
* indices >= max(N_new, N_old) from interrupted resize-up writes
|
|
381
|
+
* persist until the next `clear()` does the full sweep.
|
|
382
|
+
*
|
|
383
|
+
* Concurrency: this method is not safe to run concurrently against
|
|
384
|
+
* the same OS keychain. Two writers can interleave at chunk
|
|
385
|
+
* boundaries and produce a Frankenstein blob. axe-auth runs as a
|
|
386
|
+
* short-lived CLI so this is unlikely in practice, but a long-lived
|
|
387
|
+
* process refreshing in the background while the CLI is invoked
|
|
388
|
+
* could trip it.
|
|
389
|
+
*/
|
|
390
|
+
#saveChunked(parts) {
|
|
391
|
+
const previousN = this.#previousChunkN();
|
|
392
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
393
|
+
this.#entry(`${ACCOUNT_NAME}.${i}`).setPassword(parts[i]);
|
|
394
|
+
}
|
|
395
|
+
this.#entry(`${ACCOUNT_NAME}.0`).setPassword(parts[0]);
|
|
396
|
+
// Best-effort sweep: writes have already succeeded, so a sweep
|
|
397
|
+
// failure shouldn't roll back the save. The next save's bounded
|
|
398
|
+
// sweep cleans up anything we miss here. Same reasoning for the
|
|
399
|
+
// legacy delete below.
|
|
400
|
+
const sweepEnd = previousN ?? MAX_CHUNKS;
|
|
401
|
+
for (let i = parts.length; i < sweepEnd; i++) {
|
|
402
|
+
try {
|
|
403
|
+
this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// Sweep is best-effort; the next save handles leftovers.
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Clear any pre-chunking single-entry blob from a previous
|
|
410
|
+
// axe-auth release. This is a forever-tax (one extra
|
|
411
|
+
// deletePassword per save even after the migration is done)
|
|
412
|
+
// because we have no per-machine "migration completed" flag;
|
|
413
|
+
// adding one would mean another keychain entry to manage. The
|
|
414
|
+
// cost is one Credential Manager call per refresh — negligible
|
|
415
|
+
// relative to the OAuth round-trip.
|
|
416
|
+
try {
|
|
417
|
+
this.#entry(ACCOUNT_NAME).deletePassword();
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// Best-effort; the next save attempts again.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Reads the chunk-count header from `credentials.0` so `#saveChunked`
|
|
425
|
+
* can bound its cleanup sweep. Returns `null` when chunk 0 is
|
|
426
|
+
* missing, when the header is malformed, or when the encoded N is
|
|
427
|
+
* out of range — every "I don't know the previous count" case
|
|
428
|
+
* collapses to a full safety sweep at the call site.
|
|
429
|
+
*/
|
|
430
|
+
#previousChunkN() {
|
|
431
|
+
const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
|
|
432
|
+
if (first === null)
|
|
433
|
+
return null;
|
|
434
|
+
return parseChunkHeader(first)?.n ?? null;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Reverse of `#saveChunked`. Returns a discriminated result so the
|
|
438
|
+
* caller can distinguish "no data" from "data is malformed" without
|
|
439
|
+
* reaching for sentinel strings.
|
|
440
|
+
*/
|
|
441
|
+
#loadChunked() {
|
|
442
|
+
const first = this.#entry(`${ACCOUNT_NAME}.0`).getPassword();
|
|
443
|
+
if (first === null)
|
|
444
|
+
return { kind: "empty" };
|
|
445
|
+
const header = parseChunkHeader(first);
|
|
446
|
+
if (!header)
|
|
447
|
+
return { kind: "corrupt" };
|
|
448
|
+
const parts = [header.rest];
|
|
449
|
+
for (let i = 1; i < header.n; i++) {
|
|
450
|
+
const part = this.#entry(`${ACCOUNT_NAME}.${i}`).getPassword();
|
|
451
|
+
if (part === null)
|
|
452
|
+
return { kind: "corrupt" };
|
|
453
|
+
parts.push(part);
|
|
454
|
+
}
|
|
455
|
+
// `Buffer.from(_, 'base64')` is permissive — invalid characters
|
|
456
|
+
// are silently dropped rather than throwing. Garbage base64
|
|
457
|
+
// produces garbage UTF-8, which falls through to the upstream
|
|
458
|
+
// JSON.parse and surfaces as `corrupt` from
|
|
459
|
+
// `parseAndMigrateBlob`. So no try/catch is needed here.
|
|
460
|
+
const blob = Buffer.from(parts.join(""), "base64").toString("utf8");
|
|
461
|
+
return { kind: "present", blob };
|
|
462
|
+
}
|
|
463
|
+
#clearChunked() {
|
|
464
|
+
// Sweep the whole safety range rather than break-on-first-missing
|
|
465
|
+
// so chunk holes (from interrupted writes or manual tampering)
|
|
466
|
+
// still get cleaned up. Logout is rare enough that the
|
|
467
|
+
// unconditional sweep cost is irrelevant.
|
|
468
|
+
//
|
|
469
|
+
// Per-entry errors are caught locally so a single throw doesn't
|
|
470
|
+
// strand the remaining chunks (or the legacy entry) in the
|
|
471
|
+
// keychain. After all attempts, we surface the first failure so
|
|
472
|
+
// the user still sees that logout didn't fully complete.
|
|
473
|
+
let firstError = null;
|
|
474
|
+
for (let i = 0; i < MAX_CHUNKS; i++) {
|
|
475
|
+
try {
|
|
476
|
+
this.#entry(`${ACCOUNT_NAME}.${i}`).deletePassword();
|
|
477
|
+
}
|
|
478
|
+
catch (cause) {
|
|
479
|
+
firstError ??= cause;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// And the pre-chunking single-entry blob, in case a Windows dev
|
|
483
|
+
// had axe-auth installed before chunking shipped.
|
|
484
|
+
try {
|
|
485
|
+
this.#entry(ACCOUNT_NAME).deletePassword();
|
|
486
|
+
}
|
|
487
|
+
catch (cause) {
|
|
488
|
+
firstError ??= cause;
|
|
489
|
+
}
|
|
490
|
+
if (firstError !== null) {
|
|
491
|
+
throw firstError;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
197
494
|
}
|
|
198
495
|
exports.KeyringTokenStore = KeyringTokenStore;
|
|
496
|
+
/**
|
|
497
|
+
* Splits `blob` into the N parts that `KeyringTokenStore.#saveChunked`
|
|
498
|
+
* writes to `credentials.0..N-1`. Chunk 0 is prefixed with `<N>\n` so
|
|
499
|
+
* the reader can learn N from a single getPassword call. Each chunk
|
|
500
|
+
* stays under `CHUNK_LIMIT` UTF-16 characters; throws if the blob would
|
|
501
|
+
* require more than `MAX_CHUNKS` chunks. Exported for tests.
|
|
502
|
+
*/
|
|
503
|
+
function chunkBlobForKeyring(blob) {
|
|
504
|
+
// N depends on the header length, which depends on N. Solve by
|
|
505
|
+
// iterating until the chunk count stabilises (converges in <= a
|
|
506
|
+
// couple of steps for any realistic blob). The safety counter is
|
|
507
|
+
// belt-and-suspenders against a future tweak (different
|
|
508
|
+
// CHUNK_LIMIT, different header format) accidentally introducing
|
|
509
|
+
// oscillation; an unbounded loop here would hang `axe-auth login`
|
|
510
|
+
// with no error.
|
|
511
|
+
let n = Math.max(1, Math.ceil(blob.length / CHUNK_LIMIT));
|
|
512
|
+
let safety = 0;
|
|
513
|
+
while (true) {
|
|
514
|
+
if (++safety > 8) {
|
|
515
|
+
throw new Error(`chunkBlobForKeyring: chunk count failed to converge after ${safety} iterations (blob length ${blob.length})`);
|
|
516
|
+
}
|
|
517
|
+
const headerLen = String(n).length + 1; // "<N>\n"
|
|
518
|
+
const chunk0Capacity = CHUNK_LIMIT - headerLen;
|
|
519
|
+
if (chunk0Capacity <= 0) {
|
|
520
|
+
throw new Error(`chunkBlobForKeyring: chunk count ${n} leaves no room for data`);
|
|
521
|
+
}
|
|
522
|
+
const remaining = Math.max(0, blob.length - chunk0Capacity);
|
|
523
|
+
const next = 1 + Math.ceil(remaining / CHUNK_LIMIT);
|
|
524
|
+
if (next === n)
|
|
525
|
+
break;
|
|
526
|
+
n = next;
|
|
527
|
+
}
|
|
528
|
+
if (n > MAX_CHUNKS) {
|
|
529
|
+
// Surfaced as a distinct error code (rather than KEYRING_UNAVAILABLE)
|
|
530
|
+
// because the keystore is healthy — the failure is that the IDP's
|
|
531
|
+
// token has too many claims to fit. Wrapping this as a keychain
|
|
532
|
+
// error would attach a misleading "Credential Manager rejected"
|
|
533
|
+
// platform hint via `wrapKeyringError`'s default path.
|
|
534
|
+
throw new errors_1.OAuthFlowError("TOKEN_TOO_LARGE", `OAuth token blob would require ${n} keyring entries (max ${MAX_CHUNKS}). The IDP may be issuing tokens with unusually many claims; talk to the realm administrator.`);
|
|
535
|
+
}
|
|
536
|
+
const headerLen = String(n).length + 1;
|
|
537
|
+
const chunk0Capacity = CHUNK_LIMIT - headerLen;
|
|
538
|
+
const parts = [`${n}\n${blob.slice(0, chunk0Capacity)}`];
|
|
539
|
+
let pos = chunk0Capacity;
|
|
540
|
+
while (pos < blob.length) {
|
|
541
|
+
parts.push(blob.slice(pos, pos + CHUNK_LIMIT));
|
|
542
|
+
pos += CHUNK_LIMIT;
|
|
543
|
+
}
|
|
544
|
+
return parts;
|
|
545
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `User-Agent` header value sent on all outbound requests, per
|
|
3
|
+
* Service Development Standards §4.4.
|
|
4
|
+
*
|
|
5
|
+
* Format: `axe-auth/v<package-version>` (e.g. `axe-auth/v1.0.2`).
|
|
6
|
+
*
|
|
7
|
+
* The npm scope (`@deque/`) is deliberately omitted from the wire format:
|
|
8
|
+
* `@` and `/` are not valid `tchar` per RFC 9110 §5.6.2, so a token like
|
|
9
|
+
* `@deque/axe-auth` would make the User-Agent malformed and risk WAF
|
|
10
|
+
* rejection (e.g. OWASP CRS rule 920330).
|
|
11
|
+
*/
|
|
12
|
+
export declare const USER_AGENT: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.USER_AGENT = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
7
|
+
/**
|
|
8
|
+
* `User-Agent` header value sent on all outbound requests, per
|
|
9
|
+
* Service Development Standards §4.4.
|
|
10
|
+
*
|
|
11
|
+
* Format: `axe-auth/v<package-version>` (e.g. `axe-auth/v1.0.2`).
|
|
12
|
+
*
|
|
13
|
+
* The npm scope (`@deque/`) is deliberately omitted from the wire format:
|
|
14
|
+
* `@` and `/` are not valid `tchar` per RFC 9110 §5.6.2, so a token like
|
|
15
|
+
* `@deque/axe-auth` would make the User-Agent malformed and risk WAF
|
|
16
|
+
* rejection (e.g. OWASP CRS rule 920330).
|
|
17
|
+
*/
|
|
18
|
+
exports.USER_AGENT = `axe-auth/v${pkg.version}`;
|