@deque/axe-auth 1.4.0-rc.ee4e8048 → 1.5.0-rc.bbbeb999

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 CHANGED
@@ -22,11 +22,12 @@ axe-auth <command> [options]
22
22
 
23
23
  Commands:
24
24
 
25
- | Command | Description |
26
- | -------- | ------------------------------------------------------------------------------- |
27
- | `login` | Open a browser, complete the OAuth flow, persist tokens to the OS keychain. |
25
+ | Command | Description |
26
+ | --- | --- |
27
+ | `login` | Open a browser, complete the OAuth flow, persist tokens to the OS keychain. |
28
28
  | `logout` | Revoke the stored refresh token server-side and clear the local keychain entry. |
29
- | `token` | Print a currently-valid access token to stdout, refreshing silently if needed. |
29
+ | `token` | Print a currently-valid access token to stdout, refreshing silently if needed. |
30
+ | `run` | Launch and supervise the axe MCP server, keeping its OAuth token fresh with no restart. |
30
31
 
31
32
  Run `axe-auth <command> --help` for command-specific options.
32
33
 
@@ -34,11 +35,11 @@ Run `axe-auth <command> --help` for command-specific options.
34
35
 
35
36
  `axe-auth` discovers its OAuth coordinates by calling `<server>/api/sso-config` on the axe server. Users only supply (or default to) the axe server URL — never the underlying Keycloak URL, realm, or client ID.
36
37
 
37
- | Flag | Env var | Notes |
38
- | ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
- | `--server` | `AXE_SERVER_URL` | axe server URL. Defaults to `https://axe.deque.com` (SaaS prod) when neither flag nor env var is set, so SaaS users pass no flags at all. Customers on other deployments override with their own axe server URL. |
40
- | `--allow-insecure-issuer` | — | Permit non-loopback http URLs (default is https only; loopback http is always allowed). Applies to `login` only; `token` and `logout` use the policy persisted at login. |
41
- | `--no-allow-insecure-issuer` | — | Force `allowInsecureIssuer=false` for the new `login` (and the entry it persists). Mutually exclusive with `--allow-insecure-issuer`. `token` and `logout` ignore this flag. |
38
+ | Flag | Env var | Notes |
39
+ | --- | --- | --- |
40
+ | `--server` | `AXE_SERVER_URL` | axe server URL. Defaults to `https://axe.deque.com` (SaaS prod) when neither flag nor env var is set, so SaaS users pass no flags at all. Customers on other deployments override with their own axe server URL. |
41
+ | `--allow-insecure-issuer` | — | Permit non-loopback http URLs (default is https only; loopback http is always allowed). Applies to `login` only; `token` and `logout` use the policy persisted at login. |
42
+ | `--no-allow-insecure-issuer` | — | Force `allowInsecureIssuer=false` for the new `login` (and the entry it persists). Mutually exclusive with `--allow-insecure-issuer`. `token` and `logout` ignore this flag. |
42
43
 
43
44
  `axe-auth` stores one set of credentials per machine. On a successful `login`, the discovered issuer / client / insecure-issuer values are persisted alongside the tokens, so subsequent `axe-auth token` and `axe-auth logout` invocations work flag-free — a typical scripted call is just `$(axe-auth token)`.
44
45
 
@@ -46,12 +47,12 @@ There is no concurrent multi-issuer support. Logging in to a second deployment o
46
47
 
47
48
  ### Exit codes
48
49
 
49
- | Code | Meaning |
50
- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
- | `0` | Success. |
52
- | `1` | Not authenticated: `axe-auth token` with no stored credentials, or stored credentials are unusable (corrupt, version-mismatch, expired without a usable refresh token, or refresh rejected by the server). Branch on this in scripts that need to trigger a `login`. |
53
- | `2` | Usage or runtime error: unknown command, bad flag, missing required configuration, OAuth flow failure, or keychain failure. Details written to stderr. |
54
- | `3` | Cancelled: `axe-auth login` declined at the re-authentication prompt. Distinct from `1` so scripts can tell "needs login" from "user bailed." |
50
+ | Code | Meaning |
51
+ | --- | --- |
52
+ | `0` | Success. |
53
+ | `1` | Not authenticated: `axe-auth token` with no stored credentials, or stored credentials are unusable (corrupt, version-mismatch, expired without a usable refresh token, or refresh rejected by the server). Branch on this in scripts that need to trigger a `login`. |
54
+ | `2` | Usage or runtime error: unknown command, bad flag, missing required configuration, OAuth flow failure, or keychain failure. Details written to stderr. |
55
+ | `3` | Cancelled: `axe-auth login` declined at the re-authentication prompt. Distinct from `1` so scripts can tell "needs login" from "user bailed." |
55
56
 
56
57
  ### Examples
57
58
 
@@ -69,6 +70,33 @@ docker run -e AXE_ACCESS_TOKEN="$(axe-auth token)" axe-mcp-server
69
70
  axe-auth logout
