diff --git a/.env.example b/.env.example index 1dd08bc..11a35c5 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,21 @@ MCP_TRANSPORT=http # Generate one with: openssl rand -hex 32 MCP_AUTH_TOKEN= +# Per-user OAuth instead of the shared secret (HTTP transport only) +# MCP_AUTH_MODE=oauth makes this server an OAuth resource server (MCP authorization spec): +# callers send an access token from MCP_OAUTH_ISSUER whose audience contains +# MCP_OAUTH_RESOURCE. The server exchanges it (RFC 8693) as MCP_OAUTH_CLIENT_ID - its own +# confidential client, not the one MCP clients sign in with - for a token with audience +# BOOKSTACK_OAUTH_AUDIENCE and calls BookStack as that user. The caller's own +# token is never forwarded. MCP_AUTH_TOKEN, BOOKSTACK_API_TOKEN and BOOKSTACK_UPLOAD_ROOT must +# then be unset, and BookStack must accept OIDC access tokens (OIDC_API_ACCESS_TOKENS=true). +# MCP_AUTH_MODE=oauth +# MCP_OAUTH_ISSUER=https://idp.example.com +# MCP_OAUTH_RESOURCE=https://bookstack-mcp.example.com/message +# MCP_OAUTH_CLIENT_ID=bookstack-mcp-server +# MCP_OAUTH_CLIENT_SECRET= +# BOOKSTACK_OAUTH_AUDIENCE=bookstack-api + # Maximum accepted POST /message request body, in bytes. # Default: 73400320 (70 MiB). Sized from the largest inline upload the image and # attachment tools advertise (50000 KB), which the parser sees base64-encoded and diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..df8ee6d --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,96 @@ +name: Image + +# Builds the Docker image for a v-r tag, smoke-tests it, then pushes it to GHCR. +# is package.json's; bump to re-release the same version with changes on top. +on: + push: + tags: ['v[0-9]+.[0-9]+.[0-9]+-r[0-9]+'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: false + +env: + # Must match the Dockerfile's BUN_IMAGE; the smoke suite fails if they differ. + BUN_VERSION: 1.3.14 + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + image: + name: Build, smoke and push + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Read the version from the tag + id: version + env: + TAG: ${{ github.ref_name }} + run: | + if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-r([0-9]+)$ ]]; then + echo "::error::Tag ${TAG} is not v-r, e.g. v2.1.0-r1." >&2 + exit 1 + fi + PKG_VERSION="$(node -p 'require("./package.json").version')" + if [ "${BASH_REMATCH[1]}" != "$PKG_VERSION" ]; then + echo "::error::Tag ${TAG} names version ${BASH_REMATCH[1]}, but package.json is ${PKG_VERSION}." >&2 + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Image tags and labels + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + flavor: latest=false + tags: type=raw,value=${{ steps.version.outputs.version }} + + - name: Build image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + tags: bookstack-mcp-server:ci + labels: ${{ steps.meta.outputs.labels }} + build-args: SERVER_VERSION=${{ steps.version.outputs.version }} + + - name: Smoke the image + run: bun test tests/transport/docker-smoke.test.ts + env: + RUN_DOCKER_SMOKE: '1' + DOCKER_SMOKE_IMAGE: bookstack-mcp-server:ci + DOCKER_SMOKE_VERSION: ${{ steps.version.outputs.version }} + RUN_INTEGRATION: '0' + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Rebuilds from the builder cache above, so the pushed layers are the ones just smoked. + - name: Push image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: SERVER_VERSION=${{ steps.version.outputs.version }} + provenance: mode=max + sbom: true diff --git a/Dockerfile b/Dockerfile index 5997f6a..46bf933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ RUN bun install --production --frozen-lockfile FROM ${BUN_IMAGE} AS runtime WORKDIR /app ENV NODE_ENV=production +# Set by the image workflow from the release tag; empty falls back to package.json's version. +ARG SERVER_VERSION="" +ENV SERVER_VERSION=${SERVER_VERSION} # Bring in the resolved production dependencies. COPY --from=deps /app/node_modules ./node_modules diff --git a/README.md b/README.md index 187fdf7..42d51ef 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,14 @@ export MCP_TRANSPORT="http" | Variable | Default | Description | | --- | --- | --- | | `MCP_TRANSPORT` | `http` | Transport mode. Only the exact value `stdio` selects stdio; **any other value (or unset) starts the HTTP server**. | -| `MCP_AUTH_TOKEN` | _(none — **required** for HTTP)_ | **Inbound** secret callers must present as `Authorization: Bearer ` on `POST /message`. The HTTP transport **refuses to start** without it — there is no "no auth" mode. Unrelated to `BOOKSTACK_API_TOKEN`, and must not be the same value. Ignored in stdio mode. Generate with `openssl rand -hex 32`. | +| `MCP_AUTH_TOKEN` | _(none — **required** for HTTP, unless `MCP_AUTH_MODE=oauth`)_ | **Inbound** secret callers must present as `Authorization: Bearer ` on `POST /message`. The HTTP transport **refuses to start** without it — there is no "no auth" mode. Unrelated to `BOOKSTACK_API_TOKEN`, and must not be the same value. Ignored in stdio mode. Generate with `openssl rand -hex 32`. | +| `MCP_AUTH_MODE` | `token` | `token` uses `MCP_AUTH_TOKEN`; `oauth` makes the HTTP transport an OAuth resource server that calls BookStack as each user. See [Per-user OAuth](#per-user-oauth). | +| `MCP_OAUTH_ISSUER` | _(required with `oauth`)_ | OIDC issuer the access tokens come from (its exact `iss` value). | +| `MCP_OAUTH_RESOURCE` | _(required with `oauth`)_ | This server's public `/message` URL. Access tokens must carry it in `aud`. | +| `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET` | _(required with `oauth`)_ | Confidential client only this server holds, used for token exchange. Not the client MCP clients sign in with. | +| `BOOKSTACK_OAUTH_AUDIENCE` | _(required with `oauth`)_ | Audience BookStack requires on access tokens (its `OIDC_API_AUDIENCE`). | | `BOOKSTACK_BASE_URL` | `http://localhost:8080/api` | Full URL to the BookStack API. Must be a valid URL and include the `/api` suffix. | -| `BOOKSTACK_API_TOKEN` | _(none — required)_ | **Outbound** BookStack API token as `token_id:token_secret`. This is the credential the server spends on every tool call. Startup fails if unset. | +| `BOOKSTACK_API_TOKEN` | _(none — required, unless `MCP_AUTH_MODE=oauth`)_ | **Outbound** BookStack API token as `token_id:token_secret`. This is the credential the server spends on every tool call. Startup fails if unset. | | `BOOKSTACK_TIMEOUT` | `30000` | BookStack request timeout in milliseconds. | | `SERVER_PORT` | `3000` | Port the HTTP transport listens on. Ignored in stdio mode. | | `HTTP_BODY_LIMIT` | `73400320` (70 MiB) | Maximum accepted `POST /message` body, in bytes. Sized for the largest inline base64 upload the image/attachment tools advertise (50,000 KB). Express's own default is ~100 KB, which would reject real uploads with a `413`. Lower it if untrusted callers can reach the port. | @@ -122,8 +127,9 @@ The transport is chosen at startup from `MCP_TRANSPORT`: ### HTTP endpoints -When running in HTTP mode the server exposes exactly three endpoints. Any other -path returns a JSON `404` listing the valid ones. +When running in HTTP mode the server exposes three endpoints, plus the OAuth metadata +document in [OAuth mode](#per-user-oauth). Any other path returns a JSON `404` listing +the valid ones. | Method & path | Purpose | Status codes | | --- | --- | --- | @@ -210,7 +216,47 @@ curl -X POST http://localhost:3000/message \ Per-request credential overrides are supported on `POST /message` via the `x-bookstack-url` and `x-bookstack-token` headers; both fall back to the -`BOOKSTACK_BASE_URL` / `BOOKSTACK_API_TOKEN` environment variables. +`BOOKSTACK_BASE_URL` / `BOOKSTACK_API_TOKEN` environment variables. OAuth mode refuses them. + +### Per-user OAuth + +With `MCP_AUTH_MODE=oauth` the HTTP transport follows the +[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization): +each person signs in with your OIDC provider, and BookStack sees their own account, +permissions and audit trail instead of one shared API token. + +1. A request without a token gets `401` with + `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource/message"`, + which points the client at `MCP_OAUTH_ISSUER`. +2. Each access token is checked against the issuer's published keys (RS256), plus `iss`, `exp`, `sub`, and + an `aud` containing `MCP_OAUTH_RESOURCE`. +3. The server exchanges it (RFC 8693) as `MCP_OAUTH_CLIENT_ID` for a token with + `aud=BOOKSTACK_OAUTH_AUDIENCE`, cached until shortly before it expires, and calls + BookStack with that. **The caller's token is never forwarded.** + +Requirements: + +- BookStack accepts OIDC access tokens on its API (`OIDC_API_ACCESS_TOKENS=true`, from + [BookStack PR 6237](https://codeberg.org/bookstack/bookstack/pulls/6237)), with + `OIDC_API_ALLOWED_CLIENTS` set to `MCP_OAUTH_CLIENT_ID`. Users must have logged in to + BookStack once. +- Two OAuth clients. The one MCP clients sign in with issues access tokens whose `aud` + contains `MCP_OAUTH_RESOURCE` and `MCP_OAUTH_CLIENT_ID`, but **not** BookStack's audience, + so those tokens are useless against BookStack directly. Most providers ignore RFC 8707's + `resource` parameter, so configure that audience as a fixed one. `MCP_OAUTH_CLIENT_ID` is + a separate confidential client, held only by this server, that may exchange those tokens + for `BOOKSTACK_OAUTH_AUDIENCE`. +- `MCP_AUTH_TOKEN`, `BOOKSTACK_API_TOKEN` and `BOOKSTACK_UPLOAD_ROOT` are unset; the server + refuses to start otherwise (an upload root would be readable by every user). + +`GET /health` then checks issuer discovery and that BookStack answers, since there is +no service token to call it with. Connect Claude Code with the sign-in client, using the +callback port registered for it: + +```bash +claude mcp add --transport http --client-id --client-secret \ + --callback-port bookstack https://bookstack-mcp.example.com/message +``` ### Using with n8n diff --git a/bun.lock b/bun.lock index cc30f0a..d89ba8b 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "axios": "1.18.1", "dotenv": "17.4.2", "express": "5.2.1", + "jose": "6.2.3", "winston": "3.19.0", "zod": "4.4.3", }, diff --git a/docker-compose.yml b/docker-compose.yml index 30149ef..40043d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,6 +108,13 @@ services: # /message dispatches all 59 tools (permanent-delete, users, roles, # permissions) using BOOKSTACK_API_TOKEN. Generate with `openssl rand -hex 32`. MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-} + # Per-user OAuth instead of the two tokens above; see .env.example. + MCP_AUTH_MODE: ${MCP_AUTH_MODE:-} + MCP_OAUTH_ISSUER: ${MCP_OAUTH_ISSUER:-} + MCP_OAUTH_RESOURCE: ${MCP_OAUTH_RESOURCE:-} + MCP_OAUTH_CLIENT_ID: ${MCP_OAUTH_CLIENT_ID:-} + MCP_OAUTH_CLIENT_SECRET: ${MCP_OAUTH_CLIENT_SECRET:-} + BOOKSTACK_OAUTH_AUDIENCE: ${BOOKSTACK_OAUTH_AUDIENCE:-} SERVER_PORT: "3000" NODE_ENV: production ports: diff --git a/docs/releasing.md b/docs/releasing.md index a89565a..a9dcc22 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -373,6 +373,30 @@ Recover with one of: npm pack --dry-run # exactly what a release would contain ``` +## Docker image + +Images are versioned `-r`: `` is `package.json#version`, and +`` counts releases of that version, so a change made here without a new +`package.json` version still gets a new image. Start each version at `r1`. + +Pushing a tag like `v2.1.0-r1` runs `.github/workflows/image.yml`, which: + +1. refuses a tag whose `` is not `package.json#version`; +2. builds the image with `SERVER_VERSION=2.1.0-r1`, so `GET /` and MCP `initialize` report it; +3. runs the image smoke suite against it; +4. only then pushes `ghcr.io//bookstack-mcp-server:2.1.0-r1`, with provenance and + an SBOM. No `latest` or moving tags: deploy by digest. + +```bash +git tag v2.1.0-r1 && git push origin v2.1.0-r1 +gh workflow run image.yml --ref v2.1.0-r1 # rebuild an existing tag +``` + +Plain `vX.Y.Z` tags, including the ones release-please creates, build no image. To +follow these tags with Renovate, use regex versioning such as +`regex:^(?\d+)\.(?\d+)\.(?\d+)-r(?\d+)$`; its default +Docker versioning reads `-r1` and `-r2` as different variants and never upgrades between them. + --- ## Doing a release by hand (escape hatch) diff --git a/docs/setup-guide.md b/docs/setup-guide.md index d9143b8..d0c0341 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -168,6 +168,10 @@ MCP_TRANSPORT=http # The stdio transport ignores it. MCP_AUTH_TOKEN= +# Or per-user OAuth instead of MCP_AUTH_TOKEN/BOOKSTACK_API_TOKEN; see the README's +# "Per-user OAuth" section. +# MCP_AUTH_MODE=oauth + # BookStack API Configuration BOOKSTACK_BASE_URL=http://localhost:8080/api BOOKSTACK_API_TOKEN=your-api-token-here diff --git a/package.json b/package.json index b179277..3de0c77 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "axios": "1.18.1", "dotenv": "17.4.2", "express": "5.2.1", + "jose": "6.2.3", "winston": "3.19.0", "zod": "4.4.3" }, diff --git a/src/api/client.ts b/src/api/client.ts index 149b19c..a74110c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -428,6 +428,30 @@ function uploadFilename(params: UploadParams, fileField: string, bytes: Buffer): return `${name || 'upload'}${fallbackExt}`; } +/** + * What this client authenticates to BookStack with. + * + * `Token` is a BookStack API token (`id:secret`). `Bearer` is an OIDC access token for the + * BookStack API audience; `principal` names its user, so rate limits follow the user rather + * than each short-lived token. + */ +export type BookStackCredential = + | { scheme: 'Token'; secret: string } + | { scheme: 'Bearer'; secret: string; principal: string }; + +/** Startup refusal when no BookStack credential is configured; worded as the config check always was. */ +export const MISSING_API_TOKEN_MESSAGE = + 'Configuration validation failed: bookstack.apiToken: BookStack API token is required - ' + + 'set BOOKSTACK_API_TOKEN environment variable'; + +/** The configured API token as a credential; refuses when none is configured. */ +function configuredCredential(config: Config): BookStackCredential { + if (!config.bookstack.apiToken) { + throw new Error(MISSING_API_TOKEN_MESSAGE); + } + return { scheme: 'Token', secret: config.bookstack.apiToken }; +} + /** * BookStack API Client * @@ -441,7 +465,12 @@ export class BookStackClient implements BookStackAPIClient { private rateLimiter: RateLimiter; private config: Config; - constructor(config: Config, logger: Logger, errorHandler: ErrorHandler) { + constructor( + config: Config, + logger: Logger, + errorHandler: ErrorHandler, + credential: BookStackCredential = configuredCredential(config) + ) { this.config = config; this.logger = logger; this.errorHandler = errorHandler; @@ -468,7 +497,9 @@ export class BookStackClient implements BookStackAPIClient { // their own budget rather than draining someone else's. this.rateLimiter = getSharedRateLimiter({ baseUrl, - apiToken: config.bookstack.apiToken, + // A Bearer token rotates every few minutes, so its bucket is keyed on the user instead. + apiToken: + credential.scheme === 'Token' ? credential.secret : `Bearer\u0000${credential.principal}`, requestsPerMinute: config.rateLimit.requestsPerMinute, burstLimit: config.rateLimit.burstLimit, }); @@ -487,7 +518,7 @@ export class BookStackClient implements BookStackAPIClient { timeout: config.bookstack.timeout, httpsAgent, headers: { - Authorization: `Token ${config.bookstack.apiToken}`, + Authorization: `${credential.scheme} ${credential.secret}`, 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `${config.server.name}/${config.server.version}`, diff --git a/src/auth/exchange.ts b/src/auth/exchange.ts new file mode 100644 index 0000000..c3a4437 --- /dev/null +++ b/src/auth/exchange.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { BookStackCredential } from '../api/client'; +import { AUTH_SERVER_TIMEOUT_MS, type OidcIssuer } from './oidc'; + +const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; + +/** Stop reusing an exchanged token this long before it expires. */ +const EXPIRY_MARGIN_MS = 30_000; + +/** Default bound on cached exchanged tokens. */ +const DEFAULT_MAX_ENTRIES = 1_000; + +/** OAuth error codes that mean the user's token no longer works, so the client should re-authenticate. */ +const USER_TOKEN_ERRORS: ReadonlySet = new Set(['invalid_grant', 'invalid_token']); + +/** RFC 6749 / 8693 error codes; anything else is logged as "other". */ +const KNOWN_OAUTH_ERRORS: ReadonlySet = new Set([ + 'invalid_request', + 'invalid_client', + 'invalid_grant', + 'unauthorized_client', + 'unsupported_grant_type', + 'invalid_scope', + 'invalid_target', + 'invalid_token', + 'access_denied', + 'server_error', + 'temporarily_unavailable', +]); + +/** + * Why an exchange failed. + * - `rejected`: the user's token was refused; the MCP client should re-authenticate (401). + * - `forbidden`: the authorization server denied this user the exchange (403). + * - `misconfigured`: this server's client or the realm setup is wrong (500). + * - `unavailable`: the authorization server could not be reached, was throttling, or answered unusably (503). + */ +export type TokenExchangeFailure = 'rejected' | 'forbidden' | 'misconfigured' | 'unavailable'; + +export class TokenExchangeError extends Error { + constructor( + readonly kind: TokenExchangeFailure, + /** HTTP status from the token endpoint, when there was one. */ + readonly status?: number, + /** The OAuth error code, limited to the known vocabulary so it is safe to log. */ + readonly oauthError?: string + ) { + super(`Token exchange failed (${kind})`); + this.name = 'TokenExchangeError'; + } +} + +export interface BookStackTokenExchangerOptions { + issuer: OidcIssuer; + clientId: string; + clientSecret: string; + /** Audience BookStack requires on access tokens (OIDC_API_AUDIENCE). */ + audience: string; + maxEntries?: number; +} + +interface CachedToken { + secret: string; + reuseUntilMs: number; +} + +function classifyFailure(status: number, code: string | undefined): TokenExchangeFailure { + if (status >= 500 || status === 429) { + return 'unavailable'; + } + if (code === 'access_denied') { + return 'forbidden'; + } + return code !== undefined && USER_TOKEN_ERRORS.has(code) ? 'rejected' : 'misconfigured'; +} + +/** RFC 6749 section 2.3.1: client credentials are form-encoded before Basic encoding. */ +function basicAuthorization(clientId: string, clientSecret: string): string { + const encode = (value: string) => encodeURIComponent(value).replace(/%20/g, '+'); + return `Basic ${Buffer.from(`${encode(clientId)}:${encode(clientSecret)}`).toString('base64')}`; +} + +/** + * Exchanges a verified inbound token for a BookStack API token (RFC 8693), as this server's + * own confidential client. The inbound token is never sent to BookStack. + * + * Results are cached under a digest of the inbound token until shortly before the exchanged + * token expires, and concurrent requests for the same token share one exchange. + */ +export class BookStackTokenExchanger { + private readonly cache = new Map(); + private readonly pending = new Map>(); + private readonly maxEntries: number; + + constructor(private readonly options: BookStackTokenExchangerOptions) { + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + } + + async exchange(auth: AuthInfo): Promise { + const principal = `${String(auth.extra?.issuer)}\u0000${String(auth.extra?.subject)}`; + const key = createHash('sha256').update(auth.token).digest('hex'); + + const cached = this.cache.get(key); + if (cached && cached.reuseUntilMs > Date.now()) { + return { scheme: 'Bearer', secret: cached.secret, principal }; + } + this.cache.delete(key); + + let inFlight = this.pending.get(key); + if (!inFlight) { + inFlight = this.request(auth, key, principal).finally(() => this.pending.delete(key)); + this.pending.set(key, inFlight); + } + return inFlight; + } + + private async request( + auth: AuthInfo, + key: string, + principal: string + ): Promise { + const { tokenEndpoint } = await this.options.issuer.metadata(); + + let response: Response; + try { + response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + authorization: basicAuthorization(this.options.clientId, this.options.clientSecret), + }, + body: new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: auth.token, + subject_token_type: ACCESS_TOKEN_TYPE, + requested_token_type: ACCESS_TOKEN_TYPE, + audience: this.options.audience, + }), + // A redirect would resend the subject token and client secret to wherever it points. + redirect: 'error', + signal: AbortSignal.timeout(AUTH_SERVER_TIMEOUT_MS), + }); + } catch { + throw new TokenExchangeError('unavailable'); + } + + const body = (await response.json().catch(() => undefined)) as + | Record + | undefined; + + if (!response.ok) { + const code = typeof body?.error === 'string' ? body.error : undefined; + throw new TokenExchangeError( + classifyFailure(response.status, code), + response.status, + code === undefined || KNOWN_OAUTH_ERRORS.has(code) ? code : 'other' + ); + } + + const secret = body?.access_token; + if (typeof secret !== 'string' || secret === '') { + throw new TokenExchangeError('unavailable', response.status); + } + + this.remember(key, secret, auth, body?.expires_in); + return { scheme: 'Bearer', secret, principal }; + } + + private remember(key: string, secret: string, auth: AuthInfo, expiresIn: unknown): void { + const now = Date.now(); + // Bounded by the exchanged token only: the inbound token is re-verified on every request. + const exchangedExpiryMs = + typeof expiresIn === 'number' ? now + expiresIn * 1000 : (auth.expiresAt ?? 0) * 1000; + const reuseUntilMs = exchangedExpiryMs - EXPIRY_MARGIN_MS; + if (reuseUntilMs <= now) { + return; + } + + if (this.cache.size >= this.maxEntries) { + for (const [cachedKey, entry] of this.cache) { + if (entry.reuseUntilMs <= now) { + this.cache.delete(cachedKey); + } + } + } + while (this.cache.size >= this.maxEntries) { + const oldest = this.cache.keys().next().value as string; + this.cache.delete(oldest); + } + this.cache.set(key, { secret, reuseUntilMs }); + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..bab134c --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,44 @@ +import type { RequestHandler } from 'express'; +import type { OAuthConfig } from '../config/manager'; +import type { Logger } from '../utils/logger'; +import { BookStackTokenExchanger } from './exchange'; +import { protectedResourceMetadata, protectedResourceMetadataPath } from './metadata'; +import { requireOAuth } from './middleware'; +import { OidcIssuer } from './oidc'; +import { JwtAccessTokenVerifier } from './verifier'; + +export { bookstackCredential } from './middleware'; + +/** Everything the HTTP transport needs to act as an OAuth resource server. */ +export interface OAuthResourceServer { + issuer: OidcIssuer; + /** Authenticates POST /message and resolves the caller's BookStack credential. */ + authenticate: RequestHandler; + metadataPath: string; + metadata: ReturnType; +} + +export function createOAuthResourceServer( + oauth: OAuthConfig, + logger: Logger, + bodyLimitBytes: number +): OAuthResourceServer { + const issuer = new OidcIssuer(oauth.issuer); + return { + issuer, + authenticate: requireOAuth({ + verifier: new JwtAccessTokenVerifier({ issuer, resource: oauth.resource }), + exchanger: new BookStackTokenExchanger({ + issuer, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + audience: oauth.bookstackAudience, + }), + resource: oauth.resource, + logger, + bodyLimitBytes, + }), + metadataPath: protectedResourceMetadataPath(oauth.resource), + metadata: protectedResourceMetadata(oauth.resource, oauth.issuer), + }; +} diff --git a/src/auth/metadata.ts b/src/auth/metadata.ts new file mode 100644 index 0000000..d013b1a --- /dev/null +++ b/src/auth/metadata.ts @@ -0,0 +1,27 @@ +/** RFC 9728 section 3.1: metadata lives at the well-known prefix followed by the resource's path. */ +export function protectedResourceMetadataPath(resource: string): string { + const { pathname } = new URL(resource); + return `/.well-known/oauth-protected-resource${pathname === '/' ? '' : pathname}`; +} + +export function protectedResourceMetadataUrl(resource: string): string { + return `${new URL(resource).origin}${protectedResourceMetadataPath(resource)}`; +} + +/** The OAuth 2.0 Protected Resource Metadata document (RFC 9728) for this server. */ +export function protectedResourceMetadata(resource: string, issuer: string) { + return { + resource, + authorization_servers: [issuer], + bearer_methods_supported: ['header'], + }; +} + +/** + * A Bearer challenge pointing clients at the metadata (RFC 6750 section 3, RFC 9728 section 5.1). + * Without an error code when no token was sent, per RFC 6750 section 3.1. + */ +export function bearerChallenge(resource: string, error?: 'invalid_token'): string { + const errorPart = error ? `error="${error}", ` : ''; + return `Bearer ${errorPart}resource_metadata="${protectedResourceMetadataUrl(resource)}"`; +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..23064cf --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,126 @@ +import type { Request, RequestHandler, Response } from 'express'; +import type { BookStackCredential } from '../api/client'; +import type { Logger } from '../utils/logger'; +import { type BookStackTokenExchanger, TokenExchangeError } from './exchange'; +import { bearerChallenge } from './metadata'; +import { AuthServerUnavailableError } from './oidc'; +import { AccessTokenRejectedError, type JwtAccessTokenVerifier } from './verifier'; + +export interface OAuthMiddlewareOptions { + verifier: JwtAccessTokenVerifier; + exchanger: BookStackTokenExchanger; + resource: string; + logger: Logger; + /** Most body bytes read and discarded before an early answer; past it the request is dropped. */ + bodyLimitBytes: number; +} + +/** The BookStack credential the OAuth middleware resolved for this request. */ +export function bookstackCredential(res: Response): BookStackCredential { + return res.locals.bookstackCredential as BookStackCredential; +} + +function challenge(res: Response, resource: string, invalid: boolean): void { + res.setHeader( + 'WWW-Authenticate', + bearerChallenge(resource, invalid ? 'invalid_token' : undefined) + ); + res.status(401).json({ + error: 'Unauthorized', + message: invalid + ? 'The access token is invalid, expired or not issued for this server.' + : 'POST /message requires an OAuth access token; see the WWW-Authenticate header.', + }); +} + +function unavailable(res: Response): void { + res.status(503).json({ + error: 'Service Unavailable', + message: 'The authorization server could not be reached. Try again shortly.', + }); +} + +/** + * Read and discard an unread request body before answering early, as Node does on its own. + * Under Bun, a request answered after an await without its body read is never released. + */ +function discardBody(req: Request, limitBytes: number): Promise { + if (req.readableEnded || req.destroyed) { + return Promise.resolve(); + } + return new Promise((resolve) => { + let seen = 0; + req.on('data', (chunk: Buffer) => { + seen += chunk.length; + if (seen > limitBytes) { + req.destroy(); + } + }); + req.once('end', () => resolve()); + req.once('close', () => resolve()); + req.once('error', () => resolve()); + }); +} + +/** + * Authenticate a request as an OAuth resource server, then exchange the caller's token + * for a BookStack credential (stored for bookstackCredential()). The inbound token goes + * no further than the authorization server. + */ +export function requireOAuth(options: OAuthMiddlewareOptions): RequestHandler { + const { verifier, exchanger, resource, logger, bodyLimitBytes } = options; + + return async (req, res, next) => { + const token = req.headers.authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1]; + if (token === undefined) { + challenge(res, resource, false); + return; + } + + try { + const auth = await verifier.verifyAccessToken(token); + res.locals.bookstackCredential = await exchanger.exchange(auth); + } catch (error) { + await discardBody(req, bodyLimitBytes); + if (error instanceof AccessTokenRejectedError) { + logger.info('OAuth access token rejected', { type: error.reason }); + challenge(res, resource, true); + } else if (error instanceof TokenExchangeError && error.kind === 'rejected') { + logger.info('OAuth token exchange refused the access token', { + status: error.status, + oauth_error: error.oauthError, + }); + challenge(res, resource, true); + } else if (error instanceof TokenExchangeError && error.kind === 'forbidden') { + logger.warn('OAuth token exchange denied for this user', { + status: error.status, + oauth_error: error.oauthError, + }); + res.status(403).json({ + error: 'Forbidden', + message: 'The authorization server does not allow BookStack access for this account.', + }); + } else if (error instanceof TokenExchangeError && error.kind === 'misconfigured') { + logger.error('OAuth token exchange is misconfigured', { + status: error.status, + oauth_error: error.oauthError, + }); + res.status(500).json({ + error: 'Internal Server Error', + message: 'This server could not obtain a BookStack token; see its logs.', + }); + } else if ( + error instanceof TokenExchangeError || + error instanceof AuthServerUnavailableError + ) { + logger.warn('OAuth authorization server unavailable', { err: error }); + unavailable(res); + } else { + next(error); + } + return; + } + + next(); + }; +} diff --git a/src/auth/oidc.ts b/src/auth/oidc.ts new file mode 100644 index 0000000..d950fa6 --- /dev/null +++ b/src/auth/oidc.ts @@ -0,0 +1,107 @@ +import { createRemoteJWKSet, type JWTVerifyGetKey } from 'jose'; +import { usesSecureTransport } from '../config/manager'; + +/** Timeout for every call this server makes to the authorization server. */ +export const AUTH_SERVER_TIMEOUT_MS = 5_000; + +/** The authorization server could not be reached or answered unusably; retryable. */ +export class AuthServerUnavailableError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'AuthServerUnavailableError'; + } +} + +/** The parts of the issuer's OpenID Connect discovery document this server uses. */ +export interface IssuerMetadata { + issuer: string; + jwksUri: string; + tokenEndpoint: string; +} + +/** A discovery endpoint URL, refused unless it is absolute and over TLS (or loopback). */ +function endpoint(value: unknown, name: string): string { + if (typeof value === 'string') { + try { + if (usesSecureTransport(new URL(value))) { + return value; + } + } catch { + // Falls through to the refusal below. + } + } + throw new AuthServerUnavailableError(`OIDC discovery returned an unusable ${name}`); +} + +/** + * The configured OIDC issuer: its discovery document and signing keys. + * + * Discovery is fetched once and kept; a failure is not cached, so the next request retries. + * Keys come from jose's remote key set, which caches them and refetches on an unknown key ID. + */ +export class OidcIssuer { + private discovered: Promise | undefined; + private keys: JWTVerifyGetKey | undefined; + + constructor(readonly issuer: string) {} + + metadata(): Promise { + if (!this.discovered) { + const attempt = this.discover(); + this.discovered = attempt; + attempt.catch(() => { + if (this.discovered === attempt) { + this.discovered = undefined; + } + }); + } + return this.discovered; + } + + /** Fetch discovery afresh, bypassing the cache; for readiness checks. */ + async probe(): Promise { + await this.discover(); + } + + async keySet(): Promise { + if (!this.keys) { + const { jwksUri } = await this.metadata(); + this.keys ??= createRemoteJWKSet(new URL(jwksUri), { + timeoutDuration: AUTH_SERVER_TIMEOUT_MS, + }); + } + return this.keys; + } + + private async discover(): Promise { + const url = `${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; + let document: Record; + try { + const response = await fetch(url, { + headers: { accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(AUTH_SERVER_TIMEOUT_MS), + }); + if (!response.ok) { + throw new AuthServerUnavailableError(`OIDC discovery answered HTTP ${response.status}`); + } + document = (await response.json()) as Record; + } catch (error) { + if (error instanceof AuthServerUnavailableError) { + throw error; + } + throw new AuthServerUnavailableError('OIDC discovery failed', { cause: error }); + } + + // OIDC Discovery 1.0 section 4.3: the document must name exactly the issuer it was fetched for. + if (document.issuer !== this.issuer) { + throw new AuthServerUnavailableError('OIDC discovery names a different issuer'); + } + + return { + issuer: this.issuer, + jwksUri: endpoint(document.jwks_uri, 'jwks_uri'), + tokenEndpoint: endpoint(document.token_endpoint, 'token_endpoint'), + }; + } +} diff --git a/src/auth/verifier.ts b/src/auth/verifier.ts new file mode 100644 index 0000000..9a3269b --- /dev/null +++ b/src/auth/verifier.ts @@ -0,0 +1,92 @@ +import type { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { errors, jwtVerify } from 'jose'; +import { AuthServerUnavailableError, type OidcIssuer } from './oidc'; + +/** Algorithms accepted on inbound tokens; RS256 matches what BookStack's OIDC support verifies. */ +const ALLOWED_ALGORITHMS = ['RS256']; + +/** Tolerated clock skew between this server and the issuer, in seconds. */ +const CLOCK_TOLERANCE_SECONDS = 30; + +/** jose error codes that mean the token itself is bad, as opposed to the key set being unreachable. */ +const TOKEN_FAULT_CODES: ReadonlySet = new Set([ + errors.JWTExpired.code, + errors.JWTClaimValidationFailed.code, + errors.JWTInvalid.code, + errors.JWSInvalid.code, + errors.JWSSignatureVerificationFailed.code, + errors.JWKSNoMatchingKey.code, + errors.JWKSMultipleMatchingKeys.code, + errors.JOSEAlgNotAllowed.code, + errors.JOSENotSupported.code, +]); + +/** The inbound access token is missing, malformed, expired or not issued for this server. */ +export class AccessTokenRejectedError extends Error { + constructor(readonly reason: string) { + super('The access token is invalid, expired or not issued for this server'); + this.name = 'AccessTokenRejectedError'; + } +} + +export interface JwtAccessTokenVerifierOptions { + issuer: OidcIssuer; + /** This server's canonical URL, which the token's audience must contain. */ + resource: string; +} + +/** + * Validates inbound JWT access tokens as an OAuth resource server (MCP authorization). + * + * Signature against the issuer's published keys, exact issuer, expiry, a subject, and an + * audience naming this server. A token for any other audience - including one for BookStack + * itself, or an ID token for the client - is refused. + */ +export class JwtAccessTokenVerifier implements OAuthTokenVerifier { + private readonly audiences: string[]; + + constructor(private readonly options: JwtAccessTokenVerifierOptions) { + const withoutSlash = options.resource.replace(/\/$/, ''); + this.audiences = [withoutSlash, `${withoutSlash}/`]; + } + + async verifyAccessToken(token: string): Promise { + const keySet = await this.options.issuer.keySet(); + + let payload: Record; + try { + ({ payload } = await jwtVerify(token, keySet, { + issuer: this.options.issuer.issuer, + audience: this.audiences, + algorithms: ALLOWED_ALGORITHMS, + requiredClaims: ['exp', 'sub'], + clockTolerance: CLOCK_TOLERANCE_SECONDS, + })); + } catch (error) { + if (error instanceof errors.JOSEError && TOKEN_FAULT_CODES.has(error.code)) { + throw new AccessTokenRejectedError(error.code); + } + throw new AuthServerUnavailableError('Could not load the issuer signing keys', { + cause: error, + }); + } + + if (typeof payload.sub !== 'string' || payload.sub === '') { + throw new AccessTokenRejectedError('ERR_JWT_MISSING_SUBJECT'); + } + // Issuers that label token kinds in `typ` call access tokens `Bearer`; ID or refresh tokens must not pass. + if (payload.typ !== undefined && payload.typ !== 'Bearer') { + throw new AccessTokenRejectedError('ERR_JWT_NOT_ACCESS_TOKEN'); + } + + return { + token, + clientId: typeof payload.azp === 'string' ? payload.azp : '', + scopes: typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : [], + expiresAt: payload.exp as number, + resource: new URL(this.options.resource), + extra: { issuer: this.options.issuer.issuer, subject: payload.sub }, + }; + } +} diff --git a/src/config/manager.ts b/src/config/manager.ts index 120c840..45d0e29 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -59,9 +59,8 @@ const baseUrlCheck = (value: string, ctx: z.RefinementCtx): void => { export const ConfigSchema = z.object({ bookstack: z.object({ baseUrl: z.string().superRefine(baseUrlCheck).default('http://localhost:8080/api'), - apiToken: z - .string() - .min(1, 'BookStack API token is required - set BOOKSTACK_API_TOKEN environment variable'), + // Optional here because OAuth mode authenticates per user; BookStackClient requires it otherwise. + apiToken: z.string().min(1).optional(), timeout: z.number().positive().default(30000), }), server: z.object({ @@ -119,6 +118,74 @@ export type Config = z.infer; */ export const DEFAULT_HTTP_BODY_LIMIT_BYTES = 70 * 1024 * 1024; // 73,400,320 +/** Hosts on which plain http is acceptable for OAuth endpoints (local development). */ +const LOOPBACK_HOSTS: ReadonlySet = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** Whether an OAuth URL travels over TLS, or stays on loopback. */ +export function usesSecureTransport(url: URL): boolean { + return ( + url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)) + ); +} + +/** An OAuth URL setting: https (or loopback http), with no credentials, query or fragment. */ +function oauthUrl(name: string) { + return z + .string({ error: `${name} is required when MCP_AUTH_MODE=oauth` }) + .superRefine((value, ctx) => { + let url: URL; + try { + url = new URL(value); + } catch { + ctx.addIssue({ code: 'custom', message: `${name} must be an absolute URL` }); + return; + } + if (!usesSecureTransport(url)) { + ctx.addIssue({ code: 'custom', message: `${name} must use https (http only on loopback)` }); + } + if (url.username || url.password || url.search || url.hash) { + ctx.addIssue({ + code: 'custom', + message: `${name} must not carry credentials, a query string or a fragment`, + }); + } + }); +} + +/** A required non-empty OAuth string setting. */ +function oauthString(name: string) { + return z + .string({ error: `${name} is required when MCP_AUTH_MODE=oauth` }) + .min(1, `${name} is required when MCP_AUTH_MODE=oauth`); +} + +/** + * OAuth resource-server settings, used when MCP_AUTH_MODE=oauth. + * + * Inbound tokens must be issued by `issuer` with `resource` in their audience; each one is + * exchanged (RFC 8693) as `clientId` for a token with `bookstackAudience`, which is what + * BookStack receives. The inbound token itself is never forwarded. + */ +export const OAuthConfigSchema = z + .object({ + issuer: oauthUrl('MCP_OAUTH_ISSUER'), + resource: oauthUrl('MCP_OAUTH_RESOURCE'), + clientId: oauthString('MCP_OAUTH_CLIENT_ID'), + clientSecret: oauthString('MCP_OAUTH_CLIENT_SECRET'), + bookstackAudience: oauthString('BOOKSTACK_OAUTH_AUDIENCE'), + }) + .refine((oauth) => oauth.resource.replace(/\/$/, '') !== oauth.clientId.replace(/\/$/, ''), { + path: ['resource'], + message: + 'MCP_OAUTH_RESOURCE must differ from MCP_OAUTH_CLIENT_ID, or ID tokens pass as access tokens', + }); + +export type OAuthConfig = z.infer; + +const AuthModeSchema = z + .enum(['token', 'oauth'], { error: "MCP_AUTH_MODE must be 'token' or 'oauth'" }) + .default('token'); + /** * Settings that exist only for the HTTP transport. * @@ -146,6 +213,7 @@ export const HttpTransportConfigSchema = z.object({ * outbound credential this server spends on the caller's behalf. */ authToken: z.string().min(1).optional(), + oauth: OAuthConfigSchema.optional(), }); export type HttpTransportConfig = z.infer; @@ -206,7 +274,9 @@ function envString(value: string | undefined): string | undefined { /** Render Zod issues as `path: message` pairs for operator-facing errors. */ function formatZodIssues(error: z.ZodError): string[] { - return error.issues.map((issue: z.core.$ZodIssue) => `${issue.path.join('.')}: ${issue.message}`); + return error.issues.map((issue: z.core.$ZodIssue) => + issue.path.length > 0 ? `${issue.path.join('.')}: ${issue.message}` : issue.message + ); } /** @@ -217,10 +287,28 @@ function formatZodIssues(error: z.ZodError): string[] { */ export function loadHttpTransportConfig(env: EnvSource = process.env): HttpTransportConfig { try { - return HttpTransportConfigSchema.parse({ + const mode = AuthModeSchema.parse(envString(env.MCP_AUTH_MODE)); + const http = HttpTransportConfigSchema.parse({ bodyLimitBytes: envNumber(env.HTTP_BODY_LIMIT), authToken: envString(env.MCP_AUTH_TOKEN), + oauth: + mode === 'oauth' + ? { + issuer: envString(env.MCP_OAUTH_ISSUER), + resource: envString(env.MCP_OAUTH_RESOURCE), + clientId: envString(env.MCP_OAUTH_CLIENT_ID), + clientSecret: envString(env.MCP_OAUTH_CLIENT_SECRET), + bookstackAudience: envString(env.BOOKSTACK_OAUTH_AUDIENCE), + } + : undefined, }); + if (http.oauth && http.authToken !== undefined) { + throw new Error( + 'HTTP transport configuration validation failed: MCP_AUTH_TOKEN must be unset when ' + + 'MCP_AUTH_MODE=oauth, where OAuth access tokens replace the shared secret' + ); + } + return http; } catch (error) { if (error instanceof z.ZodError) { throw new Error( @@ -286,7 +374,7 @@ export class ConfigManager { const rawConfig = { bookstack: { baseUrl: process.env.BOOKSTACK_BASE_URL || 'http://localhost:8080/api', - apiToken: process.env.BOOKSTACK_API_TOKEN || '', + apiToken: process.env.BOOKSTACK_API_TOKEN || undefined, timeout: parseInt(process.env.BOOKSTACK_TIMEOUT || '30000', 10), }, server: { diff --git a/src/server.ts b/src/server.ts index 873bda9..f8489a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,8 @@ import express, { type RequestHandler, type Response, } from 'express'; -import { BookStackClient } from './api/client'; +import { BookStackClient, type BookStackCredential, MISSING_API_TOKEN_MESSAGE } from './api/client'; +import { bookstackCredential, createOAuthResourceServer, type OAuthResourceServer } from './auth'; import { type Config, ConfigManager, @@ -135,7 +136,7 @@ export class BookStackMCPServer { private tools: Map = new Map(); private resources: Map = new Map(); - constructor(configOverrides?: Partial) { + constructor(configOverrides?: Partial, credential?: BookStackCredential) { const baseConfig = ConfigManager.getInstance().getConfig(); // Merge overrides @@ -150,7 +151,7 @@ export class BookStackMCPServer { this.logger = Logger.getInstance(); this.errorHandler = new ErrorHandler(this.logger); this.validator = new ValidationHandler(config.validation); - this.client = new BookStackClient(config, this.logger, this.errorHandler); + this.client = new BookStackClient(config, this.logger, this.errorHandler, credential); // Initialize MCP server this.server = new Server( @@ -534,6 +535,16 @@ export const MISSING_AUTH_TOKEN_MESSAGE = '"Authorization: Bearer ", or use MCP_TRANSPORT=stdio, which has no network ' + 'surface.'; +/** OAuth mode never spends a shared BookStack credential, so one configured alongside it is refused. */ +export const OAUTH_WITH_API_TOKEN_MESSAGE = + 'BOOKSTACK_API_TOKEN must be unset when MCP_AUTH_MODE=oauth: every request calls BookStack as ' + + 'the authenticated user, with a token exchanged for theirs.'; + +/** Every OAuth user could upload, and so read back, any file under a shared upload root. */ +export const OAUTH_WITH_UPLOAD_ROOT_MESSAGE = + 'BOOKSTACK_UPLOAD_ROOT must be unset when MCP_AUTH_MODE=oauth: every authenticated user could ' + + 'upload, and so read, any file under it. Send uploads inline as base64 instead.'; + /** * body-parser tags its failures with a `type` (e.g. 'entity.too.large') and an HTTP * `status`. Neither is on the `Error` interface, so narrow to this shape rather than @@ -711,6 +722,90 @@ export const HEALTH_CHECK_FAILED_MESSAGE = 'The readiness check could not be completed. This is a fault in this server rather than a ' + "verdict about BookStack; see this server's logs for the reason."; +/** What a readiness check reports. */ +type HealthReport = Awaited>; + +/** + * Readiness in OAuth mode, where there is no service credential to call BookStack with: + * the issuer's discovery loads, and BookStack answers at all (its 401 to an anonymous call counts). + */ +async function oauthReadiness( + oauth: OAuthResourceServer, + baseUrl: string, + timeoutMs: number +): Promise { + const logger = Logger.getInstance(); + const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null; + const [authorizationServer, bookstackReachable] = await Promise.all([ + oauth.issuer.probe().then( + () => true, + (error: unknown) => { + logger.warn('Readiness: authorization server discovery failed', { err: error }); + return false; + } + ), + fetch(`${canonicalBaseUrl(baseUrl)}/system`, { signal }).then( + (response) => { + void response.body?.cancel(); + return response.status < 500; + }, + (error: unknown) => { + logger.warn('Readiness: BookStack unreachable', { err: error }); + return false; + } + ), + ]); + + const checks = [ + { + name: 'authorization_server', + healthy: authorizationServer, + message: 'OAuth issuer discovery', + }, + { + name: 'bookstack_reachable', + healthy: bookstackReachable, + message: 'BookStack API reachable', + }, + ]; + return { status: checks.every((check) => check.healthy) ? 'healthy' : 'unhealthy', checks }; +} + +/** + * Per-request credential selection in shared-secret mode. Reachable only by callers that + * cleared the bearer check; it is an outbound-request surface, since x-bookstack-url + * decides which host this server will talk to on the caller's behalf. + */ +function tokenModeOverrides(req: Request, config: Config): Partial { + const bookstackUrl = req.headers['x-bookstack-url'] as string; + const bookstackToken = req.headers['x-bookstack-token'] as string; + + return { + bookstack: { + baseUrl: bookstackUrl || config.bookstack.baseUrl, + apiToken: bookstackToken || config.bookstack.apiToken, + timeout: config.bookstack.timeout, + }, + }; +} + +/** Per-request BookStack overrides would let a caller send the user's BookStack token elsewhere. */ +const refuseUpstreamOverrides: RequestHandler = (req, res, next) => { + if ( + req.headers['x-bookstack-url'] !== undefined || + req.headers['x-bookstack-token'] !== undefined + ) { + res.status(400).json({ + error: 'Bad Request', + message: + 'x-bookstack-url and x-bookstack-token are not accepted with OAuth: BookStack is called ' + + 'as the authenticated user, at the configured BOOKSTACK_BASE_URL.', + }); + return; + } + next(); +}; + /** * Build the Express app that serves the HTTP transport. * @@ -720,14 +815,32 @@ export const HEALTH_CHECK_FAILED_MESSAGE = * suite stayed green. * * Fails closed: without an inbound secret there is no app to listen with, so "no auth - * configured" cannot degrade into "no auth required". + * configured" cannot degrade into "no auth required". With `http.oauth` the app is an OAuth + * resource server instead, and calls BookStack as each authenticated user. */ export function createHttpApp(options: HttpAppOptions): express.Express { const { config, http } = options; - const authToken = http.authToken; - - if (!authToken) { - throw new Error(MISSING_AUTH_TOKEN_MESSAGE); + const oauth = http.oauth + ? createOAuthResourceServer(http.oauth, Logger.getInstance(), http.bodyLimitBytes) + : undefined; + let authenticate: RequestHandler; + + if (oauth) { + if (config.bookstack.apiToken) { + throw new Error(OAUTH_WITH_API_TOKEN_MESSAGE); + } + if (process.env.BOOKSTACK_UPLOAD_ROOT) { + throw new Error(OAUTH_WITH_UPLOAD_ROOT_MESSAGE); + } + authenticate = oauth.authenticate; + } else { + if (!http.authToken) { + throw new Error(MISSING_AUTH_TOKEN_MESSAGE); + } + if (!config.bookstack.apiToken) { + throw new Error(MISSING_API_TOKEN_MESSAGE); + } + authenticate = requireBearerAuth(http.authToken); } // Same reason, same shape: no app, so nothing to listen with. `ConfigSchema` already @@ -754,14 +867,21 @@ export function createHttpApp(options: HttpAppOptions): express.Express { endpoints: { health: '/health (readiness: checks BookStack connectivity)', message: '/message (POST, requires an Authorization: Bearer header)', + ...(oauth ? { oauth_protected_resource: oauth.metadataPath } : {}), }, documentation: 'Send MCP protocol messages to POST /message', }); }); + if (oauth) { + app.get(oauth.metadataPath, (_req, res) => { + res.json(oauth.metadata); + }); + } + /** One readiness answer, with the instant it was true at. */ interface HealthSnapshot { - report: Awaited>; + report: HealthReport; /** Wall clock, for `checked_at` only: a timestamp a reader can act on. */ at: number; /** Monotonic reading, for every age comparison. See monotonicNow(). */ @@ -799,8 +919,10 @@ export function createHttpApp(options: HttpAppOptions): express.Express { */ function checkReadiness(): Promise { if (!checkInFlight) { - checkInFlight = healthServer() - .getHealth() + const probe = oauth + ? oauthReadiness(oauth, config.bookstack.baseUrl, config.bookstack.timeout) + : healthServer().getHealth(); + checkInFlight = probe .then((report) => { const snapshot: HealthSnapshot = { report, @@ -890,8 +1012,10 @@ export function createHttpApp(options: HttpAppOptions): express.Express { // Order is load-bearing. Authentication runs before express.json() below, so an // unauthenticated caller is turned away at the header - it can never make this // process buffer a body up to the ceiling, nor reach a BookStackMCPServer (and - // therefore the operator's BookStack token). - requireBearerAuth(authToken), + // therefore the operator's BookStack token). In OAuth mode the override refusal runs + // first, so a refused request never spends a token exchange. + ...(oauth ? [refuseUpstreamOverrides] : []), + authenticate, express.json({ limit: http.bodyLimitBytes }), createBodyErrorHandler(http.bodyLimitBytes), // `req`/`res` are annotated because Express stops inferring handler parameter types @@ -899,21 +1023,9 @@ export function createHttpApp(options: HttpAppOptions): express.Express { // implicitly `any`. async (req: Request, res: Response) => { try { - // Per-request credential selection. Reachable only by callers that cleared the - // bearer check above; it is an outbound-request surface, since x-bookstack-url - // decides which host this server will talk to on the caller's behalf. - const bookstackUrl = req.headers['x-bookstack-url'] as string; - const bookstackToken = req.headers['x-bookstack-token'] as string; - - const configOverrides: Partial = { - bookstack: { - baseUrl: bookstackUrl || config.bookstack.baseUrl, - apiToken: bookstackToken || config.bookstack.apiToken, - timeout: config.bookstack.timeout, - }, - }; - - const server = new BookStackMCPServer(configOverrides); + const server = oauth + ? new BookStackMCPServer({ bookstack: config.bookstack }, bookstackCredential(res)) + : new BookStackMCPServer(tokenModeOverrides(req, config)); // Omitting `sessionIdGenerator` selects the SDK's stateless mode, which is exactly // what passing it as `undefined` did; `exactOptionalPropertyTypes` forbids the // explicit-undefined form since the option is declared as `sessionIdGenerator?: () => string`. diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 3fbb834..5ddcb73 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -191,9 +191,10 @@ const SECRET_KEY_PATTERN = * canonicalBaseUrl()'s, which name the setting and the offending COMPONENT and * interpolate nothing at all. Without this the operator is told only * "[redacted: N chars]" at exactly the moment they need to know which variable is wrong. + * - `oauth_error` - src/auth/middleware.ts; an RFC 6749 code mapped onto a frozen list (else 'other'). */ const SAFE_STRING_KEY_PATTERN = - /^(method|url|base_origin|base_path|base_path_digest|type|content_type|format|encoding|mimeType|mime_type|source|sort|fileField|schema|issue_codes|config_errors)$/i; + /^(method|url|base_origin|base_path|base_path_digest|type|content_type|format|encoding|mimeType|mime_type|source|sort|fileField|schema|issue_codes|config_errors|oauth_error)$/i; /** * Keys whose value is a NAME this server is claimed to have defined - and which is checked diff --git a/tests/transport/docker-smoke.test.ts b/tests/transport/docker-smoke.test.ts index 7b32729..570baf6 100644 --- a/tests/transport/docker-smoke.test.ts +++ b/tests/transport/docker-smoke.test.ts @@ -21,6 +21,7 @@ */ import { afterAll, describe, expect, it } from 'bun:test'; +import pkg from '../../package.json' with { type: 'json' }; const SMOKE_ENABLED = ['1', 'true'].includes(process.env.RUN_DOCKER_SMOKE ?? ''); /** Tag CI builds. Overridable so the suite can smoke any locally built image. */ @@ -209,7 +210,12 @@ describe.skipIf(!SMOKE_ENABLED)('built image', () => { const response = await fetch(`${url}/`); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ status: 'running', mcp: true }); + // DOCKER_SMOKE_VERSION is the release version baked in by the image workflow. + expect(await response.json()).toMatchObject({ + status: 'running', + mcp: true, + version: process.env.DOCKER_SMOKE_VERSION ?? pkg.version, + }); }, STARTUP_TIMEOUT_MS + 15_000 ); @@ -277,6 +283,42 @@ describe.skipIf(!SMOKE_ENABLED)('built image', () => { STARTUP_TIMEOUT_MS + 30_000 ); + it( + 'starts in OAuth mode and challenges with its resource metadata', + async () => { + const { url } = await startContainer({ + BOOKSTACK_BASE_URL: UNREACHABLE_BOOKSTACK, + MCP_AUTH_MODE: 'oauth', + MCP_OAUTH_ISSUER: 'https://idp.invalid/smoke', + MCP_OAUTH_RESOURCE: 'https://bookstack-mcp.invalid/message', + MCP_OAUTH_CLIENT_ID: 'bookstack-mcp-server', + MCP_OAUTH_CLIENT_SECRET: 'docker-smoke-client-secret', + BOOKSTACK_OAUTH_AUDIENCE: 'bookstack-api', + }); + + const metadata = await fetch(`${url}/.well-known/oauth-protected-resource/message`); + expect(metadata.status).toBe(200); + expect(await metadata.json()).toMatchObject({ + resource: 'https://bookstack-mcp.invalid/message', + authorization_servers: ['https://idp.invalid/smoke'], + }); + + const anonymous = await fetch(`${url}/message`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(anonymous.status).toBe(401); + expect(anonymous.headers.get('www-authenticate')).toContain( + 'resource_metadata="https://bookstack-mcp.invalid/.well-known/oauth-protected-resource/message"' + ); + }, + STARTUP_TIMEOUT_MS + 15_000 + ); + it( 'runs the same Bun version that validated the code', async () => { diff --git a/tests/transport/http.test.ts b/tests/transport/http.test.ts index 20d08b0..4404dee 100644 --- a/tests/transport/http.test.ts +++ b/tests/transport/http.test.ts @@ -346,6 +346,17 @@ describe('HTTP transport startup', () => { ).toThrow(/MCP_AUTH_TOKEN is not set/); }); + it('fails closed when BOOKSTACK_API_TOKEN is unset', () => { + const withoutToken = { ...config, bookstack: { ...config.bookstack, apiToken: undefined } }; + + expect(() => + createHttpApp({ + config: withoutToken, + http: { bodyLimitBytes: DEFAULT_HTTP_BODY_LIMIT_BYTES, authToken: TEST_AUTH_TOKEN }, + }) + ).toThrow(/BOOKSTACK_API_TOKEN/); + }); + it('starts once MCP_AUTH_TOKEN is present', async () => { const { url } = await startApp({ bodyLimitBytes: DEFAULT_HTTP_BODY_LIMIT_BYTES, diff --git a/tests/transport/oauth.test.ts b/tests/transport/oauth.test.ts new file mode 100644 index 0000000..111838b --- /dev/null +++ b/tests/transport/oauth.test.ts @@ -0,0 +1,388 @@ +/** + * OAuth mode of the HTTP transport, end to end: the real Express app between a signing + * OIDC provider stub and the BookStack stub. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + type Config, + ConfigManager, + DEFAULT_HTTP_BODY_LIMIT_BYTES, + type HttpTransportConfig, +} from '../../src/config/manager'; +import { createHttpApp } from '../../src/server'; +import { resetSharedRateLimiters } from '../../src/utils/rateLimit'; +import { type BookStackStub, startBookStackStub } from './stub-bookstack'; +import { type IdpStub, startIdpStub } from './stub-idp'; + +const RESOURCE = 'https://bookstack-mcp.test/message'; +const METADATA_URL = 'https://bookstack-mcp.test/.well-known/oauth-protected-resource/message'; +const PINNED_ENV = [ + 'BOOKSTACK_BASE_URL', + 'BOOKSTACK_API_TOKEN', + 'BOOKSTACK_UPLOAD_ROOT', + 'LOG_LEVEL', + 'LOG_FORMAT', +] as const; + +const savedEnv = new Map(); +const running: Server[] = []; +let idp: IdpStub; +let bookstack: BookStackStub; +let config: Config; + +beforeAll(async () => { + for (const key of PINNED_ENV) { + savedEnv.set(key, process.env[key]); + } + bookstack = startBookStackStub(); + idp = await startIdpStub({ onIssue: (token) => bookstack.acceptedBearerTokens.add(token) }); + + process.env.BOOKSTACK_BASE_URL = bookstack.baseUrl; + delete process.env.BOOKSTACK_API_TOKEN; + process.env.LOG_LEVEL = 'debug'; + process.env.LOG_FORMAT = 'json'; + config = ConfigManager.getInstance().reload(); +}); + +afterEach(async () => { + idp.exchangeOverride = undefined; + idp.discoveryDown = false; + resetSharedRateLimiters(); + await Promise.all( + running.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))) + ); +}); + +afterAll(async () => { + for (const key of PINNED_ENV) { + const value = savedEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + ConfigManager.resetInstance(); + await idp.stop(); + await bookstack.stop(); +}); + +function oauthHttp(): HttpTransportConfig { + return { + bodyLimitBytes: DEFAULT_HTTP_BODY_LIMIT_BYTES, + oauth: { + issuer: idp.issuer, + resource: RESOURCE, + clientId: idp.clientId, + clientSecret: idp.clientSecret, + bookstackAudience: 'bookstack-api', + }, + }; +} + +async function startApp(appConfig: Config = config): Promise { + const app = createHttpApp({ config: appConfig, http: oauthHttp() }); + const server = await new Promise((resolve, reject) => { + const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); + listener.on('error', reject); + }); + running.push(server); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +/** A token the issuer would give an MCP client (`bookstack-mcp`) for this server. */ +async function userToken(overrides: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + return idp.mint({ + iss: idp.issuer, + aud: [RESOURCE, 'bookstack-mcp-server'], + azp: 'bookstack-mcp', + sub: 'user-1', + iat: now, + exp: now + 300, + ...overrides, + }); +} + +const LIST_BOOKS = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'bookstack_books_list', arguments: {} }, +}); + +const INITIALIZE = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'oauth-test', version: '1.0.0' }, + }, +}); + +function postMessage( + url: string, + body: string, + token?: string, + extraHeaders: Record = {} +): Promise { + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...extraHeaders, + }; + if (token !== undefined) { + headers.authorization = `Bearer ${token}`; + } + return fetch(`${url}/message`, { method: 'POST', headers, body }); +} + +describe('OAuth mode startup', () => { + it('refuses to start with BOOKSTACK_API_TOKEN configured', () => { + const withToken = { ...config, bookstack: { ...config.bookstack, apiToken: 'id:secret' } }; + + expect(() => createHttpApp({ config: withToken, http: oauthHttp() })).toThrow( + /BOOKSTACK_API_TOKEN/ + ); + }); +}); + +describe('OAuth mode upload root', () => { + it('refuses BOOKSTACK_UPLOAD_ROOT, which every user could read from', () => { + process.env.BOOKSTACK_UPLOAD_ROOT = '/tmp'; + try { + expect(() => createHttpApp({ config, http: oauthHttp() })).toThrow(/BOOKSTACK_UPLOAD_ROOT/); + } finally { + delete process.env.BOOKSTACK_UPLOAD_ROOT; + } + }); +}); + +describe('OAuth discovery', () => { + it('serves protected resource metadata naming the issuer', async () => { + const url = await startApp(); + + const response = await fetch(`${url}/.well-known/oauth-protected-resource/message`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + resource: RESOURCE, + authorization_servers: [idp.issuer], + bearer_methods_supported: ['header'], + }); + }); + + it('challenges a request without a token with the metadata location', async () => { + const url = await startApp(); + + const response = await postMessage(url, INITIALIZE); + + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${METADATA_URL}"` + ); + }); +}); + +describe('OAuth token validation', () => { + it.each([ + ['a token for BookStack rather than this server', { aud: ['bookstack-api'] }], + ['an expired token', { exp: Math.floor(Date.now() / 1000) - 600 }], + ['a token from another issuer', { iss: 'https://evil.example/issuer' }], + ])('rejects %s with invalid_token and never exchanges it', async (_label, overrides) => { + const url = await startApp(); + const before = idp.exchanges.length; + + const response = await postMessage(url, LIST_BOOKS, await userToken(overrides)); + + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe( + `Bearer error="invalid_token", resource_metadata="${METADATA_URL}"` + ); + expect(idp.exchanges.length).toBe(before); + }); + + it('rejects an opaque shared secret', async () => { + const url = await startApp(); + + const response = await postMessage(url, LIST_BOOKS, 'test-inbound-secret-0123456789'); + + expect(response.status).toBe(401); + }); + + it('answers MCP initialize for a valid token', async () => { + const url = await startApp(); + + const response = await postMessage(url, INITIALIZE, await userToken()); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { result?: { serverInfo?: { name?: string } } }; + expect(payload.result?.serverInfo?.name).toBe(config.server.name); + }); +}); + +describe('OAuth calls to BookStack', () => { + it('calls BookStack with an exchanged token, never the inbound one', async () => { + const url = await startApp(); + const token = await userToken(); + const before = bookstack.requests.length; + + const response = await postMessage(url, LIST_BOOKS, token); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { result?: { isError?: boolean } }; + expect(payload.result?.isError).toBeUndefined(); + const sent = bookstack.requests.slice(before); + expect(sent.length).toBeGreaterThan(0); + for (const request of sent) { + expect(request.authorization).toBe(`Bearer ${idp.issued.at(-1)}`); + expect(request.authorization).not.toContain(token); + } + }); + + it('exchanges once per inbound token', async () => { + const url = await startApp(); + const token = await userToken({ sub: 'user-2' }); + const before = idp.exchanges.length; + + await postMessage(url, LIST_BOOKS, token); + await postMessage(url, LIST_BOOKS, token); + + expect(idp.exchanges.length - before).toBe(1); + }); + + it('asks the client to re-authenticate when the exchange is refused', async () => { + const url = await startApp(); + idp.exchangeOverride = { status: 400, body: { error: 'invalid_grant' } }; + const before = bookstack.requests.length; + + const response = await postMessage(url, LIST_BOOKS, await userToken()); + + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toContain('error="invalid_token"'); + expect(bookstack.requests.length).toBe(before); + }); + + it('answers 403 when the authorization server denies this user the exchange', async () => { + const url = await startApp(); + idp.exchangeOverride = { status: 403, body: { error: 'access_denied' } }; + + const response = await postMessage(url, LIST_BOOKS, await userToken()); + + expect(response.status).toBe(403); + }); + + it('answers 500 when this server cannot exchange tokens', async () => { + const url = await startApp(); + idp.exchangeOverride = { status: 401, body: { error: 'invalid_client' } }; + + const response = await postMessage(url, LIST_BOOKS, await userToken()); + + expect(response.status).toBe(500); + }); + + it('answers 503 when the token endpoint is down', async () => { + const url = await startApp(); + idp.exchangeOverride = 'drop'; + + const response = await postMessage(url, LIST_BOOKS, await userToken()); + + expect(response.status).toBe(503); + }); + + it('answers 503 when discovery is down', async () => { + const url = await startApp(); + idp.discoveryDown = true; + + const response = await postMessage(url, LIST_BOOKS, await userToken()); + + expect(response.status).toBe(503); + }); + + it.each([ + ['x-bookstack-url', 'https://attacker.example/api'], + ['x-bookstack-token', 'other-id:other-secret'], + ])('refuses the %s override before spending a token', async (header, value) => { + const url = await startApp(); + const exchangesBefore = idp.exchanges.length; + const requestsBefore = bookstack.requests.length; + + const response = await postMessage(url, LIST_BOOKS, await userToken(), { [header]: value }); + + expect(response.status).toBe(400); + expect(idp.exchanges.length).toBe(exchangesBefore); + expect(bookstack.requests.length).toBe(requestsBefore); + }); +}); + +describe('OAuth readiness', () => { + it('reports the authorization server and BookStack as reachable', async () => { + const url = await startApp(); + + const response = await fetch(`${url}/health`); + const body = (await response.json()) as { + status: string; + checks: Array<{ name: string; healthy: boolean }>; + }; + + expect(response.status).toBe(200); + expect(body.status).toBe('healthy'); + expect(body.checks.map((check) => check.name).sort()).toEqual([ + 'authorization_server', + 'bookstack_reachable', + ]); + }); + + it('reports unhealthy when the authorization server is down', async () => { + idp.discoveryDown = true; + const url = await startApp(); + + const response = await fetch(`${url}/health`); + + expect(response.status).toBe(503); + }); +}); + +describe('OAuth secrets stay out of logs', () => { + it('never writes inbound tokens, exchanged tokens or the client secret', async () => { + const url = await startApp(); + const valid = await userToken({ sub: 'user-log' }); + const foreign = await userToken({ aud: 'bookstack-api' }); + let output = ''; + const realStdout = process.stdout.write; + const realStderr = process.stderr.write; + const record = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + output += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); + for (const arg of rest) { + if (typeof arg === 'function') { + (arg as () => void)(); + } + } + return true; + }) as typeof process.stdout.write; + process.stdout.write = record; + process.stderr.write = record; + + try { + await postMessage(url, LIST_BOOKS, valid); + await postMessage(url, LIST_BOOKS, foreign); + idp.exchangeOverride = { status: 400, body: { error: 'invalid_grant' } }; + await postMessage(url, LIST_BOOKS, await userToken({ sub: 'user-log-2' })); + idp.exchangeOverride = { status: 401, body: { error: 'invalid_client' } }; + await postMessage(url, LIST_BOOKS, await userToken({ sub: 'user-log-3' })); + } finally { + process.stdout.write = realStdout; + process.stderr.write = realStderr; + } + + expect(output.length).toBeGreaterThan(0); + for (const secret of [valid, foreign, idp.clientSecret, ...idp.issued]) { + expect(output).not.toContain(secret); + } + }); +}); diff --git a/tests/transport/stub-bookstack.ts b/tests/transport/stub-bookstack.ts index 999887a..2f00e03 100644 --- a/tests/transport/stub-bookstack.ts +++ b/tests/transport/stub-bookstack.ts @@ -43,6 +43,8 @@ export interface BookStackStub { apiToken: string; /** Requests seen so far, oldest first. */ readonly requests: RecordedRequest[]; + /** Bearer tokens the stub also accepts, as BookStack does with OIDC access tokens. */ + readonly acceptedBearerTokens: Set; stop(): Promise; } @@ -107,6 +109,7 @@ function apiError(code: number, message: string): Response { */ export function startBookStackStub(): BookStackStub { const requests: RecordedRequest[] = []; + const acceptedBearerTokens = new Set(); // Untyped binding on purpose: `Bun.serve`'s return type is generic in its WebSocket // data, and annotating it as a bare `Server` fails to compile. @@ -129,7 +132,9 @@ export function startBookStackStub(): BookStackStub { // The client sends `Authorization: Token :`. Checking it here is what // makes a tool call prove the outbound credential was actually attached: without // this the stub would answer a request that forgot to authenticate. - if (authorization !== `Token ${STUB_API_TOKEN}`) { + const bearer = authorization?.match(/^Bearer (\S+)$/)?.[1]; + const bearerAccepted = bearer !== undefined && acceptedBearerTokens.has(bearer); + if (authorization !== `Token ${STUB_API_TOKEN}` && !bearerAccepted) { return apiError(401, 'Unauthorized'); } @@ -160,6 +165,7 @@ export function startBookStackStub(): BookStackStub { baseUrl: `http://127.0.0.1:${server.port}/api`, apiToken: STUB_API_TOKEN, requests, + acceptedBearerTokens, async stop(): Promise { await server.stop(true); }, diff --git a/tests/transport/stub-idp.ts b/tests/transport/stub-idp.ts new file mode 100644 index 0000000..0d4ca5b --- /dev/null +++ b/tests/transport/stub-idp.ts @@ -0,0 +1,177 @@ +/** + * In-process OIDC provider stub: discovery, JWKS and an RFC 8693 token endpoint. + * + * Mirrors what the server needs from an OIDC provider. Tokens are really signed (RS256), so the + * server's verification runs for real; `mint` can also sign with a key the JWKS does not + * publish, to prove signatures are checked. + */ + +import { type CryptoKey, exportJWK, generateKeyPair, type JWTPayload, SignJWT } from 'jose'; + +const KEY_ID = 'stub-signing-key'; +export const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'; +export const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; + +/** One token endpoint request, as the stub received it. */ +export interface RecordedExchange { + authorization: string | undefined; + form: Record; +} + +/** What the token endpoint answers with instead of a successful exchange. */ +export type ExchangeOverride = + | { status: number; body: unknown; headers?: Record } + | 'drop'; + +export interface IdpStub { + issuer: string; + clientId: string; + clientSecret: string; + readonly exchanges: RecordedExchange[]; + /** Access tokens the stub has issued from exchanges, oldest first. */ + readonly issued: string[]; + /** Set to make the token endpoint fail; cleared by the test. */ + exchangeOverride: ExchangeOverride | undefined; + /** Set to make discovery answer 503. */ + discoveryDown: boolean; + /** Requests that reached the redirect sink, which no client should follow a redirect to. */ + readonly sinkHits: number; + /** Sign `claims` with the published key, or with a key the JWKS does not publish. */ + mint(claims: JWTPayload, options?: { untrusted?: boolean }): Promise; + stop(): Promise; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); +} + +export async function startIdpStub( + options: { onIssue?: (accessToken: string) => void } = {} +): Promise { + const trusted = await generateKeyPair('RS256', { extractable: true }); + const untrusted = await generateKeyPair('RS256'); + const publicJwk = { + ...(await exportJWK(trusted.publicKey)), + kid: KEY_ID, + alg: 'RS256', + use: 'sig', + }; + + const exchanges: RecordedExchange[] = []; + const issued: string[] = []; + const clientId = 'bookstack-mcp-server'; + const clientSecret = 'stub-client-secret'; + const expectedAuthorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; + + const state = { + exchangeOverride: undefined as ExchangeOverride | undefined, + discoveryDown: false, + sinkHits: 0, + }; + + let issuer = ''; + + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + async fetch(request: Request): Promise { + const url = new URL(request.url); + const base = new URL(issuer).pathname; + + if (url.pathname === `${base}/.well-known/openid-configuration`) { + if (state.discoveryDown) { + return json({ error: 'unavailable' }, 503); + } + return json({ + issuer, + jwks_uri: `${issuer}/jwks`, + token_endpoint: `${issuer}/token`, + }); + } + + if (url.pathname === `${base}/sink`) { + state.sinkHits += 1; + return json({ error: 'sink' }, 400); + } + + if (url.pathname === `${base}/jwks`) { + return json({ keys: [publicJwk] }); + } + + if (url.pathname === `${base}/token` && request.method === 'POST') { + const form = Object.fromEntries(new URLSearchParams(await request.text())); + const authorization = request.headers.get('authorization') ?? undefined; + exchanges.push({ authorization, form }); + + const override = state.exchangeOverride; + if (override === 'drop') { + return new Response('upstream gone', { status: 502 }); + } + if (override) { + const response = json(override.body, override.status); + for (const [name, value] of Object.entries(override.headers ?? {})) { + response.headers.set(name, value); + } + return response; + } + if (authorization !== expectedAuthorization) { + return json({ error: 'invalid_client' }, 401); + } + if (form.grant_type !== TOKEN_EXCHANGE_GRANT) { + return json({ error: 'unsupported_grant_type' }, 400); + } + + const accessToken = `stub-exchanged-${issued.length + 1}`; + issued.push(accessToken); + options.onIssue?.(accessToken); + return json({ + access_token: accessToken, + expires_in: 300, + token_type: 'Bearer', + issued_token_type: ACCESS_TOKEN_TYPE, + }); + } + + return json({ error: 'not_found' }, 404); + }, + }); + + issuer = `http://127.0.0.1:${server.port}/issuer`; + + async function mint(claims: JWTPayload, options: { untrusted?: boolean } = {}): Promise { + const key: CryptoKey = options.untrusted ? untrusted.privateKey : trusted.privateKey; + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: KEY_ID, typ: 'JWT' }) + .sign(key); + } + + return { + issuer, + clientId, + clientSecret, + exchanges, + issued, + get exchangeOverride() { + return state.exchangeOverride; + }, + set exchangeOverride(value) { + state.exchangeOverride = value; + }, + get sinkHits() { + return state.sinkHits; + }, + get discoveryDown() { + return state.discoveryDown; + }, + set discoveryDown(value) { + state.discoveryDown = value; + }, + mint, + async stop(): Promise { + await server.stop(true); + }, + }; +} diff --git a/tests/unit/bookstack-credential.test.ts b/tests/unit/bookstack-credential.test.ts new file mode 100644 index 0000000..dd22caa --- /dev/null +++ b/tests/unit/bookstack-credential.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { BookStackClient } from '../../src/api/client'; +import type { Config } from '../../src/config/manager'; +import { ErrorHandler } from '../../src/utils/errors'; +import { Logger } from '../../src/utils/logger'; +import { type BookStackStub, startBookStackStub } from '../transport/stub-bookstack'; + +const logger = Logger.getInstance(); + +function configFor(stub: BookStackStub, apiToken?: string): Config { + return { + bookstack: { baseUrl: stub.baseUrl, apiToken, timeout: 5_000 }, + server: { name: 'credential-test', version: '1.0.0', port: 3000 }, + rateLimit: { requestsPerMinute: 60_000, burstLimit: 10_000 }, + validation: { enabled: true, strictMode: true }, + logging: { level: 'error', format: 'json' }, + development: { nodeEnv: 'test', debug: false }, + }; +} + +describe('BookStackClient credential', () => { + let stub: BookStackStub; + + beforeAll(() => { + stub = startBookStackStub(); + }); + + afterAll(async () => { + await stub.stop(); + }); + + it('sends the configured API token by default', async () => { + const client = new BookStackClient( + configFor(stub, stub.apiToken), + logger, + new ErrorHandler(logger) + ); + + await client.listBooks(); + + expect(stub.requests.at(-1)?.authorization).toBe(`Token ${stub.apiToken}`); + }); + + it('sends a Bearer credential instead of the API token when given one', async () => { + stub.acceptedBearerTokens.add('exchanged-access-token'); + const client = new BookStackClient( + configFor(stub, stub.apiToken), + logger, + new ErrorHandler(logger), + { scheme: 'Bearer', secret: 'exchanged-access-token', principal: 'issuer\u0000user-1' } + ); + + await client.listBooks(); + + expect(stub.requests.at(-1)?.authorization).toBe('Bearer exchanged-access-token'); + }); + + it('refuses to build without any credential', () => { + expect(() => new BookStackClient(configFor(stub), logger, new ErrorHandler(logger))).toThrow( + /BOOKSTACK_API_TOKEN/ + ); + }); +}); diff --git a/tests/unit/oauth-config.test.ts b/tests/unit/oauth-config.test.ts new file mode 100644 index 0000000..016b5d5 --- /dev/null +++ b/tests/unit/oauth-config.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test'; +import { loadHttpTransportConfig } from '../../src/config/manager'; + +const OAUTH_ENV = { + MCP_AUTH_MODE: 'oauth', + MCP_OAUTH_ISSUER: 'https://idp.example/tenant', + MCP_OAUTH_RESOURCE: 'https://bookstack-mcp.example/message', + MCP_OAUTH_CLIENT_ID: 'bookstack-mcp-server', + MCP_OAUTH_CLIENT_SECRET: 'client-secret-value', + BOOKSTACK_OAUTH_AUDIENCE: 'bookstack-api', +}; + +describe('HTTP transport auth mode', () => { + it('defaults to the shared-secret mode with no OAuth settings', () => { + const http = loadHttpTransportConfig({ MCP_AUTH_TOKEN: 's3cret' }); + + expect(http.authToken).toBe('s3cret'); + expect(http.oauth).toBeUndefined(); + }); + + it('reads the OAuth settings when MCP_AUTH_MODE=oauth', () => { + const http = loadHttpTransportConfig(OAUTH_ENV); + + expect(http.authToken).toBeUndefined(); + expect(http.oauth).toEqual({ + issuer: 'https://idp.example/tenant', + resource: 'https://bookstack-mcp.example/message', + clientId: 'bookstack-mcp-server', + clientSecret: 'client-secret-value', + bookstackAudience: 'bookstack-api', + }); + }); + + it('rejects an unknown MCP_AUTH_MODE instead of falling back to a default', () => { + expect(() => loadHttpTransportConfig({ MCP_AUTH_MODE: 'oidc' })).toThrow(/MCP_AUTH_MODE/); + }); + + it.each([ + 'MCP_OAUTH_ISSUER', + 'MCP_OAUTH_RESOURCE', + 'MCP_OAUTH_CLIENT_ID', + 'MCP_OAUTH_CLIENT_SECRET', + 'BOOKSTACK_OAUTH_AUDIENCE', + ])('fails closed when %s is missing in OAuth mode', (name) => { + const env: Record = { ...OAUTH_ENV, [name]: '' }; + + expect(() => loadHttpTransportConfig(env)).toThrow(name); + }); + + it('refuses MCP_AUTH_TOKEN in OAuth mode, where it would have no effect', () => { + expect(() => loadHttpTransportConfig({ ...OAUTH_ENV, MCP_AUTH_TOKEN: 's3cret' })).toThrow( + /MCP_AUTH_TOKEN/ + ); + }); + + it.each([ + ['plain http to a remote issuer', { MCP_OAUTH_ISSUER: 'http://idp.example/tenant' }], + ['plain http to a remote resource', { MCP_OAUTH_RESOURCE: 'http://mcp.example/message' }], + ['a resource with a fragment', { MCP_OAUTH_RESOURCE: 'https://mcp.example/message#x' }], + ['an issuer with a query', { MCP_OAUTH_ISSUER: 'https://idp.example/tenant?x=1' }], + ['a non-URL issuer', { MCP_OAUTH_ISSUER: 'idp' }], + ])('rejects %s', (_label, override) => { + expect(() => loadHttpTransportConfig({ ...OAUTH_ENV, ...override })).toThrow( + /HTTP transport configuration/ + ); + }); + + it('allows plain http on loopback, for local development', () => { + const http = loadHttpTransportConfig({ + ...OAUTH_ENV, + MCP_OAUTH_ISSUER: 'http://127.0.0.1:8080/tenant', + MCP_OAUTH_RESOURCE: 'http://localhost:3000/message', + }); + + expect(http.oauth?.resource).toBe('http://localhost:3000/message'); + }); + + it('refuses a client ID equal to the resource up to a trailing slash', () => { + expect(() => + loadHttpTransportConfig({ + ...OAUTH_ENV, + MCP_OAUTH_CLIENT_ID: 'https://bookstack-mcp.example/message/', + }) + ).toThrow(/MCP_OAUTH_RESOURCE/); + }); + + it('refuses a resource equal to the client ID, which ID tokens would satisfy as audience', () => { + expect(() => + loadHttpTransportConfig({ + ...OAUTH_ENV, + MCP_OAUTH_RESOURCE: 'https://bookstack-mcp.example/message', + MCP_OAUTH_CLIENT_ID: 'https://bookstack-mcp.example/message', + }) + ).toThrow(/MCP_OAUTH_RESOURCE/); + }); +}); diff --git a/tests/unit/oauth-verifier.test.ts b/tests/unit/oauth-verifier.test.ts new file mode 100644 index 0000000..d2c1047 --- /dev/null +++ b/tests/unit/oauth-verifier.test.ts @@ -0,0 +1,149 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { SignJWT } from 'jose'; +import { AuthServerUnavailableError, OidcIssuer } from '../../src/auth/oidc'; +import { AccessTokenRejectedError, JwtAccessTokenVerifier } from '../../src/auth/verifier'; +import { type IdpStub, startIdpStub } from '../transport/stub-idp'; + +const RESOURCE = 'http://127.0.0.1:3000/message'; + +describe('JwtAccessTokenVerifier', () => { + let idp: IdpStub; + let verifier: JwtAccessTokenVerifier; + + /** Claims of a token the issuer would give an MCP client (`bookstack-mcp`) for this server. */ + function claims(overrides: Record = {}): Record { + const now = Math.floor(Date.now() / 1000); + return { + iss: idp.issuer, + aud: [RESOURCE, 'bookstack-mcp-server'], + azp: 'bookstack-mcp', + sub: 'user-1', + scope: 'openid profile email', + typ: 'Bearer', + iat: now, + exp: now + 300, + ...overrides, + }; + } + + beforeAll(async () => { + idp = await startIdpStub(); + verifier = new JwtAccessTokenVerifier({ + issuer: new OidcIssuer(idp.issuer), + resource: RESOURCE, + }); + }); + + afterAll(async () => { + await idp.stop(); + }); + + it('accepts a token issued for this resource and describes it', async () => { + const token = await idp.mint(claims()); + + const auth = await verifier.verifyAccessToken(token); + + expect(auth.token).toBe(token); + expect(auth.clientId).toBe('bookstack-mcp'); + expect(auth.scopes).toEqual(['openid', 'profile', 'email']); + expect(auth.resource?.href).toBe(RESOURCE); + expect(auth.extra).toEqual({ issuer: idp.issuer, subject: 'user-1' }); + expect(typeof auth.expiresAt).toBe('number'); + }); + + it('accepts the resource with a trailing slash in the audience', async () => { + const token = await idp.mint(claims({ aud: `${RESOURCE}/` })); + + await expect(verifier.verifyAccessToken(token)).resolves.toMatchObject({ + clientId: 'bookstack-mcp', + }); + }); + + it.each([ + ['another audience only', { aud: ['bookstack-api', 'bookstack-mcp-server'] }], + ['an ID token for the client', { aud: 'bookstack-mcp', typ: 'ID' }], + ['an ID token carrying this resource as audience', { typ: 'ID' }], + ['a refresh token carrying this resource as audience', { typ: 'Refresh' }], + ['another issuer', { iss: 'https://evil.example/issuer' }], + ['an expired token', { exp: Math.floor(Date.now() / 1000) - 600 }], + ['a token without a subject', { sub: undefined }], + ['a token without an expiry', { exp: undefined }], + ])('rejects %s', async (_label, overrides) => { + const token = await idp.mint(claims(overrides)); + + await expect(verifier.verifyAccessToken(token)).rejects.toBeInstanceOf( + AccessTokenRejectedError + ); + }); + + it('rejects a token signed by a key the issuer does not publish', async () => { + const token = await idp.mint(claims(), { untrusted: true }); + + await expect(verifier.verifyAccessToken(token)).rejects.toBeInstanceOf( + AccessTokenRejectedError + ); + }); + + it('rejects a symmetric (HS256) token', async () => { + const token = await new SignJWT(claims()) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode('a-guessable-shared-secret-of-32-bytes!')); + + await expect(verifier.verifyAccessToken(token)).rejects.toBeInstanceOf( + AccessTokenRejectedError + ); + }); + + it('rejects an unsigned (alg=none) token', async () => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const token = `${encode({ alg: 'none', typ: 'JWT' })}.${encode(claims())}.`; + + await expect(verifier.verifyAccessToken(token)).rejects.toBeInstanceOf( + AccessTokenRejectedError + ); + }); + + it('rejects a string that is not a JWT', async () => { + await expect(verifier.verifyAccessToken('not-a-jwt')).rejects.toBeInstanceOf( + AccessTokenRejectedError + ); + }); + + it('reports an unreachable issuer as unavailable, not as a bad token', async () => { + const offline = new JwtAccessTokenVerifier({ + issuer: new OidcIssuer('http://127.0.0.1:9/issuer'), + resource: RESOURCE, + }); + + await expect(offline.verifyAccessToken(await idp.mint(claims()))).rejects.toBeInstanceOf( + AuthServerUnavailableError + ); + }); + + it('accepts a token without a typ claim', async () => { + const token = await idp.mint(claims({ typ: undefined })); + + await expect(verifier.verifyAccessToken(token)).resolves.toMatchObject({ + clientId: 'bookstack-mcp', + }); + }); + + it('probes discovery afresh even after it was cached', async () => { + const issuer = new OidcIssuer(idp.issuer); + await issuer.metadata(); + idp.discoveryDown = true; + + try { + await expect(issuer.metadata()).resolves.toMatchObject({ issuer: idp.issuer }); + await expect(issuer.probe()).rejects.toBeInstanceOf(AuthServerUnavailableError); + } finally { + idp.discoveryDown = false; + } + }); + + it('refuses discovery that names a different issuer', async () => { + const lying = new OidcIssuer(`${idp.issuer}/`); + + await expect(lying.metadata()).rejects.toBeInstanceOf(AuthServerUnavailableError); + }); +}); diff --git a/tests/unit/token-exchange.test.ts b/tests/unit/token-exchange.test.ts new file mode 100644 index 0000000..e0a7ba1 --- /dev/null +++ b/tests/unit/token-exchange.test.ts @@ -0,0 +1,201 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { BookStackTokenExchanger, TokenExchangeError } from '../../src/auth/exchange'; +import { OidcIssuer } from '../../src/auth/oidc'; +import { + ACCESS_TOKEN_TYPE, + type IdpStub, + startIdpStub, + TOKEN_EXCHANGE_GRANT, +} from '../transport/stub-idp'; + +describe('BookStackTokenExchanger', () => { + let idp: IdpStub; + let counter = 0; + + function exchanger(overrides: { clientSecret?: string; maxEntries?: number } = {}) { + return new BookStackTokenExchanger({ + issuer: new OidcIssuer(idp.issuer), + clientId: idp.clientId, + clientSecret: overrides.clientSecret ?? idp.clientSecret, + audience: 'bookstack-api', + ...(overrides.maxEntries === undefined ? {} : { maxEntries: overrides.maxEntries }), + }); + } + + /** A verified inbound token as the middleware hands it over. */ + function inbound(expiresInSeconds = 300, subject = 'user-1'): AuthInfo { + counter += 1; + return { + token: `inbound-token-${counter}`, + clientId: 'bookstack-mcp', + scopes: [], + expiresAt: Math.floor(Date.now() / 1000) + expiresInSeconds, + extra: { issuer: idp.issuer, subject }, + }; + } + + beforeAll(async () => { + idp = await startIdpStub(); + }); + + afterEach(() => { + idp.exchangeOverride = undefined; + }); + + afterAll(async () => { + await idp.stop(); + }); + + it('exchanges the inbound token for the BookStack audience, as the configured client', async () => { + const auth = inbound(); + + const credential = await exchanger().exchange(auth); + + expect(credential).toEqual({ + scheme: 'Bearer', + secret: idp.issued.at(-1) as string, + principal: `${idp.issuer}\u0000user-1`, + }); + const request = idp.exchanges.at(-1); + expect(request?.authorization).toBe(`Basic ${btoa(`${idp.clientId}:${idp.clientSecret}`)}`); + expect(request?.form).toEqual({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: auth.token, + subject_token_type: ACCESS_TOKEN_TYPE, + requested_token_type: ACCESS_TOKEN_TYPE, + audience: 'bookstack-api', + }); + }); + + it('reuses an exchanged token for the same inbound token', async () => { + const subject = exchanger(); + const auth = inbound(); + const before = idp.exchanges.length; + + const [first, second] = await Promise.all([subject.exchange(auth), subject.exchange(auth)]); + const third = await subject.exchange(auth); + + expect(idp.exchanges.length - before).toBe(1); + expect(second).toEqual(first); + expect(third).toEqual(first); + }); + + it('exchanges again for a different inbound token', async () => { + const subject = exchanger(); + const before = idp.exchanges.length; + + await subject.exchange(inbound()); + await subject.exchange(inbound()); + + expect(idp.exchanges.length - before).toBe(2); + }); + + it('reuses an exchanged token until shortly before it expires', async () => { + const subject = exchanger(); + const auth = inbound(20); + const before = idp.exchanges.length; + + await subject.exchange(auth); + await subject.exchange(auth); + + expect(idp.exchanges.length - before).toBe(1); + }); + + it('does not reuse an exchanged token about to expire', async () => { + const subject = exchanger(); + const auth = inbound(); + idp.exchangeOverride = { status: 200, body: { access_token: 'short-lived', expires_in: 20 } }; + const before = idp.exchanges.length; + + await subject.exchange(auth); + await subject.exchange(auth); + + expect(idp.exchanges.length - before).toBe(2); + }); + + it('stays within its cache bound', async () => { + const subject = exchanger({ maxEntries: 2 }); + const first = inbound(); + await subject.exchange(first); + await subject.exchange(inbound()); + await subject.exchange(inbound()); + const before = idp.exchanges.length; + + await subject.exchange(first); + + expect(idp.exchanges.length - before).toBe(1); + }); + + it.each([ + ['invalid_grant', 400, 'rejected'], + ['invalid_token', 401, 'rejected'], + ['invalid_client', 401, 'misconfigured'], + ['unauthorized_client', 400, 'misconfigured'], + ['access_denied', 403, 'forbidden'], + ['server_error', 500, 'unavailable'], + ['too_many_requests', 429, 'unavailable'], + ] as const)('classifies %s (%i) as %s', async (code, status, kind) => { + idp.exchangeOverride = { status, body: { error: code, error_description: 'detail' } }; + + const failure = await exchanger() + .exchange(inbound()) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(TokenExchangeError); + expect((failure as TokenExchangeError).kind).toBe(kind); + }); + + it('classifies a non-JSON gateway failure as unavailable', async () => { + idp.exchangeOverride = 'drop'; + + const failure = await exchanger() + .exchange(inbound()) + .catch((error: unknown) => error); + + expect((failure as TokenExchangeError).kind).toBe('unavailable'); + }); + + it('classifies a success without an access token as unavailable', async () => { + idp.exchangeOverride = { status: 200, body: { token_type: 'Bearer' } }; + + const failure = await exchanger() + .exchange(inbound()) + .catch((error: unknown) => error); + + expect((failure as TokenExchangeError).kind).toBe('unavailable'); + }); + + it('does not cache a failed exchange', async () => { + const subject = exchanger(); + const auth = inbound(); + idp.exchangeOverride = { status: 500, body: { error: 'server_error' } }; + await subject.exchange(auth).catch(() => undefined); + idp.exchangeOverride = undefined; + + await expect(subject.exchange(auth)).resolves.toMatchObject({ scheme: 'Bearer' }); + }); + + it('does not follow a redirect with the subject token and client secret', async () => { + idp.exchangeOverride = { + status: 307, + body: {}, + headers: { location: `${idp.issuer}/sink` }, + }; + + const failure = await exchanger() + .exchange(inbound()) + .catch((error: unknown) => error); + + expect((failure as TokenExchangeError).kind).toBe('unavailable'); + expect(idp.sinkHits).toBe(0); + }); + + it('reports a wrong client secret as misconfiguration', async () => { + const failure = await exchanger({ clientSecret: 'wrong' }) + .exchange(inbound()) + .catch((error: unknown) => error); + + expect((failure as TokenExchangeError).kind).toBe('misconfigured'); + }); +});