@deque/axe-auth 1.1.0-next.907ffbd7 → 1.1.0-next.9bc60204

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 (60) hide show
  1. package/README.md +58 -17
  2. package/credits.json +42 -0
  3. package/dist/cli/commonArgs.d.ts +82 -0
  4. package/dist/cli/commonArgs.help.d.ts +2 -0
  5. package/dist/cli/commonArgs.help.js +20 -0
  6. package/dist/cli/commonArgs.js +90 -0
  7. package/dist/cli/confirm.d.ts +17 -0
  8. package/dist/cli/confirm.js +56 -0
  9. package/dist/cli/errors.d.ts +20 -0
  10. package/dist/cli/errors.js +37 -0
  11. package/dist/cli/testUtils.d.ts +52 -0
  12. package/dist/cli/testUtils.js +100 -0
  13. package/dist/cli/types.d.ts +79 -0
  14. package/dist/cli/types.js +2 -0
  15. package/dist/commands/login.d.ts +44 -0
  16. package/dist/commands/login.help.d.ts +2 -0
  17. package/dist/commands/login.help.js +41 -0
  18. package/dist/commands/login.js +117 -0
  19. package/dist/commands/logout.d.ts +24 -0
  20. package/dist/commands/logout.help.d.ts +2 -0
  21. package/dist/commands/logout.help.js +38 -0
  22. package/dist/commands/logout.js +70 -0
  23. package/dist/commands/token.d.ts +21 -0
  24. package/dist/commands/token.help.d.ts +2 -0
  25. package/dist/commands/token.help.js +41 -0
  26. package/dist/commands/token.js +44 -0
  27. package/dist/index.js +114 -22
  28. package/dist/oauth/authorize.d.ts +11 -3
  29. package/dist/oauth/authorize.js +9 -4
  30. package/dist/oauth/discoverOIDC.js +36 -8
  31. package/dist/oauth/discoverSSOConfig.d.ts +47 -0
  32. package/dist/oauth/discoverSSOConfig.js +105 -0
  33. package/dist/oauth/errors.d.ts +3 -1
  34. package/dist/oauth/getValidAccessToken.d.ts +89 -0
  35. package/dist/oauth/getValidAccessToken.js +140 -0
  36. package/dist/oauth/index.d.ts +7 -2
  37. package/dist/oauth/index.js +5 -1
  38. package/dist/oauth/keyringBinding.d.ts +22 -0
  39. package/dist/oauth/keyringBinding.js +41 -0
  40. package/dist/oauth/predicates.d.ts +7 -0
  41. package/dist/oauth/predicates.js +15 -0
  42. package/dist/oauth/refreshTokens.d.ts +30 -0
  43. package/dist/oauth/refreshTokens.js +63 -0
  44. package/dist/oauth/revokeToken.d.ts +28 -0
  45. package/dist/oauth/revokeToken.js +63 -0
  46. package/dist/oauth/testUtils.d.ts +35 -0
  47. package/dist/oauth/testUtils.js +61 -0
  48. package/dist/oauth/tokenExchange.d.ts +1 -24
  49. package/dist/oauth/tokenExchange.js +5 -97
  50. package/dist/oauth/tokenResponse.d.ts +54 -0
  51. package/dist/oauth/tokenResponse.js +121 -0
  52. package/dist/oauth/tokenStore.d.ts +62 -24
  53. package/dist/oauth/tokenStore.js +108 -82
  54. package/dist/userAgent.d.ts +12 -0
  55. package/dist/userAgent.js +18 -0
  56. package/docs/architecture.md +201 -0
  57. package/docs/callback-page.md +24 -0
  58. package/docs/callback-server.md +21 -0
  59. package/docs/oauth-flow.md +15 -0
  60. package/package.json +8 -3
