# `@deque/axe-auth` Architecture

This document describes the system architecture and data flow of the `@deque/axe-auth` CLI: the components it interacts with, the data passed between them, what is persisted, and how communication is authenticated and protected.

`axe-auth` is a developer-facing CLI that performs OAuth 2.0 Authorization Code + PKCE login (RFC 6749, RFC 7636, RFC 8252 §7.3) against a Keycloak deployment, persists the resulting tokens in the operating-system keychain, and prints currently-valid access tokens on demand. The tokens it produces are consumed by downstream tools (notably the axe MCP server) to authenticate against Deque services on behalf of the developer.

## High-level architecture

```mermaid
flowchart TB
  user[Developer]
  cli[axe-auth CLI]
  browser[System browser]
  callback[Loopback callback server<br/>127.0.0.1:ephemeral]
  keychain[(OS keychain)]
  axe[axe server]
  keycloak[Customer Keycloak]
  mcp[axe MCP server]

  user -- "axe-auth login / token / logout" --> cli
  cli -- "GET /api/sso-config (login only)" --> axe
  cli -- "spawns" --> callback
  cli -- "opens" --> browser
  browser -- "authorize redirect<br/>(state, PKCE challenge)" --> keycloak
  keycloak -- "302 to loopback<br/>(code, state)" --> browser
  browser -- "GET /callback?code=...&state=..." --> callback
  callback -- "code, state" --> cli
  cli <-- "OIDC discovery, token exchange,<br/>refresh, revoke (HTTPS)" --> keycloak
  cli <-- "tokens + issuer/client/walnutURL<br/>(versioned blob)" --> keychain
  cli -- "access token (stdout)" --> user
  cli -- "axe-auth run: spawn + supervise,<br/>access-token push (loopback POST /token)" --> mcp
```

**Components and their roles:**

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).
2. **axe-auth CLI**: this package. Drives the OAuth flow, persists tokens, prints access tokens on stdout, revokes refresh tokens on logout.
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`.
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.
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.
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.
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.
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`.

`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.

## Data flow per verb

### `axe-auth login`

```mermaid
sequenceDiagram
  participant User as Developer
  participant CLI as axe-auth CLI
  participant Browser as System browser
  participant CB as Loopback callback<br/>(127.0.0.1:port)
  participant Axe as axe server
  participant KC as Customer Keycloak

  User->>CLI: axe-auth login [--server <axe-url>]
  CLI->>Axe: GET /api/sso-config
  Axe-->>CLI: { url, realm, mcpClientId }
  CLI->>KC: GET /.well-known/openid-configuration
  KC-->>CLI: { authorization_endpoint, token_endpoint, ... }
  CLI->>CB: spawn on 127.0.0.1:<ephemeral>
  CLI->>Browser: authorize URL (+PKCE, state, redirect_uri)
  Browser->>KC: GET /authorize
  Note over KC: User authenticates via SSO
  KC-->>Browser: 302 to loopback (code, state)
  Browser->>CB: GET /callback?code=...&state=...
  CB-->>Browser: HTML success page
  CB-->>CLI: { code, state }
  CLI->>KC: POST /token (code, code_verifier)
  KC-->>CLI: { access_token, refresh_token, expires_in }
  Note over CLI: save tokens + issuer/client/walnutURL to OS keychain
  CLI-->>User: ✓ Authenticated.
```

1. The developer invokes `axe-auth login`, optionally with `--server <axe-url>` (or `AXE_SERVER_URL`); when neither is set the CLI defaults to Deque's SaaS prod axe server.
2. The CLI fetches `<axe-server>/api/sso-config` to learn the Keycloak base URL, realm, and OAuth client ID. The axe server returns `mcpClientId: null` when the deployment has not been configured for OAuth-based MCP authentication, and the field is absent on older axe server versions; both cases surface as a clear error before any browser is opened.
3. With the discovered coordinates the CLI fetches the OIDC discovery document at `<issuer>/.well-known/openid-configuration` to learn the authorization, token, and revocation endpoint URLs.
4. The CLI generates a PKCE `code_verifier` + `code_challenge` (S256) and a random `state` value.
5. The CLI starts a loopback HTTP listener on `127.0.0.1` at an OS-assigned port.
6. The CLI opens the developer's system browser to the authorization endpoint with the PKCE challenge, the state, and the loopback `redirect_uri`.
7. The developer authenticates with Keycloak (typically via the customer's federated SSO).
8. Keycloak redirects the browser to the loopback `redirect_uri` with an authorization `code` and the original `state`.
9. The loopback listener validates `state`, captures the `code`, and renders a small success page so the developer knows they can close the tab.
10. The CLI POSTs `code` + `code_verifier` to Keycloak's token endpoint and receives an `access_token`, `refresh_token`, and `expires_in`.
11. The CLI persists the resulting `StoredEntry` (tokens plus the issuer/client coordinates that minted them, plus the originating axe server URL) into the OS keychain.
12. The CLI prints `✓ Authenticated.` on stdout and exits 0.

### `axe-auth token`

```mermaid
sequenceDiagram
  participant User as Developer
  participant CLI as axe-auth CLI
  participant KC as Customer Keycloak

  User->>CLI: axe-auth token
  Note over CLI: load stored entry from OS keychain
  alt access token still fresh (within expiry buffer)
    CLI-->>User: print access_token to stdout
  else access token expired or near expiry
    CLI->>KC: POST /token (refresh_token)
    alt success (rotated tokens)
      KC-->>CLI: { access_token, refresh_token, expires_in }
      Note over CLI: save rotated entry to OS keychain
      CLI-->>User: print access_token to stdout
    else invalid_grant (refresh rejected)
      KC-->>CLI: 400 invalid_grant
      Note over CLI: clear OS keychain entry
      CLI-->>User: stderr "session expired", exit 1
    end
  end
