@deque/axe-auth 1.1.0-next.759bd5c5 → 1.1.0-next.7b8e88e6
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 +42 -0
- package/dist/cli/commonArgs.d.ts +53 -37
- package/dist/cli/commonArgs.help.d.ts +1 -1
- package/dist/cli/commonArgs.help.js +12 -11
- package/dist/cli/commonArgs.js +37 -66
- package/dist/cli/errors.d.ts +0 -10
- package/dist/cli/errors.js +1 -16
- package/dist/cli/testUtils.js +3 -3
- package/dist/cli/types.d.ts +8 -11
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.help.d.ts +1 -1
- package/dist/commands/login.help.js +11 -5
- package/dist/commands/login.js +38 -14
- 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 -15
- 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 +10 -22
- package/dist/index.js +23 -51
- package/dist/oauth/authorize.d.ts +7 -0
- package/dist/oauth/authorize.js +2 -1
- package/dist/oauth/discoverOIDC.js +31 -1
- package/dist/oauth/discoverSSOConfig.d.ts +47 -0
- package/dist/oauth/discoverSSOConfig.js +105 -0
- package/dist/oauth/getValidAccessToken.js +1 -0
- package/dist/oauth/refreshTokens.js +2 -0
- package/dist/oauth/revokeToken.js +5 -1
- package/dist/oauth/tokenExchange.js +2 -0
- package/dist/oauth/tokenStore.d.ts +5 -0
- package/dist/oauth/tokenStore.js +5 -1
- 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 +4 -2
package/dist/commands/login.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
const ts_dedent_1 = require("ts-dedent");
|
|
7
|
+
const remove_trailing_slash_1 = __importDefault(require("remove-trailing-slash"));
|
|
4
8
|
const authorize_1 = require("../oauth/authorize");
|
|
9
|
+
const discoverSSOConfig_1 = require("../oauth/discoverSSOConfig");
|
|
5
10
|
const errors_1 = require("../oauth/errors");
|
|
6
11
|
const tokenStore_1 = require("../oauth/tokenStore");
|
|
7
12
|
const confirm_1 = require("../cli/confirm");
|
|
@@ -19,6 +24,7 @@ const loginCommand = {
|
|
|
19
24
|
async run(args, deps) {
|
|
20
25
|
const isInteractive = deps.isInteractive ?? Boolean(deps.stdin.isTTY);
|
|
21
26
|
const authorizeFn = deps.authorize ?? authorize_1.authorize;
|
|
27
|
+
const discoverFn = deps.discoverSSOConfig ?? discoverSSOConfig_1.discoverSSOConfig;
|
|
22
28
|
const confirmFn = deps.confirm ??
|
|
23
29
|
((prompt) => (0, confirm_1.confirm)({
|
|
24
30
|
question: prompt,
|
|
@@ -26,9 +32,27 @@ const loginCommand = {
|
|
|
26
32
|
output: deps.stderr,
|
|
27
33
|
}));
|
|
28
34
|
const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
|
|
35
|
+
// 1. Ask the axe server where its Keycloak lives and which client to use.
|
|
36
|
+
// This is the only step that hits the axe server directly; from here on
|
|
37
|
+
// the CLI talks to Keycloak using the discovered coordinates.
|
|
38
|
+
let ssoConfig;
|
|
39
|
+
try {
|
|
40
|
+
ssoConfig = await discoverFn(args.walnutURL, {
|
|
41
|
+
allowInsecure: args.allowInsecureIssuer,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof errors_1.OAuthFlowError) {
|
|
46
|
+
throw new errors_2.CLIError("OAUTH_FAILED", err.message);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
const issuerURL = `${(0, remove_trailing_slash_1.default)(ssoConfig.url)}/auth/realms/${ssoConfig.realm}`;
|
|
51
|
+
const clientId = ssoConfig.mcpClientId;
|
|
52
|
+
// 2. Re-auth confirmation. Same UX as the previous flag-driven
|
|
53
|
+
// flow, but the comparison is against the *discovered* issuer
|
|
54
|
+
// + client, not user-supplied flags.
|
|
29
55
|
if (!args.force) {
|
|
30
|
-
// Prefer the entry the dispatcher already loaded; only fall
|
|
31
|
-
// back to a fresh read when there isn't one (test path).
|
|
32
56
|
const stored = deps.loadedEntry ?? (await tokenStore.load());
|
|
33
57
|
if (!stored.ok && stored.reason !== "empty") {
|
|
34
58
|
// Existing entry is unreadable (corrupt or stored under a
|
|
@@ -38,26 +62,22 @@ const loginCommand = {
|
|
|
38
62
|
deps.stderr.write(`axe-auth: replacing unreadable stored credentials (${stored.reason}).\n`);
|
|
39
63
|
}
|
|
40
64
|
if (stored.ok) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Both warrant confirmation; the message text differs so the
|
|
44
|
-
// user understands what's about to be lost.
|
|
45
|
-
const sameIssuer = stored.entry.issuerURL === args.issuerURL &&
|
|
46
|
-
stored.entry.clientId === args.clientId;
|
|
65
|
+
const sameIssuer = stored.entry.issuerURL === issuerURL &&
|
|
66
|
+
stored.entry.clientId === clientId;
|
|
47
67
|
if (!isInteractive) {
|
|
48
68
|
throw new errors_2.CLIError("ALREADY_AUTHENTICATED", sameIssuer
|
|
49
|
-
? `Already authenticated against ${
|
|
69
|
+
? `Already authenticated against ${issuerURL}. Re-run with --force to override.`
|
|
50
70
|
: (0, ts_dedent_1.dedent) `
|
|
51
71
|
Currently authenticated against ${stored.entry.issuerURL}.
|
|
52
|
-
Logging in to ${
|
|
72
|
+
Logging in to ${issuerURL} would replace those tokens.
|
|
53
73
|
Re-run with --force to override.
|
|
54
74
|
`);
|
|
55
75
|
}
|
|
56
76
|
const prompt = sameIssuer
|
|
57
|
-
? `Already authenticated against ${
|
|
77
|
+
? `Already authenticated against ${issuerURL}. Re-authenticate? [y/N]`
|
|
58
78
|
: (0, ts_dedent_1.dedent) `
|
|
59
79
|
Currently authenticated against ${stored.entry.issuerURL} (client ${stored.entry.clientId}).
|
|
60
|
-
Logging in to ${
|
|
80
|
+
Logging in to ${issuerURL} (client ${clientId}) will replace those tokens.
|
|
61
81
|
Continue? [y/N]
|
|
62
82
|
`;
|
|
63
83
|
const ok = await confirmFn(prompt);
|
|
@@ -66,10 +86,14 @@ const loginCommand = {
|
|
|
66
86
|
}
|
|
67
87
|
}
|
|
68
88
|
}
|
|
89
|
+
// 3. Drive the OAuth flow. The originating axe server URL rides along
|
|
90
|
+
// so it lands in the StoredEntry and future verbs (re-discovery,
|
|
91
|
+
// revoke) can operate without user-supplied flags.
|
|
69
92
|
try {
|
|
70
93
|
await authorizeFn({
|
|
71
|
-
issuerURL
|
|
72
|
-
clientId
|
|
94
|
+
issuerURL,
|
|
95
|
+
clientId,
|
|
96
|
+
walnutURL: args.walnutURL,
|
|
73
97
|
scopes: ["offline_access"],
|
|
74
98
|
allowInsecureIssuer: args.allowInsecureIssuer,
|
|
75
99
|
tokenStore,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Help text for `axe-auth logout --help`. */
|
|
2
|
-
export declare const HELP_LOGOUT = "axe-auth logout\n\nRevoke the stored refresh token server-side (best-effort) and clear\nthe local OS keychain entry.\n\nUsage:\n axe-auth logout\n\n --server / --
|
|
2
|
+
export declare const HELP_LOGOUT = "axe-auth logout\n\nRevoke the stored refresh token server-side (best-effort) and clear\nthe local OS keychain entry.\n\nUsage:\n axe-auth logout\n\n Operates exclusively on the keychain entry written by\n `axe-auth login`. The --server / --allow-insecure-issuer /\n --no-allow-insecure-issuer flags are accepted for parity with\n other commands but ignored; revocation uses the issuer, client,\n and insecure-issuer policy persisted alongside the tokens at login.\n\nOptions:\n --server <url> axe server URL. Used by `login` to fetch\n /api/sso-config and derive the OAuth\n coordinates. Falls back to AXE_SERVER_URL,\n then to https://axe.deque.com (SaaS prod).\n --allow-insecure-issuer Permit non-loopback http URLs (default is\n https only; loopback http is always\n allowed). Applies to `login` only;\n `token` and `logout` use the policy\n persisted at login.\n --no-allow-insecure-issuer\n Force allowInsecureIssuer=false for the new\n `login` (and the entry it persists).\n Ignored by `token` and `logout`.\n Mutually exclusive with\n --allow-insecure-issuer.\n -h, --help Show this help.\n\nBehavior:\n - If no tokens are stored, prints a note and exits 0 (logout is\n idempotent).\n - If the stored blob is unreadable (corrupt or from an\n unsupported schema version), the local entry is cleared without\n attempting server-side revocation, and a warning is printed.\n - Otherwise: discovers the revocation endpoint at the stored\n issuer, POSTs the stored refresh token per RFC 7009, then\n clears the local keychain entry. Revocation failures or a\n missing revocation endpoint are warned about; the local clear\n still runs.\n\nExit codes:\n 0 Tokens cleared (server-side revocation may have failed; see\n stderr for warnings).\n 2 Local clear failure.";
|
|
@@ -11,10 +11,11 @@ the local OS keychain entry.
|
|
|
11
11
|
Usage:
|
|
12
12
|
axe-auth logout
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
Operates exclusively on the keychain entry written by
|
|
15
|
+
\`axe-auth login\`. The --server / --allow-insecure-issuer /
|
|
16
|
+
--no-allow-insecure-issuer flags are accepted for parity with
|
|
17
|
+
other commands but ignored; revocation uses the issuer, client,
|
|
18
|
+
and insecure-issuer policy persisted alongside the tokens at login.
|
|
18
19
|
|
|
19
20
|
Options:
|
|
20
21
|
${commonArgs_help_1.HELP_COMMON_OPTIONS}
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
const ts_dedent_1 = require("ts-dedent");
|
|
4
3
|
const discoverOIDC_1 = require("../oauth/discoverOIDC");
|
|
5
4
|
const revokeToken_1 = require("../oauth/revokeToken");
|
|
6
5
|
const tokenStore_1 = require("../oauth/tokenStore");
|
|
@@ -13,7 +12,7 @@ const logoutCommand = {
|
|
|
13
12
|
helpText: logout_help_1.HELP_LOGOUT,
|
|
14
13
|
options: {},
|
|
15
14
|
requiresConfig: false,
|
|
16
|
-
async run(
|
|
15
|
+
async run(_args, deps) {
|
|
17
16
|
const discoverFn = deps.discoverOIDC ?? discoverOIDC_1.discoverOIDC;
|
|
18
17
|
const revokeFn = deps.revokeRefreshToken ?? revokeToken_1.revokeRefreshToken;
|
|
19
18
|
const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
|
|
@@ -34,20 +33,7 @@ const logoutCommand = {
|
|
|
34
33
|
deps.stdout.write("✓ Logged out.\n");
|
|
35
34
|
return;
|
|
36
35
|
}
|
|
37
|
-
// Use the stored entry's metadata for revocation, not args. Under
|
|
38
|
-
// single-entry keying there's only one set of credentials in the
|
|
39
|
-
// keychain; trying to revoke A's refresh token at B's revocation
|
|
40
|
-
// endpoint (because the user passed --server B) would just fail
|
|
41
|
-
// and leave A's token valid server-side.
|
|
42
36
|
const { issuerURL, clientId, allowInsecureIssuer } = loaded.entry;
|
|
43
|
-
if (issuerURL !== args.issuerURL ||
|
|
44
|
-
clientId !== args.clientId ||
|
|
45
|
-
allowInsecureIssuer !== args.allowInsecureIssuer) {
|
|
46
|
-
deps.stderr.write((0, ts_dedent_1.dedent) `
|
|
47
|
-
axe-auth: ignoring --server / --realm / --client-id / --allow-insecure-issuer flags.
|
|
48
|
-
Logging out of stored issuer ${issuerURL} (client ${clientId}) instead.
|
|
49
|
-
` + "\n");
|
|
50
|
-
}
|
|
51
37
|
// Best-effort server-side revocation. Any failure here is a
|
|
52
38
|
// warning; the local clear runs unconditionally below so the
|
|
53
39
|
// user is "logged out" from this machine regardless.
|
package/dist/commands/token.d.ts
CHANGED
|
@@ -6,12 +6,7 @@ import type { CommandDeps } from "../cli/types";
|
|
|
6
6
|
export interface TokenDeps extends CommandDeps {
|
|
7
7
|
/** Override `getValidAccessToken` so tests don't touch the keychain or network. */
|
|
8
8
|
getToken?: typeof getValidAccessToken;
|
|
9
|
-
/**
|
|
10
|
-
* Override the token store. Defaults to a fresh
|
|
11
|
-
* `KeyringTokenStore()`. Used to detect when caller-supplied flags
|
|
12
|
-
* mismatch the stored entry's issuer/client so the warning fires
|
|
13
|
-
* with the same store instance the eventual `getToken` call sees.
|
|
14
|
-
*/
|
|
9
|
+
/** Override the token store. Defaults to a fresh `KeyringTokenStore()`. */
|
|
15
10
|
tokenStore?: TokenStore;
|
|
16
11
|
}
|
|
17
12
|
/** `axe-auth token` — print a currently-valid access token to stdout. */
|
|
@@ -21,6 +16,6 @@ declare const tokenCommand: {
|
|
|
21
16
|
helpText: string;
|
|
22
17
|
options: {};
|
|
23
18
|
requiresConfig: false;
|
|
24
|
-
run(
|
|
19
|
+
run(_args: CommonArgs, deps: TokenDeps): Promise<void>;
|
|
25
20
|
};
|
|
26
21
|
export default tokenCommand;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Help text for `axe-auth token --help`. */
|
|
2
|
-
export declare const HELP_TOKEN = "axe-auth token\n\nPrint a currently-valid OAuth access token to stdout, refreshing\nsilently against the stored refresh token if needed.\n\nUsage:\n axe-auth token\n\n --server / --
|
|
2
|
+
export declare const HELP_TOKEN = "axe-auth token\n\nPrint a currently-valid OAuth access token to stdout, refreshing\nsilently against the stored refresh token if needed.\n\nUsage:\n axe-auth token\n\n Operates exclusively on the keychain entry written by\n `axe-auth login`. The --server / --allow-insecure-issuer /\n --no-allow-insecure-issuer flags are accepted for parity with\n other commands but ignored here; refresh uses the issuer, client,\n and insecure-issuer policy persisted alongside the tokens at login.\n\nOptions:\n --server <url> axe server URL. Used by `login` to fetch\n /api/sso-config and derive the OAuth\n coordinates. Falls back to AXE_SERVER_URL,\n then to https://axe.deque.com (SaaS prod).\n --allow-insecure-issuer Permit non-loopback http URLs (default is\n https only; loopback http is always\n allowed). Applies to `login` only;\n `token` and `logout` use the policy\n persisted at login.\n --no-allow-insecure-issuer\n Force allowInsecureIssuer=false for the new\n `login` (and the entry it persists).\n Ignored by `token` and `logout`.\n Mutually exclusive with\n --allow-insecure-issuer.\n -h, --help Show this help.\n\nOutput:\n The access token is written to stdout with a trailing newline so\n shell substitution (`$(axe-auth token)`) works cleanly. Nothing\n else is written to stdout.\n\nSecurity note:\n Using `$(axe-auth token)` in a shell command exposes the access\n token briefly in the system process-list (observable via `ps` on\n POSIX, Task Manager on Windows). OAuth access tokens are\n short-lived (typically minutes), which limits this exposure\n compared to a static API key, but you should prefer streaming the\n token into a file or env var on platforms where process listings\n are sensitive.\n\nExit codes:\n 0 Success; the token was printed.\n 1 Not authenticated; run `axe-auth login` to re-authenticate.\n 2 Configuration error or transient failure (network, server,\n keychain).";
|
|
@@ -11,11 +11,11 @@ silently against the stored refresh token if needed.
|
|
|
11
11
|
Usage:
|
|
12
12
|
axe-auth token
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Operates exclusively on the keychain entry written by
|
|
15
|
+
\`axe-auth login\`. The --server / --allow-insecure-issuer /
|
|
16
|
+
--no-allow-insecure-issuer flags are accepted for parity with
|
|
17
|
+
other commands but ignored here; refresh uses the issuer, client,
|
|
18
|
+
and insecure-issuer policy persisted alongside the tokens at login.
|
|
19
19
|
|
|
20
20
|
Options:
|
|
21
21
|
${commonArgs_help_1.HELP_COMMON_OPTIONS}
|
package/dist/commands/token.js
CHANGED
|
@@ -12,34 +12,22 @@ const tokenCommand = {
|
|
|
12
12
|
helpText: token_help_1.HELP_TOKEN,
|
|
13
13
|
options: {},
|
|
14
14
|
requiresConfig: false,
|
|
15
|
-
async run(
|
|
15
|
+
async run(_args, deps) {
|
|
16
16
|
const getToken = deps.getToken ?? getValidAccessToken_1.getValidAccessToken;
|
|
17
17
|
const tokenStore = deps.tokenStore ?? new tokenStore_1.KeyringTokenStore();
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// Stored entry is the only source of truth: single-entry-per-machine
|
|
19
|
+
// model, the blob carries the issuer/client coordinates the tokens
|
|
20
|
+
// were minted against, and there is no longer a flag to override
|
|
21
|
+
// them with.
|
|
20
22
|
const loaded = deps.loadedEntry ?? (await tokenStore.load());
|
|
21
|
-
// The stored entry is the source of truth for issuer/client
|
|
22
|
-
// (single-entry-per-machine model). Refreshing tokens against a
|
|
23
|
-
// different issuer than the one they were minted at would just
|
|
24
|
-
// get them rejected, so args.* are advisory at most: we warn on
|
|
25
|
-
// mismatch and route the refresh to the stored coordinates.
|
|
26
|
-
let target = args;
|
|
27
|
-
if (loaded.ok) {
|
|
28
|
-
if (loaded.entry.issuerURL !== args.issuerURL ||
|
|
29
|
-
loaded.entry.clientId !== args.clientId ||
|
|
30
|
-
loaded.entry.allowInsecureIssuer !== args.allowInsecureIssuer) {
|
|
31
|
-
deps.stderr.write(`axe-auth: ignoring --server / --realm / --client-id / --allow-insecure-issuer flags; using stored issuer ${loaded.entry.issuerURL} (client ${loaded.entry.clientId}).\n`);
|
|
32
|
-
}
|
|
33
|
-
target = {
|
|
34
|
-
issuerURL: loaded.entry.issuerURL,
|
|
35
|
-
clientId: loaded.entry.clientId,
|
|
36
|
-
allowInsecureIssuer: loaded.entry.allowInsecureIssuer,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
23
|
let token;
|
|
40
24
|
try {
|
|
41
25
|
token = await getToken({
|
|
42
|
-
|
|
26
|
+
issuerURL: loaded.ok ? loaded.entry.issuerURL : "",
|
|
27
|
+
clientId: loaded.ok ? loaded.entry.clientId : "",
|
|
28
|
+
allowInsecureIssuer: loaded.ok
|
|
29
|
+
? loaded.entry.allowInsecureIssuer
|
|
30
|
+
: false,
|
|
43
31
|
tokenStore,
|
|
44
32
|
loadedEntry: loaded,
|
|
45
33
|
});
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
7
7
|
const node_fs_1 = require("node:fs");
|
|
8
8
|
const node_path_1 = require("node:path");
|
|
9
9
|
const node_util_1 = require("node:util");
|
|
10
|
-
const ts_dedent_1 = require("ts-dedent");
|
|
11
10
|
const commonArgs_1 = require("./cli/commonArgs");
|
|
12
11
|
const tokenStore_1 = require("./oauth/tokenStore");
|
|
13
12
|
const login_1 = __importDefault(require("./commands/login"));
|
|
@@ -83,64 +82,37 @@ async function dispatch(argv) {
|
|
|
83
82
|
process.stdout.write(`${command.helpText}\n`);
|
|
84
83
|
return 0;
|
|
85
84
|
}
|
|
86
|
-
// Best-effort load of the stored
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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.
|
|
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.
|
|
97
93
|
let defaults = null;
|
|
98
|
-
let defaultLoadError = null;
|
|
99
94
|
let loadedEntry;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
issuerURL: loadedEntry.entry.issuerURL,
|
|
108
|
-
clientId: loadedEntry.entry.clientId,
|
|
109
|
-
allowInsecureIssuer: loadedEntry.entry.allowInsecureIssuer,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
defaultLoadError = err;
|
|
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
|
+
};
|
|
115
102
|
}
|
|
116
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
|
+
}
|
|
117
109
|
let common;
|
|
118
110
|
try {
|
|
119
|
-
common = (0, commonArgs_1.parseCommonArgs)(
|
|
111
|
+
common = (0, commonArgs_1.parseCommonArgs)(parsed.values, process.env, defaults);
|
|
120
112
|
}
|
|
121
113
|
catch (err) {
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
}
|
|
114
|
+
process.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
|
|
115
|
+
return 2;
|
|
144
116
|
}
|
|
145
117
|
const deps = {
|
|
146
118
|
stdin: process.stdin,
|
|
@@ -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
|
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(), 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
|
|
@@ -109,6 +109,7 @@ async function authorize(options) {
|
|
|
109
109
|
issuerURL,
|
|
110
110
|
clientId,
|
|
111
111
|
allowInsecureIssuer: allowInsecureIssuer ?? false,
|
|
112
|
+
walnutURL,
|
|
112
113
|
});
|
|
113
114
|
return tokens;
|
|
114
115
|
}
|
|
@@ -4,6 +4,7 @@ exports.discoverOIDC = discoverOIDC;
|
|
|
4
4
|
const errors_1 = require("./errors");
|
|
5
5
|
const issuerURL_1 = require("./issuerURL");
|
|
6
6
|
const predicates_1 = require("./predicates");
|
|
7
|
+
const userAgent_1 = require("../userAgent");
|
|
7
8
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
9
|
function optionalString(v) {
|
|
9
10
|
return (0, predicates_1.isNonEmptyString)(v) ? v : undefined;
|
|
@@ -69,6 +70,31 @@ function parseConfiguration(body, url) {
|
|
|
69
70
|
endSessionEndpoint: optionalString(body.end_session_endpoint),
|
|
70
71
|
};
|
|
71
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
|
+
}
|
|
72
98
|
/**
|
|
73
99
|
* Fetches and parses the OpenID Connect discovery document for a given
|
|
74
100
|
* issuer. Fails fast (no retry) so the caller does not open a browser
|
|
@@ -98,7 +124,10 @@ async function discoverOIDC(issuerURL, options = {}) {
|
|
|
98
124
|
const url = buildDiscoveryURL(issuerURL);
|
|
99
125
|
let response;
|
|
100
126
|
try {
|
|
101
|
-
response = await fetch(url, {
|
|
127
|
+
response = await fetch(url, {
|
|
128
|
+
headers: { "User-Agent": userAgent_1.USER_AGENT },
|
|
129
|
+
signal: options.signal,
|
|
130
|
+
});
|
|
102
131
|
}
|
|
103
132
|
catch (cause) {
|
|
104
133
|
throw new errors_1.OAuthFlowError("DISCOVERY_FAILED", `Could not reach the authentication server at ${url}. Check the URL and your network connection.`, { cause });
|
|
@@ -139,5 +168,6 @@ async function discoverOIDC(issuerURL, options = {}) {
|
|
|
139
168
|
if (config.endSessionEndpoint) {
|
|
140
169
|
assertSecureURL(config.endSessionEndpoint, "end_session_endpoint", allowInsecure);
|
|
141
170
|
}
|
|
171
|
+
assertPKCESupport(body, url);
|
|
142
172
|
return config;
|
|
143
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
|
+
}
|