package/dist/index.js CHANGED
@@ -1,47 +1,139 @@
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 commonArgs_1 = require("./cli/commonArgs");
11
+ const tokenStore_1 = require("./oauth/tokenStore");
12
+ const login_1 = __importDefault(require("./commands/login"));
13
+ const logout_1 = __importDefault(require("./commands/logout"));
14
+ const token_1 = __importDefault(require("./commands/token"));
15
+ const errors_1 = require("./cli/errors");
7
16
  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}
17
+ // Iteration order is the order verbs appear in `axe-auth --help`.
18
+ const COMMANDS = [
19
+ login_1.default,
20
+ logout_1.default,
21
+ token_1.default,
22
+ ];
23
+ function findCommand(verb) {
24
+ return COMMANDS.find((c) => c.name === verb);
25
+ }
26
+ function topLevelHelp() {
27
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
28
+ const verbList = COMMANDS.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`).join("\n");
29
+ return `${pkg.name} v${pkg.version}
9
30
 
10
31
  ${pkg.description}
11
32
 
12
33
  Usage:
13
- axe-auth [options]
34
+ axe-auth <command> [options]
14
35
 
15
- Options:
16
- -v, --version Show version number
17
- -h, --help Show this help message`;
18
- const main = async () => {
19
- let values;
36
+ Commands:
37
+ ${verbList}
38
+
39
+ Run \`axe-auth <command> --help\` for command-specific options.
40
+
41
+ Top-level options:
42
+ -v, --version Show version number.
43
+ -h, --help Show this help.`;
44
+ }
45
+ async function dispatch(argv) {
46
+ const [first, ...rest] = argv;
47
+ if (first === undefined || first === "-h" || first === "--help") {
48
+ process.stdout.write(`${topLevelHelp()}\n`);
49
+ return 0;
50
+ }
51
+ if (first === "-v" || first === "--version") {
52
+ process.stdout.write(`${pkg.version}\n`);
53
+ return 0;
54
+ }
55
+ if (first.startsWith("-")) {
56
+ process.stderr.write(`Unknown option: ${first}. Run \`axe-auth --help\` for usage.\n`);
57
+ return 2;
58
+ }
59
+ const command = findCommand(first);
60
+ if (!command) {
61
+ process.stderr.write(`Unknown command: ${first}. Run \`axe-auth --help\` for usage.\n`);
62
+ return 2;
63
+ }
64
+ let parsed;
20
65
  try {
21
- ({ values } = (0, node_util_1.parseArgs)({
66
+ parsed = (0, node_util_1.parseArgs)({
67
+ args: rest,
22
68
  options: {
23
- version: { type: "boolean", short: "v" },
69
+ ...commonArgs_1.COMMON_OPTIONS,
70
+ ...command.options,
24
71
  help: { type: "boolean", short: "h" },
25
72
  },
26
73
  strict: true,
27
- }));
74
+ allowPositionals: false,
75
+ });
28
76
  }
29
77
  catch (err) {
30
- console.error(err.message);
31
- return 1;
78
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
79
+ return 2;
32
80
  }
33
- if (values.version) {
34
- console.log(pkg.version);
81
+ if (parsed.values.help) {
82
+ process.stdout.write(`${command.helpText}\n`);
35
83
  return 0;
36
84
  }
37
- if (values.help) {
38
- console.log(helpText);
85
+ // Best-effort load of the stored entry. Used to (a) supply the
86
+ // `allowInsecureIssuer` fallback on flag-free invocations, and (b)
87
+ // hand the entry to the verb via `deps.loadedEntry` so a single
88
+ // `axe-auth token` invocation hits the keychain once instead of
89
+ // twice. A read failure here is non-fatal — `parseCommonArgs`
90
+ // always succeeds (the SaaS prod default fills any unsupplied
91
+ // walnut URL), and verbs that need a stored entry have their own
92
+ // empty/corrupt-entry handling.
93
+ let defaults = null;
94
+ let loadedEntry;
95
+ try {
96
+ loadedEntry = await new tokenStore_1.KeyringTokenStore().load();
97
+ if (loadedEntry.ok) {
98
+ defaults = {
99
+ walnutURL: loadedEntry.entry.walnutURL,
100
+ allowInsecureIssuer: loadedEntry.entry.allowInsecureIssuer,
101
+ };
102
+ }
103
+ }
104
+ catch {
105
+ // Keychain unavailable: leave defaults null. login will fail at
106
+ // `tokenStore.save()` with a clearer error than we can produce
107
+ // here; token / logout's own empty-entry path handles it.
108
+ }
109
+ let common;
110
+ try {
111
+ common = (0, commonArgs_1.parseCommonArgs)(parsed.values, process.env, defaults);
112
+ }
113
+ catch (err) {
114
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
115
+ return 2;
116
+ }
117
+ const deps = {
118
+ stdin: process.stdin,
119
+ stdout: process.stdout,
120
+ stderr: process.stderr,
121
+ loadedEntry,
122
+ };
123
+ try {
124
+ await command.run({ ...common, ...parsed.values }, deps);
39
125
  return 0;
40
126
  }
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);
127
+ catch (err) {
128
+ if (err instanceof errors_1.CLIError) {
129
+ process.stderr.write(`${err.message}\n`);
130
+ return err.exitCode;
131
+ }
132
+ process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
133
+ return 2;
134
+ }
135
+ }
136
+ dispatch(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
137
+ process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
46
138
  process.exit(1);
47
139
  });