70
71
  ```
71
72
 
73
+ ### Long-running sessions
74
+
75
+ Access tokens are short-lived, so a session that outlives the token's TTL (an agent driving the MCP server for hours, say) would otherwise start seeing auth failures. `axe-auth run` handles this automatically: it launches and supervises the server, then keeps its access token fresh for the whole session with no restart and no manual step. Point your MCP client at `axe-auth run` as the server command; it manages the server process's lifetime like any stdio server. Your refresh token never leaves the machine; only short-lived access tokens are sent to the server.
76
+
77
+ The server just needs token refresh enabled on a port; `run` supplies the token and secret. Under Docker, publish the port on host loopback, forward the auth vars, and bind the listener to `0.0.0.0` inside the container so the published port can reach it:
78
+
79
+ ```sh
80
+ AXE_TOKEN_REFRESH_PORT=9223 npx @deque/axe-auth run -- \
81
+ docker run -i --rm \
82
+ -p 127.0.0.1:9223:9223 \
83
+ -e AXE_ACCESS_TOKEN \
84
+ -e AXE_TOKEN_REFRESH_PORT \
85
+ -e AXE_TOKEN_REFRESH_SECRET \
86
+ -e AXE_TOKEN_REFRESH_HOST=0.0.0.0 \
87
+ dequesystems/axe-mcp-server
88
+ ```
89
+
90
+ `AXE_TOKEN_REFRESH_HOST=0.0.0.0` is required under Docker: a published port forwards to the container's network interface, not its loopback, so the default loopback bind would be unreachable. The `-p 127.0.0.1:9223:9223` publish keeps the endpoint off the host's external interfaces, and the shared secret — not container isolation — is what guards the endpoint itself.
91
+
92
+ Under npm the wrapped process inherits the values directly and the listener stays on loopback:
93
+
94
+ ```sh
95
+ AXE_TOKEN_REFRESH_PORT=9223 npx @deque/axe-auth run -- npx axe-mcp-server
96
+ ```
97
+
98
+ Set the port with `AXE_TOKEN_REFRESH_PORT` or `--port`. `run` generates the shared secret unless you pin one with `AXE_TOKEN_REFRESH_SECRET` / `--secret`. The secret authenticates the push; the server's listener binds loopback by default and never starts without a secret.
99
+
72
100
  ## Architecture
73
101
 
74
102
  See [`docs/architecture.md`](./docs/architecture.md) for the system architecture: components, per-verb data flow, communication security, and persisted data.
package/credits.json CHANGED
@@ -16,6 +16,30 @@
16
16
  "licenseText": "# `@napi-rs/keyring-linux-x64-gnu`\n\nThis is the **x86_64-unknown-linux-gnu** binary for `@napi-rs/keyring`\n",
17
17
  "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/@napi-rs+keyring-linux-x64-gnu@1.3.0/node_modules/@napi-rs/keyring-linux-x64-gnu/README.md"
18
18
  },
19
+ "cross-spawn@7.0.6": {
20
+ "name": "cross-spawn",
21
+ "version": "7.0.6",
22
+ "licenses": "MIT",
23
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/cross-spawn@7.0.6/node_modules/cross-spawn",
24
+ "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n",
25
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/cross-spawn@7.0.6/node_modules/cross-spawn/LICENSE",
26
+ "repository": "https://github.com/moxystudio/node-cross-spawn",
27
+ "publisher": "André Cruz",
28
+ "email": "andre@moxy.studio",
29
+ "copyright": "Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>"
30
+ },
31
+ "path-key@3.1.1": {
32
+ "name": "path-key",
33
+ "version": "3.1.1",
34
+ "licenses": "MIT",
35
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/path-key@3.1.1/node_modules/path-key",
36
+ "licenseText": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n",
37
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/path-key@3.1.1/node_modules/path-key/license",
38
+ "publisher": "Sindre Sorhus",
39
+ "email": "sindresorhus@gmail.com",
40
+ "url": "sindresorhus.com",
41
+ "copyright": "Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)"
42
+ },
19
43
  "remove-trailing-slash@0.1.1": {
20
44
  "name": "remove-trailing-slash",
21
45
  "version": "0.1.1",
@@ -27,6 +51,30 @@
27
51
  "publisher": "Stephen Mathieson",
28
52
  "email": "me@stephenmathieson.com"
29
53
  },
54
+ "shebang-command@2.0.0": {
55
+ "name": "shebang-command",
56
+ "version": "2.0.0",
57
+ "licenses": "MIT",
58
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shebang-command@2.0.0/node_modules/shebang-command",
59
+ "licenseText": "MIT License\n\nCopyright (c) Kevin Mårtensson <kevinmartensson@gmail.com> (github.com/kevva)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n",
60
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shebang-command@2.0.0/node_modules/shebang-command/license",
61
+ "publisher": "Kevin Mårtensson",
62
+ "email": "kevinmartensson@gmail.com",
63
+ "url": "github.com/kevva",
64
+ "copyright": "Copyright (c) Kevin Mårtensson <kevinmartensson@gmail.com> (github.com/kevva)"
65
+ },
66
+ "shebang-regex@3.0.0": {
67
+ "name": "shebang-regex",
68
+ "version": "3.0.0",
69
+ "licenses": "MIT",
70
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shebang-regex@3.0.0/node_modules/shebang-regex",
71
+ "licenseText": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n",
72
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/shebang-regex@3.0.0/node_modules/shebang-regex/license",
73
+ "publisher": "Sindre Sorhus",
74
+ "email": "sindresorhus@gmail.com",
75
+ "url": "sindresorhus.com",
76
+ "copyright": "Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)"
77
+ },
30
78
  "shlex@3.0.0": {
31
79
  "name": "shlex",
32
80
  "version": "3.0.0",
@@ -49,5 +97,31 @@
49
97
  "publisher": "Tamino Martinius",
50
98
  "email": "dev@zaku.eu",
51
99
  "copyright": "Copyright (c) 2018 Tamino Martinius"
100
+ },
101
+ "isexe@2.0.0": {
102
+ "name": "isexe",
103
+ "version": "2.0.0",
104
+ "licenses": "ISC",
105
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/isexe@2.0.0/node_modules/isexe",
106
+ "licenseText": "The ISC License\n\nCopyright (c) Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n",
107
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/isexe@2.0.0/node_modules/isexe/LICENSE",
108
+ "repository": "https://github.com/isaacs/isexe",
109
+ "publisher": "Isaac Z. Schlueter",
110
+ "email": "i@izs.me",
111
+ "url": "http://blog.izs.me/",
112
+ "copyright": "Copyright (c) Isaac Z. Schlueter and Contributors"
113
+ },
114
+ "which@2.0.2": {
115
+ "name": "which",
116
+ "version": "2.0.2",
117
+ "licenses": "ISC",
118
+ "path": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/which@2.0.2/node_modules/which",
119
+ "licenseText": "The ISC License\n\nCopyright (c) Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n",
120
+ "licenseFile": "/home/runner/work/axe-mcp-server/axe-mcp-server/node_modules/.pnpm/which@2.0.2/node_modules/which/LICENSE",
121
+ "repository": "https://github.com/isaacs/node-which",
122
+ "publisher": "Isaac Z. Schlueter",
123
+ "email": "i@izs.me",
124
+ "url": "http://blog.izs.me",
125
+ "copyright": "Copyright (c) Isaac Z. Schlueter and Contributors"
52
126
  }
53
127
  }
@@ -1,5 +1,5 @@
1
1
  /** Discriminator for `CLIError`, used by tests and the dispatcher. */
2
- export type CLIErrorCode = "NOT_AUTHENTICATED" | "USER_CANCELLED" | "ALREADY_AUTHENTICATED" | "OAUTH_FAILED" | "KEYCHAIN_FAILURE";
2
+ export type CLIErrorCode = "NOT_AUTHENTICATED" | "USER_CANCELLED" | "ALREADY_AUTHENTICATED" | "OAUTH_FAILED" | "KEYCHAIN_FAILURE" | "REFRESH_UNREACHABLE";
3
3
  /**
4
4
  * Thrown from a verb's `run` to signal a known failure. The
5
5
  * dispatcher writes `message` to stderr and exits with `exitCode`.
@@ -8,6 +8,7 @@ const EXIT_CODE_BY_ERROR_CODE = {
8
8
  ALREADY_AUTHENTICATED: 2,
9
9
  OAUTH_FAILED: 2,
10
10
  KEYCHAIN_FAILURE: 2,
11
+ REFRESH_UNREACHABLE: 2,
11
12
  };
12
13
  /**
13
14
  * Thrown from a verb's `run` to signal a known failure. The
@@ -0,0 +1,16 @@
1
+ import defaultRunSession from "../run/runSession";
2
+ import type { CommandDeps } from "../cli/types";
3
+ /** Extra dependencies `run` needs beyond the shared CLI deps. */
4
+ export interface RunDeps extends CommandDeps {
5
+ /** Environment source. Defaults to `process.env`. */
6
+ env?: NodeJS.ProcessEnv;
7
+ /** Override the session supervisor (tests). */
8
+ runSession?: typeof defaultRunSession;
9
+ }
10
+ /**
11
+ * `axe-auth run [--port] [--secret] -- <command> [args...]`. Unlike the other
12
+ * verbs, everything after `--` is the wrapped server command, so this bypasses
13
+ * the shared flag-only dispatch and parses its own portion. Returns the exit
14
+ * code to hand to the process.
15
+ */
16
+ export declare function dispatchRun(rest: string[], deps: RunDeps): Promise<number>;
@@ -0,0 +1,2 @@
1
+ /** Help text for `axe-auth run --help`. */
2
+ export declare const HELP_RUN = "npx @deque/axe-auth run -- <server launch command>\n\nLaunch and supervise the axe MCP server so it keeps a valid OAuth access\ntoken for the whole session, refreshing before expiry without a restart. Use\nthis as your MCP client's server command; the client manages its lifetime like\nany stdio server. Everything after `--` is the command it launches.\n\nUsage:\n npx @deque/axe-auth run [--port <port>] [--secret <secret>] -- <command> [args...]\n\n The wrapped server must be started with token refresh on a loopback port.\n Under Docker, publish it with `-p 127.0.0.1:<port>:<port>`, forward the auth\n vars with `-e AXE_ACCESS_TOKEN -e AXE_TOKEN_REFRESH_PORT -e AXE_TOKEN_REFRESH_SECRET`,\n and set `-e AXE_TOKEN_REFRESH_HOST=0.0.0.0` so the published port can reach the\n listener; `run` supplies the token/port/secret values. Under npm the wrapped\n node process inherits them directly.\n\nOptions:\n --port <port> Loopback port the server's refresh listener uses.\n Defaults to AXE_TOKEN_REFRESH_PORT.\n --secret <secret> Shared secret for the refresh channel. Defaults to\n AXE_TOKEN_REFRESH_SECRET, otherwise a generated one.\n Prefer the env var: a value passed here is visible to other\n local users via the process list.\n -h, --help Show this help.\n\nExit codes:\n 0 The wrapped server exited normally.\n 1 Not authenticated; run `npx @deque/axe-auth login` first.\n 2 Configuration or connectivity error (missing/invalid port, no command\n after `--`, or the refresh channel could not reach the server).\n <n> Otherwise, the wrapped server's own exit code.";
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP_RUN = void 0;
4
+ /** Help text for `axe-auth run --help`. */
5
+ exports.HELP_RUN = `npx @deque/axe-auth run -- <server launch command>
6
+
7
+ Launch and supervise the axe MCP server so it keeps a valid OAuth access
8
+ token for the whole session, refreshing before expiry without a restart. Use
9
+ this as your MCP client's server command; the client manages its lifetime like
10
+ any stdio server. Everything after \`--\` is the command it launches.
11
+
12
+ Usage:
13
+ npx @deque/axe-auth run [--port <port>] [--secret <secret>] -- <command> [args...]
14
+
15
+ The wrapped server must be started with token refresh on a loopback port.
16
+ Under Docker, publish it with \`-p 127.0.0.1:<port>:<port>\`, forward the auth
17
+ vars with \`-e AXE_ACCESS_TOKEN -e AXE_TOKEN_REFRESH_PORT -e AXE_TOKEN_REFRESH_SECRET\`,
18
+ and set \`-e AXE_TOKEN_REFRESH_HOST=0.0.0.0\` so the published port can reach the
19
+ listener; \`run\` supplies the token/port/secret values. Under npm the wrapped
20
+ node process inherits them directly.
21
+
22
+ Options:
23
+ --port <port> Loopback port the server's refresh listener uses.
24
+ Defaults to AXE_TOKEN_REFRESH_PORT.
25
+ --secret <secret> Shared secret for the refresh channel. Defaults to
26
+ AXE_TOKEN_REFRESH_SECRET, otherwise a generated one.
27
+ Prefer the env var: a value passed here is visible to other
28
+ local users via the process list.
29
+ -h, --help Show this help.
30
+
31
+ Exit codes:
32
+ 0 The wrapped server exited normally.
33
+ 1 Not authenticated; run \`npx @deque/axe-auth login\` first.
34
+ 2 Configuration or connectivity error (missing/invalid port, no command
35
+ after \`--\`, or the refresh channel could not reach the server).
36
+ <n> Otherwise, the wrapped server's own exit code.`;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.dispatchRun = dispatchRun;
7
+ const node_util_1 = require("node:util");
8
+ const runSession_1 = __importDefault(require("../run/runSession"));
9
+ const run_help_1 = require("./run.help");
10
+ const errors_1 = require("../cli/errors");
11
+ /**
12
+ * `axe-auth run [--port] [--secret] -- <command> [args...]`. Unlike the other
13
+ * verbs, everything after `--` is the wrapped server command, so this bypasses
14
+ * the shared flag-only dispatch and parses its own portion. Returns the exit
15
+ * code to hand to the process.
16
+ */
17
+ async function dispatchRun(rest, deps) {
18
+ const env = deps.env ?? process.env;
19
+ // Split on the first `--`: our flags precede it, the wrapped command follows.
20
+ const separator = rest.indexOf("--");
21
+ const own = separator === -1 ? rest : rest.slice(0, separator);
22
+ const childArgv = separator === -1 ? [] : rest.slice(separator + 1);
23
+ let values;
24
+ try {
25
+ values = (0, node_util_1.parseArgs)({
26
+ args: own,
27
+ options: {
28
+ port: { type: "string" },
29
+ secret: { type: "string" },
30
+ help: { type: "boolean", short: "h" },
31
+ },
32
+ strict: true,
33
+ allowPositionals: false,
34
+ }).values;
35
+ }
36
+ catch (err) {
37
+ deps.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
38
+ return 2;
39
+ }
40
+ if (values.help) {
41
+ deps.stdout.write(`${run_help_1.HELP_RUN}\n`);
42
+ return 0;
43
+ }
44
+ if (childArgv.length === 0) {
45
+ deps.stderr.write("axe-auth run needs a command after `--`, e.g. `npx @deque/axe-auth run -- docker run ... axe-mcp-server`\n");
46
+ return 2;
47
+ }
48
+ const portRaw = values.port ?? env.AXE_TOKEN_REFRESH_PORT;
49
+ if (!portRaw) {
50
+ deps.stderr.write("axe-auth run needs a refresh port: set AXE_TOKEN_REFRESH_PORT or pass --port\n");
51
+ return 2;
52
+ }
53
+ const port = Number(portRaw);
54
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
55
+ deps.stderr.write(`Invalid port ${portRaw}: must be a TCP port (1-65535)\n`);
56
+ return 2;
57
+ }
58
+ // Mirror the server's minimum-length check so a short pinned secret fails
59
+ // here, at the command the user ran, rather than as a downstream server
60
+ // startup crash. An unset secret is generated by `runSession`.
61
+ const secret = values.secret ?? env.AXE_TOKEN_REFRESH_SECRET;
62
+ if (secret && secret.length < 16) {
63
+ deps.stderr.write("AXE_TOKEN_REFRESH_SECRET must be at least 16 characters\n");
64
+ return 2;
65
+ }
66
+ const runSession = deps.runSession ?? runSession_1.default;
67
+ try {
68
+ return await runSession({
69
+ command: childArgv[0],
70
+ commandArgs: childArgv.slice(1),
71
+ port,
72
+ secret,
73
+ env,
74
+ stderr: deps.stderr,
75
+ });
76
+ }
77
+ catch (err) {
78
+ if (err instanceof errors_1.CLIError) {
79
+ deps.stderr.write(`${err.message}\n`);
80
+ return err.exitCode;
81
+ }
82
+ deps.stderr.write(`${(0, errors_1.describeError)(err)}\n`);
83
+ return 2;
84
+ }
85
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ const tokenStore_1 = require("./oauth/tokenStore");
12
12
  const login_1 = __importDefault(require("./commands/login"));