```

1. The developer invokes `axe-auth token` (typically inside shell substitution: `$(axe-auth token)`).
2. The CLI loads the stored entry from the OS keychain.
3. If the access token is still within its expiry buffer, the CLI prints it on stdout and exits 0 with no network call.
4. Otherwise the CLI POSTs the refresh token to Keycloak's token endpoint to obtain a fresh access token (and a rotated refresh token, since Keycloak rotates by default).
5. On success, the CLI persists the rotated entry and prints the new access token on stdout.
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.

### `axe-auth run`

```mermaid
sequenceDiagram
  participant User as Developer
  participant Client as MCP client
  participant CLI as axe-auth run
  participant KC as Customer Keycloak
  participant Server as axe MCP server

  User->>Client: set axe-auth run as the stdio server command
  Client->>CLI: spawn (stdio)
  alt --port or AXE_TOKEN_REFRESH_PORT pinned
    Note over CLI: use it as given (required for a container runtime)
  else nothing pinned
    Note over CLI: take a free loopback port from the OS,<br/>so a leftover server cannot collide
  end
  Note over CLI: mint initial access token (refresh via Keycloak if near expiry)
  opt token near expiry
    CLI->>KC: POST /token (refresh_token)
    KC-->>CLI: { access_token, expires_in }
  end
  CLI->>Server: spawn child (AXE_ACCESS_TOKEN, refresh port + secret injected)
  Client->>CLI: MCP JSON-RPC (stdin)
  CLI->>Server: bridged stdin
  Server->>CLI: bridged stdout
  CLI->>Client: MCP JSON-RPC (stdout)
  loop each interval, before expiry
    CLI->>KC: POST /token (refresh_token)
    KC-->>CLI: { access_token }
    CLI->>Server: POST /token (x-refresh-secret) — swap in-memory token
  end
  alt wrapped server exits on its own
    Server-->>CLI: child exits
    CLI-->>Client: exit with the child's code
  else session ends (stdin EOF, forwarded signal, launcher gone, or fatal startup abort)
    CLI->>Server: polite rung — forwarded signal or SIGTERM across the process group, or a closed stdin on Windows
    opt still alive after the grace period
      CLI->>Server: SIGKILL (process group, or taskkill /T /F on Windows)
    end
    Server-->>CLI: child exits
  end
```

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.
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.
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.
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.
5. When the wrapped server exits on its own, `run` exits with the same code.
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.

#### Known limitations

Two cases are bounded rather than closed.

- **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).
- **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.

### `axe-auth logout`

```mermaid
sequenceDiagram
  participant User as Developer
  participant CLI as axe-auth CLI
  participant KC as Customer Keycloak

  User->>CLI: axe-auth logout
  Note over CLI: load stored entry from OS keychain
  alt no entry stored
    CLI-->>User: "Already logged out."
  else entry unreadable (corrupt or version-mismatch)
    Note over CLI: clear OS keychain entry
    CLI-->>User: stderr warning + ✓ Logged out.
  else valid entry
    CLI->>KC: GET /.well-known/openid-configuration
    KC-->>CLI: { revocation_endpoint }
    CLI->>KC: POST /revoke (refresh_token)
    KC-->>CLI: 200 (best-effort, non-2xx warns but does not block)
    Note over CLI: clear OS keychain entry
    CLI-->>User: ✓ Logged out.
  end