@@ -1,4 +1,4 @@
1
- import { type TokenSet } from "./tokenExchange";
1
+ import type { TokenSet } from "./tokenResponse";
2
2
  import { type TokenStore } from "./tokenStore";
3
3
  /** Options for `authorize`. */
4
4
  export interface AuthorizeOptions {
@@ -11,6 +11,13 @@ export interface AuthorizeOptions {
11
11
  issuerURL: string;
12
12
  /** OAuth client ID registered with the authorization server. */
13
13
  clientId: string;
14
+ /**
15
+ * Originating walnut (axe server) URL the user supplied (or the
16
+ * SaaS prod default) at login. Persisted in the stored entry
17
+ * alongside the OAuth coordinates so future verbs can re-discover
18
+ * `/api/sso-config` without user-supplied flags.
19
+ */
20
+ walnutURL: string;
14
21
  /**
15
22
  * OAuth scopes to request. Required — this library has no opinion
16
23
  * about which scopes your provider expects. Keycloak callers who
@@ -25,8 +32,9 @@ export interface AuthorizeOptions {
25
32
  /** Aborts the in-flight discovery, callback wait, and token exchange. */
26
33
  signal?: AbortSignal;
27
34
  /**
28
- * Override for the token persistence layer. Defaults to
29
- * `KeyringTokenStore` keyed by the normalized issuer URL.
35
+ * Override for the token persistence layer. Defaults to a fresh
36
+ * `KeyringTokenStore()` (single keychain entry per machine; the
37
+ * blob carries its own issuer/client coordinates).
30
38
  */
31
39
  tokenStore?: TokenStore;
32
40
  /** 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, walnutURL, 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,19 @@ 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
+ walnutURL,
113
+ });
109
114
  return tokens;
110
115
  }
111
116
  finally {
@@ -3,12 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.discoverOIDC = discoverOIDC;
4
4
  const errors_1 = require("./errors");
5
5
  const issuerURL_1 = require("./issuerURL");
6
+ const predicates_1 = require("./predicates");
7
+ const userAgent_1 = require("../userAgent");
6
8
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
7
- function isNonEmptyString(v) {
8
- return typeof v === "string" && v.length > 0;
9
- }
10
9
  function optionalString(v) {
11
- return isNonEmptyString(v) ? v : undefined;
10
+ return (0, predicates_1.isNonEmptyString)(v) ? v : undefined;
12
11
  }
13
12
  /**
14
13
  * Throws `DISCOVERY_FAILED` if `url` is not safe to transmit OAuth
@@ -53,12 +52,12 @@ function buildDiscoveryURL(issuerURL) {
53
52
  }
54
53
  function parseConfiguration(body, url) {
55
54
  const missing = [];
56
- if (!isNonEmptyString(body.issuer))
55
+ if (!(0, predicates_1.isNonEmptyString)(body.issuer))
57
56
  missing.push("issuer");
58
- if (!isNonEmptyString(body.authorization_endpoint)) {
57
+ if (!(0, predicates_1.isNonEmptyString)(body.authorization_endpoint)) {
59
58
  missing.push("authorization_endpoint");
60
59
  }
61
- if (!isNonEmptyString(body.token_endpoint))
60
+ if (!(0, predicates_1.isNonEmptyString)(body.token_endpoint))
62
61
  missing.push("token_endpoint");
63
62
  if (missing.length > 0) {
64
63
  throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `OpenID configuration at ${url} is missing required field(s): ${missing.join(", ")}`);
@@ -71,6 +70,31 @@ function parseConfiguration(body, url) {
71
70
  endSessionEndpoint: optionalString(body.end_session_endpoint),
72
71
  };
73
72
  }
73
+ /**
74
+ * `code_challenge_methods_supported` is OPTIONAL in OIDC discovery, so its
75
+ * absence proves nothing — older providers may support PKCE without
76
+ * advertising it. But when the list IS present and does not include
77
+ * `S256` (the only method this CLI uses, per RFC 7636), the server has
78
+ * explicitly declared it does not support the flow we need. Fail fast
79
+ * with an actionable message instead of letting the user hit a generic
80
+ * OAuth error several steps deeper into the flow.
81
+ *
82
+ * An empty list (`[]`) is treated the same as a populated list missing
83
+ * `S256`: the server has explicitly advertised zero supported methods,
84
+ * which is incompatible.
85
+ *
86
+ * Called from `discoverOIDC` after issuer verification so that a
87
+ * tampered discovery doc surfaces the more security-critical issuer
88
+ * mismatch first.
89
+ */
90
+ function assertPKCESupport(body, url) {
91
+ const methods = body.code_challenge_methods_supported;
92
+ if (!Array.isArray(methods))
93
+ return;
94
+ if (methods.includes("S256"))
95
+ return;
96
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `OpenID configuration at ${url} advertises code_challenge_methods_supported = ${JSON.stringify(methods)}, but axe-auth requires S256 (PKCE per RFC 7636). The OAuth client used by axe-auth needs PKCE enabled, or you may be on an axe server version that predates OAuth-based MCP authentication.`);
97
+ }
74
98
  /**
75
99
  * Fetches and parses the OpenID Connect discovery document for a given
76
100
  * issuer. Fails fast (no retry) so the caller does not open a browser
@@ -100,7 +124,10 @@ async function discoverOIDC(issuerURL, options = {}) {
100
124
  const url = buildDiscoveryURL(issuerURL);
101
125
  let response;
102
126
  try {
103
- response = await fetch(url, { signal: options.signal });
127
+ response = await fetch(url, {
128
+ headers: { "User-Agent": userAgent_1.USER_AGENT },
129
+ signal: options.signal,
130
+ });
104
131
  }
105
132
  catch (cause) {
106
133
  throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Could not reach the authentication server at ${url}. Check the URL and your network connection.`, { cause });
@@ -141,5 +168,6 @@ async function discoverOIDC(issuerURL, options = {}) {
141
168
  if (config.endSessionEndpoint) {
142
169
  assertSecureURL(config.endSessionEndpoint, "end_session_endpoint", allowInsecure);
143
170
  }
171
+ assertPKCESupport(body, url);
144
172
  return config;
145
173
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Subset of the axe server's `/api/sso-config` response that this
3
+ * package consumes. The full response may carry additional fields
4
+ * (e.g. `publicClientId` for the SPA frontend); we ignore everything
5
+ * except what the CLI needs to drive its OAuth flow.
6
+ */
7
+ export interface SSOConfig {
8
+ /** Keycloak base URL, e.g. `https://auth.example.com`. */
9
+ url: string;
10
+ /** Keycloak realm name. */
11
+ realm: string;
12
+ /** OAuth client ID for the axe-auth CLI. */
13
+ mcpClientId: string;
14
+ }
15
+ /** Options for `discoverSSOConfig`. */
16
+ export interface DiscoverSSOConfigOptions {
17
+ /** Aborts the underlying fetch when fired. */
18
+ signal?: AbortSignal;
19
+ /**
20
+ * Permit non-HTTPS axe server URLs whose host is not a loopback
21
+ * literal. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are
22
+ * always allowed over http; this flag is the opt-in for non-loopback
23
+ * http (corporate dev / reverse-proxy setups). Default `false`.
24
+ */
25
+ allowInsecure?: boolean;
26
+ }
27
+ /**
28
+ * Fetches and parses the axe server's `/api/sso-config` discovery
29
+ * endpoint. Used by `axe-auth login` to derive the OAuth issuer URL,
30
+ * realm, and CLI-specific client ID from the axe server URL the user
31
+ * supplied (or the SaaS prod default), so users no longer have to know
32
+ * the underlying Keycloak coordinates.
33
+ *
34
+ * Distinguishes three failure shapes for the operator-relevant cases:
35
+ *
36
+ * - `mcpClientId` field absent: the axe server deployment predates the
37
+ * field entirely. Surfaces as "needs upgrading".
38
+ * - `mcpClientId` is `null`: the axe server version supports the field
39
+ * but the operator has not configured `KEYCLOAK_MCP_PUBLIC_CLIENT_ID`.
40
+ * Surfaces as "ask the operator to configure".
41
+ * - any non-empty string: returned as-is.
42
+ *
43
+ * Other failure modes (unreachable, non-2xx, malformed JSON, missing
44
+ * `url` / `realm`) all map to `DISCOVERY_FAILED` with a descriptive
45
+ * message. The caller is expected to surface these errors verbatim.
46
+ */
47
+ export declare function discoverSSOConfig(serverURL: string, options?: DiscoverSSOConfigOptions): Promise<SSOConfig>;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverSSOConfig = discoverSSOConfig;
4
+ const errors_1 = require("./errors");
5
+ const predicates_1 = require("./predicates");
6
+ const userAgent_1 = require("../userAgent");
7
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
8
+ function assertSecureServerURL(serverURL, allowInsecure) {
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(serverURL);
12
+ }
13
+ catch {
14
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server URL is not a valid URL: ${serverURL}`);
15
+ }
16
+ if (parsed.protocol === "https:")
17
+ return parsed;
18
+ if (parsed.protocol === "http:") {
19
+ const hostname = parsed.host.toLowerCase().replace(/:\d+$/, "");
20
+ if (LOOPBACK_HOSTS.has(hostname))
21
+ return parsed;
22
+ if (allowInsecure)
23
+ return parsed;
24
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Refusing to use axe server URL over http:// against non-loopback host ${parsed.host}. Use https:// or pass --allow-insecure-issuer to override (only do this on trusted networks).`);
25
+ }
26
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Unsupported axe server URL scheme '${parsed.protocol}'; expected https: or http: (loopback only).`);
27
+ }
28
+ function buildSSOConfigURL(parsed) {
29
+ const copy = new URL(parsed.toString());
30
+ copy.search = "";
31
+ copy.hash = "";
32
+ copy.pathname = `${copy.pathname.replace(/\/+$/, "")}/api/sso-config`;
33
+ return copy.toString();
34
+ }
35
+ /**
36
+ * Fetches and parses the axe server's `/api/sso-config` discovery
37
+ * endpoint. Used by `axe-auth login` to derive the OAuth issuer URL,
38
+ * realm, and CLI-specific client ID from the axe server URL the user
39
+ * supplied (or the SaaS prod default), so users no longer have to know
40
+ * the underlying Keycloak coordinates.
41
+ *
42
+ * Distinguishes three failure shapes for the operator-relevant cases:
43
+ *
44
+ * - `mcpClientId` field absent: the axe server deployment predates the
45
+ * field entirely. Surfaces as "needs upgrading".
46
+ * - `mcpClientId` is `null`: the axe server version supports the field
47
+ * but the operator has not configured `KEYCLOAK_MCP_PUBLIC_CLIENT_ID`.
48
+ * Surfaces as "ask the operator to configure".
49
+ * - any non-empty string: returned as-is.
50
+ *
51
+ * Other failure modes (unreachable, non-2xx, malformed JSON, missing
52
+ * `url` / `realm`) all map to `DISCOVERY_FAILED` with a descriptive
53
+ * message. The caller is expected to surface these errors verbatim.
54
+ */
55
+ async function discoverSSOConfig(serverURL, options = {}) {
56
+ const allowInsecure = options.allowInsecure ?? false;
57
+ const parsed = assertSecureServerURL(serverURL, allowInsecure);
58
+ const url = buildSSOConfigURL(parsed);
59
+ let response;
60
+ try {
61
+ response = await fetch(url, {
62
+ headers: { "User-Agent": userAgent_1.USER_AGENT },
63
+ signal: options.signal,
64
+ });
65
+ }
66
+ catch (cause) {
67
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Could not reach the axe server at ${url}. Check the --server URL and your network connection.`, { cause });
68
+ }
69
+ if (!response.ok) {
70
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${url} responded with HTTP ${response.status}. Check the --server URL.`);
71
+ }
72
+ let body;
73
+ try {
74
+ body = await response.json();
75
+ }
76
+ catch (cause) {
77
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${url} did not return valid JSON.`, { cause });
78
+ }
79
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
80
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${url} returned a non-object response body.`);
81
+ }
82
+ const raw = body;
83
+ const missing = [];
84
+ if (!(0, predicates_1.isNonEmptyString)(raw.url))
85
+ missing.push("url");
86
+ if (!(0, predicates_1.isNonEmptyString)(raw.realm))
87
+ missing.push("realm");
88
+ if (missing.length > 0) {
89
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `${url} is missing required field(s): ${missing.join(", ")}`);
90
+ }
91
+ if (!("mcpClientId" in raw)) {
92
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${serverURL} does not advertise OAuth-based MCP authentication. The deployment may need to be upgraded to a version that supports the axe-auth CLI.`);
93
+ }
94
+ if (raw.mcpClientId === null) {
95
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${serverURL} has not been configured for OAuth-based MCP authentication. Ask your operator to set the KEYCLOAK_MCP_PUBLIC_CLIENT_ID environment variable on the axe server.`);
96
+ }
97
+ if (!(0, predicates_1.isNonEmptyString)(raw.mcpClientId)) {
98
+ throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `axe server at ${serverURL} returned a malformed mcpClientId (expected a non-empty string).`);
99
+ }
100
+ return {
101
+ url: raw.url,
102
+ realm: raw.realm,
103
+ mcpClientId: raw.mcpClientId,
104
+ };
105
+ }
@@ -42,7 +42,9 @@ export type OAuthFlowErrorCode =
42
42
  /** System keychain is unavailable (e.g. no D-Bus secret service on Linux). */
43
43
  | "KEYRING_UNAVAILABLE"
44
44
  /** Authorization endpoint returned by discovery cannot be used (e.g. already carries an OAuth-required param). Server misconfiguration. */
45
- | "INVALID_AUTHORIZATION_ENDPOINT";
45
+ | "INVALID_AUTHORIZATION_ENDPOINT"
46
+ /** No usable stored credentials; the user needs to run `login` to re-authenticate. Covers empty / corrupt / version-mismatched store and refresh tokens the authorization server has revoked. */
47
+ | "NOT_AUTHENTICATED";
46
48
  /** Options for `OAuthFlowError`. */
47
49
  export interface OAuthFlowErrorOptions {
48
50
  /** Structured metadata for callers that want to surface specific fields. */
@@ -0,0 +1,89 @@
1
+ import { type LoadResult, type TokenStore } from "./tokenStore";
2
+ /** Options for `getValidAccessToken`. */
3
+ export interface GetValidAccessTokenOptions {
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
+ */
10
+ issuerURL: string;
11
+ /**
12
+ * OAuth client identifier. Must match the stored entry's
13
+ * `clientId`; see the note on `issuerURL` for the mismatch
14
+ * behavior.
15
+ */
16
+ clientId: string;
17
+ /**
18
+ * How close to expiry we start preemptively refreshing, in
19
+ * milliseconds. Defaults to 60_000 (60s). The buffer gives headroom
20
+ * between our "still fresh enough" check and the server's view of
21
+ * expiry (which may differ by a few seconds of clock skew) and
22
+ * prevents a token from expiring mid-request after we hand it out.
23
+ *
24
+ * Assumes the access-token TTL is much larger than the buffer. With
25
+ * TTLs ≤ `expiryBufferMs`, every call will trigger a refresh.
26
+ */
27
+ expiryBufferMs?: number;
28
+ /**
29
+ * Override for the token store. Defaults to a fresh
30
+ * `KeyringTokenStore()` (single keychain entry per machine).
31
+ */
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;
43
+ /** Aborts discovery + the refresh POST when fired. */
44
+ signal?: AbortSignal;
45
+ /**
46
+ * Forwarded to discovery. Loopback issuers are always permitted
47
+ * over http; this flag is the opt-in for non-loopback http.
48
+ */
49
+ allowInsecureIssuer?: boolean;
50
+ /**
51
+ * Called for soft warnings that are not errors but warrant user
52
+ * attention (e.g. a fresh `TokenSet` could not be written to the
53
+ * keychain, stranding the rotated refresh token — see the hazard
54
+ * note in the body of `getValidAccessToken`). The default prints
55
+ * to stderr only when stderr is a TTY. Pass a custom handler to
56
+ * route warnings through your own UI, or `() => {}` to suppress.
57
+ */
58
+ onWarning?: (message: string) => void;
59
+ /** Source of `now`. Defaults to `Date.now`. Injected for test determinism. */
60
+ now?: () => number;
61
+ }
62
+ /**
63
+ * Returns a currently-valid access token string for the given issuer,
64
+ * refreshing via the stored refresh token if the cached access token
65
+ * is within `expiryBufferMs` of expiring (or already expired).
66
+ *
67
+ * Throws `OAuthFlowError("NOT_AUTHENTICATED", ...)` when the user
68
+ * must re-run `axe-auth login` — covers an empty / corrupt /
69
+ * version-mismatched store, an expired access token with no refresh
70
+ * token to rotate with, and a refresh attempt rejected with
71
+ * `invalid_grant` (which also clears the stored tokens).
72
+ *
73
+ * Throws `OAuthFlowError("TOKEN_EXCHANGE_FAILED", ...)` for transient
74
+ * failures during refresh (network errors, 5xx, malformed responses)
75
+ * and leaves the stored tokens intact so a retry is possible.
76
+ *
77
+ * Throws `OAuthFlowError("DISCOVERY_FAILED", ...)` when the issuer
78
+ * URL cannot be reached or parsed at refresh time.
79
+ *
80
+ * **Concurrency note.** Not safe against parallel invocations for
81
+ * the same issuer. Keycloak rotates refresh tokens by default; if
82
+ * two parallel calls both land on the refresh path, only one
83
+ * winner's rotated refresh token will be persisted and the loser's
84
+ * rotated token is stranded. The intended consumer is the
85
+ * `axe-auth token` CLI (a one-shot), so this is fine in context;
86
+ * per-request callers should wrap in an in-flight-Promise singleton
87
+ * keyed by issuer.
88
+ */
89
+ export declare function getValidAccessToken(options: GetValidAccessTokenOptions): Promise<string>;