@deque/axe-auth 1.1.0-next.5c407e02 → 1.1.0-next.6820fe60

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.
Files changed (38) hide show
  1. package/README.md +56 -9
  2. package/dist/cli/commonArgs.d.ts +66 -0
  3. package/dist/cli/commonArgs.help.d.ts +2 -0
  4. package/dist/cli/commonArgs.help.js +19 -0
  5. package/dist/cli/commonArgs.js +119 -0
  6. package/dist/cli/confirm.d.ts +17 -0
  7. package/dist/cli/confirm.js +56 -0
  8. package/dist/cli/errors.d.ts +30 -0
  9. package/dist/cli/errors.js +52 -0
  10. package/dist/cli/testUtils.d.ts +52 -0
  11. package/dist/cli/testUtils.js +100 -0
  12. package/dist/cli/types.d.ts +82 -0
  13. package/dist/cli/types.js +2 -0
  14. package/dist/commands/login.d.ts +41 -0
  15. package/dist/commands/login.help.d.ts +2 -0
  16. package/dist/commands/login.help.js +35 -0
  17. package/dist/commands/login.js +93 -0
  18. package/dist/commands/logout.d.ts +24 -0
  19. package/dist/commands/logout.help.d.ts +2 -0
  20. package/dist/commands/logout.help.js +37 -0
  21. package/dist/commands/logout.js +84 -0
  22. package/dist/commands/token.d.ts +26 -0
  23. package/dist/commands/token.help.d.ts +2 -0
  24. package/dist/commands/token.help.js +41 -0
  25. package/dist/commands/token.js +56 -0
  26. package/dist/index.js +142 -22
  27. package/dist/oauth/authorize.d.ts +3 -2
  28. package/dist/oauth/authorize.js +8 -4
  29. package/dist/oauth/getValidAccessToken.d.ts +24 -5
  30. package/dist/oauth/getValidAccessToken.js +23 -6
  31. package/dist/oauth/index.d.ts +2 -1
  32. package/dist/oauth/keyringBinding.d.ts +22 -0
  33. package/dist/oauth/keyringBinding.js +41 -0
  34. package/dist/oauth/revokeToken.d.ts +28 -0
  35. package/dist/oauth/revokeToken.js +59 -0
  36. package/dist/oauth/tokenStore.d.ts +56 -23
  37. package/dist/oauth/tokenStore.js +104 -82
  38. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -1,47 +1,167 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
4
- const node_util_1 = require("node:util");
5
7
  const node_fs_1 = require("node:fs");
6
8
  const node_path_1 = require("node:path");
9
+ const node_util_1 = require("node:util");
10
+ const ts_dedent_1 = require("ts-dedent");
11
+ const commonArgs_1 = require("./cli/commonArgs");
12
+ const tokenStore_1 = require("./oauth/tokenStore");
13
+ const login_1 = __importDefault(require("./commands/login"));
14
+ const logout_1 = __importDefault(require("./commands/logout"));
15
+ const token_1 = __importDefault(require("./commands/token"));
16
+ const errors_1 = require("./cli/errors");
7
17
  const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