```

1. The developer invokes `axe-auth logout`.
2. The CLI loads the stored entry. If nothing is stored, it prints "Already logged out." and exits 0. If the entry is unreadable (corrupt or under a schema version we cannot migrate), it clears the local entry and exits 0 with a warning on stderr.
3. With a valid entry, the CLI re-fetches the OIDC discovery document to find the revocation endpoint, then POSTs the refresh token there per RFC 7009.
4. Server-side revocation is best-effort: a non-2xx response is logged to stderr but does not block the local clear. The response body is deliberately not echoed (the request body contains the refresh token, and some Keycloak / WAF / reverse-proxy error templates reflect request fields back into 4xx pages).
5. The CLI clears the local OS keychain entry and prints `✓ Logged out.`.

## Communication security

### Transport

- **CLI ↔ Customer Keycloak**: HTTPS by default. The CLI refuses non-loopback http issuers unless explicitly opted in via `--allow-insecure-issuer` (loopback http remains allowed because RFC 8252 §7.3 mandates it). All outbound requests carry a `User-Agent: @deque/axe-auth/v<version>` header per [Service Development Standards §4.4](https://github.com/dequelabs/product-org/blob/main/policies/services/standards.md#44-client-user-agents).
- **Browser ↔ Customer Keycloak**: HTTPS, same as any standard web SSO interaction. `axe-auth` does not see, store, or proxy the developer's password or any SSO session cookies.
- **Browser ↔ Loopback callback**: Loopback HTTP. Per RFC 8252 §7.3 this is acceptable because traffic stays on the local machine. The loopback listener accepts a single request, validates `state` against the value generated at the start of the flow, and shuts down immediately afterwards.
- **CLI ↔ OS keychain**: Native OS APIs (Keychain Services, Credential Manager, Secret Service). Access control is the OS's: the entry is readable only by the same user account on the same machine.

### Flow integrity

- **Authorization-code interception (PKCE)**: The CLI generates a fresh `code_verifier` per login, sends only the `code_challenge` (SHA-256 hash) to the authorize endpoint, and supplies the `code_verifier` at the token-exchange step. An attacker who intercepts the authorization code on the loopback redirect cannot exchange it without the verifier.
- **CSRF on the loopback redirect (`state`)**: The CLI generates a cryptographically random `state` per login, sends it on the authorize request, and rejects any callback whose `state` does not match.

### Token handling

- **Refresh-token confidentiality**: The refresh token never leaves the OS keychain except as the body of the POST to Keycloak's `/token` (refresh) or `/revoke` endpoints. It is never printed to stdout, written to logs, or surfaced in error messages. (See the deliberate response-body suppression in the revocation path, called out under `logout` above.)
- **Access-token surfacing (`axe-auth token`)**: Access tokens are printed to stdout for shell substitution. They appear briefly in process arguments (`ps` on POSIX, Task Manager on Windows) and in terminal scrollback buffers (iTerm2, Terminal.app, tmux). Mitigation guidance is documented in the README's caveats section. Access tokens are short-lived (Keycloak default ~5 minutes), which limits the exposure window.
- **Browser session isolation**: The browser used for OAuth login is the developer's host browser. Any tooling consuming the access token (e.g. the axe MCP server in a Docker container) runs in its own browser context with no shared cookies, storage, or session state.

## Persisted data

`axe-auth` writes exactly one keychain entry per machine, under the service name `axe-auth` and the account name `credentials`. The stored payload is a versioned JSON blob:

```json
{
  "v": 1,
  "accessToken": "...",
  "refreshToken": "...",
  "expiresAt": 1714426800000,
  "issuerURL": "https://auth.customer.example.com/auth/realms/customer",
  "clientId": "axe-auth-cli",
  "allowInsecureIssuer": false,
  "walnutURL": "https://axe.customer.example.com"
}
```

- **Tokens** (`accessToken`, `refreshToken`, `expiresAt`): the OAuth token set returned by Keycloak. `refreshToken` is omitted if the granted scopes did not include `offline_access`.
- **Issuer / client coordinates** (`issuerURL`, `clientId`, `allowInsecureIssuer`): the values the tokens were minted against. Persisting them lets `token` and `logout` operate flag-free after first login: the CLI resolves the right discovery URL, token endpoint, and revocation endpoint from the stored values, with no separate "default issuer" pointer to drift out of sync with the tokens themselves.
- **`walnutURL`**: the originating axe server URL that the SSO discovery used to resolve the OAuth coordinates. Persisted so future verbs can re-discover `/api/sso-config` without user-supplied flags.
- **Schema version** (`v`): incremented when the blob shape changes incompatibly. A mismatch surfaces as `version-mismatch` from `KeyringTokenStore.load()`, and the CLI prompts re-authentication rather than guessing at unknown shapes.

No other persistent state exists. There is no filesystem cache of OIDC discovery documents (each `login` and `logout` re-fetches), no separate config file, and no logs written to disk by default.

## Related documentation

- [`oauth-flow.md`](./oauth-flow.md) — protocol-level walkthrough of the OAuth 2.0 + PKCE flow as implemented here.
- [`callback-server.md`](./callback-server.md) — the `startCallbackServer` API, RFC 8252 §7.3 conformance, port allocation, and listener teardown.
- [`callback-page.md`](./callback-page.md) — the HTML rendered to the developer's browser after the redirect, branding, and CSP rationale.