13
13
  const logout_1 = __importDefault(require("./commands/logout"));
14
14
  const token_1 = __importDefault(require("./commands/token"));
15
+ const run_1 = require("./commands/run");
15
16
  const errors_1 = require("./cli/errors");
16
17
  const safeExit_1 = require("./cli/safeExit");
17
18
  const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
@@ -20,12 +21,18 @@ const COMMANDS = [
20
21
  logout_1.default,
21
22
  token_1.default,
22
23
  ];
24
+ // `run` has its own dispatch (it takes a `-- <command>` passthrough), so it is
25
+ // not a `CommandSpec`; list it in help by hand.
26
+ const RUN_SUMMARY = "Launch and supervise the axe MCP server, keeping its OAuth token fresh with no restart.";
23
27
  function findCommand(verb) {
24
28
  return COMMANDS.find((c) => c.name === verb);
25
29
  }
26
30
  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");
31
+ const width = Math.max(...COMMANDS.map((c) => c.name.length), "run".length);
32
+ const verbList = [
33
+ ...COMMANDS.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`),
34
+ ` ${"run".padEnd(width)} ${RUN_SUMMARY}`,
35
+ ].join("\n");
29
36
  return `${pkg.name} v${pkg.version}
30
37
 
31
38
  ${pkg.description}
@@ -56,6 +63,15 @@ async function dispatch(argv) {
56
63
  process.stderr.write(`Unknown option: ${first}. Run \`axe-auth --help\` for usage.\n`);
57
64
  return 2;
58
65
  }
66
+ // `run` wraps a launch command after `--`; it can't go through the shared
67
+ // flag-only parse below.
68
+ if (first === "run") {
69
+ return (0, run_1.dispatchRun)(rest, {
70
+ stdin: process.stdin,
71
+ stdout: process.stdout,
72
+ stderr: process.stderr,
73
+ });
74
+ }
59
75
  const command = findCommand(first);
60
76
  if (!command) {
61
77
  process.stderr.write(`Unknown command: ${first}. Run \`axe-auth --help\` for usage.\n`);
@@ -19,4 +19,4 @@ export type KeyringEntryFactory = (service: string, account: string) => KeyringE
19
19
  * `KEYRING_UNAVAILABLE` on first store construction, not on module
20
20
  * import.
21
21
  */
22
- export declare const defaultEntryFactory: KeyringEntryFactory;
22
+ export declare function defaultEntryFactory(service: string, account: string): KeyringEntry;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultEntryFactory = void 0;
3
+ exports.defaultEntryFactory = defaultEntryFactory;
4
4
  const node_module_1 = require("node:module");
5
5
  const errors_1 = require("./errors");
6
6
  const requireFromHere = (0, node_module_1.createRequire)(__filename);
@@ -34,8 +34,7 @@ function resolveEntryCtor() {
34
34
  * `KEYRING_UNAVAILABLE` on first store construction, not on module
35
35
  * import.
36
36
  */
37
- const defaultEntryFactory = (service, account) => {
37
+ function defaultEntryFactory(service, account) {
38
38
  const Ctor = resolveEntryCtor();
39
39
  return new Ctor(service, account);
40
- };
41
- exports.defaultEntryFactory = defaultEntryFactory;
40
+ }
@@ -0,0 +1,10 @@
1
+ /** Header carrying the shared secret on a token-refresh push. Matches the server's listener. */
2
+ export declare const REFRESH_SECRET_HEADER = "x-refresh-secret";
3
+ /** Pushes a fresh access token to the running server's loopback listener. */
4
+ export type PushToken = (params: {
5
+ port: number;
6
+ secret: string;
7
+ accessToken: string;
8
+ }) => Promise<void>;
9
+ /** POST the fresh token to the server's loopback refresh listener. */
10
+ export default function pushToken({ port, secret, accessToken, }: Parameters<PushToken>[0]): Promise<void>;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REFRESH_SECRET_HEADER = void 0;
4
+ exports.default = pushToken;
5
+ /** Header carrying the shared secret on a token-refresh push. Matches the server's listener. */
6
+ exports.REFRESH_SECRET_HEADER = "x-refresh-secret";
7
+ /** Bound the push so a stalled server can't hang the caller. */
8
+ const PUSH_TIMEOUT_MS = 5_000;
9
+ /** POST the fresh token to the server's loopback refresh listener. */
10
+ async function pushToken({ port, secret, accessToken, }) {
11
+ const res = await fetch(`http://127.0.0.1:${port}/token`, {
12
+ method: "POST",
13
+ headers: {
14
+ "Content-Type": "application/json",
15
+ [exports.REFRESH_SECRET_HEADER]: secret,
16
+ },
17
+ body: JSON.stringify({ accessToken }),
18
+ signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
19
+ });
20
+ if (!res.ok) {
21
+ const detail = await res.text().catch(() => "");
22
+ throw new Error(`server responded ${res.status}${detail ? `: ${detail}` : ""}`);
23
+ }
24
+ }
@@ -0,0 +1,56 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { getValidAccessToken } from "../oauth/getValidAccessToken";
3
+ import { type TokenStore } from "../oauth/tokenStore";
4
+ import { type SuperviseOptions } from "./supervise";
5
+ import { type PushToken } from "./pushToken";
6
+ /** Starts a repeating callback and returns a canceller. Injectable so tests drive ticks by hand. */
7
+ export type Scheduler = (intervalMs: number, tick: () => void) => () => void;
8
+ /** Options for {@link runSession}. */
9
+ export interface RunSessionOptions {
10
+ /** Executable of the wrapped server command (everything after `--`). */
11
+ command: string;
12
+ /** Arguments for the wrapped command. */
13
+ commandArgs: string[];
14
+ /** Loopback port the server's refresh listener is on. */
15
+ port: number;
16
+ /** Shared secret authenticating pushes. Generated if omitted. */
17
+ secret?: string;
18
+ /** Cadence of the refresh-and-push loop, ms. */
19
+ refreshIntervalMs?: number;
20
+ /** Override token minting (tests). */
21
+ getToken?: typeof getValidAccessToken;
22
+ /** Override the token store (tests). */
23
+ tokenStore?: TokenStore;
24
+ /** Override the push (tests). */
25
+ push?: PushToken;
26
+ /** Override the child supervisor (tests). */
27
+ supervise?: (options: SuperviseOptions) => Promise<number>;
28
+ /** Override the refresh scheduler (tests). */
29
+ scheduler?: Scheduler;
30
+ /** Base environment for the wrapped child. Defaults to `process.env`. */
31
+ env?: NodeJS.ProcessEnv;
32
+ /** Forwarded to the supervisor as the child's stdin. Defaults to `process.stdin`. */
33
+ stdin?: Readable;
34
+ /** Forwarded to the supervisor as the child's stdout (the MCP channel). Defaults to `process.stdout`. */
35
+ stdout?: Writable;
36
+ /** Diagnostics stream. Defaults to `process.stderr`. NEVER stdout (that is the MCP channel). */
37
+ stderr?: Writable;
38
+ /** Forwarded to the supervisor's signal registrar (tests). Defaults to `process.on`. */
39
+ onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
40
+ }
41
+ /**
42
+ * Supervise the wrapped server command for a session: mint an initial access
43
+ * token, inject it plus the refresh secret and port into the child's
44
+ * environment, and keep the running server's token fresh by refreshing and
45
+ * pushing before expiry. Resolves with the child's exit code.
46
+ *
47
+ * Fails fast with a `NOT_AUTHENTICATED` {@link CLIError} if there are no stored
48
+ * credentials to mint from, and with `REFRESH_UNREACHABLE` if the very first
49
+ * push cannot reach the server (almost always a misconfigured port/secret) —
50
+ * exiting so the MCP client sees the failure rather than letting the session
51
+ * silently die at expiry. Once a push has succeeded, a later refresh failure is
52
+ * logged (the server keeps its last good token) rather than tearing the session
53
+ * down, with one louder diagnostic after {@link CONSECUTIVE_FAILURE_WARNING_THRESHOLD}
54
+ * consecutive failures.
55
+ */
56
+ export default function runSession(options: RunSessionOptions): Promise<number>;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = runSession;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const errors_1 = require("../oauth/errors");
9
+ const getValidAccessToken_1 = require("../oauth/getValidAccessToken");
10
+ const tokenStore_1 = require("../oauth/tokenStore");
11
+ const errors_2 = require("../cli/errors");
12
+ const supervise_1 = __importDefault(require("./supervise"));
13
+ const pushToken_1 = __importDefault(require("./pushToken"));
14
+ /**
15
+ * Cadence of the refresh-and-push loop. Must be shorter than the access-token
16
+ * TTL: each tick mints (which refreshes when the token is near expiry) and
17
+ * pushes any change, so the token only lapses if a whole TTL elapses between
18
+ * ticks. 60s suits typical Keycloak access-token lifetimes (minutes); a
19
+ * deployment issuing sub-minute tokens would need a shorter interval.
20
+ */
21
+ const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
22
+ /** Consecutive mid-session refresh failures before escalating from a per-tick line to one loud diagnostic. */
23
+ const CONSECUTIVE_FAILURE_WARNING_THRESHOLD = 3;
24
+ const realScheduler = (intervalMs, tick) => {
25
+ const timer = setInterval(tick, intervalMs);
26
+ timer.unref();
27
+ return () => clearInterval(timer);
28
+ };
29
+ /**
30
+ * Supervise the wrapped server command for a session: mint an initial access
31
+ * token, inject it plus the refresh secret and port into the child's
32
+ * environment, and keep the running server's token fresh by refreshing and
33
+ * pushing before expiry. Resolves with the child's exit code.
34
+ *
35
+ * Fails fast with a `NOT_AUTHENTICATED` {@link CLIError} if there are no stored
36
+ * credentials to mint from, and with `REFRESH_UNREACHABLE` if the very first
37
+ * push cannot reach the server (almost always a misconfigured port/secret) —
38
+ * exiting so the MCP client sees the failure rather than letting the session
39
+ * silently die at expiry. Once a push has succeeded, a later refresh failure is
40
+ * logged (the server keeps its last good token) rather than tearing the session
41
+ * down, with one louder diagnostic after {@link CONSECUTIVE_FAILURE_WARNING_THRESHOLD}
42
+ * consecutive failures.
43
+ */
44
+ async function runSession(options) {
45
+ const { command, commandArgs, port, secret = (0, node_crypto_1.randomBytes)(24).toString("hex"), refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, getToken = getValidAccessToken_1.getValidAccessToken, tokenStore = new tokenStore_1.KeyringTokenStore(), push = pushToken_1.default, supervise = supervise_1.default, scheduler = realScheduler, env = process.env, stdin, stdout, stderr = process.stderr, onSignal, } = options;
46
+ const loaded = await tokenStore.load();
47
+ const coordinates = {
48
+ issuerURL: loaded.ok ? loaded.entry.issuerURL : "",
49
+ clientId: loaded.ok ? loaded.entry.clientId : "",
50
+ allowInsecureIssuer: loaded.ok ? loaded.entry.allowInsecureIssuer : false,
51
+ };
52
+ // No `loadedEntry`: each call re-reads the store so it picks up the rotated
53
+ // refresh token from the previous refresh instead of replaying a stale one.
54
+ const mint = async () => {
55
+ try {
56
+ return await getToken({ ...coordinates, tokenStore });
57
+ }
58
+ catch (err) {
59
+ if (err instanceof errors_1.OAuthFlowError && err.code === "NOT_AUTHENTICATED") {
60
+ throw new errors_2.CLIError("NOT_AUTHENTICATED", err.message);
61
+ }
62
+ throw err;
63
+ }
64
+ };
65
+ const initialToken = await mint();
66
+ const childEnv = {
67
+ ...env,
68
+ AXE_ACCESS_TOKEN: initialToken,
69
+ AXE_TOKEN_REFRESH_PORT: String(port),
70
+ AXE_TOKEN_REFRESH_SECRET: secret,
71
+ };
72
+ let lastPushed = initialToken;
73
+ let pushedSuccessfully = false;
74
+ let consecutiveFailures = 0;
75
+ let refreshing = false;
76
+ // Lets a fatal first-push failure interrupt the awaited `supervise` below.
77
+ let rejectFatal;
78
+ const fatal = new Promise((_, reject) => {
79
+ rejectFatal = reject;
80
+ });
81
+ // A recoverable refresh failure: log it, and once failures pile up emit one
82
+ // louder line (they will keep failing every tick otherwise). The session
83
+ // stays alive on its last good token.
84
+ const noteRecoverableFailure = (message) => {
85
+ stderr.write(`axe-auth run: token refresh failed: ${message}\n`);
86
+ consecutiveFailures += 1;
87
+ if (consecutiveFailures === CONSECUTIVE_FAILURE_WARNING_THRESHOLD) {
88
+ stderr.write(`axe-auth run: token refresh has failed ${consecutiveFailures} times in a row; the server's access token will expire and calls will start failing — check that the refresh listener is reachable\n`);
89
+ }
90
+ };
91
+ const cancel = scheduler(refreshIntervalMs, () => {
92
+ if (refreshing) {
93
+ return;
94
+ }
95
+ refreshing = true;
96
+ void (async () => {
97
+ try {
98
+ let fresh;
99
+ try {
100
+ fresh = await mint();
101
+ }
102
+ catch (err) {
103
+ // Mint failure (e.g. a transient Keycloak blip): the injected token
104
+ // may still be valid, so keep the session alive rather than exiting.
105
+ noteRecoverableFailure((0, errors_2.describeError)(err));
106
+ return;
107
+ }
108
+ if (fresh === lastPushed) {
109
+ consecutiveFailures = 0;
110
+ return;
111
+ }
112
+ try {
113
+ await push({ port, secret, accessToken: fresh });
114
+ }
115
+ catch (err) {
116
+ if (!pushedSuccessfully) {
117
+ // The first push never reached the server — almost always a
118
+ // misconfigured port/secret. Fail so the MCP client sees the exit.
119
+ rejectFatal(new errors_2.CLIError("REFRESH_UNREACHABLE", `token refresh could not reach the server on port ${port} (${(0, errors_2.describeError)(err)}); check the refresh port and secret`));
120
+ }
121
+ else {
122
+ noteRecoverableFailure((0, errors_2.describeError)(err));
123
+ }
124
+ return;
125
+ }
126
+ lastPushed = fresh;
127
+ pushedSuccessfully = true;
128
+ consecutiveFailures = 0;
129
+ }
130
+ finally {
131
+ refreshing = false;
132
+ }
133
+ })();
134
+ });
135
+ try {
136
+ return await Promise.race([
137
+ supervise({
138
+ command,
139
+ args: commandArgs,
140
+ env: childEnv,
141
+ stdin,
142
+ stdout,
143
+ stderr,
144
+ onSignal,
145
+ }),
146
+ fatal,
147
+ ]);
148
+ }
149
+ finally {
150
+ cancel();
151
+ }
152
+ }
@@ -0,0 +1,31 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /** Options for {@link supervise}. */
3
+ export interface SuperviseOptions {
4
+ /** Executable to run (e.g. `docker`, `npx`). */
5
+ command: string;
6
+ /** Arguments passed to the executable. */
7
+ args: string[];
8
+ /** Environment for the child. Defaults to `process.env`. */
9
+ env?: NodeJS.ProcessEnv;
10
+ /** Stream feeding the child's stdin. Defaults to `process.stdin`. */
11
+ stdin?: Readable;
12
+ /** Stream the child's stdout is written to. Defaults to `process.stdout`. */
13
+ stdout?: Writable;
14
+ /** Stream the child's stderr is written to. Defaults to `process.stderr`. */
15
+ stderr?: Writable;
16
+ /** Registers a termination-signal handler. Injectable for tests; defaults to `process.on`. */
17
+ onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
18
+ }
19
+ /**
20
+ * Spawn `command` and transparently bridge this process's stdio to it: the
21
+ * parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
22
+ * reaches the wrapped server untouched), the child's stdout and stderr flow
23
+ * back out, termination signals are forwarded, and the child's exit is
24
+ * propagated. Resolves with the child's exit code, or `128 + signal` when the
25
+ * child is killed by a signal (shell convention); rejects only if the child
26
+ * fails to spawn.
27
+ *
28
+ * This is the core of `axe-auth run`: it lets the MCP client spawn `axe-auth`
29
+ * as its stdio server command while axe-auth manages tokens out of band.
30
+ */
31
+ export default function supervise(options: SuperviseOptions): Promise<number>;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = supervise;
7
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
8
+ const node_os_1 = require("node:os");
9
+ /** Signals forwarded from this process to the supervised child. */
10
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
11
+ /**
12
+ * Spawn `command` and transparently bridge this process's stdio to it: the
13
+ * parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
14
+ * reaches the wrapped server untouched), the child's stdout and stderr flow
15
+ * back out, termination signals are forwarded, and the child's exit is
16
+ * propagated. Resolves with the child's exit code, or `128 + signal` when the
17
+ * child is killed by a signal (shell convention); rejects only if the child
18
+ * fails to spawn.
19
+ *
20
+ * This is the core of `axe-auth run`: it lets the MCP client spawn `axe-auth`
21
+ * as its stdio server command while axe-auth manages tokens out of band.
22
+ */
23
+ function supervise(options) {
24
+ const { command, args, env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, onSignal = (signal, handler) => {
25
+ process.on(signal, handler);
26
+ }, } = options;
27
+ return new Promise((resolve, reject) => {
28
+ // Not `detached`: if this process dies without cleanup (e.g. SIGKILL), the
29
+ // closed stdin pipe gives the child EOF, which is how the wrapped server
30
+ // shuts down and avoids being orphaned. Detaching would sever that.
31
+ const child = (0, cross_spawn_1.default)(command, args, {
32
+ env,
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ });
35
+ child.once("error", reject);
36
+ if (child.stdin) {
37
+ stdin.pipe(child.stdin);
38
+ // The child may exit while the parent is still writing; a write to its
39
+ // closed stdin would otherwise throw EPIPE and crash the supervisor.
40
+ child.stdin.on("error", () => { });
41
+ }
42
+ // `end: false` so the child closing its stream never tears down the
43
+ // parent's shared stdout/stderr.
44
+ child.stdout?.pipe(stdout, { end: false });
45
+ child.stderr?.pipe(stderr, { end: false });
46
+ for (const signal of FORWARDED_SIGNALS) {
47
+ onSignal(signal, () => {
48
+ child.kill(signal);
49
+ });
50
+ }
51
+ child.once("exit", (code, signal) => {
52
+ if (child.stdin) {
53
+ stdin.unpipe(child.stdin);
54
+ }
55
+ if (code !== null) {
56
+ resolve(code);
57
+ }
58
+ else {
59
+ resolve(128 + (node_os_1.constants.signals[signal ?? "SIGTERM"] ?? 0));
60
+ }
61
+ });
62
+ });
63
+ }
@@ -15,6 +15,7 @@ flowchart TB
15
15
  keychain[(OS keychain)]
16
16
  axe[axe server]
17
17
  keycloak[Customer Keycloak]
18
+ mcp[axe MCP server]
18
19
 
19
20
  user -- "axe-auth login / token / logout" --> cli
20
21
  cli -- "GET /api/sso-config (login only)" --> axe
@@ -27,19 +28,21 @@ flowchart TB
27
28
  cli <-- "OIDC discovery, token exchange,<br/>refresh, revoke (HTTPS)" --> keycloak
28
29
  cli <-- "tokens + issuer/client/walnutURL<br/>(versioned blob)" --> keychain
29
30
  cli -- "access token (stdout)" --> user
31
+ cli -- "axe-auth run: spawn + supervise,<br/>access-token push (loopback POST /token)" --> mcp
30
32
  ```
31
33
 
32
34
  **Components and their roles:**
33
35
 
34
- 1. **Developer**: invokes `axe-auth login`, `axe-auth token`, or `axe-auth logout` on their host machine.
36
+ 1. **Developer**: invokes `axe-auth login`, `axe-auth token`, or `axe-auth logout` on their host machine. `axe-auth run` is not typed directly; the developer configures it as their MCP client's server command, and the client spawns the CLI over stdio (see [`axe-auth run`](#axe-auth-run) below).
35
37
  2. **axe-auth CLI**: this package. Drives the OAuth flow, persists tokens, prints access tokens on stdout, revokes refresh tokens on logout.
36
38
  3. **System browser**: the developer's default OS browser (Chrome, Safari, Firefox, etc.). Used only for the user-interactive part of the OAuth Authorization Code flow. Runs on the host, never in a container or sandbox controlled by `axe-auth`.
37
39
  4. **Loopback callback server**: an HTTP listener bound to `127.0.0.1` on an OS-assigned ephemeral port. Spawned by the CLI at the start of `login` and torn down as soon as the OAuth callback fires. Per RFC 8252 §7.3, this is the standard pattern for native-app OAuth.
38
40
  5. **OS keychain**: the platform-native credential store accessed through [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring) — macOS Keychain, Windows Credential Manager, or Linux Secret Service (GNOME Keyring, KWallet). The CLI writes one entry per machine.
39
41
  6. **axe server**: the customer's deployment of the axe API. The CLI hits its `/api/sso-config` endpoint at the start of `login` to discover the Keycloak URL, realm, and OAuth client ID; no other CLI traffic flows through the axe server.
40
42
  7. **Customer Keycloak**: the OAuth authorization server for the customer's deployment. Issues access and refresh tokens. Federation between Keycloak and any upstream enterprise IdP (Okta, AAD, etc.) is the customer's concern and out of scope for this document.
43
+ 8. **axe MCP server**: the server that `axe-auth run` launches and supervises. It receives freshly-minted access tokens pushed over loopback (`POST /token`) so a long session survives token expiry without a restart. Present only in the `run` flow, not in `login`/`token`/`logout`.
41
44
 
42
- `axe-auth` itself does **not** communicate with Deque's API services directly. The access tokens it produces are consumed by downstream tools, most notably the axe MCP server, which presents them to Deque's services as `Authorization: Bearer ...`.
45
+ `axe-auth` itself does **not** communicate with Deque's API services directly. The access tokens it produces are consumed by downstream tools, most notably the axe MCP server, which presents them to Deque's services as `Authorization: Bearer ...`. The one exception is `axe-auth run`, which supervises a running axe MCP server and pushes freshly-minted access tokens to it over a loopback (`127.0.0.1`) connection so a long session survives token expiry without a restart; it never sends the refresh token, only short-lived access tokens.
43
46
 
44
47
  ## Data flow per verb
45
48
 
@@ -119,6 +122,43 @@ sequenceDiagram
119
122
  5. On success, the CLI persists the rotated entry and prints the new access token on stdout.
120
123
  6. On `invalid_grant` (refresh token revoked or expired server-side), the CLI clears the local entry and exits 1 with a "re-authenticate" message on stderr. Other transient failures (network, 5xx) leave the stored entry intact so the user can retry.
121
124
 
125
+ ### `axe-auth run`
126
+
127
+ ```mermaid
128
+ sequenceDiagram
129
+ participant User as Developer
130
+ participant Client as MCP client
131
+ participant CLI as axe-auth run
132
+ participant KC as Customer Keycloak
133
+ participant Server as axe MCP server
134
+
135
+ User->>Client: set axe-auth run as the stdio server command
136
+ Client->>CLI: spawn (stdio)
137
+ Note over CLI: mint initial access token (refresh via Keycloak if near expiry)
138
+ opt token near expiry
139
+ CLI->>KC: POST /token (refresh_token)
140
+ KC-->>CLI: { access_token, expires_in }
141
+ end
142
+ CLI->>Server: spawn child (AXE_ACCESS_TOKEN, refresh port + secret injected)
143
+ Client->>CLI: MCP JSON-RPC (stdin)
144
+ CLI->>Server: bridged stdin
145
+ Server->>CLI: bridged stdout
146
+ CLI->>Client: MCP JSON-RPC (stdout)
147
+ loop each interval, before expiry
148
+ CLI->>KC: POST /token (refresh_token)
149
+ KC-->>CLI: { access_token }
150
+ CLI->>Server: POST /token (x-refresh-secret) — swap in-memory token
151
+ end
152
+ Server-->>CLI: child exits
153
+ CLI-->>Client: exit with the child's code
154
+ ```
155
+
156
+ 1. The developer points their MCP client at `axe-auth run -- <server launch command>` as the stdio server command, configuring a loopback refresh port via `--port` or `AXE_TOKEN_REFRESH_PORT`.
157
+ 2. `run` obtains a currently-valid access token (exactly as `axe-auth token` does, refreshing against Keycloak if needed), generates a shared secret unless one is provided, and launches the wrapped server as a child process with the token, port, and secret injected into its environment.
158
+ 3. `run` transparently bridges the client's stdio to the child so the MCP session flows through untouched, and supervises the child for the session's lifetime.
159
+ 4. In the background, `run` keeps the access token fresh and pushes each new token to the server's loopback listener at `http://127.0.0.1:<port>/token` (secret in an `x-refresh-secret` header). The refresh token is never sent; only short-lived access tokens.
160
+ 5. When the wrapped server exits, `run` exits with the same code.
161
+
122
162
  ### `axe-auth logout`
123
163
 
124
164
  ```mermaid
@@ -10,12 +10,12 @@ Request handling additionally rejects non-loopback `remoteAddress` values with `
10
10
 
11
11
  ## RFC 8252 conformance
12
12
 
13
- | Clause | Requirement | Handled by |
14
- | ------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
15
- | §7.3 | IP literal, not `localhost` | Binds `127.0.0.1` or `[::1]`; redirect URI uses the literal. |
16
- | §7.3 | Ephemeral OS-assigned port | `listen(0, ...)`; port read from `listening` event. |
17
- | §7.3 | Attempt both IPv4 and IPv6 | IPv4-first, IPv6 fallback when the IPv4 family is unavailable. |
18
- | §8.3 | Open the port only during the auth request | One-shot: closed on first consuming request, timeout, or abort. |
19
- | §8.3 | Listen on loopback only | IP literal only; `remoteAddress` check as defense in depth. |
20
- | §8.1 | PKCE | Out of scope — owned by the auth-URL + token-exchange layer. |
21
- | §8.1 | Auth code interception mitigation | Success HTML does not echo `code`; CSP locks the page down (see [`callback-page.md`](./callback-page.md)). |
13
+ | Clause | Requirement | Handled by |
14
+ | --- | --- | --- |
15
+ | §7.3 | IP literal, not `localhost` | Binds `127.0.0.1` or `[::1]`; redirect URI uses the literal. |
16
+ | §7.3 | Ephemeral OS-assigned port | `listen(0, ...)`; port read from `listening` event. |
17
+ | §7.3 | Attempt both IPv4 and IPv6 | IPv4-first, IPv6 fallback when the IPv4 family is unavailable. |
18
+ | §8.3 | Open the port only during the auth request | One-shot: closed on first consuming request, timeout, or abort. |
19
+ | §8.3 | Listen on loopback only | IP literal only; `remoteAddress` check as defense in depth. |
20
+ | §8.1 | PKCE | Out of scope — owned by the auth-URL + token-exchange layer. |
21
+ | §8.1 | Auth code interception mitigation | Success HTML does not echo `code`; CSP locks the page down (see [`callback-page.md`](./callback-page.md)). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deque/axe-auth",
3
- "version": "1.4.0-rc.ee4e8048",
3
+ "version": "1.5.0-rc.bbbeb999",
4
4
  "description": "CLI authentication utility for Deque services",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -31,15 +31,17 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@napi-rs/keyring": "^1.3.0",
34
+ "cross-spawn": "^7.0.6",
34
35
  "remove-trailing-slash": "^0.1.1",
35
36
  "shlex": "^3.0.0",
36
37
  "ts-dedent": "^2.2.0"
37
38
  },
38
39
  "devDependencies": {
39
- "@hono/node-server": "^1.19.14",
40
+ "@hono/node-server": "^2.0.11",
41
+ "@types/cross-spawn": "^6.0.6",
40
42
  "@types/node": "^24.13.2",
41
43
  "c8": "^11.0.0",
42
- "hono": "^4.12.27",
44
+ "hono": "^4.12.34",
43
45
  "tsx": "^4.22.4",
44
46
  "typescript": "^6.0.3"
45
47
  },