@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.
- package/README.md +58 -17
- package/credits.json +42 -0
- package/dist/cli/commonArgs.d.ts +82 -0
- package/dist/cli/commonArgs.help.d.ts +2 -0
- package/dist/cli/commonArgs.help.js +20 -0
- package/dist/cli/commonArgs.js +90 -0
- package/dist/cli/confirm.d.ts +17 -0
- package/dist/cli/confirm.js +56 -0
- package/dist/cli/errors.d.ts +20 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/testUtils.d.ts +52 -0
- package/dist/cli/testUtils.js +100 -0
- package/dist/cli/types.d.ts +79 -0
- package/dist/cli/types.js +2 -0
- package/dist/commands/login.d.ts +44 -0
- package/dist/commands/login.help.d.ts +2 -0
- package/dist/commands/login.help.js +41 -0
- package/dist/commands/login.js +117 -0
- package/dist/commands/logout.d.ts +24 -0
- package/dist/commands/logout.help.d.ts +2 -0
- package/dist/commands/logout.help.js +38 -0
- package/dist/commands/logout.js +70 -0
- package/dist/commands/token.d.ts +21 -0
- package/dist/commands/token.help.d.ts +2 -0
- package/dist/commands/token.help.js +41 -0
- package/dist/commands/token.js +44 -0
- package/dist/index.js +114 -22
- package/dist/oauth/authorize.d.ts +11 -3
- package/dist/oauth/authorize.js +9 -4
- package/dist/oauth/discoverOIDC.js +36 -8
- package/dist/oauth/discoverSSOConfig.d.ts +47 -0
- package/dist/oauth/discoverSSOConfig.js +105 -0
- package/dist/oauth/errors.d.ts +3 -1
- package/dist/oauth/getValidAccessToken.d.ts +89 -0
- package/dist/oauth/getValidAccessToken.js +140 -0
- package/dist/oauth/index.d.ts +7 -2
- package/dist/oauth/index.js +5 -1
- package/dist/oauth/keyringBinding.d.ts +22 -0
- package/dist/oauth/keyringBinding.js +41 -0
- package/dist/oauth/predicates.d.ts +7 -0
- package/dist/oauth/predicates.js +15 -0
- package/dist/oauth/refreshTokens.d.ts +30 -0
- package/dist/oauth/refreshTokens.js +63 -0
- package/dist/oauth/revokeToken.d.ts +28 -0
- package/dist/oauth/revokeToken.js +63 -0
- package/dist/oauth/testUtils.d.ts +35 -0
- package/dist/oauth/testUtils.js +61 -0
- package/dist/oauth/tokenExchange.d.ts +1 -24
- package/dist/oauth/tokenExchange.js +5 -97
- package/dist/oauth/tokenResponse.d.ts +54 -0
- package/dist/oauth/tokenResponse.js +121 -0
- package/dist/oauth/tokenStore.d.ts +62 -24
- package/dist/oauth/tokenStore.js +108 -82
- 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 +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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
66
|
+
parsed = (0, node_util_1.parseArgs)({
|
|
67
|
+
args: rest,
|
|
22
68
|
options: {
|
|
23
|
-
|
|
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
|
-
|
|
31
|
-
return
|
|
78
|
+
process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
|
|
79
|
+
return 2;
|
|
32
80
|
}
|
|
33
|
-
if (values.
|
|
34
|
-
|
|
81
|
+
if (parsed.values.help) {
|
|
82
|
+
process.stdout.write(`${command.helpText}\n`);
|
|
35
83
|
return 0;
|
|
36
84
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
|
|
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 {
|
|
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`
|
|
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. */
|
package/dist/oauth/authorize.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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, {
|
|
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
|
+
}
|
package/dist/oauth/errors.d.ts
CHANGED
|
@@ -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>;
|