8
- const helpText = `${pkg.name} v${pkg.version}
18
+ // Iteration order is the order verbs appear in `axe-auth --help`.
19
+ const COMMANDS = [
20
+ login_1.default,
21
+ logout_1.default,
22
+ token_1.default,
23
+ ];
24
+ function findCommand(verb) {
25
+ return COMMANDS.find((c) => c.name === verb);
26
+ }
27
+ function topLevelHelp() {
28
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
29
+ const verbList = COMMANDS.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`).join("\n");
30
+ return `${pkg.name} v${pkg.version}
9
31
 
10
32
  ${pkg.description}
11
33
 
12
34
  Usage:
13
- axe-auth [options]
35
+ axe-auth <command> [options]
36
+
37
+ Commands:
38
+ ${verbList}
39
+
40
+ Run \`axe-auth <command> --help\` for command-specific options.
14
41
 
15
- Options:
16
- -v, --version Show version number
17
- -h, --help Show this help message`;
18
- const main = async () => {
19
- let values;
42
+ Top-level options:
43
+ -v, --version Show version number.
44
+ -h, --help Show this help.`;
45
+ }
46
+ async function dispatch(argv) {
47
+ const [first, ...rest] = argv;
48
+ if (first === undefined || first === "-h" || first === "--help") {
49
+ process.stdout.write(`${topLevelHelp()}\n`);
50
+ return 0;
51
+ }
52
+ if (first === "-v" || first === "--version") {
53
+ process.stdout.write(`${pkg.version}\n`);
54
+ return 0;
55
+ }
56
+ if (first.startsWith("-")) {
57
+ process.stderr.write(`Unknown option: ${first}. Run \`axe-auth --help\` for usage.\n`);
58
+ return 2;
59
+ }
60
+ const command = findCommand(first);
61
+ if (!command) {
62
+ process.stderr.write(`Unknown command: ${first}. Run \`axe-auth --help\` for usage.\n`);
63
+ return 2;
64
+ }
65
+ let parsed;
20
66
  try {
21
- ({ values } = (0, node_util_1.parseArgs)({
67
+ parsed = (0, node_util_1.parseArgs)({
68
+ args: rest,
22
69
  options: {
23
- version: { type: "boolean", short: "v" },
70
+ ...commonArgs_1.COMMON_OPTIONS,
71
+ ...command.options,
24
72
  help: { type: "boolean", short: "h" },
25
73
  },
26
74
  strict: true,
27
- }));
75
+ allowPositionals: false,
76
+ });
28
77
  }
29
78
  catch (err) {
30
- console.error(err.message);
31
- return 1;
79
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
80
+ return 2;
32
81
  }
