@deque/axe-auth 1.4.0 → 1.5.0-next.f1b72ed9

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.
@@ -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,64 @@ 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
+ alt --port or AXE_TOKEN_REFRESH_PORT pinned
138
+ Note over CLI: use it as given (required for a container runtime)
139
+ else nothing pinned
140
+ Note over CLI: take a free loopback port from the OS,<br/>so a leftover server cannot collide
141
+ end
142
+ Note over CLI: mint initial access token (refresh via Keycloak if near expiry)
143
+ opt token near expiry
144
+ CLI->>KC: POST /token (refresh_token)
145
+ KC-->>CLI: { access_token, expires_in }
146
+ end
147
+ CLI->>Server: spawn child (AXE_ACCESS_TOKEN, refresh port + secret injected)
148
+ Client->>CLI: MCP JSON-RPC (stdin)
149
+ CLI->>Server: bridged stdin
150
+ Server->>CLI: bridged stdout
151
+ CLI->>Client: MCP JSON-RPC (stdout)
152
+ loop each interval, before expiry
153
+ CLI->>KC: POST /token (refresh_token)
154
+ KC-->>CLI: { access_token }
155
+ CLI->>Server: POST /token (x-refresh-secret) — swap in-memory token
156
+ end
157
+ alt wrapped server exits on its own
158
+ Server-->>CLI: child exits
159
+ CLI-->>Client: exit with the child's code
160
+ else session ends (stdin EOF, forwarded signal, launcher gone, or fatal startup abort)
161
+ CLI->>Server: polite rung — forwarded signal or SIGTERM across the process group, or a closed stdin on Windows
162
+ opt still alive after the grace period
163
+ CLI->>Server: SIGKILL (process group, or taskkill /T /F on Windows)
164
+ end
165
+ Server-->>CLI: child exits
166
+ end
167
+ ```
168
+
169
+ 1. The developer points their MCP client at `axe-auth run -- <server launch command>` as the stdio server command. A free loopback refresh port is taken from the OS per session, so a server left behind by an earlier session cannot collide with it; `--port` or `AXE_TOKEN_REFRESH_PORT` pins one instead. A container only reaches a host port it was told to publish with `-p`, so `run` refuses to guess one when it recognises the wrapped command as `docker`, `podman`, or `nerdctl`. Detection is by command name, so another runtime, or one reached through a wrapper script, takes an auto-selected port it cannot reach and degrades to no token refresh; pin a port yourself there.
170
+ 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.
171
+ 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.
172
+ 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.
173
+ 5. When the wrapped server exits on its own, `run` exits with the same code.
174
+ 6. `run` also ends the session itself, so the server cannot outlive it holding the refresh port: on stdin EOF, on a forwarded `SIGINT`/`SIGTERM`/`SIGHUP`, when the launching process disappears, and on a fatal error. Each puts the child on a teardown ladder: a polite rung the server can shut down on, then a forced one. On macOS and Linux that is the forwarded signal (or `SIGTERM`), then `SIGKILL` across the process group. Windows has no polite signal, so the closed stdin pipe is the polite rung and the forced one is `taskkill /T /F` across the process tree. The wrapped server separately ends the session when the client stops answering `ping`, which catches a client that never closed the pipe.
175
+
176
+ #### Known limitations
177
+
178
+ Two cases are bounded rather than closed.
179
+
180
+ - **A container can outlive an ungraceful teardown.** When the wrapped command is a container runtime, `axe-auth` supervises the client, not the container, so the polite rung is forwarded to the container but `SIGKILL` reaches only the client. A server that does not stop within the grace therefore leaves the container running on the port it published, which the next session cannot reuse. Recover with `docker rm -f <container>`. Pinning a port is already required here, so the collision is not silent. Tracked in [#1027](https://github.com/dequelabs/axe-mcp-server/issues/1027).
181
+ - **A browser can outlive an ungraceful teardown.** On POSIX the final `SIGKILL` reaches the server's process group, but Playwright runs Chromium in its own, so a scan still mid-flight when the grace expires leaves a browser for the user to close by hand. It holds no port, so it does not block the next session. Windows is unaffected: the forced rung there walks the whole process tree.
182
+
122
183
  ### `axe-auth logout`
123
184
 
124
185
  ```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",
3
+ "version": "1.5.0-next.f1b72ed9",
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.1.0",
41
+ "@types/cross-spawn": "^6.0.6",
40
42
  "@types/node": "^24.13.2",
41
- "c8": "^11.0.0",
42
- "hono": "^4.12.27",
43
+ "c8": "^12.0.0",
44
+ "hono": "^4.12.34",
43
45
  "tsx": "^4.22.4",
44
46
  "typescript": "^6.0.3"
45
47
  },