33
- if (values.version) {
34
- console.log(pkg.version);
82
+ if (parsed.values.help) {
83
+ process.stdout.write(`${command.helpText}\n`);
35
84
  return 0;
36
85
  }
37
- if (values.help) {
38
- console.log(helpText);
86
+ // Best-effort load of the stored config — but only when at least
87
+ // one common field is unspecified. The dispatcher's fallback is
88
+ // all-or-nothing (parseCommonArgs ignores defaults if any flag/env
89
+ // is set), so a fully-flagged invocation gains nothing from a
90
+ // keychain hit on the hot path.
91
+ //
92
+ // The load failure isn't fatal on its own — if the user passed
93
+ // flags/env, the stored config doesn't matter — but if
94
+ // `parseCommonArgs` then throws `MissingConfigError`, we surface
95
+ // this failure alongside it so the user understands the keychain
96
+ // (not just missing flags) is the proximate cause.
97
+ let defaults = null;
98
+ let defaultLoadError = null;
99
+ let loadedEntry;
100
+ const commonValues = parsed.values;
101
+ const allFlagsPresent = Boolean(commonValues.server && commonValues.realm && commonValues["client-id"]);
102
+ if (!allFlagsPresent) {
103
+ try {
104
+ loadedEntry = await new tokenStore_1.KeyringTokenStore().load();
105
+ if (loadedEntry.ok) {
106
+ defaults = {
107
+ issuerURL: loadedEntry.entry.issuerURL,
108
+ clientId: loadedEntry.entry.clientId,
109
+ allowInsecureIssuer: loadedEntry.entry.allowInsecureIssuer,
110
+ };
111
+ }
112
+ }
113
+ catch (err) {
114
+ defaultLoadError = err;
115
+ }
116
+ }
117
+ let common;
118
+ try {
119
+ common = (0, commonArgs_1.parseCommonArgs)(commonValues, process.env, defaults);
120
+ }
121
+ catch (err) {
122
+ if (err instanceof errors_1.MissingConfigError && !command.requiresConfig) {
123
+ // The verb operates on the stored entry alone (token,
124
+ // logout). It has its own handling for the empty / corrupt /
125
+ // version-mismatch cases, which is friendlier than the
126
+ // generic "missing required configuration" error
127
+ // (`No stored credentials. Run \`axe-auth login\` first.` for
128
+ // token; `Already logged out.` for logout). Pass a
129
+ // sentinel-empty CommonArgs so the verb runs and decides.
130
+ common = { issuerURL: "", clientId: "", allowInsecureIssuer: false };
131
+ }
132
+ else if (err instanceof errors_1.MissingConfigError && defaultLoadError !== null) {
133
+ process.stderr.write((0, ts_dedent_1.dedent) `
134
+ ${err.message}
135
+ Could not read the stored credentials from the keychain (${(0, errors_1.describeError)(defaultLoadError)});
136
+ pass the flags explicitly or fix the keychain.
137
+ ` + "\n");
138
+ return 2;
139
+ }
140
+ else {
141
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
142
+ return 2;
143
+ }
144
+ }
145
+ const deps = {
146
+ stdin: process.stdin,
147
+ stdout: process.stdout,
148
+ stderr: process.stderr,
149
+ loadedEntry,
150
+ };
151
+ try {
152
+ await command.run({ ...common, ...parsed.values }, deps);
39
153
  return 0;
40
154
  }
41
- console.log("No arguments provided. Use --help for usage information.");
42
- return 1;
43
- };
44
- main().then((code) => process.exit(code), (err) => {
45
- console.error(err);
155
+ catch (err) {
156
+ if (err instanceof errors_1.CLIError) {
157
+ process.stderr.write(`${err.message}\n`);
158
+ return err.exitCode;
159
+ }
160
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
161
+ return 2;
162
+ }
163
+ }
164
+ dispatch(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
165
+ process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
46
166
  process.exit(1);
47
167
  });
@@ -25,8 +25,9 @@ export interface AuthorizeOptions {
25
25
  /** Aborts the in-flight discovery, callback wait, and token exchange. */
26
26
  signal?: AbortSignal;
27
27
  /**
28
- * Override for the token persistence layer. Defaults to
29
- * `KeyringTokenStore` keyed by the normalized issuer URL.
28
+ * Override for the token persistence layer. Defaults to a fresh
29
+ * `KeyringTokenStore()` (single keychain entry per machine; the
30
+ * blob carries its own issuer/client coordinates).
30
31
  */
31
32
  tokenStore?: TokenStore;
32
33
  /** Override for the system browser launcher. Injected for tests. */
@@ -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, scopes, timeoutMs, signal, tokenStore = new tokenStore_1.KeyringTokenStore(issuerURL), openBrowser = openBrowser_1.openBrowser, onAuthorizationUrl = defaultOnAuthorizationUrl, onWarning = defaultOnWarning, allowInsecureIssuer, } = options;
41
+ const { issuerURL, clientId, scopes, timeoutMs, signal, tokenStore = new tokenStore_1.KeyringTokenStore(), openBrowser = openBrowser_1.openBrowser, onAuthorizationUrl = defaultOnAuthorizationUrl, onWarning = defaultOnWarning, allowInsecureIssuer, } = options;
42
42
  // Discovery first. If the auth server is unreachable we want to fail
43
43
  // *before* opening a browser — a rejected discovery throw is
44
44
  // strictly more useful than a browser tab pointing at a
@@ -98,14 +98,18 @@ async function authorize(options) {
98
98
  // came back, warn. Prefer the server's reported `grantedScope`
99
99
  // when present (RFC 6749 §5.1) since the provider's consent
100
100
  // screen may have dropped the scope.
101
- if (scopes.includes("offline_access") &&
102
- tokens.refreshToken === undefined) {
101
+ if (scopes.includes("offline_access") && !tokens.refreshToken) {
103
102
  const grantedSuffix = tokens.grantedScope
104
103
  ? ` (server granted: ${tokens.grantedScope})`
105
104
  : "";
106
105
  onWarning(`'offline_access' was requested but no refresh_token was returned${grantedSuffix}. Cross-session refresh will not be available.`);
107
106
  }
108
- await tokenStore.save(tokens);
107
+ await tokenStore.save({
108
+ tokens,
109
+ issuerURL,
110
+ clientId,
111
+ allowInsecureIssuer: allowInsecureIssuer ?? false,
112
+ });
109
113
  return tokens;
110
114
  }
111
115
  finally {
@@ -1,9 +1,18 @@
1
- import { type TokenStore } from "./tokenStore";
1
+ import { type LoadResult, type TokenStore } from "./tokenStore";
2
2
  /** Options for `getValidAccessToken`. */
3
3
  export interface GetValidAccessTokenOptions {
4
- /** OIDC issuer URL (same value passed to `authorize`). */
4
+ /**
5
+ * OIDC issuer URL (same value passed to `authorize`). Must match
6
+ * the stored entry's `issuerURL`; mismatch throws
7
+ * `OAuthFlowError("NOT_AUTHENTICATED", ...)` rather than refreshing
8
+ * the wrong issuer's tokens at the requested endpoint.
9
+ */
5
10
  issuerURL: string;
6
- /** OAuth client identifier. */
11
+ /**
12
+ * OAuth client identifier. Must match the stored entry's
13
+ * `clientId`; see the note on `issuerURL` for the mismatch
14
+ * behavior.
15
+ */
7
16
  clientId: string;
8
17
  /**
9
18
  * How close to expiry we start preemptively refreshing, in
@@ -17,10 +26,20 @@ export interface GetValidAccessTokenOptions {
17
26
  */
18
27
  expiryBufferMs?: number;
19
28
  /**
20
- * Override for the token store. Defaults to `KeyringTokenStore`
21
- * keyed by `issuerURL`.
29
+ * Override for the token store. Defaults to a fresh
30
+ * `KeyringTokenStore()` (single keychain entry per machine).
22
31
  */
23
32
  tokenStore?: TokenStore;
33
+ /**
34
+ * Pre-loaded result of `tokenStore.load()`. When provided, the
35
+ * function skips its own keychain read and uses this value
36
+ * instead — lets a caller that already loaded the entry (the CLI
37
+ * dispatcher does, to derive `parseCommonArgs` defaults) avoid a
38
+ * redundant second read on the hot path. The same `tokenStore` is
39
+ * still used for the post-refresh `save()` and the
40
+ * `invalid_grant` `clear()`.
41
+ */
42
+ loadedEntry?: LoadResult;
24
43
  /** Aborts discovery + the refresh POST when fired. */
25
44
  signal?: AbortSignal;
26
45
  /**
@@ -47,24 +47,36 @@ function notAuthenticated(message, cause) {
47
47
  * keyed by issuer.
48
48
  */
49
49
  async function getValidAccessToken(options) {
50
- const { issuerURL, clientId, expiryBufferMs = DEFAULT_EXPIRY_BUFFER_MS, tokenStore = new tokenStore_1.KeyringTokenStore(issuerURL), signal, allowInsecureIssuer, onWarning = defaultOnWarning, now = Date.now, } = options;
51
- const loaded = await tokenStore.load();
50
+ const { issuerURL, clientId, expiryBufferMs = DEFAULT_EXPIRY_BUFFER_MS, tokenStore = new tokenStore_1.KeyringTokenStore(), loadedEntry, signal, allowInsecureIssuer, onWarning = defaultOnWarning, now = Date.now, } = options;
51
+ const loaded = loadedEntry ?? (await tokenStore.load());
52
52
  if (!loaded.ok) {
53
53
  switch (loaded.reason) {
54
54
  case "empty":
55
- throw notAuthenticated(`No stored credentials for ${issuerURL}. Run \`axe-auth login\` first.`);
55
+ throw notAuthenticated("No stored credentials. Run `axe-auth login` first.");
56
56
  case "corrupt":
57
57
  throw notAuthenticated("Stored credentials are unreadable. Run `axe-auth login` to re-authenticate.");
58
58
  case "version-mismatch":
59
59
  throw notAuthenticated(`Stored credentials are from an unsupported schema version (v:${loaded.storedVersion}). Run \`axe-auth login\` to re-authenticate.`);
60
60
  }
61
61
  }
62
- const tokens = loaded.tokens;
62
+ // Guard against a mismatch between the requested issuer/client and
63
+ // the stored entry's. Under single-entry storage, the keychain
64
+ // holds one set of tokens minted against one (issuer, client)
65
+ // pair. Refreshing those tokens against a different
66
+ // discovery/token endpoint would land an unrelated refresh token
67
+ // at the wrong server and leak it. Refuse rather than silently
68
+ // proceed so direct library callers (the CLI's verbs warn + route
69
+ // before getting here) get a clear signal.
70
+ if (loaded.entry.issuerURL !== issuerURL ||
71
+ loaded.entry.clientId !== clientId) {
72
+ throw notAuthenticated(`Stored credentials are for issuer ${loaded.entry.issuerURL} (client ${loaded.entry.clientId}), but the requested issuer is ${issuerURL} (client ${clientId}). Run \`axe-auth login\` to re-authenticate.`);
73
+ }
74
+ const tokens = loaded.entry.tokens;
63
75
  if (now() + expiryBufferMs < tokens.expiresAt) {
64
76
  // Still fresh — no network call, no store write.
65
77
  return tokens.accessToken;
66
78
  }
67
- if (tokens.refreshToken === undefined) {
79
+ if (!tokens.refreshToken) {
68
80
  throw notAuthenticated("Access token has expired and no refresh token is available. Run `axe-auth login` to re-authenticate.");
69
81
  }
70
82
  const config = await (0, discoverOIDC_1.discoverOIDC)(issuerURL, {
@@ -113,7 +125,12 @@ async function getValidAccessToken(options) {
113
125
  // useful, and warn the caller so "why does the next run need me to
114
126
  // log in again?" has a breadcrumb.
115
127
  try {
116
- await tokenStore.save(fresh);
128
+ await tokenStore.save({
129
+ tokens: fresh,
130
+ issuerURL: loaded.entry.issuerURL,
131
+ clientId: loaded.entry.clientId,
132
+ allowInsecureIssuer: loaded.entry.allowInsecureIssuer,
133
+ });
117
134
  }
118
135
  catch (err) {
119
136
  onWarning(`Refreshed tokens could not be saved: ${err instanceof Error ? err.message : String(err)}. The current call will succeed, but the next invocation will require re-authentication.`);
@@ -10,6 +10,7 @@ export { refreshTokens } from "./refreshTokens";
10
10
  export type { RefreshTokensOptions } from "./refreshTokens";
11
11
  export type { TokenSet } from "./tokenResponse";
12
12
  export { KeyringTokenStore, STORED_BLOB_VERSION } from "./tokenStore";
13
- export type { TokenStore, LoadResult, KeyringEntry, KeyringEntryFactory, } from "./tokenStore";
13
+ export type { LoadResult, StoredEntry, TokenStore } from "./tokenStore";
14
+ export type { KeyringEntry, KeyringEntryFactory } from "./keyringBinding";
14
15
  export { discoverOIDC } from "./discoverOIDC";
15
16
  export type { OIDCConfiguration, DiscoverOIDCOptions } from "./discoverOIDC";
@@ -0,0 +1,22 @@
1
+ /** Minimal keyring-entry surface consumed by the package's stores. */
2
+ export interface KeyringEntry {
3
+ /** Writes the password for this entry. */
4
+ setPassword(password: string): void;
5
+ /** Reads the current password, or returns `null` if none is set. */
6
+ getPassword(): string | null;
7
+ /** Deletes the password and returns `true` if one existed. */
8
+ deletePassword(): boolean;
9
+ }
10
+ /**
11
+ * Factory for `KeyringEntry` values. Injection seam for tests;
12
+ * production callers use the default that constructs
13
+ * `@napi-rs/keyring` entries lazily.
14
+ */
15
+ export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry;
16
+ /**
17
+ * Default `KeyringEntryFactory`. Resolves `@napi-rs/keyring` lazily
18
+ * so platforms without a prebuilt binding only see a
19
+ * `KEYRING_UNAVAILABLE` on first store construction, not on module
20
+ * import.
21
+ */
22
+ export declare const defaultEntryFactory: KeyringEntryFactory;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultEntryFactory = void 0;
4
+ const node_module_1 = require("node:module");
5
+ const errors_1 = require("./errors");
6
+ const requireFromHere = (0, node_module_1.createRequire)(__filename);
7
+ // Lazy-resolved Entry constructor. Importing @napi-rs/keyring at the
8
+ // top of this module would run its native binding loader at
9
+ // module-load time, throwing before any of our OAuthFlowError code
10
+ // catches it and preventing the module from being imported at all on
11
+ // platforms without a prebuilt. We defer the require into
12
+ // `defaultEntryFactory`, which turns that import-time failure into a
13
+ // `KEYRING_UNAVAILABLE` surfaced on the first store construction —
14
+ // not on first save/load/clear, since callers construct stores
15
+ // eagerly as default-arg expressions. Runtime keychain errors
16
+ // (missing D-Bus Secret Service, macOS Keychain denial, etc.) are a
17
+ // separate concern and surface later, inside save/load/clear.
18
+ let cachedEntryCtor = null;
19
+ function resolveEntryCtor() {
20
+ if (cachedEntryCtor)
21
+ return cachedEntryCtor;
22
+ try {
23
+ const mod = requireFromHere("@napi-rs/keyring");
24
+ cachedEntryCtor = mod.Entry;
25
+ return cachedEntryCtor;
26
+ }
27
+ catch (cause) {
28
+ throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", `Could not load @napi-rs/keyring. A prebuilt native binding for this platform may be missing.`, { cause });
29
+ }
30
+ }
31
+ /**
32
+ * Default `KeyringEntryFactory`. Resolves `@napi-rs/keyring` lazily
33
+ * so platforms without a prebuilt binding only see a
34
+ * `KEYRING_UNAVAILABLE` on first store construction, not on module
35
+ * import.
36
+ */
37
+ const defaultEntryFactory = (service, account) => {
38
+ const Ctor = resolveEntryCtor();
39
+ return new Ctor(service, account);
40
+ };
41
+ exports.defaultEntryFactory = defaultEntryFactory;
@@ -0,0 +1,28 @@
1
+ /** Options for `revokeRefreshToken`. */
2
+ export interface RevokeRefreshTokenOptions {
3
+ /** Revocation endpoint resolved from OIDC discovery. */
4
+ revocationEndpoint: string;
5
+ /** OAuth client ID. */
6
+ clientId: string;
7
+ /** The refresh token to revoke server-side. */
8
+ refreshToken: string;
9
+ /** Aborts the underlying fetch when fired. */
10
+ signal?: AbortSignal;
11
+ }
12
+ /**
13
+ * Revokes a refresh token via RFC 7009. Servers SHOULD return 200
14
+ * regardless of whether the token was valid (the spec doesn't want
15
+ * revocation to be a probing oracle for token existence). In
16
+ * practice this helper still surfaces network errors and any
17
+ * non-2xx response from the revocation endpoint, on the assumption
18
+ * that a 4xx is more likely a misconfiguration the user should hear
19
+ * about than a routine condition to swallow.
20
+ *
21
+ * Throws a plain `Error` rather than `OAuthFlowError`: revocation
22
+ * is best-effort cleanup invoked from `axe-auth logout`, and the
23
+ * caller already handles failure by warning + continuing with the
24
+ * local clear. Adding a dedicated `OAuthFlowError` code for this
25
+ * one shallow operation is more bloat than the discrimination is
26
+ * worth.
27
+ */
28
+ export declare function revokeRefreshToken(options: RevokeRefreshTokenOptions): Promise<void>;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.revokeRefreshToken = revokeRefreshToken;
4
+ /**
5
+ * Revokes a refresh token via RFC 7009. Servers SHOULD return 200
6
+ * regardless of whether the token was valid (the spec doesn't want
7
+ * revocation to be a probing oracle for token existence). In
8
+ * practice this helper still surfaces network errors and any
9
+ * non-2xx response from the revocation endpoint, on the assumption
10
+ * that a 4xx is more likely a misconfiguration the user should hear
11
+ * about than a routine condition to swallow.
12
+ *
13
+ * Throws a plain `Error` rather than `OAuthFlowError`: revocation
14
+ * is best-effort cleanup invoked from `axe-auth logout`, and the
15
+ * caller already handles failure by warning + continuing with the
16
+ * local clear. Adding a dedicated `OAuthFlowError` code for this
17
+ * one shallow operation is more bloat than the discrimination is
18
+ * worth.
19
+ */
20
+ async function revokeRefreshToken(options) {
21
+ const body = new URLSearchParams({
22
+ token: options.refreshToken,
23
+ token_type_hint: "refresh_token",
24
+ client_id: options.clientId,
25
+ });
26
+ let response;
27
+ try {
28
+ response = await fetch(options.revocationEndpoint, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
31
+ body,
32
+ signal: options.signal,
33
+ });
34
+ }
35
+ catch (cause) {
36
+ const reason = cause instanceof Error ? cause.message : String(cause);
37
+ throw new Error(`Could not reach the revocation endpoint at ${options.revocationEndpoint}: ${reason}`, { cause });
38
+ }
39
+ if (!response.ok) {
40
+ // Deliberately do NOT include the response body. The request
41
+ // body we POSTed contains the refresh token; some Keycloak
42
+ // custom error templates and many WAFs / reverse proxies echo
43
+ // request fields back into 4xx pages, which would land the
44
+ // refresh token on stderr (the caller's `describeError(err)`
45
+ // path is `axe-auth: server-side revocation failed (...)`). Status
46
+ // alone is enough for the user to act on; if more detail is
47
+ // needed they can hit the revocation endpoint directly.
48
+ //
49
+ // We also drain the body so the underlying connection isn't
50
+ // held open by the unread stream.
51
+ try {
52
+ await response.text();
53
+ }
54
+ catch {
55
+ // ignore — body is purely diagnostic
56
+ }
57
+ throw new Error(`Revocation endpoint at ${options.revocationEndpoint} returned HTTP ${response.status}`);
58
+ }
59
+ }
@@ -1,3 +1,4 @@
1
+ import { type KeyringEntryFactory } from "./keyringBinding";
1
2
  import type { TokenSet } from "./tokenResponse";
2
3
  /**
3
4
  * Current on-disk blob schema version. Exported so consumers can
@@ -5,6 +6,22 @@ import type { TokenSet } from "./tokenResponse";
5
6
  * a `version-mismatch` result.
6
7
  */
7
8
  export declare const STORED_BLOB_VERSION = 1;
9
+ /**
10
+ * What `KeyringTokenStore` persists: the OAuth tokens plus the
11
+ * issuer/client coordinates they were minted against. Carrying the
12
+ * coordinates inside the entry means a verb can recover its full
13
+ * config from the keychain alone, with no separate "default issuer"
14
+ * pointer.
15
+ */
16
+ export interface StoredEntry {
17
+ tokens: TokenSet;
18
+ /** OIDC issuer URL the tokens were minted against. */
19
+ issuerURL: string;
20
+ /** OAuth client ID used at login. */
21
+ clientId: string;
22
+ /** Whether the original login allowed a non-loopback http issuer. */
23
+ allowInsecureIssuer: boolean;
24
+ }
8
25
  /**
9
26
  * Outcome of a `TokenStore.load()` call.
10
27
  *
@@ -19,7 +36,7 @@ export declare const STORED_BLOB_VERSION = 1;
19
36
  */
20
37
  export type LoadResult = {
21
38
  ok: true;
22
- tokens: TokenSet;
39
+ entry: StoredEntry;
23
40
  } | {
24
41
  ok: false;
25
42
  reason: "empty";
@@ -31,48 +48,64 @@ export type LoadResult = {
31
48
  reason: "version-mismatch";
32
49
  storedVersion: number;
33
50
  };
34
- /** Persistence layer for an OAuth `TokenSet`. */
51
+ /** Persistence layer for an OAuth `StoredEntry`. */
35
52
  export interface TokenStore {
36
- /** Write-through save. Replaces any previously stored tokens. */
37
- save(tokens: TokenSet): Promise<void>;
53
+ /** Write-through save. Replaces any previously stored entry. */
54
+ save(entry: StoredEntry): Promise<void>;
38
55
  /**
39
- * Reads the stored tokens and returns a structured result.
56
+ * Reads the stored entry and returns a structured result.
40
57
  *
41
58
  * Callers should branch on `result.ok` first. When `ok` is `false`,
42
- * `reason` tells them *why* there is no usable `TokenSet`: `empty`
59
+ * `reason` tells them *why* there is no usable entry: `empty`
43
60
  * (nothing stored), `corrupt` (unparseable or shape-invalid), or
44
61
  * `version-mismatch` (stored under a schema we cannot migrate from).
45
62
  * The library does not emit output on these cases — surfacing them
46
63
  * to the user is the caller's responsibility.
47
64
  */
48
65
  load(): Promise<LoadResult>;
49
- /** Removes any stored tokens. No-op if none are present. */
66
+ /** Removes any stored entry. No-op if none is present. */
50
67
  clear(): Promise<void>;
51
68
  }
52
- /** Minimal keyring-entry surface consumed by `KeyringTokenStore`. */
53
- export interface KeyringEntry {
54
- /** Writes the password for this entry. */
55
- setPassword(password: string): void;
56
- /** Reads the current password, or returns `null` if none is set. */
57
- getPassword(): string | null;
58
- /** Deletes the password and returns `true` if one existed. */
59
- deletePassword(): boolean;
60
- }
61
69
  /**
62
- * Factory for `KeyringEntry` values. Injection seam for tests;
63
- * production callers use the default that constructs
64
- * `@napi-rs/keyring` entries lazily.
70
+ * Outcome of `parseAndMigrateBlob`: same set of failure reasons as
71
+ * `LoadResult`, but on success carries the post-migration blob as an
72
+ * unknown payload. The caller is responsible for shape-validating
73
+ * that payload against the latest schema.
74
+ */
75
+ export type BlobChainResult = {
76
+ ok: true;
77
+ blob: unknown;
78
+ } | {
79
+ ok: false;
80
+ reason: "empty";
81
+ } | {
82
+ ok: false;
83
+ reason: "corrupt";
84
+ } | {
85
+ ok: false;
86
+ reason: "version-mismatch";
87
+ storedVersion: number;
88
+ };
89
+ /**
90
+ * JSON-parses the raw keychain password and walks the migrator chain
91
+ * until it reaches `expectedVersion`. Exported with `expectedVersion`
92
+ * and `migrators` parameters only for testing the chain mechanics
93
+ * against synthetic versions / migrators; production callers use
94
+ * `KeyringTokenStore.load()`, which feeds in `STORED_BLOB_VERSION`
95
+ * and `MIGRATORS` and applies the latest-shape check on top.
65
96
  */
66
- export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry;
97
+ export declare function parseAndMigrateBlob(raw: string | null, expectedVersion?: number, migrators?: ReadonlyMap<number, (old: unknown) => unknown | null>): BlobChainResult;
67
98
  /**
68
99
  * `TokenStore` backed by the operating system's native keychain via
69
100
  * `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
70
- * Secret Service). Account is keyed by the normalized issuer URL.
101
+ * Secret Service). One entry per machine, keyed by a fixed account
102
+ * name; the blob carries its own issuer/client coordinates so verbs
103
+ * can recover full config without per-issuer keying.
71
104
  */
72
105
  export declare class KeyringTokenStore implements TokenStore {
73
106
  #private;
74
- constructor(issuerURL: string, entryFactory?: KeyringEntryFactory);
75
- save(tokens: TokenSet): Promise<void>;
107
+ constructor(entryFactory?: KeyringEntryFactory);
108
+ save(entry: StoredEntry): Promise<void>;
76
109
  load(): Promise<LoadResult>;
77
110
  clear(): Promise<void>;
78
111
  }