From 8d7f70cf243b46cd4caa930557243377dfe33548 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:32:35 +0000 Subject: [PATCH 01/18] Add opt-in Enterprise-Managed Authorization (MCP ID-JAG extension) Implements the io.modelcontextprotocol/enterprise-managed-authorization extension so organizations can gate /mcp access through their IdP: - Built-in Resource Authorization Server: POST /token accepts the RFC 7523 jwt-bearer grant with an ID-JAG from the enterprise IdP and issues short-lived HS256 access tokens audience-restricted to the MCP resource identifier - Discovery metadata per RFC 8414 and RFC 9728, advertising urn:ietf:params:oauth:grant-profile:id-jag - ID-JAG validation: typ oauth-id-jag+jwt, IdP JWKS signature (explicit URI or OIDC discovery), issuer/audience/expiry, resource claim, optional client_id allowlist, best-effort jti replay detection - /mcp gate middleware with required and optional modes; 401 responses carry a WWW-Authenticate resource_metadata challenge - Skyflow credentials under enterprise auth resolve from the X-Skyflow-Authorization header, then SKYFLOW_API_KEY, then existing fallbacks (apiKey query param, anonymous mode) - Fully opt-in via ENTERPRISE_AUTH_ENABLED; fails closed when enabled but misconfigured - 82 new unit tests; setup guide in docs/enterprise-managed-auth.md covering both the Skyflow-hosted-with-Okta and self-hosted-with- customer-IdP scenarios Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- CHANGELOG.md | 9 + CLAUDE.md | 20 +- README.md | 10 +- docs/enterprise-managed-auth.md | 141 ++++++++++ package.json | 1 + pnpm-lock.yaml | 11 +- src/lib/auth/accessTokens.ts | 118 ++++++++ src/lib/auth/config.ts | 155 +++++++++++ src/lib/auth/idJag.ts | 257 ++++++++++++++++++ src/lib/auth/routes.ts | 185 +++++++++++++ src/lib/middleware/authenticateBearer.ts | 7 + src/lib/middleware/enterpriseAuth.ts | 176 ++++++++++++ src/server.ts | 29 +- tests/unit/auth/accessTokens.test.ts | 131 +++++++++ tests/unit/auth/config.test.ts | 189 +++++++++++++ tests/unit/auth/helpers.ts | 109 ++++++++ tests/unit/auth/idJag.test.ts | 223 +++++++++++++++ tests/unit/auth/routes.test.ts | 231 ++++++++++++++++ .../middleware/authenticateBearer.test.ts | 18 ++ tests/unit/middleware/enterpriseAuth.test.ts | 257 ++++++++++++++++++ 20 files changed, 2270 insertions(+), 7 deletions(-) create mode 100644 docs/enterprise-managed-auth.md create mode 100644 src/lib/auth/accessTokens.ts create mode 100644 src/lib/auth/config.ts create mode 100644 src/lib/auth/idJag.ts create mode 100644 src/lib/auth/routes.ts create mode 100644 src/lib/middleware/enterpriseAuth.ts create mode 100644 tests/unit/auth/accessTokens.test.ts create mode 100644 tests/unit/auth/config.test.ts create mode 100644 tests/unit/auth/helpers.ts create mode 100644 tests/unit/auth/idJag.test.ts create mode 100644 tests/unit/auth/routes.test.ts create mode 100644 tests/unit/middleware/enterpriseAuth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4b8f2..6279410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Added +- **Enterprise-Managed Authorization (opt-in)** — Implements the MCP `io.modelcontextprotocol/enterprise-managed-authorization` extension (ID-JAG profile). When `ENTERPRISE_AUTH_ENABLED=true`, the server acts as its own Resource Authorization Server: it validates Identity Assertion JWT Authorization Grants issued by an enterprise IdP (Okta, Entra, any OIDC IdP) and issues short-lived, audience-restricted access tokens that gate `/mcp`. + - New endpoints: `POST /token` (RFC 7523 jwt-bearer grant), `GET /.well-known/oauth-authorization-server` (RFC 8414, advertises `urn:ietf:params:oauth:grant-profile:id-jag`), `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728). All 404 when the feature is disabled. + - New middleware gates `/mcp` in `required` or `optional` mode; 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge for client discovery. + - Skyflow vault credentials under enterprise auth resolve from the `X-Skyflow-Authorization` header, then the new `SKYFLOW_API_KEY` service-credential env var, then existing fallbacks (`apiKey` query param, anonymous mode). + - ID-JAG validation covers `typ: oauth-id-jag+jwt`, IdP JWKS signature (explicit URI or OIDC discovery), issuer/audience/expiry, `resource` claim matching, optional `client_id` allowlist, and best-effort `jti` replay detection. Misconfiguration fails closed. + - New env vars: `ENTERPRISE_AUTH_ENABLED`, `ENTERPRISE_AUTH_ISSUER`, `ENTERPRISE_IDP_ISSUER`, `ENTERPRISE_AUTH_SIGNING_KEY`, `ENTERPRISE_AUTH_MODE`, `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS`, `SKYFLOW_API_KEY`. + - 82 new unit tests (`tests/unit/auth/`, `tests/unit/middleware/enterpriseAuth.test.ts`) and a setup guide in `docs/enterprise-managed-auth.md`. + - Added `jose` dependency for JWT/JWKS handling. + - **MCP Apps UI for all three tools** — Each tool (`dehydrate`, `rehydrate`, `dehydrate_file`) now has an interactive vanilla TypeScript UI that renders inline in MCP Apps-capable hosts. Text-only hosts continue to receive JSON responses as before. - **Dehydrate UI**: Side-by-side before/after text panels with color-coded entity highlights, confidence scores, and an entity breakdown table. Shows anonymous mode banner when applicable. - **Rehydrate UI**: Token-to-original mapping display with color-matched highlights across before/after panels. diff --git a/CLAUDE.md b/CLAUDE.md index bb0ccbf..724e808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,17 @@ curl -X POST "http://localhost:3000/mcp?vaultId={vault_id}&vaultUrl={vault_url}" - Accepts query parameters: `vaultId`, `vaultUrl`, `apiKey` (optional) - Uses credentials extraction middleware to validate either Authorization header or apiKey query parameter - Configured with 5MB JSON payload limit to support base64-encoded files +- Optionally exposes enterprise-managed authorization endpoints (see below) + +**Enterprise-Managed Authorization Layer** (`src/lib/auth/`, opt-in) +- Implements the MCP `io.modelcontextprotocol/enterprise-managed-authorization` extension (ID-JAG profile: RFC 8693 token exchange at the IdP + RFC 7523 jwt-bearer grant at this server) +- When `ENTERPRISE_AUTH_ENABLED=true`, the server acts as its own Resource Authorization Server: it validates ID-JAGs issued by an enterprise IdP (Okta, Entra, any OIDC IdP) and issues short-lived HS256 access tokens audience-restricted to the MCP resource identifier +- `src/lib/auth/config.ts` — env var loading/validation (fails closed on misconfiguration) +- `src/lib/auth/idJag.ts` — ID-JAG validation: `typ: oauth-id-jag+jwt` header, IdP JWKS signature (explicit URI or OIDC discovery), issuer/audience/expiry, `resource` claim, client allowlist, best-effort in-memory `jti` replay detection +- `src/lib/auth/accessTokens.ts` — issue/verify enterprise access tokens (`typ: at+jwt`) +- `src/lib/auth/routes.ts` — `POST /token` plus RFC 8414 (`/.well-known/oauth-authorization-server`) and RFC 9728 (`/.well-known/oauth-protected-resource[/mcp]`) metadata; all return 404 when disabled +- `src/lib/middleware/enterpriseAuth.ts` — gates `/mcp` with issued tokens (`required` or `optional` mode); 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge. After verifying the enterprise token it resolves Skyflow credentials from the `X-Skyflow-Authorization` header → `SKYFLOW_API_KEY` env var → existing fallbacks, and exposes the enterprise identity as `req.enterpriseAuth` +- Full setup guide (Skyflow-hosted-with-Okta and self-hosted-with-customer-IdP scenarios): `docs/enterprise-managed-auth.md` **MCP Server Instance** - Registers two active tools: `de-identify` and `re-identify` @@ -127,14 +138,19 @@ This ensures type safety and provides clear error messages for invalid inputs. 1. **Bearer token via header** (JWT): Clients provide their Skyflow bearer token via `Authorization: Bearer ` header. The server auto-detects JWTs by their format (3 dot-separated base64url parts). 2. **API key via header**: Clients can also pass a Skyflow API key via `Authorization: Bearer ` header. If the value doesn't look like a JWT, it's treated as an API key. 3. **API key via query parameter** (fallback): Clients can pass a Skyflow API key via `apiKey` query parameter +4. **Enterprise-managed authorization** (opt-in): the `Authorization` header carries an enterprise access token issued by this server's `/token` endpoint after an ID-JAG exchange with the org's IdP; Skyflow credentials then come from the `X-Skyflow-Authorization` header, `SKYFLOW_API_KEY` env var, or existing fallbacks. See `docs/enterprise-managed-auth.md`. Optional fallback variables in `.env.local`: - `VAULT_ID`: Your Skyflow vault identifier (can be overridden via query parameter) - `VAULT_URL`: Full vault URL (e.g., `https://ebfc9bee4242.vault.skyflowapis.com`) (can be overridden via query parameter) - `PORT`: Server port (default: 3000) +**Enterprise-managed authorization variables** (all optional; feature off unless `ENTERPRISE_AUTH_ENABLED=true`): +- `ENTERPRISE_AUTH_ENABLED`, `ENTERPRISE_AUTH_ISSUER`, `ENTERPRISE_IDP_ISSUER`, `ENTERPRISE_AUTH_SIGNING_KEY` (required when enabled) +- `ENTERPRISE_AUTH_MODE` (`required`|`optional`), `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS` +- `SKYFLOW_API_KEY`: server-side Skyflow service credential, used only for requests authenticated via enterprise auth + **Removed variables** (no longer used): -- `SKYFLOW_API_KEY`: No longer needed - credentials are passed from client - `REQUIRED_BEARER_TOKEN`: No longer needed - all valid credentials are accepted and forwarded to Skyflow - `ACCOUNT_ID` / `WORKSPACE_ID`: Never consumed by Skyflow SDK - removed @@ -283,6 +299,7 @@ The `isError` property is set to `true` when a tool returns an error condition ( - `skyflow-node`: Skyflow SDK for deidentification (v2.0.0+) - `express`: Web framework (v5.1.0+) - `zod`: Schema validation for tool inputs/outputs +- `jose`: JWT signing/verification and JWKS handling for enterprise-managed authorization - `dotenv`: Environment variable management - `tsx`: TypeScript execution (via npx) - `vite` + `vite-plugin-singlefile`: UI build pipeline (dev dependencies) @@ -329,3 +346,4 @@ All of the above, plus: 6. **AsyncLocalStorage context** - Tools must run within the request context to access Skyflow instance via `getCurrentSkyflow()` and `isAnonymousMode()` 7. **Anonymous mode limitations** - Only the `de-identify` tool works in anonymous mode; `re-identify` returns an error with setup instructions 8. **Keep schemas in sync** - When modifying tool inputs or return values, always update the corresponding `inputSchema` and `outputSchema` in the tool registration. The schemas must match the actual implementation. +9. **Enterprise auth is opt-in and fails closed** - Nothing changes unless `ENTERPRISE_AUTH_ENABLED=true`. When enabled but misconfigured, `/mcp` returns 500 rather than skipping authorization. In `required` mode the Authorization header must carry an enterprise access token, so Skyflow credentials move to `X-Skyflow-Authorization`, `SKYFLOW_API_KEY`, or the `apiKey` query parameter — direct Skyflow tokens in the Authorization header get 401. Keep middleware order on `/mcp`: enterprise auth → authenticateBearer → rate limiter. diff --git a/README.md b/README.md index eb8d957..76651b9 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ See [Integration with Claude Desktop](#integration-with-claude-desktop) for one When self-hosting, `VAULT_ID` and `VAULT_URL` can be set as environment variables in `.env.local` — query parameters override them per request. Useful for pinning a single vault without rewriting client URLs. See [Environment Variables](#environment-variables). +## Enterprise-Managed Authorization (SSO) + +The server supports the MCP [Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/enterprise-managed-authorization): organizations can gate access to `/mcp` through their identity provider (Okta, Entra, any OIDC IdP). Employees sign in to their MCP client with corporate SSO, the client exchanges the resulting identity assertion for an ID-JAG at the IdP, and this server's built-in `/token` endpoint turns the ID-JAG into a short-lived access token for `/mcp` — no per-user Skyflow credentials or per-server authorization prompts needed. + +The feature is off by default and enabled via `ENTERPRISE_AUTH_ENABLED=true`. It supports a `required` mode (every request needs SSO-derived auth — for self-hosted enterprise deployments) and an `optional` mode (SSO tokens accepted alongside ordinary Skyflow credentials — for shared endpoints). See [docs/enterprise-managed-auth.md](docs/enterprise-managed-auth.md) for the full flow, environment variable reference, and IdP setup for both deployment scenarios. + ## Installation ```bash @@ -159,7 +165,9 @@ Create a `.env.local` file with optional fallback values: - `VAULT_URL`: Your Skyflow vault URL (optional - can be provided via query parameter, e.g., `https://ebfc9bee4242.vault.skyflowapis.com`) - `PORT`: Server port (default: 3000) -**Note**: `SKYFLOW_API_KEY`, `REQUIRED_BEARER_TOKEN`, `ACCOUNT_ID`, and `WORKSPACE_ID` are no longer used. The bearer token is passed through from the client to Skyflow; account/workspace IDs were never consumed by the SDK. +For enterprise-managed authorization variables (`ENTERPRISE_AUTH_*`, `ENTERPRISE_IDP_*`, `SKYFLOW_API_KEY`), see [docs/enterprise-managed-auth.md](docs/enterprise-managed-auth.md). + +**Note**: `REQUIRED_BEARER_TOKEN`, `ACCOUNT_ID`, and `WORKSPACE_ID` are no longer used. The bearer token is passed through from the client to Skyflow; account/workspace IDs were never consumed by the SDK. `SKYFLOW_API_KEY` is only consumed as a service credential for enterprise-managed authorization deployments. ## Anonymous Mode (Try Before You Buy) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md new file mode 100644 index 0000000..d4d7b70 --- /dev/null +++ b/docs/enterprise-managed-auth.md @@ -0,0 +1,141 @@ +# Enterprise-Managed Authorization + +This server supports the MCP [Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/enterprise-managed-authorization) (`io.modelcontextprotocol/enterprise-managed-authorization`), which lets an organization control access to the MCP server centrally through its identity provider (IdP) — Okta, Azure AD/Entra, or any OIDC-compliant IdP that can issue Identity Assertion JWT Authorization Grants (ID-JAGs). + +The feature is **entirely opt-in**. When `ENTERPRISE_AUTH_ENABLED` is not set, server behavior is unchanged. + +## How it works + +The extension profiles [draft-ietf-oauth-identity-assertion-authz-grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/). When enabled, this server plays two of the spec's roles at once: + +- **MCP Resource Server** — the `/mcp` endpoint, now gated by enterprise access tokens +- **Resource Authorization Server** — a built-in, stateless authorization server that validates ID-JAGs from your IdP and issues the access tokens + +``` +MCP Client Enterprise IdP This server + | | | + |--- SSO login ----------->| | + |<-- ID Token -------------| | + | | | + |--- Token Exchange ------>| (RFC 8693, | + | (ID Token in, | policy evaluated | + | ID-JAG out) | by IdP admin rules) | + |<-- ID-JAG ---------------| | + | | | + |--- POST /token (RFC 7523 jwt-bearer grant) ------>| + |<-- enterprise access token ----------------------| + | | | + |--- POST /mcp with Authorization: Bearer ->| + |<-- MCP responses ---------------------------------| +``` + +The IdP decides *who* may use *which MCP client* against this server (group membership, conditional access, etc.). This server validates the resulting ID-JAG — signature (against the IdP's JWKS), issuer, audience, expiry, `typ: oauth-id-jag+jwt` header, `resource` claim, optional client allowlist, and best-effort `jti` replay detection — and then issues its own short-lived HS256 access token, audience-restricted to the MCP resource identifier as the spec requires. + +## Endpoints added when enabled + +| Endpoint | Purpose | +|----------|---------| +| `GET /.well-known/oauth-authorization-server` | RFC 8414 metadata. Advertises `authorization_grant_profiles_supported: ["urn:ietf:params:oauth:grant-profile:id-jag"]`, which is how clients discover that this server supports the extension. | +| `GET /.well-known/oauth-protected-resource` (also `/mcp`-suffixed) | RFC 9728 protected resource metadata pointing at the built-in authorization server. | +| `POST /token` | Token endpoint. Accepts `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` with the ID-JAG as `assertion`; returns a Bearer access token. | + +All three return 404 when the feature is disabled. Unauthenticated `/mcp` requests receive `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` challenge so spec-compliant clients can discover the flow. + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `ENTERPRISE_AUTH_ENABLED` | yes (`true`/`1`) | Master switch. Everything below is ignored unless enabled. | +| `ENTERPRISE_AUTH_ISSUER` | yes | Public base URL of this deployment (e.g. `https://mcp.example.com`). Used as the authorization server issuer identifier: the expected `aud` of ID-JAGs and the `iss` of issued access tokens. | +| `ENTERPRISE_IDP_ISSUER` | yes | Your IdP's issuer identifier (e.g. `https://yourorg.okta.com`). Expected `iss` of ID-JAGs. | +| `ENTERPRISE_AUTH_SIGNING_KEY` | yes | Secret (≥32 chars) used to sign/verify issued access tokens. Generate with `openssl rand -hex 32`. Must be identical across instances of the same deployment. | +| `ENTERPRISE_AUTH_MODE` | no | `required` (default): every `/mcp` request must carry an enterprise access token. `optional`: enterprise tokens are accepted, but direct Skyflow credentials and anonymous mode keep working. | +| `ENTERPRISE_IDP_JWKS_URI` | no | Explicit JWKS URI for the IdP's signing keys. When omitted, discovered from `{ENTERPRISE_IDP_ISSUER}/.well-known/openid-configuration`. | +| `ENTERPRISE_IDP_AUDIENCE` | no | Expected `aud` of ID-JAGs, if your IdP is configured with an audience other than `ENTERPRISE_AUTH_ISSUER`. | +| `ENTERPRISE_MCP_RESOURCE` | no | RFC 9728 resource identifier of the MCP endpoint. Defaults to `{ENTERPRISE_AUTH_ISSUER}/mcp`. Issued tokens are audience-restricted to this value. | +| `ENTERPRISE_ALLOWED_CLIENT_IDS` | no | Comma-separated allowlist of MCP client IDs (matched against the ID-JAG `client_id` claim). Empty = any client the IdP authorizes. | +| `ENTERPRISE_TOKEN_TTL_SECONDS` | no | Lifetime of issued access tokens. Default 3600. | +| `SKYFLOW_API_KEY` | no | Server-side Skyflow service credential used for vault access on requests authenticated via enterprise auth (see below). | + +Misconfiguration fails **closed**: if the feature is enabled but required variables are missing or invalid, `/mcp` returns 500 rather than silently skipping authorization. + +## Skyflow vault credentials under enterprise auth + +The `Authorization` header now carries the enterprise access token, so Skyflow vault credentials are resolved separately, in order of precedence: + +1. **`X-Skyflow-Authorization` header** — a per-user Skyflow bearer token or API key (with or without a `Bearer ` prefix), for deployments where each user has their own vault credentials. +2. **`SKYFLOW_API_KEY` environment variable** — a server-wide service credential. The typical setup for enterprise deployments: employees authenticate with SSO only and never handle Skyflow credentials. +3. **Existing fallbacks** — the `apiKey` query parameter, then anonymous mode if configured. + +## Deployment scenario 1: Skyflow-hosted endpoint + Skyflow Okta + +For Skyflow's own hosted MCP endpoint, gate access by Skyflow's Okta org while keeping existing consumers working: + +```bash +ENTERPRISE_AUTH_ENABLED=true +ENTERPRISE_AUTH_MODE=optional # existing Skyflow-credential and anonymous users unaffected +ENTERPRISE_AUTH_ISSUER=https://mcp.skyflow.com +ENTERPRISE_IDP_ISSUER=https://skyflow.okta.com +ENTERPRISE_AUTH_SIGNING_KEY= +``` + +In Okta, this uses [Cross App Access](https://developer.okta.com/docs/guides/cross-app-access-overview/) (Okta's implementation of the ID-JAG token exchange): + +1. Register the MCP client application (e.g. Claude, an internal agent platform) for SSO in the Okta org. +2. Register this MCP server as a connected resource with issuer `https://mcp.skyflow.com` and resource `https://mcp.skyflow.com/mcp`. +3. Define the access policy (which groups/users may connect which clients). + +Employees signed in to an enterprise-enabled MCP client then connect without any per-server authorization prompt; Okta policy decides access. Once per-user vault credential mapping is available, clients can supply per-user credentials via `X-Skyflow-Authorization`; until then, requests fall through to the `apiKey` query parameter or anonymous mode exactly as before. + +> **Note:** As of this writing there are no Skyflow Okta test credentials provisioned, so this scenario is implemented and unit/smoke-tested against a simulated IdP, but not yet validated against the production Okta org. + +## Deployment scenario 2: self-hosted with your own IdP + +Customers deploying this server themselves can require SSO through their IdP of choice in front of vault access: + +```bash +ENTERPRISE_AUTH_ENABLED=true +# ENTERPRISE_AUTH_MODE defaults to "required": no enterprise token, no access +ENTERPRISE_AUTH_ISSUER=https://mcp.internal.example.com +ENTERPRISE_IDP_ISSUER=https://login.example.com +ENTERPRISE_AUTH_SIGNING_KEY= +ENTERPRISE_ALLOWED_CLIENT_IDS=approved-client-1,approved-client-2 + +# Employees never see Skyflow credentials; the server holds one service key: +SKYFLOW_API_KEY= +VAULT_ID= +VAULT_URL=https://.vault.skyflowapis.com +``` + +Any OIDC IdP that supports the ID-JAG token exchange works; the server discovers its keys via standard OIDC discovery (or set `ENTERPRISE_IDP_JWKS_URI` explicitly). + +## Trying the flow with curl + +Simulating what an enterprise-enabled MCP client does after SSO (you need a real ID-JAG from your IdP): + +```bash +# 1. Discover the authorization server +curl https://mcp.example.com/.well-known/oauth-protected-resource/mcp + +# 2. Exchange the ID-JAG for an access token +curl -X POST https://mcp.example.com/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ + -d "assertion=" + +# 3. Call the MCP endpoint with the issued token +curl -X POST https://mcp.example.com/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer " \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +## Security notes + +- **Fail closed**: enabled-but-misconfigured deployments reject `/mcp` requests instead of bypassing auth. +- **Audience restriction**: issued access tokens carry `aud = ENTERPRISE_MCP_RESOURCE` and `typ: at+jwt`; they are only accepted by this deployment. +- **Algorithm pinning**: ID-JAGs must be asymmetrically signed (RS/PS/ES/EdDSA); symmetric algorithms are rejected to prevent key-confusion attacks. Issued tokens are pinned to HS256. +- **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. +- **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. Store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. +- The enterprise identity (`sub`, `email`, `scope`, `client_id`) of a verified request is available to request handling as `req.enterpriseAuth`, with the `sub` claim as the stable identifier for account linking per the spec. diff --git a/package.json b/package.json index 90b34f5..8d36baa 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "dotenv": "^17.2.3", "express": "^5.1.0", + "jose": "^6.2.3", "skyflow-node": "^2.0.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0670dc5..3cd815d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + jose: + specifier: ^6.2.3 + version: 6.2.3 skyflow-node: specifier: ^2.0.0 version: 2.0.0 @@ -940,8 +943,8 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-base64@3.7.7: resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} @@ -1650,7 +1653,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.9 - jose: 6.2.2 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.0 raw-body: 3.0.1 @@ -2307,7 +2310,7 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jose@6.2.2: {} + jose@6.2.3: {} js-base64@3.7.7: {} diff --git a/src/lib/auth/accessTokens.ts b/src/lib/auth/accessTokens.ts new file mode 100644 index 0000000..d6f0adb --- /dev/null +++ b/src/lib/auth/accessTokens.ts @@ -0,0 +1,118 @@ +/** + * Access tokens issued by this server's built-in Resource Authorization Server. + * + * After a valid ID-JAG is presented to the /token endpoint, the server issues + * a short-lived HS256-signed JWT access token, audience-restricted to the MCP + * resource identifier as required by the enterprise-managed-authorization + * extension. The same server later verifies these tokens on /mcp requests, + * so a shared symmetric secret (ENTERPRISE_AUTH_SIGNING_KEY) is sufficient + * and keeps stateless/serverless deployments simple. + */ +import { SignJWT, jwtVerify, decodeJwt } from "jose"; +import { randomUUID } from "node:crypto"; +import type { EnterpriseAuthConfig } from "./config.js"; + +/** JWT typ header for issued access tokens (RFC 9068 style) */ +export const ACCESS_TOKEN_TYP = "at+jwt"; + +/** Clock skew tolerance for exp/iat validation, in seconds */ +const CLOCK_TOLERANCE_SECONDS = 60; + +/** Identity claims carried by an enterprise access token */ +export interface EnterpriseIdentity { + /** Stable subject identifier from the enterprise IdP */ + subject: string; + /** User email, when the IdP provided one */ + email?: string; + /** Space-delimited scopes granted by the IdP policy */ + scope?: string; + /** MCP client the grant was issued to */ + clientId?: string; +} + +export interface IssuedAccessToken { + accessToken: string; + /** Lifetime in seconds */ + expiresIn: number; + scope?: string; +} + +function signingSecret(config: EnterpriseAuthConfig): Uint8Array { + return new TextEncoder().encode(config.signingKey); +} + +/** + * Issue an enterprise access token for a validated ID-JAG. + * The token is audience-restricted to the MCP resource identifier. + */ +export async function issueAccessToken( + identity: EnterpriseIdentity, + config: EnterpriseAuthConfig +): Promise { + const now = Math.floor(Date.now() / 1000); + const jwt = new SignJWT({ + ...(identity.email && { email: identity.email }), + ...(identity.scope && { scope: identity.scope }), + ...(identity.clientId && { client_id: identity.clientId }), + }) + .setProtectedHeader({ alg: "HS256", typ: ACCESS_TOKEN_TYP }) + .setIssuer(config.issuer) + .setAudience(config.resource) + .setSubject(identity.subject) + .setIssuedAt(now) + .setExpirationTime(now + config.tokenTtlSeconds) + .setJti(randomUUID()); + + return { + accessToken: await jwt.sign(signingSecret(config)), + expiresIn: config.tokenTtlSeconds, + scope: identity.scope, + }; +} + +/** + * Verify an enterprise access token presented on an /mcp request. + * Checks signature, issuer, audience (MCP resource identifier), typ, and expiry. + * + * @throws jose errors when the token is invalid + */ +export async function verifyAccessToken( + token: string, + config: EnterpriseAuthConfig +): Promise { + const { payload } = await jwtVerify(token, signingSecret(config), { + issuer: config.issuer, + audience: config.resource, + typ: ACCESS_TOKEN_TYP, + algorithms: ["HS256"], + clockTolerance: CLOCK_TOLERANCE_SECONDS, + }); + + if (typeof payload.sub !== "string" || payload.sub.length === 0) { + throw new Error("Enterprise access token is missing a sub claim"); + } + + return { + subject: payload.sub, + email: typeof payload.email === "string" ? payload.email : undefined, + scope: typeof payload.scope === "string" ? payload.scope : undefined, + clientId: typeof payload.client_id === "string" ? payload.client_id : undefined, + }; +} + +/** + * Cheap structural check: does this bearer value claim to be a token issued + * by this server? Used in "optional" mode to decide whether to verify it as + * an enterprise token or fall through to ordinary Skyflow credential handling. + * Does NOT validate the signature — callers must still verifyAccessToken(). + */ +export function looksLikeEnterpriseToken( + token: string, + config: EnterpriseAuthConfig +): boolean { + try { + return decodeJwt(token).iss === config.issuer; + } catch { + return false; + } +} diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts new file mode 100644 index 0000000..4a173bc --- /dev/null +++ b/src/lib/auth/config.ts @@ -0,0 +1,155 @@ +/** + * Configuration for the Enterprise-Managed Authorization extension + * (io.modelcontextprotocol/enterprise-managed-authorization). + * + * When enabled, this server acts as the "Resource Authorization Server" for + * its own /mcp endpoint: it validates Identity Assertion JWT Authorization + * Grants (ID-JAGs) issued by an enterprise IdP and issues audience-restricted + * access tokens that gate access to /mcp. + * + * The feature is entirely opt-in via ENTERPRISE_AUTH_ENABLED. When disabled, + * server behavior is unchanged. + */ + +export type EnterpriseAuthMode = "required" | "optional"; + +export interface EnterpriseAuthConfig { + /** + * Issuer identifier of this server's built-in authorization server — + * the public base URL of this deployment (e.g. https://mcp.example.com). + * Used as the expected `aud` of incoming ID-JAGs (unless overridden by + * idpAudience) and as the `iss` of issued access tokens. + */ + issuer: string; + /** + * Resource identifier of the MCP endpoint per RFC 9728. Used to validate + * the ID-JAG `resource` claim and as the `aud` of issued access tokens. + */ + resource: string; + /** Enterprise IdP issuer identifier — expected `iss` of incoming ID-JAGs. */ + idpIssuer: string; + /** + * Explicit JWKS URI for the IdP's signing keys. When omitted, the JWKS URI + * is discovered from `{idpIssuer}/.well-known/openid-configuration`. + */ + idpJwksUri?: string; + /** Expected `aud` of incoming ID-JAGs. Defaults to `issuer`. */ + idpAudience: string; + /** HS256 secret used to sign and verify issued access tokens. */ + signingKey: string; + /** Allowlisted MCP client IDs. Empty array = any client_id is accepted. */ + allowedClientIds: string[]; + /** Lifetime of issued access tokens, in seconds. */ + tokenTtlSeconds: number; + /** + * required — every /mcp request must present an enterprise access token. + * optional — enterprise access tokens are accepted, but requests carrying + * ordinary Skyflow credentials (or none, for anonymous mode) still work. + */ + mode: EnterpriseAuthMode; +} + +export class EnterpriseAuthConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "EnterpriseAuthConfigError"; + } +} + +const MIN_SIGNING_KEY_LENGTH = 32; + +function isEnabled(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; +} + +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function requireUrl(name: string, value: string | undefined): string { + if (!value || value.trim().length === 0) { + throw new EnterpriseAuthConfigError( + `${name} is required when ENTERPRISE_AUTH_ENABLED is true` + ); + } + const trimmed = value.trim(); + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new EnterpriseAuthConfigError(`${name} must be a valid URL, got: ${trimmed}`); + } + const isLocalhost = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) { + throw new EnterpriseAuthConfigError( + `${name} must use https (http is only allowed for localhost), got: ${trimmed}` + ); + } + return stripTrailingSlash(trimmed); +} + +/** + * Load and validate enterprise auth configuration from environment variables. + * + * @returns null when the feature is disabled (ENTERPRISE_AUTH_ENABLED unset/false) + * @throws EnterpriseAuthConfigError when enabled but misconfigured (fail closed) + */ +export function loadEnterpriseAuthConfig( + env: NodeJS.ProcessEnv = process.env +): EnterpriseAuthConfig | null { + if (!isEnabled(env.ENTERPRISE_AUTH_ENABLED)) { + return null; + } + + const issuer = requireUrl("ENTERPRISE_AUTH_ISSUER", env.ENTERPRISE_AUTH_ISSUER); + const idpIssuer = requireUrl("ENTERPRISE_IDP_ISSUER", env.ENTERPRISE_IDP_ISSUER); + + const signingKey = env.ENTERPRISE_AUTH_SIGNING_KEY; + if (!signingKey || signingKey.length < MIN_SIGNING_KEY_LENGTH) { + throw new EnterpriseAuthConfigError( + `ENTERPRISE_AUTH_SIGNING_KEY is required and must be at least ${MIN_SIGNING_KEY_LENGTH} characters` + ); + } + + const mode = (env.ENTERPRISE_AUTH_MODE || "required").trim().toLowerCase(); + if (mode !== "required" && mode !== "optional") { + throw new EnterpriseAuthConfigError( + `ENTERPRISE_AUTH_MODE must be "required" or "optional", got: ${mode}` + ); + } + + const tokenTtlSeconds = parseInt(env.ENTERPRISE_TOKEN_TTL_SECONDS || "3600", 10); + if (isNaN(tokenTtlSeconds) || tokenTtlSeconds <= 0) { + throw new EnterpriseAuthConfigError( + "ENTERPRISE_TOKEN_TTL_SECONDS must be a positive integer" + ); + } + + const resource = env.ENTERPRISE_MCP_RESOURCE + ? requireUrl("ENTERPRISE_MCP_RESOURCE", env.ENTERPRISE_MCP_RESOURCE) + : `${issuer}/mcp`; + + const idpJwksUri = env.ENTERPRISE_IDP_JWKS_URI + ? requireUrl("ENTERPRISE_IDP_JWKS_URI", env.ENTERPRISE_IDP_JWKS_URI) + : undefined; + + const allowedClientIds = (env.ENTERPRISE_ALLOWED_CLIENT_IDS || "") + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0); + + return { + issuer, + resource, + idpIssuer, + idpJwksUri, + idpAudience: env.ENTERPRISE_IDP_AUDIENCE?.trim() || issuer, + signingKey, + allowedClientIds, + tokenTtlSeconds, + mode, + }; +} diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts new file mode 100644 index 0000000..1550394 --- /dev/null +++ b/src/lib/auth/idJag.ts @@ -0,0 +1,257 @@ +/** + * Identity Assertion JWT Authorization Grant (ID-JAG) validation. + * + * Implements the Resource Authorization Server side of the MCP + * Enterprise-Managed Authorization extension, which profiles + * draft-ietf-oauth-identity-assertion-authz-grant: + * the enterprise IdP issues an ID-JAG (a JWT with typ "oauth-id-jag+jwt"), + * and this server validates it before issuing its own access token. + */ +import { + jwtVerify, + createRemoteJWKSet, + type JWTVerifyGetKey, +} from "jose"; +import type { EnterpriseAuthConfig } from "./config.js"; + +/** JWT typ header required on ID-JAGs */ +export const ID_JAG_TYP = "oauth-id-jag+jwt"; +/** Grant profile URN advertised in authorization server metadata */ +export const ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"; +/** Grant type used to exchange an ID-JAG for an access token (RFC 7523) */ +export const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/** Asymmetric signature algorithms accepted on ID-JAGs */ +const ALLOWED_ID_JAG_ALGORITHMS = [ + "RS256", "RS384", "RS512", + "PS256", "PS384", "PS512", + "ES256", "ES384", "ES512", + "EdDSA", +]; + +/** Clock skew tolerance for exp/iat validation, in seconds */ +const CLOCK_TOLERANCE_SECONDS = 60; + +export type OAuthTokenErrorCode = + | "invalid_request" + | "invalid_grant" + | "unauthorized_client" + | "unsupported_grant_type"; + +/** + * Validation failure carrying the OAuth 2.0 token error code to return + * per Section 5.2 of RFC 6749. + */ +export class IdJagValidationError extends Error { + constructor( + public readonly oauthError: OAuthTokenErrorCode, + message: string + ) { + super(message); + this.name = "IdJagValidationError"; + } +} + +/** Claims extracted from a validated ID-JAG */ +export interface IdJagClaims { + /** Stable subject identifier of the enterprise user */ + subject: string; + /** User email, when the IdP includes it (useful for account linking) */ + email?: string; + /** Space-delimited scopes granted by the IdP policy */ + scope?: string; + /** MCP client the IdP issued the grant to */ + clientId?: string; + /** Unique token identifier (used for replay detection) */ + jti: string; +} + +// --------------------------------------------------------------------------- +// IdP JWKS resolution (cached per process instance) +// --------------------------------------------------------------------------- + +const discoveredJwksUris = new Map(); +let cachedRemoteJwks: { uri: string; resolver: JWTVerifyGetKey } | null = null; + +/** + * Discover the IdP's JWKS URI from its OIDC discovery document. + * Results are cached for the lifetime of the process. + */ +async function discoverJwksUri(idpIssuer: string): Promise { + const cached = discoveredJwksUris.get(idpIssuer); + if (cached) return cached; + + const discoveryUrl = `${idpIssuer}/.well-known/openid-configuration`; + let response: Response; + try { + response = await fetch(discoveryUrl); + } catch (error) { + throw new Error( + `Failed to reach IdP discovery endpoint ${discoveryUrl}: ${ + error instanceof Error ? error.message : "unknown error" + }` + ); + } + if (!response.ok) { + throw new Error( + `IdP discovery endpoint ${discoveryUrl} returned HTTP ${response.status}` + ); + } + const metadata = (await response.json()) as { jwks_uri?: unknown }; + if (typeof metadata.jwks_uri !== "string" || metadata.jwks_uri.length === 0) { + throw new Error(`IdP discovery document at ${discoveryUrl} has no jwks_uri`); + } + + discoveredJwksUris.set(idpIssuer, metadata.jwks_uri); + return metadata.jwks_uri; +} + +/** + * Get a jose key resolver for the enterprise IdP's signing keys. + * Uses ENTERPRISE_IDP_JWKS_URI when set, otherwise OIDC discovery. + * The remote JWKS is cached and refreshed by jose as needed. + */ +export async function getIdpKeyResolver( + config: EnterpriseAuthConfig +): Promise { + const uri = config.idpJwksUri ?? (await discoverJwksUri(config.idpIssuer)); + if (!cachedRemoteJwks || cachedRemoteJwks.uri !== uri) { + cachedRemoteJwks = { uri, resolver: createRemoteJWKSet(new URL(uri)) }; + } + return cachedRemoteJwks.resolver; +} + +/** Clear cached discovery/JWKS state (useful for testing) */ +export function resetIdJagCaches(): void { + discoveredJwksUris.clear(); + cachedRemoteJwks = null; + seenJtis.clear(); +} + +// --------------------------------------------------------------------------- +// Replay detection (best effort, per process instance) +// --------------------------------------------------------------------------- + +// In-memory jti cache. On serverless/multi-instance deployments this is +// best-effort only: each instance tracks its own set. ID-JAGs are short-lived +// (typically 5 minutes), which bounds the replay window regardless. +const seenJtis = new Map(); + +function isReplayedJti(jti: string, expiresAtMs: number): boolean { + const now = Date.now(); + for (const [key, expiry] of seenJtis.entries()) { + if (now > expiry) { + seenJtis.delete(key); + } + } + if (seenJtis.has(jti)) { + return true; + } + seenJtis.set(jti, expiresAtMs); + return false; +} + +// --------------------------------------------------------------------------- +// ID-JAG validation +// --------------------------------------------------------------------------- + +/** + * Validate an ID-JAG presented to the token endpoint as an RFC 7523 + * authorization grant, per the enterprise-managed-authorization extension: + * + * - `typ` header must be "oauth-id-jag+jwt" + * - signature must verify against the enterprise IdP's JWKS + * - `iss` must be the configured IdP issuer + * - `aud` must be this authorization server's issuer identifier + * - `exp`/`iat` must be valid (with small clock tolerance) + * - `resource`, when present, must match this server's MCP resource identifier + * - `client_id` must be allowlisted when an allowlist is configured + * - `jti` must be present and not previously seen (best-effort replay check) + * + * @param assertion - The ID-JAG JWT from the token request's `assertion` param + * @param config - Enterprise auth configuration + * @param keyResolver - Override for the IdP key resolver (used in tests) + * @throws IdJagValidationError with the appropriate OAuth error code + */ +export async function validateIdJag( + assertion: string, + config: EnterpriseAuthConfig, + keyResolver?: JWTVerifyGetKey +): Promise { + let resolver: JWTVerifyGetKey; + try { + resolver = keyResolver ?? (await getIdpKeyResolver(config)); + } catch (error) { + // IdP discovery failure is a server-side problem, not a bad grant + throw error instanceof Error ? error : new Error("IdP key resolution failed"); + } + + let payload: Record; + try { + const result = await jwtVerify(assertion, resolver, { + issuer: config.idpIssuer, + audience: config.idpAudience, + typ: ID_JAG_TYP, + algorithms: ALLOWED_ID_JAG_ALGORITHMS, + clockTolerance: CLOCK_TOLERANCE_SECONDS, + }); + payload = result.payload; + } catch (error) { + throw new IdJagValidationError( + "invalid_grant", + `ID-JAG validation failed: ${ + error instanceof Error ? error.message : "unknown error" + }` + ); + } + + const subject = payload.sub; + if (typeof subject !== "string" || subject.length === 0) { + throw new IdJagValidationError("invalid_grant", "ID-JAG is missing a sub claim"); + } + + // The resource claim, when present, MUST be this MCP server's resource + // identifier — the issued access token is audience-restricted to it. + const resource = payload.resource; + if (resource !== undefined) { + const matches = Array.isArray(resource) + ? resource.includes(config.resource) + : resource === config.resource; + if (!matches) { + throw new IdJagValidationError( + "invalid_grant", + `ID-JAG resource claim does not match this MCP server (expected ${config.resource})` + ); + } + } + + const clientId = typeof payload.client_id === "string" ? payload.client_id : undefined; + if (config.allowedClientIds.length > 0) { + if (!clientId || !config.allowedClientIds.includes(clientId)) { + throw new IdJagValidationError( + "unauthorized_client", + "ID-JAG client_id is not authorized for this MCP server" + ); + } + } + + const jti = payload.jti; + if (typeof jti !== "string" || jti.length === 0) { + throw new IdJagValidationError("invalid_grant", "ID-JAG is missing a jti claim"); + } + const expiresAtMs = + typeof payload.exp === "number" + ? payload.exp * 1000 + CLOCK_TOLERANCE_SECONDS * 1000 + : Date.now() + 5 * 60 * 1000; + if (isReplayedJti(`${config.idpIssuer}:${jti}`, expiresAtMs)) { + throw new IdJagValidationError("invalid_grant", "ID-JAG has already been used"); + } + + return { + subject, + email: typeof payload.email === "string" ? payload.email : undefined, + scope: typeof payload.scope === "string" ? payload.scope : undefined, + clientId, + jti, + }; +} diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts new file mode 100644 index 0000000..6206860 --- /dev/null +++ b/src/lib/auth/routes.ts @@ -0,0 +1,185 @@ +/** + * HTTP endpoints for the built-in Resource Authorization Server: + * + * - GET /.well-known/oauth-authorization-server (RFC 8414 metadata) + * - GET /.well-known/oauth-protected-resource[/mcp] (RFC 9728 metadata) + * - POST /token (RFC 7523 jwt-bearer grant) + * + * All endpoints return 404 when enterprise auth is disabled, so they have no + * effect on existing deployments. + */ +import express, { Router } from "express"; +import type { Request, RequestHandler, Response } from "express"; +import type { JWTVerifyGetKey } from "jose"; +import { + loadEnterpriseAuthConfig, + EnterpriseAuthConfigError, + type EnterpriseAuthConfig, +} from "./config.js"; +import { + validateIdJag, + IdJagValidationError, + ID_JAG_GRANT_PROFILE, + JWT_BEARER_GRANT_TYPE, +} from "./idJag.js"; +import { issueAccessToken } from "./accessTokens.js"; + +export interface EnterpriseAuthRouteDeps { + /** Environment source, defaults to process.env (injectable for tests) */ + env?: NodeJS.ProcessEnv; + /** Override for the IdP JWKS key resolver (injectable for tests) */ + keyResolver?: JWTVerifyGetKey; +} + +/** + * Resolve config for a request, translating outcomes to HTTP: + * disabled → 404, misconfigured → 500, otherwise returns the config. + */ +function configForRequest( + res: Response, + env: NodeJS.ProcessEnv +): EnterpriseAuthConfig | null { + let config: EnterpriseAuthConfig | null; + try { + config = loadEnterpriseAuthConfig(env); + } catch (error) { + if (error instanceof EnterpriseAuthConfigError) { + console.error("Enterprise auth configuration error:", error.message); + res.status(500).json({ + error: "server_error", + error_description: "Enterprise-managed authorization is misconfigured", + }); + return null; + } + throw error; + } + if (!config) { + res.status(404).json({ error: "not_found" }); + return null; + } + return config; +} + +/** RFC 8414 authorization server metadata handler */ +export function createAuthServerMetadataHandler( + deps: EnterpriseAuthRouteDeps = {} +): RequestHandler { + return (req: Request, res: Response) => { + const config = configForRequest(res, deps.env ?? process.env); + if (!config) return; + res.json({ + issuer: config.issuer, + token_endpoint: `${config.issuer}/token`, + grant_types_supported: [JWT_BEARER_GRANT_TYPE], + // Advertises support for the enterprise-managed-authorization extension + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + token_endpoint_auth_methods_supported: ["none"], + response_types_supported: [], + }); + }; +} + +/** RFC 9728 protected resource metadata handler */ +export function createProtectedResourceMetadataHandler( + deps: EnterpriseAuthRouteDeps = {} +): RequestHandler { + return (req: Request, res: Response) => { + const config = configForRequest(res, deps.env ?? process.env); + if (!config) return; + res.json({ + resource: config.resource, + authorization_servers: [config.issuer], + bearer_methods_supported: ["header"], + resource_name: "Skyflow Runtime MCP Server", + }); + }; +} + +/** + * Token endpoint handler: exchanges a valid ID-JAG (presented via the RFC 7523 + * jwt-bearer grant) for an enterprise access token. Errors follow Section 5.2 + * of RFC 6749. + */ +export function createTokenHandler( + deps: EnterpriseAuthRouteDeps = {} +): RequestHandler { + return async (req: Request, res: Response) => { + const config = configForRequest(res, deps.env ?? process.env); + if (!config) return; + + res.set("Cache-Control", "no-store"); + res.set("Pragma", "no-cache"); + + const body = (req.body ?? {}) as Record; + const grantType = body.grant_type; + if (grantType !== JWT_BEARER_GRANT_TYPE) { + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: `Only ${JWT_BEARER_GRANT_TYPE} is supported`, + }); + } + + const assertion = body.assertion; + if (typeof assertion !== "string" || assertion.length === 0) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing assertion parameter", + }); + } + + try { + const claims = await validateIdJag(assertion, config, deps.keyResolver); + const issued = await issueAccessToken(claims, config); + return res.json({ + token_type: "Bearer", + access_token: issued.accessToken, + expires_in: issued.expiresIn, + ...(issued.scope && { scope: issued.scope }), + }); + } catch (error) { + if (error instanceof IdJagValidationError) { + return res.status(400).json({ + error: error.oauthError, + error_description: error.message, + }); + } + // JWKS/discovery failures and other unexpected errors + console.error( + "Token endpoint error:", + error instanceof Error ? error.message : "unknown error" + ); + return res.status(500).json({ + error: "server_error", + error_description: "Failed to process the authorization grant", + }); + } + }; +} + +/** + * Router exposing the authorization server endpoints. Safe to mount + * unconditionally — every endpoint 404s when enterprise auth is disabled. + */ +export function createEnterpriseAuthRouter( + deps: EnterpriseAuthRouteDeps = {} +): Router { + const router = Router(); + + router.get( + "/.well-known/oauth-authorization-server", + createAuthServerMetadataHandler(deps) + ); + // RFC 9728 allows path-suffixed metadata URLs for resources with a path + // component (our resource identifier ends in /mcp), so serve both. + router.get( + ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], + createProtectedResourceMetadataHandler(deps) + ); + router.post( + "/token", + express.urlencoded({ extended: false }), + createTokenHandler(deps) + ); + + return router; +} diff --git a/src/lib/middleware/authenticateBearer.ts b/src/lib/middleware/authenticateBearer.ts index 9d0ffe2..fa5dbd7 100644 --- a/src/lib/middleware/authenticateBearer.ts +++ b/src/lib/middleware/authenticateBearer.ts @@ -182,6 +182,13 @@ export function authenticateBearer( res: Response, next: NextFunction ) { + // Credentials may already be resolved by an upstream middleware (e.g. + // enterprise auth resolving X-Skyflow-Authorization or SKYFLOW_API_KEY). + // Don't overwrite them. + if (req.skyflowCredentials) { + return next(); + } + const result = extractCredentials( req.headers.authorization, req.query.apiKey as string | undefined diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts new file mode 100644 index 0000000..428e130 --- /dev/null +++ b/src/lib/middleware/enterpriseAuth.ts @@ -0,0 +1,176 @@ +/** + * Express middleware gating /mcp with enterprise-managed authorization. + * + * When enterprise auth is enabled, requests must present an access token + * issued by this server's /token endpoint (obtained via the ID-JAG flow) in + * the Authorization header. Skyflow vault credentials are then resolved from, + * in order of precedence: + * + * 1. X-Skyflow-Authorization header (per-user Skyflow bearer token/API key) + * 2. SKYFLOW_API_KEY environment variable (server-wide service credential) + * 3. Existing fallbacks in authenticateBearer (apiKey query param, anonymous mode) + * + * When enterprise auth is disabled this middleware is a no-op. + */ +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import { + loadEnterpriseAuthConfig, + EnterpriseAuthConfigError, + type EnterpriseAuthConfig, +} from "../auth/config.js"; +import { + verifyAccessToken, + looksLikeEnterpriseToken, +} from "../auth/accessTokens.js"; +import { extractCredentials } from "./authenticateBearer.js"; + +/** Header for passing Skyflow credentials alongside an enterprise token */ +export const SKYFLOW_AUTH_HEADER = "x-skyflow-authorization"; + +export interface EnterpriseAuthMiddlewareDeps { + /** Environment source, defaults to process.env (injectable for tests) */ + env?: NodeJS.ProcessEnv; +} + +/** + * 401 response with the WWW-Authenticate challenge required by the MCP + * authorization spec (RFC 9728 §5.1), pointing clients at the protected + * resource metadata for discovery. + */ +function unauthorized( + res: Response, + config: EnterpriseAuthConfig, + options: { error?: string; description?: string } = {} +): void { + const challengeParts: string[] = []; + if (options.error) { + challengeParts.push(`error="${options.error}"`); + } + if (options.description) { + challengeParts.push(`error_description="${options.description}"`); + } + challengeParts.push( + `resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"` + ); + res.set("WWW-Authenticate", `Bearer ${challengeParts.join(", ")}`); + res.status(401).json({ + error: + options.description || + "Enterprise authorization required. Obtain an access token via the ID-JAG flow described in the protected resource metadata.", + }); +} + +/** + * Resolve Skyflow credentials for a request that passed enterprise auth. + * Returns false (after sending a 401) when an X-Skyflow-Authorization header + * is present but malformed. + */ +function resolveSkyflowCredentials( + req: Request, + res: Response, + config: EnterpriseAuthConfig, + env: NodeJS.ProcessEnv +): boolean { + const skyflowHeader = req.headers[SKYFLOW_AUTH_HEADER]; + const headerValue = Array.isArray(skyflowHeader) ? skyflowHeader[0] : skyflowHeader; + + if (headerValue && headerValue.trim().length > 0) { + const normalized = headerValue.startsWith("Bearer ") + ? headerValue + : `Bearer ${headerValue}`; + const result = extractCredentials(normalized, undefined); + if (!result.isPresent || !result.credentials) { + unauthorized(res, config, { + error: "invalid_request", + description: "X-Skyflow-Authorization header is malformed", + }); + return false; + } + // SECURITY: req.skyflowCredentials contains secrets — never log or serialize the request object. + req.skyflowCredentials = result.credentials; + req.isAnonymousMode = false; + return true; + } + + if (env.SKYFLOW_API_KEY) { + // SECURITY: req.skyflowCredentials contains secrets — never log or serialize the request object. + req.skyflowCredentials = { apiKey: env.SKYFLOW_API_KEY }; + req.isAnonymousMode = false; + return true; + } + + // Leave credentials unresolved: authenticateBearer will fall back to the + // apiKey query parameter or anonymous mode. + return true; +} + +/** + * Create the enterprise auth middleware for /mcp. + * + * required mode: every request must carry a valid enterprise access token. + * optional mode: tokens issued by this server are verified and consumed; + * anything else (Skyflow JWTs, API keys, no credentials) falls through to + * the existing authentication chain unchanged. + */ +export function createEnterpriseAuthMiddleware( + deps: EnterpriseAuthMiddlewareDeps = {} +): RequestHandler { + return async (req: Request, res: Response, next: NextFunction) => { + const env = deps.env ?? process.env; + + let config: EnterpriseAuthConfig | null; + try { + config = loadEnterpriseAuthConfig(env); + } catch (error) { + if (error instanceof EnterpriseAuthConfigError) { + // Fail closed: a misconfigured deployment must not silently skip auth + console.error("Enterprise auth configuration error:", error.message); + return res.status(500).json({ + error: "Enterprise-managed authorization is misconfigured", + }); + } + throw error; + } + + if (!config) { + return next(); // feature disabled — no behavior change + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") + ? authHeader.substring(7).trim() + : undefined; + + if (!token) { + if (config.mode === "optional") { + return next(); + } + return unauthorized(res, config); + } + + // In optional mode, bearer values not issued by this server (Skyflow + // JWTs, API keys) flow through to the ordinary credential chain. + if (config.mode === "optional" && !looksLikeEnterpriseToken(token, config)) { + return next(); + } + + try { + req.enterpriseAuth = await verifyAccessToken(token, config); + } catch { + return unauthorized(res, config, { + error: "invalid_token", + description: "Enterprise access token is invalid or expired", + }); + } + + // The Authorization header held the enterprise token, now consumed. + // Remove it so authenticateBearer doesn't mistake it for Skyflow credentials. + delete req.headers.authorization; + + if (!resolveSkyflowCredentials(req, res, config, env)) { + return; + } + + next(); + }; +} diff --git a/src/server.ts b/src/server.ts index 81923ed..00859ad 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,10 @@ import { handleDeIdentify } from "./lib/tools/deIdentify.js"; import { handleReIdentify } from "./lib/tools/reIdentify.js"; import { toStructuredContent } from "./lib/tools/types.js"; import { authenticateBearer } from "./lib/middleware/authenticateBearer.js"; +import { createEnterpriseAuthMiddleware } from "./lib/middleware/enterpriseAuth.js"; +import { createEnterpriseAuthRouter } from "./lib/auth/routes.js"; +import { loadEnterpriseAuthConfig } from "./lib/auth/config.js"; +import type { EnterpriseIdentity } from "./lib/auth/accessTokens.js"; import { createAnonymousRateLimiter, getAnonymousRateLimitConfig, @@ -174,6 +178,28 @@ app.use(express.json({ limit: "5mb" })); // Limit for base64-encoded files // Serve static files from the public directory app.use(express.static("public")); +// Enterprise-managed authorization (MCP extension +// io.modelcontextprotocol/enterprise-managed-authorization): OAuth discovery +// metadata and the ID-JAG token endpoint. All routes 404 unless +// ENTERPRISE_AUTH_ENABLED=true. +app.use(createEnterpriseAuthRouter()); + +// Surface enterprise auth status/misconfiguration at startup. A misconfigured +// deployment still fails closed per-request (the middleware returns 500). +try { + const enterpriseConfig = loadEnterpriseAuthConfig(); + if (enterpriseConfig) { + console.log( + `Enterprise-managed authorization enabled (${enterpriseConfig.mode} mode, IdP: ${enterpriseConfig.idpIssuer})` + ); + } +} catch (error) { + console.error( + "Enterprise-managed authorization is misconfigured:", + error instanceof Error ? error.message : error + ); +} + // Create rate limiter for anonymous mode const anonymousRateLimiter = createAnonymousRateLimiter( getAnonymousRateLimitConfig() @@ -186,11 +212,12 @@ declare global { skyflowCredentials?: { token: string } | { apiKey: string }; isAnonymousMode: boolean; // Always set by authenticateBearer middleware anonVaultConfig?: { vaultId: string; vaultUrl: string }; + enterpriseAuth?: EnterpriseIdentity; // Set when enterprise auth verified the request } } } -app.post("/mcp", authenticateBearer, anonymousRateLimiter, async (req, res) => { +app.post("/mcp", createEnterpriseAuthMiddleware(), authenticateBearer, anonymousRateLimiter, async (req, res) => { // Determine vault configuration based on mode let vaultId: string | undefined; let vaultUrl: string | undefined; diff --git a/tests/unit/auth/accessTokens.test.ts b/tests/unit/auth/accessTokens.test.ts new file mode 100644 index 0000000..55b022b --- /dev/null +++ b/tests/unit/auth/accessTokens.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { decodeJwt, decodeProtectedHeader } from "jose"; +import { + issueAccessToken, + verifyAccessToken, + looksLikeEnterpriseToken, + ACCESS_TOKEN_TYP, +} from "../../../src/lib/auth/accessTokens"; +import { testConfig, TEST_ISSUER, TEST_RESOURCE } from "./helpers"; + +const identity = { + subject: "okta-user-123", + email: "employee@example.com", + scope: "de-identify re-identify", + clientId: "mcp-client-1", +}; + +describe("enterprise access tokens", () => { + describe("issueAccessToken()", () => { + it("issues a token that verifies and round-trips the identity", async () => { + const issued = await issueAccessToken(identity, testConfig()); + expect(issued.expiresIn).toBe(3600); + expect(issued.scope).toBe(identity.scope); + + const verified = await verifyAccessToken(issued.accessToken, testConfig()); + expect(verified).toEqual(identity); + }); + + it("is audience-restricted to the MCP resource identifier", async () => { + const issued = await issueAccessToken(identity, testConfig()); + const payload = decodeJwt(issued.accessToken); + expect(payload.aud).toBe(TEST_RESOURCE); + expect(payload.iss).toBe(TEST_ISSUER); + expect(payload.jti).toBeTruthy(); + }); + + it(`uses the ${ACCESS_TOKEN_TYP} typ header`, async () => { + const issued = await issueAccessToken(identity, testConfig()); + expect(decodeProtectedHeader(issued.accessToken).typ).toBe(ACCESS_TOKEN_TYP); + }); + + it("omits optional claims that were not present", async () => { + const issued = await issueAccessToken( + { subject: "user-1" }, + testConfig() + ); + const verified = await verifyAccessToken(issued.accessToken, testConfig()); + expect(verified.subject).toBe("user-1"); + expect(verified.email).toBeUndefined(); + expect(verified.scope).toBeUndefined(); + expect(verified.clientId).toBeUndefined(); + }); + + it("honors the configured TTL", async () => { + const issued = await issueAccessToken( + identity, + testConfig({ tokenTtlSeconds: 900 }) + ); + expect(issued.expiresIn).toBe(900); + const payload = decodeJwt(issued.accessToken); + expect(payload.exp! - payload.iat!).toBe(900); + }); + }); + + describe("verifyAccessToken()", () => { + it("rejects a tampered token", async () => { + const issued = await issueAccessToken(identity, testConfig()); + const tampered = issued.accessToken.slice(0, -4) + "AAAA"; + await expect( + verifyAccessToken(tampered, testConfig()) + ).rejects.toThrow(); + }); + + it("rejects a token signed with a different key", async () => { + const issued = await issueAccessToken(identity, testConfig()); + const otherConfig = testConfig({ + signingKey: "a-completely-different-signing-key-32ch", + }); + await expect( + verifyAccessToken(issued.accessToken, otherConfig) + ).rejects.toThrow(); + }); + + it("rejects an expired token", async () => { + // TTL beyond the 60s clock tolerance in the past + const issued = await issueAccessToken( + identity, + testConfig({ tokenTtlSeconds: -120 }) + ); + await expect( + verifyAccessToken(issued.accessToken, testConfig()) + ).rejects.toThrow(); + }); + + it("rejects a token issued for a different resource", async () => { + const issued = await issueAccessToken( + identity, + testConfig({ resource: "https://other.example.com/mcp" }) + ); + await expect( + verifyAccessToken(issued.accessToken, testConfig()) + ).rejects.toThrow(); + }); + + it("rejects arbitrary bearer values", async () => { + await expect( + verifyAccessToken("sky-api-key-123", testConfig()) + ).rejects.toThrow(); + }); + }); + + describe("looksLikeEnterpriseToken()", () => { + it("recognizes tokens issued by this server", async () => { + const issued = await issueAccessToken(identity, testConfig()); + expect(looksLikeEnterpriseToken(issued.accessToken, testConfig())).toBe(true); + }); + + it("does not match JWTs from other issuers", async () => { + const issued = await issueAccessToken( + identity, + testConfig({ issuer: "https://other.example.com" }) + ); + expect(looksLikeEnterpriseToken(issued.accessToken, testConfig())).toBe(false); + }); + + it("does not match non-JWT values", () => { + expect(looksLikeEnterpriseToken("sky-api-key-123", testConfig())).toBe(false); + expect(looksLikeEnterpriseToken("", testConfig())).toBe(false); + }); + }); +}); diff --git a/tests/unit/auth/config.test.ts b/tests/unit/auth/config.test.ts new file mode 100644 index 0000000..d0a8adb --- /dev/null +++ b/tests/unit/auth/config.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { + loadEnterpriseAuthConfig, + EnterpriseAuthConfigError, +} from "../../../src/lib/auth/config"; +import { enabledEnv, TEST_ISSUER, TEST_IDP_ISSUER } from "./helpers"; + +describe("loadEnterpriseAuthConfig()", () => { + describe("disabled states", () => { + it("returns null when ENTERPRISE_AUTH_ENABLED is unset", () => { + expect(loadEnterpriseAuthConfig({} as NodeJS.ProcessEnv)).toBeNull(); + }); + + it("returns null when ENTERPRISE_AUTH_ENABLED is false", () => { + expect( + loadEnterpriseAuthConfig({ + ENTERPRISE_AUTH_ENABLED: "false", + } as NodeJS.ProcessEnv) + ).toBeNull(); + }); + + it("ignores other enterprise vars while disabled", () => { + expect( + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_ENABLED: "no" }) + ) + ).toBeNull(); + }); + }); + + describe("enabled with minimal configuration", () => { + it("returns config with defaults", () => { + const config = loadEnterpriseAuthConfig(enabledEnv()); + expect(config).not.toBeNull(); + expect(config!.issuer).toBe(TEST_ISSUER); + expect(config!.idpIssuer).toBe(TEST_IDP_ISSUER); + expect(config!.resource).toBe(`${TEST_ISSUER}/mcp`); + expect(config!.idpAudience).toBe(TEST_ISSUER); + expect(config!.mode).toBe("required"); + expect(config!.tokenTtlSeconds).toBe(3600); + expect(config!.allowedClientIds).toEqual([]); + expect(config!.idpJwksUri).toBeUndefined(); + }); + + it('accepts "1" as enabled', () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_ENABLED: "1" }) + ); + expect(config).not.toBeNull(); + }); + + it("strips trailing slashes from URLs", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ + ENTERPRISE_AUTH_ISSUER: "https://mcp.example.com/", + ENTERPRISE_IDP_ISSUER: "https://idp.example.com/", + }) + ); + expect(config!.issuer).toBe("https://mcp.example.com"); + expect(config!.idpIssuer).toBe("https://idp.example.com"); + expect(config!.resource).toBe("https://mcp.example.com/mcp"); + }); + }); + + describe("validation failures (fail closed)", () => { + it("throws when ENTERPRISE_AUTH_ISSUER is missing", () => { + const env = enabledEnv(); + delete env.ENTERPRISE_AUTH_ISSUER; + expect(() => loadEnterpriseAuthConfig(env)).toThrow( + EnterpriseAuthConfigError + ); + }); + + it("throws when ENTERPRISE_IDP_ISSUER is missing", () => { + const env = enabledEnv(); + delete env.ENTERPRISE_IDP_ISSUER; + expect(() => loadEnterpriseAuthConfig(env)).toThrow( + EnterpriseAuthConfigError + ); + }); + + it("throws when the signing key is missing", () => { + const env = enabledEnv(); + delete env.ENTERPRISE_AUTH_SIGNING_KEY; + expect(() => loadEnterpriseAuthConfig(env)).toThrow( + EnterpriseAuthConfigError + ); + }); + + it("throws when the signing key is too short", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_SIGNING_KEY: "short" }) + ) + ).toThrow(/32 characters/); + }); + + it("throws on a non-URL issuer", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_ISSUER: "not a url" }) + ) + ).toThrow(EnterpriseAuthConfigError); + }); + + it("throws on http URLs for non-localhost hosts", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_IDP_ISSUER: "http://idp.example.com" }) + ) + ).toThrow(/https/); + }); + + it("allows http for localhost (local development)", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_ISSUER: "http://localhost:3000" }) + ); + expect(config!.issuer).toBe("http://localhost:3000"); + }); + + it("throws on an invalid mode", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_MODE: "sometimes" }) + ) + ).toThrow(/required.*optional/); + }); + + it("throws on a non-positive token TTL", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_TOKEN_TTL_SECONDS: "0" }) + ) + ).toThrow(/positive integer/); + }); + }); + + describe("overrides", () => { + it("accepts optional mode", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_AUTH_MODE: "optional" }) + ); + expect(config!.mode).toBe("optional"); + }); + + it("accepts a custom resource identifier", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_MCP_RESOURCE: "https://other.example.com/mcp" }) + ); + expect(config!.resource).toBe("https://other.example.com/mcp"); + }); + + it("accepts a custom IdP audience", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_IDP_AUDIENCE: "urn:example:mcp-auth" }) + ); + expect(config!.idpAudience).toBe("urn:example:mcp-auth"); + }); + + it("accepts an explicit JWKS URI", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ + ENTERPRISE_IDP_JWKS_URI: "https://idp.example.com/oauth2/v1/keys", + }) + ); + expect(config!.idpJwksUri).toBe("https://idp.example.com/oauth2/v1/keys"); + }); + + it("parses the client allowlist CSV with whitespace", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ + ENTERPRISE_ALLOWED_CLIENT_IDS: " client-a , client-b ,,client-c", + }) + ); + expect(config!.allowedClientIds).toEqual([ + "client-a", + "client-b", + "client-c", + ]); + }); + + it("parses a custom token TTL", () => { + const config = loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_TOKEN_TTL_SECONDS: "900" }) + ); + expect(config!.tokenTtlSeconds).toBe(900); + }); + }); +}); diff --git a/tests/unit/auth/helpers.ts b/tests/unit/auth/helpers.ts new file mode 100644 index 0000000..191c34f --- /dev/null +++ b/tests/unit/auth/helpers.ts @@ -0,0 +1,109 @@ +/** + * Shared helpers for enterprise auth tests: a fake enterprise IdP that signs + * ID-JAGs with a locally generated RSA key, plus config factories. + */ +import { + SignJWT, + generateKeyPair, + exportJWK, + createLocalJWKSet, + type JWTVerifyGetKey, + type CryptoKey, +} from "jose"; +import type { EnterpriseAuthConfig } from "../../../src/lib/auth/config"; +import { ID_JAG_TYP } from "../../../src/lib/auth/idJag"; + +export const TEST_IDP_ISSUER = "https://idp.example.com"; +export const TEST_ISSUER = "https://mcp.example.com"; +export const TEST_RESOURCE = "https://mcp.example.com/mcp"; +export const TEST_SIGNING_KEY = "unit-test-signing-key-with-at-least-32-chars"; + +export function testConfig( + overrides: Partial = {} +): EnterpriseAuthConfig { + return { + issuer: TEST_ISSUER, + resource: TEST_RESOURCE, + idpIssuer: TEST_IDP_ISSUER, + idpAudience: TEST_ISSUER, + signingKey: TEST_SIGNING_KEY, + allowedClientIds: [], + tokenTtlSeconds: 3600, + mode: "required", + ...overrides, + }; +} + +/** Environment variables matching testConfig() for env-injected code paths */ +export function enabledEnv( + overrides: Record = {} +): NodeJS.ProcessEnv { + return { + ENTERPRISE_AUTH_ENABLED: "true", + ENTERPRISE_AUTH_ISSUER: TEST_ISSUER, + ENTERPRISE_IDP_ISSUER: TEST_IDP_ISSUER, + ENTERPRISE_AUTH_SIGNING_KEY: TEST_SIGNING_KEY, + ...overrides, + } as NodeJS.ProcessEnv; +} + +export interface TestIdp { + /** Resolves the fake IdP's public key, for injecting into validateIdJag */ + keyResolver: JWTVerifyGetKey; + privateKey: CryptoKey; + /** + * Sign an ID-JAG with sensible default claims. Override individual claims + * via `overrides` (set a claim to undefined to omit it). Override the JWT + * typ header via `headerTyp` (pass null to omit the typ header). + */ + signIdJag( + overrides?: Record, + headerTyp?: string | null + ): Promise; +} + +let jtiCounter = 0; + +export async function createTestIdp(): Promise { + const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, + }); + const jwk = await exportJWK(publicKey); + jwk.kid = "test-idp-key"; + jwk.alg = "RS256"; + const keyResolver = createLocalJWKSet({ keys: [jwk] }); + + async function signIdJag( + overrides: Record = {}, + headerTyp: string | null = ID_JAG_TYP + ): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: Record = { + iss: TEST_IDP_ISSUER, + aud: TEST_ISSUER, + sub: "okta-user-123", + email: "employee@example.com", + resource: TEST_RESOURCE, + client_id: "mcp-client-1", + scope: "de-identify re-identify", + jti: `jag-${++jtiCounter}-${Date.now()}`, + iat: now, + exp: now + 300, + ...overrides, + }; + for (const key of Object.keys(payload)) { + if (payload[key] === undefined) { + delete payload[key]; + } + } + const header: Record = { alg: "RS256", kid: "test-idp-key" }; + if (headerTyp !== null) { + header.typ = headerTyp; + } + return new SignJWT(payload) + .setProtectedHeader(header as { alg: string }) + .sign(privateKey); + } + + return { keyResolver, privateKey, signIdJag }; +} diff --git a/tests/unit/auth/idJag.test.ts b/tests/unit/auth/idJag.test.ts new file mode 100644 index 0000000..70e0608 --- /dev/null +++ b/tests/unit/auth/idJag.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, beforeAll } from "vitest"; +import { SignJWT } from "jose"; +import { + validateIdJag, + IdJagValidationError, + resetIdJagCaches, + ID_JAG_TYP, +} from "../../../src/lib/auth/idJag"; +import { + createTestIdp, + testConfig, + TEST_RESOURCE, + type TestIdp, +} from "./helpers"; + +describe("validateIdJag()", () => { + let idp: TestIdp; + + beforeAll(async () => { + idp = await createTestIdp(); + }); + + beforeEach(() => { + resetIdJagCaches(); + }); + + async function expectOAuthError( + promise: Promise, + oauthError: string, + messagePattern?: RegExp + ) { + const error = await promise.then( + () => null, + (e) => e + ); + expect(error).toBeInstanceOf(IdJagValidationError); + expect((error as IdJagValidationError).oauthError).toBe(oauthError); + if (messagePattern) { + expect((error as IdJagValidationError).message).toMatch(messagePattern); + } + } + + describe("valid grants", () => { + it("returns the claims from a valid ID-JAG", async () => { + const assertion = await idp.signIdJag(); + const claims = await validateIdJag(assertion, testConfig(), idp.keyResolver); + expect(claims.subject).toBe("okta-user-123"); + expect(claims.email).toBe("employee@example.com"); + expect(claims.scope).toBe("de-identify re-identify"); + expect(claims.clientId).toBe("mcp-client-1"); + expect(claims.jti).toBeTruthy(); + }); + + it("accepts an ID-JAG without a resource claim", async () => { + const assertion = await idp.signIdJag({ resource: undefined }); + const claims = await validateIdJag(assertion, testConfig(), idp.keyResolver); + expect(claims.subject).toBe("okta-user-123"); + }); + + it("accepts a resource claim array containing this server", async () => { + const assertion = await idp.signIdJag({ + resource: ["https://other.example.com/mcp", TEST_RESOURCE], + }); + const claims = await validateIdJag(assertion, testConfig(), idp.keyResolver); + expect(claims.subject).toBe("okta-user-123"); + }); + + it("accepts an ID-JAG without optional email/scope claims", async () => { + const assertion = await idp.signIdJag({ + email: undefined, + scope: undefined, + }); + const claims = await validateIdJag(assertion, testConfig(), idp.keyResolver); + expect(claims.email).toBeUndefined(); + expect(claims.scope).toBeUndefined(); + }); + + it("accepts an allowlisted client", async () => { + const assertion = await idp.signIdJag({ client_id: "mcp-client-1" }); + const config = testConfig({ allowedClientIds: ["mcp-client-1", "other"] }); + const claims = await validateIdJag(assertion, config, idp.keyResolver); + expect(claims.clientId).toBe("mcp-client-1"); + }); + }); + + describe("JWT-level rejections (invalid_grant)", () => { + it("rejects a wrong typ header", async () => { + const assertion = await idp.signIdJag({}, "JWT"); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects a missing typ header", async () => { + const assertion = await idp.signIdJag({}, null); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects a wrong issuer", async () => { + const assertion = await idp.signIdJag({ iss: "https://evil.example.com" }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects a wrong audience", async () => { + const assertion = await idp.signIdJag({ aud: "https://other-as.example.com" }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects an expired ID-JAG", async () => { + const now = Math.floor(Date.now() / 1000); + const assertion = await idp.signIdJag({ iat: now - 600, exp: now - 300 }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects a signature from an unknown key", async () => { + const otherIdp = await createTestIdp(); + const assertion = await otherIdp.signIdJag(); + // Validated against the first IdP's JWKS + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects symmetric (HS256) signatures", async () => { + const now = Math.floor(Date.now() / 1000); + const secret = new TextEncoder().encode(testConfig().signingKey); + const assertion = await new SignJWT({ + iss: testConfig().idpIssuer, + aud: testConfig().idpAudience, + sub: "okta-user-123", + jti: "hs256-attack", + iat: now, + exp: now + 300, + }) + .setProtectedHeader({ alg: "HS256", typ: ID_JAG_TYP }) + .sign(secret); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + + it("rejects garbage assertions", async () => { + await expectOAuthError( + validateIdJag("not-a-jwt", testConfig(), idp.keyResolver), + "invalid_grant" + ); + }); + }); + + describe("claim-level rejections", () => { + it("rejects a missing sub claim", async () => { + const assertion = await idp.signIdJag({ sub: undefined }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /sub/ + ); + }); + + it("rejects a resource claim for a different server", async () => { + const assertion = await idp.signIdJag({ + resource: "https://other.example.com/mcp", + }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /resource/ + ); + }); + + it("rejects a missing jti claim", async () => { + const assertion = await idp.signIdJag({ jti: undefined }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /jti/ + ); + }); + + it("rejects a replayed ID-JAG", async () => { + const assertion = await idp.signIdJag(); + await validateIdJag(assertion, testConfig(), idp.keyResolver); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /already been used/ + ); + }); + + it("rejects a non-allowlisted client (unauthorized_client)", async () => { + const assertion = await idp.signIdJag({ client_id: "rogue-client" }); + const config = testConfig({ allowedClientIds: ["mcp-client-1"] }); + await expectOAuthError( + validateIdJag(assertion, config, idp.keyResolver), + "unauthorized_client" + ); + }); + + it("rejects a missing client_id when an allowlist is configured", async () => { + const assertion = await idp.signIdJag({ client_id: undefined }); + const config = testConfig({ allowedClientIds: ["mcp-client-1"] }); + await expectOAuthError( + validateIdJag(assertion, config, idp.keyResolver), + "unauthorized_client" + ); + }); + }); +}); diff --git a/tests/unit/auth/routes.test.ts b/tests/unit/auth/routes.test.ts new file mode 100644 index 0000000..2ede191 --- /dev/null +++ b/tests/unit/auth/routes.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import type { Request, Response } from "express"; +import { + createAuthServerMetadataHandler, + createProtectedResourceMetadataHandler, + createTokenHandler, +} from "../../../src/lib/auth/routes"; +import { + ID_JAG_GRANT_PROFILE, + JWT_BEARER_GRANT_TYPE, + resetIdJagCaches, +} from "../../../src/lib/auth/idJag"; +import { verifyAccessToken } from "../../../src/lib/auth/accessTokens"; +import { + createTestIdp, + enabledEnv, + testConfig, + TEST_ISSUER, + TEST_RESOURCE, + type TestIdp, +} from "./helpers"; + +function createMockRequest(overrides: Partial = {}): Request { + return { headers: {}, query: {}, body: {}, ...overrides } as Request; +} + +interface MockResponse { + res: Response; + statusCode: number; + jsonBody: any; + headers: Record; +} + +function createMockResponse(): MockResponse { + const captured: MockResponse = { + res: undefined as unknown as Response, + statusCode: 200, + jsonBody: null, + headers: {}, + }; + const res = { + status: vi.fn().mockImplementation((code: number) => { + captured.statusCode = code; + return res; + }), + json: vi.fn().mockImplementation((body: unknown) => { + captured.jsonBody = body; + return res; + }), + set: vi.fn().mockImplementation((name: string, value: string) => { + captured.headers[name.toLowerCase()] = value; + return res; + }), + } as unknown as Response; + captured.res = res; + return captured; +} + +const disabledEnv = {} as NodeJS.ProcessEnv; +const brokenEnv = { ENTERPRISE_AUTH_ENABLED: "true" } as NodeJS.ProcessEnv; + +describe("authorization server metadata endpoint", () => { + it("returns 404 when enterprise auth is disabled", () => { + const mock = createMockResponse(); + createAuthServerMetadataHandler({ env: disabledEnv })( + createMockRequest(), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(404); + }); + + it("returns 500 when enterprise auth is misconfigured", () => { + const mock = createMockResponse(); + createAuthServerMetadataHandler({ env: brokenEnv })( + createMockRequest(), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(500); + expect(mock.jsonBody.error).toBe("server_error"); + }); + + it("advertises the ID-JAG grant profile per the extension spec", () => { + const mock = createMockResponse(); + createAuthServerMetadataHandler({ env: enabledEnv() })( + createMockRequest(), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(200); + expect(mock.jsonBody).toMatchObject({ + issuer: TEST_ISSUER, + token_endpoint: `${TEST_ISSUER}/token`, + grant_types_supported: [JWT_BEARER_GRANT_TYPE], + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + token_endpoint_auth_methods_supported: ["none"], + }); + }); +}); + +describe("protected resource metadata endpoint", () => { + it("returns 404 when enterprise auth is disabled", () => { + const mock = createMockResponse(); + createProtectedResourceMetadataHandler({ env: disabledEnv })( + createMockRequest(), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(404); + }); + + it("points clients at this server's authorization server", () => { + const mock = createMockResponse(); + createProtectedResourceMetadataHandler({ env: enabledEnv() })( + createMockRequest(), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(200); + expect(mock.jsonBody).toMatchObject({ + resource: TEST_RESOURCE, + authorization_servers: [TEST_ISSUER], + bearer_methods_supported: ["header"], + }); + }); +}); + +describe("token endpoint", () => { + let idp: TestIdp; + + beforeAll(async () => { + idp = await createTestIdp(); + }); + + beforeEach(() => { + resetIdJagCaches(); + }); + + function tokenHandler(env = enabledEnv()) { + return createTokenHandler({ env, keyResolver: idp.keyResolver }); + } + + async function postToken(body: Record, env?: NodeJS.ProcessEnv) { + const mock = createMockResponse(); + await tokenHandler(env)( + createMockRequest({ body } as Partial), + mock.res, + vi.fn() + ); + return mock; + } + + it("returns 404 when enterprise auth is disabled", async () => { + const mock = await postToken({}, disabledEnv); + expect(mock.statusCode).toBe(404); + }); + + it("rejects unsupported grant types", async () => { + const mock = await postToken({ + grant_type: "authorization_code", + code: "abc", + }); + expect(mock.statusCode).toBe(400); + expect(mock.jsonBody.error).toBe("unsupported_grant_type"); + }); + + it("rejects a missing assertion", async () => { + const mock = await postToken({ grant_type: JWT_BEARER_GRANT_TYPE }); + expect(mock.statusCode).toBe(400); + expect(mock.jsonBody.error).toBe("invalid_request"); + }); + + it("rejects an invalid assertion with invalid_grant", async () => { + const mock = await postToken({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion: "not-a-jwt", + }); + expect(mock.statusCode).toBe(400); + expect(mock.jsonBody.error).toBe("invalid_grant"); + }); + + it("propagates unauthorized_client for non-allowlisted clients", async () => { + const assertion = await idp.signIdJag({ client_id: "rogue-client" }); + const mock = await postToken( + { grant_type: JWT_BEARER_GRANT_TYPE, assertion }, + enabledEnv({ ENTERPRISE_ALLOWED_CLIENT_IDS: "mcp-client-1" }) + ); + expect(mock.statusCode).toBe(400); + expect(mock.jsonBody.error).toBe("unauthorized_client"); + }); + + it("exchanges a valid ID-JAG for an enterprise access token", async () => { + const assertion = await idp.signIdJag(); + const mock = await postToken({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }); + + expect(mock.statusCode).toBe(200); + expect(mock.jsonBody.token_type).toBe("Bearer"); + expect(mock.jsonBody.expires_in).toBe(3600); + expect(mock.jsonBody.scope).toBe("de-identify re-identify"); + expect(mock.headers["cache-control"]).toBe("no-store"); + + // The issued token must verify against this server's own config + const identity = await verifyAccessToken( + mock.jsonBody.access_token, + testConfig() + ); + expect(identity.subject).toBe("okta-user-123"); + expect(identity.email).toBe("employee@example.com"); + expect(identity.clientId).toBe("mcp-client-1"); + }); + + it("rejects reuse of the same ID-JAG (replay)", async () => { + const assertion = await idp.signIdJag(); + const first = await postToken({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }); + expect(first.statusCode).toBe(200); + + const second = await postToken({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }); + expect(second.statusCode).toBe(400); + expect(second.jsonBody.error).toBe("invalid_grant"); + }); +}); diff --git a/tests/unit/middleware/authenticateBearer.test.ts b/tests/unit/middleware/authenticateBearer.test.ts index e62faee..9f677dc 100644 --- a/tests/unit/middleware/authenticateBearer.test.ts +++ b/tests/unit/middleware/authenticateBearer.test.ts @@ -415,6 +415,24 @@ describe("Credentials Authentication", () => { vi.restoreAllMocks(); }); + describe("Pre-resolved credentials (enterprise auth)", () => { + it("should not overwrite credentials resolved by an upstream middleware", () => { + const req = createMockRequest({ + headers: { authorization: "Bearer sky-other-key" }, + }) as Request; + req.skyflowCredentials = { apiKey: "resolved-upstream" }; + req.isAnonymousMode = false; + const { res } = createMockResponse(); + const next = vi.fn(); + + authenticateBearer(req, res as Response, next); + + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ apiKey: "resolved-upstream" }); + expect(req.isAnonymousMode).toBe(false); + }); + }); + describe("Anonymous Mode Detection", () => { describe("when credentials missing and ANON env vars configured", () => { beforeEach(() => { diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts new file mode 100644 index 0000000..0b037ed --- /dev/null +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Request, Response } from "express"; +import { + createEnterpriseAuthMiddleware, + SKYFLOW_AUTH_HEADER, +} from "../../../src/lib/middleware/enterpriseAuth"; +import { issueAccessToken } from "../../../src/lib/auth/accessTokens"; +import { + enabledEnv, + testConfig, + TEST_ISSUER, +} from "../auth/helpers"; + +const identity = { + subject: "okta-user-123", + email: "employee@example.com", + scope: "de-identify", + clientId: "mcp-client-1", +}; + +// Structurally valid Skyflow JWT (not issued by the enterprise AS) +const SKYFLOW_JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; // gitleaks:allow + +function createMockRequest(overrides: Partial = {}): Request { + return { headers: {}, query: {}, ...overrides } as Request; +} + +function createMockResponse() { + const captured = { + statusCode: 200, + jsonBody: null as any, + headers: {} as Record, + }; + const res = { + status: vi.fn().mockImplementation((code: number) => { + captured.statusCode = code; + return res; + }), + json: vi.fn().mockImplementation((body: unknown) => { + captured.jsonBody = body; + return res; + }), + set: vi.fn().mockImplementation((name: string, value: string) => { + captured.headers[name.toLowerCase()] = value; + return res; + }), + } as unknown as Response; + return { res, captured }; +} + +async function runMiddleware(env: NodeJS.ProcessEnv, req: Request) { + const { res, captured } = createMockResponse(); + const next = vi.fn(); + await createEnterpriseAuthMiddleware({ env })(req, res, next); + return { next, captured }; +} + +async function validToken(): Promise { + const issued = await issueAccessToken(identity, testConfig()); + return issued.accessToken; +} + +describe("enterprise auth middleware", () => { + describe("disabled", () => { + it("is a no-op when enterprise auth is not enabled", async () => { + const req = createMockRequest({ + headers: { authorization: "Bearer some-skyflow-key" }, + }); + const { next } = await runMiddleware({} as NodeJS.ProcessEnv, req); + expect(next).toHaveBeenCalled(); + expect(req.headers.authorization).toBe("Bearer some-skyflow-key"); + expect(req.enterpriseAuth).toBeUndefined(); + }); + + it("fails closed (500) when enabled but misconfigured", async () => { + const req = createMockRequest(); + const { next, captured } = await runMiddleware( + { ENTERPRISE_AUTH_ENABLED: "true" } as NodeJS.ProcessEnv, + req + ); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(500); + }); + }); + + describe("required mode", () => { + it("rejects requests without a token, advertising resource metadata", async () => { + const req = createMockRequest(); + const { next, captured } = await runMiddleware(enabledEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + expect(captured.headers["www-authenticate"]).toContain( + `resource_metadata="${TEST_ISSUER}/.well-known/oauth-protected-resource"` + ); + }); + + it("rejects Skyflow credentials that are not enterprise tokens", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${SKYFLOW_JWT}` }, + }); + const { next, captured } = await runMiddleware(enabledEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + expect(captured.headers["www-authenticate"]).toContain('error="invalid_token"'); + }); + + it("rejects expired enterprise tokens", async () => { + const issued = await issueAccessToken( + identity, + testConfig({ tokenTtlSeconds: -120 }) + ); + const req = createMockRequest({ + headers: { authorization: `Bearer ${issued.accessToken}` }, + }); + const { next, captured } = await runMiddleware(enabledEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + }); + + it("accepts a valid token and records the enterprise identity", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.enterpriseAuth).toEqual(identity); + // The enterprise token must not leak downstream as a Skyflow credential + expect(req.headers.authorization).toBeUndefined(); + }); + }); + + describe("Skyflow credential resolution after enterprise auth", () => { + it("uses a JWT from X-Skyflow-Authorization as a bearer token", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: `Bearer ${SKYFLOW_JWT}`, + }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ token: SKYFLOW_JWT }); + expect(req.isAnonymousMode).toBe(false); + }); + + it("accepts a raw API key in X-Skyflow-Authorization without Bearer prefix", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: "sky-abc123-def456", + }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ apiKey: "sky-abc123-def456" }); + }); + + it("rejects a malformed X-Skyflow-Authorization header", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: "Bearer ", + }, + }); + const { next, captured } = await runMiddleware(enabledEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + }); + + it("falls back to the SKYFLOW_API_KEY service credential", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const env = enabledEnv({ SKYFLOW_API_KEY: "service-api-key" }); + const { next } = await runMiddleware(env, req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ apiKey: "service-api-key" }); + expect(req.isAnonymousMode).toBe(false); + }); + + it("leaves credentials unresolved for downstream fallbacks when none provided", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toBeUndefined(); + }); + + it("prefers X-Skyflow-Authorization over SKYFLOW_API_KEY", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: "per-user-key", + }, + }); + const env = enabledEnv({ SKYFLOW_API_KEY: "service-api-key" }); + const { next } = await runMiddleware(env, req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ apiKey: "per-user-key" }); + }); + }); + + describe("optional mode", () => { + const optionalEnv = () => enabledEnv({ ENTERPRISE_AUTH_MODE: "optional" }); + + it("lets requests without credentials through unchanged", async () => { + const req = createMockRequest(); + const { next } = await runMiddleware(optionalEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.enterpriseAuth).toBeUndefined(); + }); + + it("lets Skyflow JWTs from other issuers fall through", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${SKYFLOW_JWT}` }, + }); + const { next } = await runMiddleware(optionalEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.headers.authorization).toBe(`Bearer ${SKYFLOW_JWT}`); + expect(req.enterpriseAuth).toBeUndefined(); + }); + + it("lets Skyflow API keys fall through", async () => { + const req = createMockRequest({ + headers: { authorization: "Bearer sky-abc123" }, + }); + const { next } = await runMiddleware(optionalEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.headers.authorization).toBe("Bearer sky-abc123"); + }); + + it("verifies and consumes tokens issued by this server", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const { next } = await runMiddleware(optionalEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.enterpriseAuth).toEqual(identity); + expect(req.headers.authorization).toBeUndefined(); + }); + + it("still rejects invalid tokens that claim this server as issuer", async () => { + const issued = await issueAccessToken( + identity, + testConfig({ signingKey: "wrong-signing-key-that-is-32-chars-x" }) + ); + const req = createMockRequest({ + headers: { authorization: `Bearer ${issued.accessToken}` }, + }); + const { next, captured } = await runMiddleware(optionalEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + }); + }); +}); From bc4e880e9411ca5de29b03e4c7f1f7e1f4406f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:35:50 +0000 Subject: [PATCH 02/18] Annotate fake test API keys for gitleaks Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- tests/unit/middleware/enterpriseAuth.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts index 0b037ed..13abc11 100644 --- a/tests/unit/middleware/enterpriseAuth.test.ts +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -148,12 +148,12 @@ describe("enterprise auth middleware", () => { const req = createMockRequest({ headers: { authorization: `Bearer ${await validToken()}`, - [SKYFLOW_AUTH_HEADER]: "sky-abc123-def456", + [SKYFLOW_AUTH_HEADER]: "sky-abc123-def456", // gitleaks:allow }, }); const { next } = await runMiddleware(enabledEnv(), req); expect(next).toHaveBeenCalled(); - expect(req.skyflowCredentials).toEqual({ apiKey: "sky-abc123-def456" }); + expect(req.skyflowCredentials).toEqual({ apiKey: "sky-abc123-def456" }); // gitleaks:allow }); it("rejects a malformed X-Skyflow-Authorization header", async () => { From 2f9e08b6f4ea086afdfa0f74636f7870a4118d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:38:31 +0000 Subject: [PATCH 03/18] Annotate remaining fake signing key for gitleaks Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- tests/unit/middleware/enterpriseAuth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts index 13abc11..a86eb81 100644 --- a/tests/unit/middleware/enterpriseAuth.test.ts +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -244,7 +244,7 @@ describe("enterprise auth middleware", () => { it("still rejects invalid tokens that claim this server as issuer", async () => { const issued = await issueAccessToken( identity, - testConfig({ signingKey: "wrong-signing-key-that-is-32-chars-x" }) + testConfig({ signingKey: "wrong-signing-key-that-is-32-chars-x" }) // gitleaks:allow ); const req = createMockRequest({ headers: { authorization: `Bearer ${issued.accessToken}` }, From 1bf441e996535e60ee187cf876d6f0d22e10c3d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:44:16 +0000 Subject: [PATCH 04/18] Address code review: discovery timeout, /token rate limit, OAuth error bodies - Add 5s timeout to the OIDC discovery fetch so a hung IdP cannot stall /token requests - Rate-limit the unauthenticated /token endpoint per client IP (ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS/WINDOW_MS, default 30/60s), extracting a shared IP rate limiter used by anonymous mode - Align /mcp 401 and 500 bodies with the RFC 6749 5.2 {error, error_description} shape used by /token - Document the intentional behaviors reviewers flagged: optional resource claim per the ID-JAG profile (aud still binds the grant; one resource per issuer), and the anonymous-mode fallback for enterprise-authenticated requests without Skyflow credentials - Add tests for the token endpoint rate limiter and 401 body shape Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- docs/enterprise-managed-auth.md | 6 ++ src/lib/auth/idJag.ts | 11 ++- src/lib/auth/routes.ts | 5 + src/lib/middleware/enterpriseAuth.ts | 14 ++- src/lib/middleware/rateLimiter.ts | 99 +++++++++++++++++--- tests/unit/middleware/enterpriseAuth.test.ts | 4 + tests/unit/middleware/rateLimiter.test.ts | 83 ++++++++++++++++ 9 files changed, 205 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6279410..d2665cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - **Enterprise-Managed Authorization (opt-in)** — Implements the MCP `io.modelcontextprotocol/enterprise-managed-authorization` extension (ID-JAG profile). When `ENTERPRISE_AUTH_ENABLED=true`, the server acts as its own Resource Authorization Server: it validates Identity Assertion JWT Authorization Grants issued by an enterprise IdP (Okta, Entra, any OIDC IdP) and issues short-lived, audience-restricted access tokens that gate `/mcp`. - - New endpoints: `POST /token` (RFC 7523 jwt-bearer grant), `GET /.well-known/oauth-authorization-server` (RFC 8414, advertises `urn:ietf:params:oauth:grant-profile:id-jag`), `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728). All 404 when the feature is disabled. + - New endpoints: `POST /token` (RFC 7523 jwt-bearer grant, rate-limited per client IP via `ENTERPRISE_TOKEN_RATE_LIMIT_*`), `GET /.well-known/oauth-authorization-server` (RFC 8414, advertises `urn:ietf:params:oauth:grant-profile:id-jag`), `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728). All 404 when the feature is disabled. - New middleware gates `/mcp` in `required` or `optional` mode; 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge for client discovery. - Skyflow vault credentials under enterprise auth resolve from the `X-Skyflow-Authorization` header, then the new `SKYFLOW_API_KEY` service-credential env var, then existing fallbacks (`apiKey` query param, anonymous mode). - ID-JAG validation covers `typ: oauth-id-jag+jwt`, IdP JWKS signature (explicit URI or OIDC discovery), issuer/audience/expiry, `resource` claim matching, optional `client_id` allowlist, and best-effort `jti` replay detection. Misconfiguration fails closed. diff --git a/CLAUDE.md b/CLAUDE.md index 724e808..3296c5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,7 @@ Optional fallback variables in `.env.local`: **Enterprise-managed authorization variables** (all optional; feature off unless `ENTERPRISE_AUTH_ENABLED=true`): - `ENTERPRISE_AUTH_ENABLED`, `ENTERPRISE_AUTH_ISSUER`, `ENTERPRISE_IDP_ISSUER`, `ENTERPRISE_AUTH_SIGNING_KEY` (required when enabled) -- `ENTERPRISE_AUTH_MODE` (`required`|`optional`), `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS` +- `ENTERPRISE_AUTH_MODE` (`required`|`optional`), `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS`, `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS`, `ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS` - `SKYFLOW_API_KEY`: server-side Skyflow service credential, used only for requests authenticated via enterprise auth **Removed variables** (no longer used): diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index d4d7b70..145ab02 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -55,6 +55,8 @@ All three return 404 when the feature is disabled. Unauthenticated `/mcp` reques | `ENTERPRISE_MCP_RESOURCE` | no | RFC 9728 resource identifier of the MCP endpoint. Defaults to `{ENTERPRISE_AUTH_ISSUER}/mcp`. Issued tokens are audience-restricted to this value. | | `ENTERPRISE_ALLOWED_CLIENT_IDS` | no | Comma-separated allowlist of MCP client IDs (matched against the ID-JAG `client_id` claim). Empty = any client the IdP authorizes. | | `ENTERPRISE_TOKEN_TTL_SECONDS` | no | Lifetime of issued access tokens. Default 3600. | +| `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` | no | Max `/token` requests per client IP per window. Default 30. | +| `ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS` | no | `/token` rate limit window in milliseconds. Default 60000. | | `SKYFLOW_API_KEY` | no | Server-side Skyflow service credential used for vault access on requests authenticated via enterprise auth (see below). | Misconfiguration fails **closed**: if the feature is enabled but required variables are missing or invalid, `/mcp` returns 500 rather than silently skipping authorization. @@ -67,6 +69,8 @@ The `Authorization` header now carries the enterprise access token, so Skyflow v 2. **`SKYFLOW_API_KEY` environment variable** — a server-wide service credential. The typical setup for enterprise deployments: employees authenticate with SSO only and never handle Skyflow credentials. 3. **Existing fallbacks** — the `apiKey` query parameter, then anonymous mode if configured. +Note the last fallback: an enterprise-authenticated request with no Skyflow credentials at all degrades to [anonymous mode](../README.md#anonymous-mode-try-before-you-buy) when `ANON_MODE_*` is configured (responses are clearly marked with `anonymousMode: true`), and receives a 401 otherwise. This is deliberate — it gives SSO users a working demo path before vault credentials are provisioned. Deployments that want enterprise users to always hit a real vault should set `SKYFLOW_API_KEY`; deployments that want a hard failure instead should leave `ANON_MODE_*` unset. + ## Deployment scenario 1: Skyflow-hosted endpoint + Skyflow Okta For Skyflow's own hosted MCP endpoint, gate access by Skyflow's Okta org while keeping existing consumers working: @@ -137,5 +141,7 @@ curl -X POST https://mcp.example.com/mcp \ - **Audience restriction**: issued access tokens carry `aud = ENTERPRISE_MCP_RESOURCE` and `typ: at+jwt`; they are only accepted by this deployment. - **Algorithm pinning**: ID-JAGs must be asymmetrically signed (RS/PS/ES/EdDSA); symmetric algorithms are rejected to prevent key-confusion attacks. Issued tokens are pinned to HS256. - **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. +- **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. +- **`resource` claim**: an ID-JAG without a `resource` claim is accepted — the extension makes the token-exchange `resource` parameter optional and only constrains the claim "if present". The `aud` check still binds every grant to this authorization server, which serves exactly one resource. - **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. Store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. - The enterprise identity (`sub`, `email`, `scope`, `client_id`) of a verified request is available to request handling as `req.enterpriseAuth`, with the `sub` claim as the stable identifier for account linking per the spec. diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 1550394..7fe5f18 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -32,6 +32,9 @@ const ALLOWED_ID_JAG_ALGORITHMS = [ /** Clock skew tolerance for exp/iat validation, in seconds */ const CLOCK_TOLERANCE_SECONDS = 60; +/** Timeout for the OIDC discovery request, so a hung IdP can't stall /token */ +const DISCOVERY_TIMEOUT_MS = 5000; + export type OAuthTokenErrorCode = | "invalid_request" | "invalid_grant" @@ -84,7 +87,9 @@ async function discoverJwksUri(idpIssuer: string): Promise { const discoveryUrl = `${idpIssuer}/.well-known/openid-configuration`; let response: Response; try { - response = await fetch(discoveryUrl); + response = await fetch(discoveryUrl, { + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }); } catch (error) { throw new Error( `Failed to reach IdP discovery endpoint ${discoveryUrl}: ${ @@ -212,6 +217,10 @@ export async function validateIdJag( // The resource claim, when present, MUST be this MCP server's resource // identifier — the issued access token is audience-restricted to it. + // Absence is intentionally permitted: the `resource` parameter is OPTIONAL + // in the extension's token exchange, and the profile only constrains the + // claim "if present". The `aud` check above still binds the grant to this + // authorization server, which serves exactly one resource. const resource = payload.resource; if (resource !== undefined) { const matches = Array.isArray(resource) diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 6206860..2875a3e 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -23,6 +23,10 @@ import { JWT_BEARER_GRANT_TYPE, } from "./idJag.js"; import { issueAccessToken } from "./accessTokens.js"; +import { + createTokenEndpointRateLimiter, + getTokenEndpointRateLimitConfig, +} from "../middleware/rateLimiter.js"; export interface EnterpriseAuthRouteDeps { /** Environment source, defaults to process.env (injectable for tests) */ @@ -177,6 +181,7 @@ export function createEnterpriseAuthRouter( ); router.post( "/token", + createTokenEndpointRateLimiter(getTokenEndpointRateLimitConfig(deps.env)), express.urlencoded({ extended: false }), createTokenHandler(deps) ); diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index 428e130..c0b2837 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -53,8 +53,11 @@ function unauthorized( `resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"` ); res.set("WWW-Authenticate", `Bearer ${challengeParts.join(", ")}`); + // Body mirrors the RFC 6749 §5.2 shape used by /token so programmatic + // clients get a machine-readable code, not just the header challenge. res.status(401).json({ - error: + error: options.error || "unauthorized", + error_description: options.description || "Enterprise authorization required. Obtain an access token via the ID-JAG flow described in the protected resource metadata.", }); @@ -100,7 +103,11 @@ function resolveSkyflowCredentials( } // Leave credentials unresolved: authenticateBearer will fall back to the - // apiKey query parameter or anonymous mode. + // apiKey query parameter or anonymous mode. Deliberate trade-off: an + // enterprise-authenticated user without Skyflow credentials degrades to + // anonymous mode (clearly marked via anonymousMode:true in tool responses) + // rather than being rejected. Deployments that don't want this should set + // SKYFLOW_API_KEY or leave ANON_MODE_* unconfigured (yielding a 401). return true; } @@ -126,7 +133,8 @@ export function createEnterpriseAuthMiddleware( // Fail closed: a misconfigured deployment must not silently skip auth console.error("Enterprise auth configuration error:", error.message); return res.status(500).json({ - error: "Enterprise-managed authorization is misconfigured", + error: "server_error", + error_description: "Enterprise-managed authorization is misconfigured", }); } throw error; diff --git a/src/lib/middleware/rateLimiter.ts b/src/lib/middleware/rateLimiter.ts index d1f522a..9e3fb01 100644 --- a/src/lib/middleware/rateLimiter.ts +++ b/src/lib/middleware/rateLimiter.ts @@ -56,23 +56,34 @@ const cleanupInterval = setInterval(cleanupExpiredEntries, CLEANUP_INTERVAL_MS); // Allow cleanup interval to not prevent process exit cleanupInterval.unref(); +interface RateLimiterOptions { + /** Namespace for store keys so different limiters don't collide */ + keyPrefix: string; + /** Return true to bypass rate limiting for this request */ + skip?: (req: Request) => boolean; + /** Body for the 429 response */ + errorBody: (retryAfterSeconds: number) => Record; +} + /** - * Create rate limiter middleware for anonymous mode - * Only applies rate limiting to requests where req.isAnonymousMode is true + * Create a generic per-client-IP rate limiter middleware. + * Shared implementation behind the anonymous-mode and token-endpoint limiters. */ -export function createAnonymousRateLimiter(config: RateLimiterConfig) { - return function anonymousRateLimiter( +function createIpRateLimiter( + config: RateLimiterConfig, + options: RateLimiterOptions +) { + return function ipRateLimiter( req: Request, res: Response, next: NextFunction ) { - // Only apply rate limiting to anonymous mode requests - if (!req.isAnonymousMode) { + if (options.skip?.(req)) { return next(); } const clientId = getClientId(req); - const key = `anon:${clientId}`; + const key = `${options.keyPrefix}:${clientId}`; const now = Date.now(); let entry = rateLimitStore.get(key); @@ -94,14 +105,7 @@ export function createAnonymousRateLimiter(config: RateLimiterConfig) { res.setHeader("X-RateLimit-Limit", config.maxRequests); res.setHeader("X-RateLimit-Remaining", 0); res.setHeader("X-RateLimit-Reset", resetSeconds); - return res.status(429).json({ - error: "Rate limit exceeded for anonymous mode", - message: - "You have exceeded the rate limit for anonymous mode. " + - "Please try again later or configure your Skyflow credentials for unlimited access.", - retryAfterSeconds: resetSeconds, - helpUrl: "https://docs.skyflow.com/", - }); + return res.status(429).json(options.errorBody(resetSeconds)); } // Request allowed - increment count AFTER the check @@ -117,6 +121,71 @@ export function createAnonymousRateLimiter(config: RateLimiterConfig) { }; } +/** + * Create rate limiter middleware for anonymous mode + * Only applies rate limiting to requests where req.isAnonymousMode is true + */ +export function createAnonymousRateLimiter(config: RateLimiterConfig) { + return createIpRateLimiter(config, { + keyPrefix: "anon", + skip: (req) => !req.isAnonymousMode, + errorBody: (retryAfterSeconds) => ({ + error: "Rate limit exceeded for anonymous mode", + message: + "You have exceeded the rate limit for anonymous mode. " + + "Please try again later or configure your Skyflow credentials for unlimited access.", + retryAfterSeconds, + helpUrl: "https://docs.skyflow.com/", + }), + }); +} + +/** + * Create rate limiter middleware for the enterprise auth /token endpoint. + * The endpoint is unauthenticated by design (clients present ID-JAGs), so a + * per-IP limit bounds how fast a caller can drive signature verifications. + * Errors follow the RFC 6749 §5.2 body shape used by the endpoint itself. + */ +export function createTokenEndpointRateLimiter(config: RateLimiterConfig) { + return createIpRateLimiter(config, { + keyPrefix: "token", + errorBody: (retryAfterSeconds) => ({ + error: "rate_limit_exceeded", + error_description: `Too many token requests. Try again in ${retryAfterSeconds} seconds.`, + }), + }); +} + +/** + * Get token endpoint rate limit configuration from environment variables + * @throws Error if environment variables contain invalid values + */ +export function getTokenEndpointRateLimitConfig( + env: NodeJS.ProcessEnv = process.env +): RateLimiterConfig { + const maxRequests = parseInt( + env.ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS || "30", + 10 + ); + const windowMs = parseInt( + env.ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS || "60000", + 10 + ); + + if (isNaN(maxRequests) || maxRequests <= 0) { + throw new Error( + "Invalid ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: must be a positive integer" + ); + } + if (isNaN(windowMs) || windowMs <= 0) { + throw new Error( + "Invalid ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS: must be a positive integer" + ); + } + + return { maxRequests, windowMs }; +} + /** * Get rate limit configuration from environment variables * @throws Error if environment variables contain invalid values diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts index a86eb81..3517f92 100644 --- a/tests/unit/middleware/enterpriseAuth.test.ts +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -93,6 +93,9 @@ describe("enterprise auth middleware", () => { expect(captured.headers["www-authenticate"]).toContain( `resource_metadata="${TEST_ISSUER}/.well-known/oauth-protected-resource"` ); + // Body follows the RFC 6749 §5.2 shape for programmatic clients + expect(captured.jsonBody.error).toBe("unauthorized"); + expect(captured.jsonBody.error_description).toBeTruthy(); }); it("rejects Skyflow credentials that are not enterprise tokens", async () => { @@ -103,6 +106,7 @@ describe("enterprise auth middleware", () => { expect(next).not.toHaveBeenCalled(); expect(captured.statusCode).toBe(401); expect(captured.headers["www-authenticate"]).toContain('error="invalid_token"'); + expect(captured.jsonBody.error).toBe("invalid_token"); }); it("rejects expired enterprise tokens", async () => { diff --git a/tests/unit/middleware/rateLimiter.test.ts b/tests/unit/middleware/rateLimiter.test.ts index b77883b..8446374 100644 --- a/tests/unit/middleware/rateLimiter.test.ts +++ b/tests/unit/middleware/rateLimiter.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createAnonymousRateLimiter, getAnonymousRateLimitConfig, + createTokenEndpointRateLimiter, + getTokenEndpointRateLimitConfig, getClientId, clearRateLimitStore, getRateLimitStoreSize, @@ -354,3 +356,84 @@ describe("Anonymous Rate Limiter", () => { }); }); }); + +describe("Token Endpoint Rate Limiter", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + clearRateLimitStore(); + }); + + describe("getTokenEndpointRateLimitConfig()", () => { + it("should default to 30 requests per 60s window", () => { + const config = getTokenEndpointRateLimitConfig({} as NodeJS.ProcessEnv); + expect(config).toEqual({ maxRequests: 30, windowMs: 60000 }); + }); + + it("should use env var values when set", () => { + const config = getTokenEndpointRateLimitConfig({ + ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: "5", + ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS: "1000", + } as NodeJS.ProcessEnv); + expect(config).toEqual({ maxRequests: 5, windowMs: 1000 }); + }); + + it("should throw on invalid values", () => { + expect(() => + getTokenEndpointRateLimitConfig({ + ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: "0", + } as NodeJS.ProcessEnv) + ).toThrow(/positive integer/); + }); + }); + + describe("createTokenEndpointRateLimiter()", () => { + it("should rate-limit all requests regardless of anonymous mode", () => { + const rateLimiter = createTokenEndpointRateLimiter({ + maxRequests: 2, + windowMs: 60000, + }); + + for (let i = 0; i < 2; i++) { + const req = createMockRequest() as Request; + const { res } = createMockResponse(); + const next = vi.fn(); + rateLimiter(req, res as Response, next); + expect(next).toHaveBeenCalled(); + } + + const req = createMockRequest() as Request; + const mock = createMockResponse(); + const next = vi.fn(); + rateLimiter(req, mock.res as Response, next); + expect(next).not.toHaveBeenCalled(); + expect(mock.statusCode).toBe(429); + expect((mock.jsonBody as { error: string }).error).toBe( + "rate_limit_exceeded" + ); + }); + + it("should track clients independently of the anonymous limiter", () => { + const tokenLimiter = createTokenEndpointRateLimiter({ + maxRequests: 1, + windowMs: 60000, + }); + const anonLimiter = createAnonymousRateLimiter({ + maxRequests: 1, + windowMs: 60000, + }); + + // Exhaust the token limiter for this IP + tokenLimiter( + createMockRequest() as Request, + createMockResponse().res as Response, + vi.fn() + ); + + // The anonymous limiter still allows the same IP (separate key space) + const req = createMockRequest({ isAnonymousMode: true }) as Request; + const next = vi.fn(); + anonLimiter(req, createMockResponse().res as Response, next); + expect(next).toHaveBeenCalled(); + }); + }); +}); From 1af5c4af730640f61a11977f4631fbf200d694ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:54:39 +0000 Subject: [PATCH 05/18] Enforce enterprise token scopes per tool; harden /token and discovery - Tool-level scope enforcement: when the enterprise access token carries a scope claim, each scope names a permitted tool (de-identify, re-identify); denied calls return an insufficient_scope error result. Tokens without a scope claim remain unrestricted (connection-level gating). New src/lib/auth/scopes.ts with unit tests. - Defer /token rate-limit config reading until enterprise auth is known-enabled, so invalid ENTERPRISE_TOKEN_RATE_LIMIT_* values cannot crash startup or change behavior for disabled deployments - Mount the enterprise auth router before the 5MB JSON parser so /token only parses its own small form-urlencoded bodies (100kb limit) - Validate that a discovered jwks_uri uses https (localhost http excepted), matching the policy for configured URLs - Document that ENTERPRISE_AUTH_SIGNING_KEY must be high-entropy random, not merely long Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- CHANGELOG.md | 3 +- CLAUDE.md | 1 + docs/enterprise-managed-auth.md | 12 ++++++- src/lib/auth/idJag.ts | 17 ++++++++++ src/lib/auth/routes.ts | 45 +++++++++++++++++++++++-- src/lib/auth/scopes.ts | 58 +++++++++++++++++++++++++++++++++ src/server.ts | 45 ++++++++++++++++++++++--- tests/unit/auth/idJag.test.ts | 39 +++++++++++++++++++++- tests/unit/auth/routes.test.ts | 13 ++++++++ tests/unit/auth/scopes.test.ts | 57 ++++++++++++++++++++++++++++++++ 10 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 src/lib/auth/scopes.ts create mode 100644 tests/unit/auth/scopes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2665cc..747d531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - New endpoints: `POST /token` (RFC 7523 jwt-bearer grant, rate-limited per client IP via `ENTERPRISE_TOKEN_RATE_LIMIT_*`), `GET /.well-known/oauth-authorization-server` (RFC 8414, advertises `urn:ietf:params:oauth:grant-profile:id-jag`), `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728). All 404 when the feature is disabled. - New middleware gates `/mcp` in `required` or `optional` mode; 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge for client discovery. - Skyflow vault credentials under enterprise auth resolve from the `X-Skyflow-Authorization` header, then the new `SKYFLOW_API_KEY` service-credential env var, then existing fallbacks (`apiKey` query param, anonymous mode). - - ID-JAG validation covers `typ: oauth-id-jag+jwt`, IdP JWKS signature (explicit URI or OIDC discovery), issuer/audience/expiry, `resource` claim matching, optional `client_id` allowlist, and best-effort `jti` replay detection. Misconfiguration fails closed. + - ID-JAG validation covers `typ: oauth-id-jag+jwt`, IdP JWKS signature (explicit URI or OIDC discovery with a 5s timeout and https-only `jwks_uri`), issuer/audience/expiry, `resource` claim matching, optional `client_id` allowlist, and best-effort `jti` replay detection. Misconfiguration fails closed. + - Tool-level scope enforcement: when the enterprise access token carries a `scope` claim, each scope names a permitted tool (`de-identify`, `re-identify`); other tools return an `insufficient_scope` error result. Tokens without a scope claim are unrestricted. - New env vars: `ENTERPRISE_AUTH_ENABLED`, `ENTERPRISE_AUTH_ISSUER`, `ENTERPRISE_IDP_ISSUER`, `ENTERPRISE_AUTH_SIGNING_KEY`, `ENTERPRISE_AUTH_MODE`, `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS`, `SKYFLOW_API_KEY`. - 82 new unit tests (`tests/unit/auth/`, `tests/unit/middleware/enterpriseAuth.test.ts`) and a setup guide in `docs/enterprise-managed-auth.md`. - Added `jose` dependency for JWT/JWKS handling. diff --git a/CLAUDE.md b/CLAUDE.md index 3296c5e..84ccf35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ curl -X POST "http://localhost:3000/mcp?vaultId={vault_id}&vaultUrl={vault_url}" - `src/lib/auth/accessTokens.ts` — issue/verify enterprise access tokens (`typ: at+jwt`) - `src/lib/auth/routes.ts` — `POST /token` plus RFC 8414 (`/.well-known/oauth-authorization-server`) and RFC 9728 (`/.well-known/oauth-protected-resource[/mcp]`) metadata; all return 404 when disabled - `src/lib/middleware/enterpriseAuth.ts` — gates `/mcp` with issued tokens (`required` or `optional` mode); 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge. After verifying the enterprise token it resolves Skyflow credentials from the `X-Skyflow-Authorization` header → `SKYFLOW_API_KEY` env var → existing fallbacks, and exposes the enterprise identity as `req.enterpriseAuth` +- `src/lib/auth/scopes.ts` — tool-level scope enforcement: when the enterprise access token carries a `scope` claim, each scope names a permitted tool (`de-identify`, `re-identify`); tokens without a scope claim are unrestricted. Denied calls return an `insufficient_scope` isError tool result - Full setup guide (Skyflow-hosted-with-Okta and self-hosted-with-customer-IdP scenarios): `docs/enterprise-managed-auth.md` **MCP Server Instance** diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 145ab02..21abf8c 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -61,6 +61,16 @@ All three return 404 when the feature is disabled. Unauthenticated `/mcp` reques Misconfiguration fails **closed**: if the feature is enabled but required variables are missing or invalid, `/mcp` returns 500 rather than silently skipping authorization. +## Scope enforcement + +Scopes let IdP administrators control *which tools* a user or client may invoke, not just whether they can connect. The IdP grants scopes in the ID-JAG (per admin policy), the `/token` endpoint copies them into the access token, and the server enforces them per tool call: + +- **Scope values name tools**: grant `de-identify` and/or `re-identify`. +- **A token with a `scope` claim** may only invoke the named tools; other tools return an `insufficient_scope` error result. +- **A token without a `scope` claim** is unrestricted — access is gated at the connection level only. + +For example, an Okta policy granting the `de-identify` scope to the "support" group lets those users redact text but never restore original PII, while the "compliance" group gets both scopes. + ## Skyflow vault credentials under enterprise auth The `Authorization` header now carries the enterprise access token, so Skyflow vault credentials are resolved separately, in order of precedence: @@ -143,5 +153,5 @@ curl -X POST https://mcp.example.com/mcp \ - **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. - **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. - **`resource` claim**: an ID-JAG without a `resource` claim is accepted — the extension makes the token-exchange `resource` parameter optional and only constrains the claim "if present". The `aud` check still binds every grant to this authorization server, which serves exactly one resource. -- **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. Store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. +- **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. It must be a high-entropy random value — the server enforces a minimum length, but length alone is not enough (a long dictionary phrase is forgeable). Generate it with `openssl rand -base64 32`, store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. - The enterprise identity (`sub`, `email`, `scope`, `client_id`) of a verified request is available to request handling as `req.enterpriseAuth`, with the `sub` claim as the stable identifier for account linking per the spec. diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 7fe5f18..de0fa7c 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -107,6 +107,23 @@ async function discoverJwksUri(idpIssuer: string): Promise { throw new Error(`IdP discovery document at ${discoveryUrl} has no jwks_uri`); } + // Apply the same scheme policy as configured URLs: keys must come over + // https (http only for localhost development), even if the discovery + // document says otherwise. + let jwksUrl: URL; + try { + jwksUrl = new URL(metadata.jwks_uri); + } catch { + throw new Error(`IdP discovery document has an invalid jwks_uri: ${metadata.jwks_uri}`); + } + const isLocalhost = + jwksUrl.hostname === "localhost" || jwksUrl.hostname === "127.0.0.1"; + if (jwksUrl.protocol !== "https:" && !(jwksUrl.protocol === "http:" && isLocalhost)) { + throw new Error( + `IdP discovery document has a non-https jwks_uri: ${metadata.jwks_uri}` + ); + } + discoveredJwksUris.set(idpIssuer, metadata.jwks_uri); return metadata.jwks_uri; } diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 2875a3e..7e0834f 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -181,10 +181,51 @@ export function createEnterpriseAuthRouter( ); router.post( "/token", - createTokenEndpointRateLimiter(getTokenEndpointRateLimitConfig(deps.env)), - express.urlencoded({ extended: false }), + createLazyTokenRateLimiter(deps), + express.urlencoded({ extended: false, limit: "100kb" }), createTokenHandler(deps) ); return router; } + +/** + * Rate limiter for /token that defers reading ENTERPRISE_TOKEN_RATE_LIMIT_* + * until enterprise auth is known-enabled, so invalid values can't crash + * startup (or change /token's 404) for deployments with the feature disabled. + * With the feature enabled, an invalid rate-limit config fails closed (500). + */ +function createLazyTokenRateLimiter( + deps: EnterpriseAuthRouteDeps +): RequestHandler { + let limiter: RequestHandler | undefined; + return (req, res, next) => { + const env = deps.env ?? process.env; + let enabled: boolean; + try { + enabled = loadEnterpriseAuthConfig(env) !== null; + } catch { + enabled = true; // enabled but misconfigured — let the handler 500 + } + if (!enabled) { + return next(); // handler responds 404 without touching limiter config + } + if (!limiter) { + try { + limiter = createTokenEndpointRateLimiter( + getTokenEndpointRateLimitConfig(env) + ); + } catch (error) { + console.error( + "Invalid token endpoint rate limit configuration:", + error instanceof Error ? error.message : "unknown error" + ); + return res.status(500).json({ + error: "server_error", + error_description: "Enterprise-managed authorization is misconfigured", + }); + } + } + return limiter(req, res, next); + }; +} diff --git a/src/lib/auth/scopes.ts b/src/lib/auth/scopes.ts new file mode 100644 index 0000000..f301726 --- /dev/null +++ b/src/lib/auth/scopes.ts @@ -0,0 +1,58 @@ +/** + * Tool-level scope enforcement for enterprise-managed authorization. + * + * The enterprise IdP grants scopes in the ID-JAG (per admin policy), the + * token endpoint copies them into the issued access token, and this module + * enforces them when tools are invoked. Convention: each scope value names a + * permitted tool (e.g. "de-identify", "re-identify"). + * + * A token WITHOUT a scope claim is unrestricted — the IdP chose to gate at + * the connection level only. A token WITH a scope claim is restricted to + * exactly the named tools. + */ + +/** + * Parse the space-delimited scope claim from an enterprise access token. + * Returns undefined when no scope claim was present (= unrestricted). + */ +export function parseGrantedScopes( + scope: string | undefined +): string[] | undefined { + if (scope === undefined) { + return undefined; + } + return scope.split(" ").filter((s) => s.length > 0); +} + +/** + * Check whether a tool may be invoked under the granted scopes. + * `undefined` scopes (no scope claim, or non-enterprise request) permit all. + */ +export function isToolPermitted( + toolName: string, + grantedScopes: string[] | undefined +): boolean { + return grantedScopes === undefined || grantedScopes.includes(toolName); +} + +/** Structured error output returned when a tool is denied by scope */ +export interface ScopeDenialOutput { + error: string; + message: string; + [key: string]: unknown; +} + +export function buildScopeDenial( + toolName: string, + grantedScopes: string[] +): ScopeDenialOutput { + return { + error: "insufficient_scope", + message: + `The enterprise access token does not grant the "${toolName}" scope. ` + + (grantedScopes.length > 0 + ? `Granted scopes: ${grantedScopes.join(", ")}.` + : "The token grants no tool scopes.") + + " Ask your identity provider administrator to grant access to this tool.", + }; +} diff --git a/src/server.ts b/src/server.ts index 00859ad..ebf8413 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,11 @@ import { createEnterpriseAuthMiddleware } from "./lib/middleware/enterpriseAuth. import { createEnterpriseAuthRouter } from "./lib/auth/routes.js"; import { loadEnterpriseAuthConfig } from "./lib/auth/config.js"; import type { EnterpriseIdentity } from "./lib/auth/accessTokens.js"; +import { + parseGrantedScopes, + isToolPermitted, + buildScopeDenial, +} from "./lib/auth/scopes.js"; import { createAnonymousRateLimiter, getAnonymousRateLimitConfig, @@ -37,6 +42,8 @@ interface RequestContext { skyflow: Skyflow; vaultId: string; isAnonymousMode: boolean; + /** Scopes granted by the enterprise access token; undefined = unrestricted */ + enterpriseScopes?: string[]; } const requestContextStorage = new AsyncLocalStorage(); @@ -63,6 +70,26 @@ function isAnonymousMode(): boolean { return context.isAnonymousMode; } +/** + * Enforce enterprise token scopes at the tool level. When the current + * request's enterprise access token carries a scope claim, each scope names + * a permitted tool; tokens without a scope claim (and non-enterprise + * requests) are unrestricted. Returns a ready-to-return isError tool result + * when the tool is denied, or null when permitted. + */ +function scopeDenialFor(toolName: string) { + const scopes = requestContextStorage.getStore()?.enterpriseScopes; + if (isToolPermitted(toolName, scopes)) { + return null; + } + const output = buildScopeDenial(toolName, scopes!); + return { + content: [{ type: "text" as const, text: JSON.stringify(output) }], + structuredContent: toStructuredContent(output), + isError: true as const, + }; +} + // Create an MCP server const server = new McpServer({ name: "Skyflow Runtime MCP Server", @@ -129,6 +156,8 @@ registerAppTool( _meta: { ui: { resourceUri: DE_IDENTIFY_RESOURCE_URI } }, }, async ({ inputString, entities }) => { + const denial = scopeDenialFor("de-identify"); + if (denial) return denial; const result = await handleDeIdentify(inputString, entities, getCurrentSkyflow(), isAnonymousMode()); return { content: [{ type: "text", text: JSON.stringify(result.output) }], @@ -163,6 +192,8 @@ registerAppTool( _meta: { ui: { resourceUri: RE_IDENTIFY_RESOURCE_URI } }, }, async ({ inputString }) => { + const denial = scopeDenialFor("re-identify"); + if (denial) return denial; const result = await handleReIdentify(inputString, getCurrentSkyflow(), isAnonymousMode()); return { content: [{ type: "text", text: JSON.stringify(result.output) }], @@ -173,17 +204,20 @@ registerAppTool( ); const app: Express = express(); -app.use(express.json({ limit: "5mb" })); // Limit for base64-encoded files - -// Serve static files from the public directory -app.use(express.static("public")); // Enterprise-managed authorization (MCP extension // io.modelcontextprotocol/enterprise-managed-authorization): OAuth discovery // metadata and the ID-JAG token endpoint. All routes 404 unless -// ENTERPRISE_AUTH_ENABLED=true. +// ENTERPRISE_AUTH_ENABLED=true. Mounted BEFORE the 5MB JSON parser so the +// unauthenticated /token endpoint only ever parses its own small +// form-urlencoded bodies. app.use(createEnterpriseAuthRouter()); +app.use(express.json({ limit: "5mb" })); // Limit for base64-encoded files + +// Serve static files from the public directory +app.use(express.static("public")); + // Surface enterprise auth status/misconfiguration at startup. A misconfigured // deployment still fails closed per-request (the middleware returns 500). try { @@ -314,6 +348,7 @@ app.post("/mcp", createEnterpriseAuthMiddleware(), authenticateBearer, anonymous skyflow: skyflowInstance, vaultId: validatedVaultId, isAnonymousMode: useAnonymousMode, + enterpriseScopes: parseGrantedScopes(req.enterpriseAuth?.scope), }, async () => { await server.connect(transport); diff --git a/tests/unit/auth/idJag.test.ts b/tests/unit/auth/idJag.test.ts index 70e0608..007cee5 100644 --- a/tests/unit/auth/idJag.test.ts +++ b/tests/unit/auth/idJag.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach, beforeAll } from "vitest"; +import { describe, it, expect, beforeEach, beforeAll, afterEach, vi } from "vitest"; import { SignJWT } from "jose"; import { validateIdJag, IdJagValidationError, resetIdJagCaches, + getIdpKeyResolver, ID_JAG_TYP, } from "../../../src/lib/auth/idJag"; import { @@ -162,6 +163,42 @@ describe("validateIdJag()", () => { }); }); + describe("JWKS discovery", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubDiscovery(jwksUri: unknown) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: jwksUri }), + }) + ); + } + + it("accepts an https jwks_uri from the discovery document", async () => { + stubDiscovery("https://idp.example.com/oauth2/v1/keys"); + const resolver = await getIdpKeyResolver(testConfig()); + expect(typeof resolver).toBe("function"); + }); + + it("rejects a non-https jwks_uri from the discovery document", async () => { + stubDiscovery("http://idp.example.com/oauth2/v1/keys"); + await expect(getIdpKeyResolver(testConfig())).rejects.toThrow( + /non-https jwks_uri/ + ); + }); + + it("rejects a discovery document without a jwks_uri", async () => { + stubDiscovery(undefined); + await expect(getIdpKeyResolver(testConfig())).rejects.toThrow( + /no jwks_uri/ + ); + }); + }); + describe("claim-level rejections", () => { it("rejects a missing sub claim", async () => { const assertion = await idp.signIdJag({ sub: undefined }); diff --git a/tests/unit/auth/routes.test.ts b/tests/unit/auth/routes.test.ts index 2ede191..00056fd 100644 --- a/tests/unit/auth/routes.test.ts +++ b/tests/unit/auth/routes.test.ts @@ -4,6 +4,7 @@ import { createAuthServerMetadataHandler, createProtectedResourceMetadataHandler, createTokenHandler, + createEnterpriseAuthRouter, } from "../../../src/lib/auth/routes"; import { ID_JAG_GRANT_PROFILE, @@ -126,6 +127,18 @@ describe("protected resource metadata endpoint", () => { }); }); +describe("createEnterpriseAuthRouter()", () => { + it("does not crash at creation on invalid rate-limit env when the feature is disabled", () => { + expect(() => + createEnterpriseAuthRouter({ + env: { + ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: "not-a-number", + } as NodeJS.ProcessEnv, + }) + ).not.toThrow(); + }); +}); + describe("token endpoint", () => { let idp: TestIdp; diff --git a/tests/unit/auth/scopes.test.ts b/tests/unit/auth/scopes.test.ts new file mode 100644 index 0000000..f131702 --- /dev/null +++ b/tests/unit/auth/scopes.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { + parseGrantedScopes, + isToolPermitted, + buildScopeDenial, +} from "../../../src/lib/auth/scopes"; + +describe("enterprise scope enforcement", () => { + describe("parseGrantedScopes()", () => { + it("returns undefined when no scope claim was present (unrestricted)", () => { + expect(parseGrantedScopes(undefined)).toBeUndefined(); + }); + + it("splits space-delimited scopes", () => { + expect(parseGrantedScopes("de-identify re-identify")).toEqual([ + "de-identify", + "re-identify", + ]); + }); + + it("returns an empty array for an empty scope claim (nothing granted)", () => { + expect(parseGrantedScopes("")).toEqual([]); + expect(parseGrantedScopes(" ")).toEqual([]); + }); + }); + + describe("isToolPermitted()", () => { + it("permits everything when scopes are undefined", () => { + expect(isToolPermitted("de-identify", undefined)).toBe(true); + expect(isToolPermitted("re-identify", undefined)).toBe(true); + }); + + it("permits only the named tools when scopes are present", () => { + expect(isToolPermitted("de-identify", ["de-identify"])).toBe(true); + expect(isToolPermitted("re-identify", ["de-identify"])).toBe(false); + }); + + it("permits nothing for an empty scope list", () => { + expect(isToolPermitted("de-identify", [])).toBe(false); + }); + }); + + describe("buildScopeDenial()", () => { + it("returns an insufficient_scope error naming the tool and granted scopes", () => { + const denial = buildScopeDenial("re-identify", ["de-identify"]); + expect(denial.error).toBe("insufficient_scope"); + expect(denial.message).toContain('"re-identify"'); + expect(denial.message).toContain("de-identify"); + }); + + it("explains when no tool scopes were granted at all", () => { + const denial = buildScopeDenial("de-identify", []); + expect(denial.error).toBe("insufficient_scope"); + expect(denial.message).toContain("no tool scopes"); + }); + }); +}); From 15539f0fc4f70d8786b56ea096f5e6aacc02e3a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:05:16 +0000 Subject: [PATCH 06/18] Fix empty-scope fail-open; add HTTP integration tests; polish - An ID-JAG with scope: "" (IdP granted no tools) previously had the claim dropped at issuance, inverting deny-all into an unrestricted token. Empty scope claims now round-trip through the issued token and the token response, and deny all tools per the scopes convention. - Add tests/integration/enterpriseAuth.integration.test.ts: drives the real Express app over HTTP against a mock OIDC IdP (discovery, token exchange, gated /mcp, scope + empty-scope denial, replay, tampered token, optional-mode fall-through), locking in middleware ordering and Authorization-header handling. src/generated/ui-html.js is stubbed via a vitest alias so no UI build is needed. - Sweep the jti replay cache on a 60s interval instead of scanning the full map on every validation - Document proxy considerations for the /token rate limiter and note the constants-only invariant on the WWW-Authenticate builder Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- CHANGELOG.md | 3 +- docs/enterprise-managed-auth.md | 1 + src/lib/auth/accessTokens.ts | 4 +- src/lib/auth/idJag.ts | 13 +- src/lib/auth/routes.ts | 4 +- src/lib/middleware/enterpriseAuth.ts | 2 + .../enterpriseAuth.integration.test.ts | 245 ++++++++++++++++++ tests/stubs/ui-html.ts | 9 + tests/unit/auth/accessTokens.test.ts | 13 + tests/unit/auth/routes.test.ts | 15 ++ vitest.config.ts | 9 + 11 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 tests/integration/enterpriseAuth.integration.test.ts create mode 100644 tests/stubs/ui-html.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 747d531..e0f4f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ - New middleware gates `/mcp` in `required` or `optional` mode; 401 responses carry a `WWW-Authenticate: Bearer resource_metadata="..."` challenge for client discovery. - Skyflow vault credentials under enterprise auth resolve from the `X-Skyflow-Authorization` header, then the new `SKYFLOW_API_KEY` service-credential env var, then existing fallbacks (`apiKey` query param, anonymous mode). - ID-JAG validation covers `typ: oauth-id-jag+jwt`, IdP JWKS signature (explicit URI or OIDC discovery with a 5s timeout and https-only `jwks_uri`), issuer/audience/expiry, `resource` claim matching, optional `client_id` allowlist, and best-effort `jti` replay detection. Misconfiguration fails closed. - - Tool-level scope enforcement: when the enterprise access token carries a `scope` claim, each scope names a permitted tool (`de-identify`, `re-identify`); other tools return an `insufficient_scope` error result. Tokens without a scope claim are unrestricted. + - Tool-level scope enforcement: when the enterprise access token carries a `scope` claim, each scope names a permitted tool (`de-identify`, `re-identify`); other tools return an `insufficient_scope` error result. Tokens without a scope claim are unrestricted; an empty scope claim (`""`) is preserved end-to-end and denies all tools. + - HTTP integration tests (`tests/integration/`) exercising the real Express app over the wire: discovery → `/token` → gated `/mcp`, scope enforcement, replay rejection, and optional-mode fall-through, against a mock OIDC IdP. - New env vars: `ENTERPRISE_AUTH_ENABLED`, `ENTERPRISE_AUTH_ISSUER`, `ENTERPRISE_IDP_ISSUER`, `ENTERPRISE_AUTH_SIGNING_KEY`, `ENTERPRISE_AUTH_MODE`, `ENTERPRISE_IDP_JWKS_URI`, `ENTERPRISE_IDP_AUDIENCE`, `ENTERPRISE_MCP_RESOURCE`, `ENTERPRISE_ALLOWED_CLIENT_IDS`, `ENTERPRISE_TOKEN_TTL_SECONDS`, `SKYFLOW_API_KEY`. - 82 new unit tests (`tests/unit/auth/`, `tests/unit/middleware/enterpriseAuth.test.ts`) and a setup guide in `docs/enterprise-managed-auth.md`. - Added `jose` dependency for JWT/JWKS handling. diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 21abf8c..06c97b7 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -152,6 +152,7 @@ curl -X POST https://mcp.example.com/mcp \ - **Algorithm pinning**: ID-JAGs must be asymmetrically signed (RS/PS/ES/EdDSA); symmetric algorithms are rejected to prevent key-confusion attacks. Issued tokens are pinned to HS256. - **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. - **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. +- **Rate limiting and proxies**: the client IP is taken from the rightmost `X-Forwarded-For` entry (the hop that reached the trusted proxy — resistant to spoofing on single-proxy platforms like Vercel). If you self-host behind multiple proxy hops, verify what your outermost proxy puts there: if the rightmost entry is a shared internal proxy IP, distinct callers collapse into one rate-limit bucket and you should raise `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` or enforce limits at the proxy instead. - **`resource` claim**: an ID-JAG without a `resource` claim is accepted — the extension makes the token-exchange `resource` parameter optional and only constrains the claim "if present". The `aud` check still binds every grant to this authorization server, which serves exactly one resource. - **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. It must be a high-entropy random value — the server enforces a minimum length, but length alone is not enough (a long dictionary phrase is forgeable). Generate it with `openssl rand -base64 32`, store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. - The enterprise identity (`sub`, `email`, `scope`, `client_id`) of a verified request is available to request handling as `req.enterpriseAuth`, with the `sub` claim as the stable identifier for account linking per the spec. diff --git a/src/lib/auth/accessTokens.ts b/src/lib/auth/accessTokens.ts index d6f0adb..042472f 100644 --- a/src/lib/auth/accessTokens.ts +++ b/src/lib/auth/accessTokens.ts @@ -52,7 +52,9 @@ export async function issueAccessToken( const now = Math.floor(Date.now() / 1000); const jwt = new SignJWT({ ...(identity.email && { email: identity.email }), - ...(identity.scope && { scope: identity.scope }), + // Preserve an empty scope claim ("" = no tools granted) — dropping it + // would invert the IdP's deny-all into an unrestricted token. + ...(identity.scope !== undefined && { scope: identity.scope }), ...(identity.clientId && { client_id: identity.clientId }), }) .setProtectedHeader({ alg: "HS256", typ: ACCESS_TOKEN_TYP }) diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index de0fa7c..426e4cf 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -159,13 +159,24 @@ export function resetIdJagCaches(): void { // (typically 5 minutes), which bounds the replay window regardless. const seenJtis = new Map(); -function isReplayedJti(jti: string, expiresAtMs: number): boolean { +/** Interval for sweeping expired jti entries (keeps validation O(1)) */ +const JTI_CLEANUP_INTERVAL_MS = 60_000; + +const jtiCleanupInterval = setInterval(() => { const now = Date.now(); for (const [key, expiry] of seenJtis.entries()) { if (now > expiry) { seenJtis.delete(key); } } +}, JTI_CLEANUP_INTERVAL_MS); + +// Allow the cleanup interval to not prevent process exit +jtiCleanupInterval.unref(); + +function isReplayedJti(jti: string, expiresAtMs: number): boolean { + // Lingering expired entries are harmless: a token past its exp already + // fails signature/expiry validation before the replay check runs. if (seenJtis.has(jti)) { return true; } diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 7e0834f..1693e44 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -138,7 +138,9 @@ export function createTokenHandler( token_type: "Bearer", access_token: issued.accessToken, expires_in: issued.expiresIn, - ...(issued.scope && { scope: issued.scope }), + // Included even when empty: "" means the IdP granted no tool scopes, + // which is different from omitting the claim (unrestricted). + ...(issued.scope !== undefined && { scope: issued.scope }), }); } catch (error) { if (error instanceof IdJagValidationError) { diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index c0b2837..d84bb10 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -42,6 +42,8 @@ function unauthorized( config: EnterpriseAuthConfig, options: { error?: string; description?: string } = {} ): void { + // INVARIANT: error/description are interpolated into the WWW-Authenticate + // header unescaped — callers must pass constants, never request-derived text. const challengeParts: string[] = []; if (options.error) { challengeParts.push(`error="${options.error}"`); diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts new file mode 100644 index 0000000..7bab610 --- /dev/null +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -0,0 +1,245 @@ +/** + * Integration test for the enterprise-managed authorization HTTP flow, + * exercising the real Express app (src/server.ts) over the wire: + * + * mock enterprise IdP (OIDC discovery + JWKS) + * → GET /.well-known/* discovery metadata + * → POST /token (ID-JAG exchange, RFC 7523 jwt-bearer) + * → POST /mcp gated by the issued token + * → tool-level scope enforcement + * + * This locks in the middleware ordering and header handling in server.ts + * that unit tests can't see (enterprise auth → authenticateBearer → rate + * limiter, Authorization header consumption, router-before-json-parser). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { generateKeyPair, exportJWK, SignJWT, type CryptoKey, type JWK } from "jose"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +const SIGNING_KEY = "integration-test-signing-key-32-chars!"; +const JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +let idpServer: http.Server; +let appServer: http.Server; +let idpIssuer: string; +let baseUrl: string; +let privateKey: CryptoKey; +let jtiCounter = 0; + +async function signIdJag(overrides: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: Record = { + iss: idpIssuer, + aud: baseUrl, + sub: "okta-user-42", + email: "employee@example.com", + resource: `${baseUrl}/mcp`, + client_id: "integration-client", + scope: "de-identify re-identify", + jti: `integration-${++jtiCounter}-${Date.now()}`, + iat: now, + exp: now + 300, + ...overrides, + }; + for (const key of Object.keys(payload)) { + if (payload[key] === undefined) delete payload[key]; + } + return new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", kid: "integration-key", typ: "oauth-id-jag+jwt" }) + .sign(privateKey); +} + +async function exchangeToken(assertion: string): Promise<{ status: number; body: any }> { + const res = await fetch(`${baseUrl}/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: JWT_BEARER, assertion }), + }); + return { status: res.status, body: await res.json() }; +} + +async function callMcp( + body: Record, + accessToken?: string +): Promise<{ status: number; body: any; wwwAuthenticate: string | null }> { + const res = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...(accessToken && { authorization: `Bearer ${accessToken}` }), + }, + body: JSON.stringify(body), + }); + return { + status: res.status, + body: await res.json().catch(() => null), + wwwAuthenticate: res.headers.get("www-authenticate"), + }; +} + +beforeAll(async () => { + // Mock enterprise IdP: OIDC discovery + JWKS over localhost HTTP + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const jwk: JWK = await exportJWK(keys.publicKey); + jwk.kid = "integration-key"; + jwk.alg = "RS256"; + + idpServer = http.createServer((req, res) => { + res.setHeader("content-type", "application/json"); + if (req.url === "/.well-known/openid-configuration") { + res.end(JSON.stringify({ issuer: idpIssuer, jwks_uri: `${idpIssuer}/jwks` })); + } else if (req.url === "/jwks") { + res.end(JSON.stringify({ keys: [jwk] })); + } else { + res.statusCode = 404; + res.end("{}"); + } + }); + await new Promise((resolve) => idpServer.listen(0, resolve)); + idpIssuer = `http://localhost:${(idpServer.address() as AddressInfo).port}`; + + // Enterprise auth env must be in place before importing the app; the + // issuer is corrected to the real port after listen (config is read + // per request, so this is safe). + process.env.ENTERPRISE_AUTH_ENABLED = "true"; + process.env.ENTERPRISE_AUTH_ISSUER = "http://localhost:9"; + process.env.ENTERPRISE_IDP_ISSUER = idpIssuer; + process.env.ENTERPRISE_AUTH_SIGNING_KEY = SIGNING_KEY; + process.env.VAULT_ID = "integration-vault"; + process.env.VAULT_URL = "https://abc123.vault.skyflowapis.com"; + process.env.SKYFLOW_API_KEY = "sky-integration-dummy-key"; + delete process.env.ENTERPRISE_AUTH_MODE; + + const { default: app } = await import("../../src/server.js"); + appServer = app.listen(0); + await new Promise((resolve) => appServer.on("listening", resolve)); + baseUrl = `http://localhost:${(appServer.address() as AddressInfo).port}`; + process.env.ENTERPRISE_AUTH_ISSUER = baseUrl; +}, 30000); + +afterAll(async () => { + appServer?.close(); + idpServer?.close(); + for (const key of [ + "ENTERPRISE_AUTH_ENABLED", + "ENTERPRISE_AUTH_ISSUER", + "ENTERPRISE_IDP_ISSUER", + "ENTERPRISE_AUTH_SIGNING_KEY", + "ENTERPRISE_AUTH_MODE", + "VAULT_ID", + "VAULT_URL", + "SKYFLOW_API_KEY", + ]) { + delete process.env[key]; + } +}); + +describe("enterprise auth HTTP flow (integration)", () => { + it("serves discovery metadata advertising the ID-JAG grant profile", async () => { + const asMeta = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`).then((r) => + r.json() + ); + expect(asMeta.issuer).toBe(baseUrl); + expect(asMeta.token_endpoint).toBe(`${baseUrl}/token`); + expect(asMeta.authorization_grant_profiles_supported).toContain( + "urn:ietf:params:oauth:grant-profile:id-jag" + ); + + const prm = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`).then((r) => + r.json() + ); + expect(prm.resource).toBe(`${baseUrl}/mcp`); + expect(prm.authorization_servers).toEqual([baseUrl]); + }); + + it("rejects /mcp without a token, advertising the resource metadata", async () => { + const res = await callMcp({ jsonrpc: "2.0", method: "tools/list", id: 1 }); + expect(res.status).toBe(401); + expect(res.wwwAuthenticate).toContain( + `resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` + ); + expect(res.body.error).toBe("unauthorized"); + }); + + it("exchanges an ID-JAG for a token that unlocks /mcp; replay is rejected", async () => { + const assertion = await signIdJag(); + const first = await exchangeToken(assertion); + expect(first.status).toBe(200); + expect(first.body.token_type).toBe("Bearer"); + + const tools = await callMcp( + { jsonrpc: "2.0", method: "tools/list", id: 2 }, + first.body.access_token + ); + expect(tools.status).toBe(200); + const names = tools.body?.result?.tools?.map((t: { name: string }) => t.name); + expect(names).toContain("de-identify"); + expect(names).toContain("re-identify"); + + const replay = await exchangeToken(assertion); + expect(replay.status).toBe(400); + expect(replay.body.error).toBe("invalid_grant"); + }); + + it("rejects tampered tokens on /mcp", async () => { + const { body } = await exchangeToken(await signIdJag()); + const res = await callMcp( + { jsonrpc: "2.0", method: "tools/list", id: 3 }, + `${body.access_token}tampered` + ); + expect(res.status).toBe(401); + expect(res.body.error).toBe("invalid_token"); + }); + + it("enforces tool scopes from the granted token", async () => { + const { body } = await exchangeToken(await signIdJag({ scope: "de-identify" })); + expect(body.scope).toBe("de-identify"); + + const denied = await callMcp( + { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "re-identify", arguments: { inputString: "[EMAIL_ADDRESS_1]" } }, + id: 4, + }, + body.access_token + ); + expect(denied.status).toBe(200); + expect(denied.body?.result?.isError).toBe(true); + expect(denied.body?.result?.structuredContent?.error).toBe("insufficient_scope"); + }); + + it("treats an empty scope claim as deny-all, not unrestricted", async () => { + const { status, body } = await exchangeToken(await signIdJag({ scope: "" })); + expect(status).toBe(200); + expect(body.scope).toBe(""); + + const denied = await callMcp( + { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "de-identify", arguments: { inputString: "test" } }, + id: 5, + }, + body.access_token + ); + expect(denied.body?.result?.isError).toBe(true); + expect(denied.body?.result?.structuredContent?.error).toBe("insufficient_scope"); + }); + + it("lets legacy Skyflow credentials fall through in optional mode", async () => { + process.env.ENTERPRISE_AUTH_MODE = "optional"; + try { + const res = await callMcp( + { jsonrpc: "2.0", method: "tools/list", id: 6 }, + "sky-legacy-api-key" + ); + expect(res.status).toBe(200); + } finally { + delete process.env.ENTERPRISE_AUTH_MODE; + } + }); +}); diff --git a/tests/stubs/ui-html.ts b/tests/stubs/ui-html.ts new file mode 100644 index 0000000..80e8928 --- /dev/null +++ b/tests/stubs/ui-html.ts @@ -0,0 +1,9 @@ +/** + * Test stub for src/generated/ui-html.ts (a gitignored build artifact + * produced by `pnpm build:ui-imports`). Substituted via a resolve alias in + * vitest.config.ts so integration tests can import src/server.ts without + * building the UI first. + */ +export const deIdentifyHtml = "de-identify stub"; +export const reIdentifyHtml = "re-identify stub"; +export const deIdentifyFileHtml = "de-identify-file stub"; diff --git a/tests/unit/auth/accessTokens.test.ts b/tests/unit/auth/accessTokens.test.ts index 55b022b..07d94b4 100644 --- a/tests/unit/auth/accessTokens.test.ts +++ b/tests/unit/auth/accessTokens.test.ts @@ -51,6 +51,19 @@ describe("enterprise access tokens", () => { expect(verified.clientId).toBeUndefined(); }); + it("preserves an empty scope claim (deny-all) instead of dropping it", async () => { + // scope: "" means the IdP granted no tool scopes — dropping the claim + // would invert that into an unrestricted token + const issued = await issueAccessToken( + { subject: "user-1", scope: "" }, + testConfig() + ); + const payload = decodeJwt(issued.accessToken); + expect(payload.scope).toBe(""); + const verified = await verifyAccessToken(issued.accessToken, testConfig()); + expect(verified.scope).toBe(""); + }); + it("honors the configured TTL", async () => { const issued = await issueAccessToken( identity, diff --git a/tests/unit/auth/routes.test.ts b/tests/unit/auth/routes.test.ts index 00056fd..0ea03a5 100644 --- a/tests/unit/auth/routes.test.ts +++ b/tests/unit/auth/routes.test.ts @@ -226,6 +226,21 @@ describe("token endpoint", () => { expect(identity.clientId).toBe("mcp-client-1"); }); + it("round-trips an empty scope claim (deny-all) into the issued token", async () => { + const assertion = await idp.signIdJag({ scope: "" }); + const mock = await postToken({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }); + expect(mock.statusCode).toBe(200); + expect(mock.jsonBody.scope).toBe(""); + const identity = await verifyAccessToken( + mock.jsonBody.access_token, + testConfig() + ); + expect(identity.scope).toBe(""); + }); + it("rejects reuse of the same ID-JAG (replay)", async () => { const assertion = await idp.signIdJag(); const first = await postToken({ diff --git a/vitest.config.ts b/vitest.config.ts index 3273888..1ae2635 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,16 @@ import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; export default defineConfig({ test: { + // src/generated/ui-html.ts is a gitignored build artifact; tests that + // import src/server.ts use this stub instead of requiring a UI build. + alias: [ + { + find: /^.*generated\/ui-html\.js$/, + replacement: fileURLToPath(new URL("./tests/stubs/ui-html.ts", import.meta.url)), + }, + ], globals: true, environment: "node", coverage: { From afd7aebcca755b8a6f692d7aea989b5b3d0b4c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:12:34 +0000 Subject: [PATCH 07/18] Case-insensitive X-Skyflow-Authorization prefix; conformance polish - Strip the Bearer prefix from X-Skyflow-Authorization case-insensitively and trim surrounding whitespace (RFC 7235 schemes are case-insensitive); previously 'bearer ' was double-prefixed and the literal string leaked into the API key - Point the WWW-Authenticate resource_metadata challenge at the RFC 9728 path-suffixed URL derived from the resource identifier, and serve metadata under any resource path suffix (custom ENTERPRISE_MCP_RESOURCE paths included) - Pin an explicit 5s timeout on the remote JWKS fetch alongside the discovery timeout - Escape WWW-Authenticate parameter values defensively so the constants-only invariant is enforced rather than assumed - Comment the deliberate burn-jti-before-issuance ordering Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- src/lib/auth/idJag.ts | 18 ++++++++--- src/lib/auth/routes.ts | 7 +++-- src/lib/middleware/enterpriseAuth.ts | 30 +++++++++++++------ .../enterpriseAuth.integration.test.ts | 2 +- tests/unit/middleware/enterpriseAuth.test.ts | 27 ++++++++++++++++- 5 files changed, 66 insertions(+), 18 deletions(-) diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 426e4cf..1fb5bb2 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -32,8 +32,8 @@ const ALLOWED_ID_JAG_ALGORITHMS = [ /** Clock skew tolerance for exp/iat validation, in seconds */ const CLOCK_TOLERANCE_SECONDS = 60; -/** Timeout for the OIDC discovery request, so a hung IdP can't stall /token */ -const DISCOVERY_TIMEOUT_MS = 5000; +/** Timeout for IdP HTTP requests (discovery and JWKS), so a hung IdP can't stall /token */ +const IDP_HTTP_TIMEOUT_MS = 5000; export type OAuthTokenErrorCode = | "invalid_request" @@ -88,7 +88,7 @@ async function discoverJwksUri(idpIssuer: string): Promise { let response: Response; try { response = await fetch(discoveryUrl, { - signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + signal: AbortSignal.timeout(IDP_HTTP_TIMEOUT_MS), }); } catch (error) { throw new Error( @@ -138,7 +138,14 @@ export async function getIdpKeyResolver( ): Promise { const uri = config.idpJwksUri ?? (await discoverJwksUri(config.idpIssuer)); if (!cachedRemoteJwks || cachedRemoteJwks.uri !== uri) { - cachedRemoteJwks = { uri, resolver: createRemoteJWKSet(new URL(uri)) }; + cachedRemoteJwks = { + uri, + // Explicit timeout, matching the discovery fetch (jose defaults to 5s; + // pinning it keeps the hardening intent visible and version-proof) + resolver: createRemoteJWKSet(new URL(uri), { + timeoutDuration: IDP_HTTP_TIMEOUT_MS, + }), + }; } return cachedRemoteJwks.resolver; } @@ -280,6 +287,9 @@ export async function validateIdJag( typeof payload.exp === "number" ? payload.exp * 1000 + CLOCK_TOLERANCE_SECONDS * 1000 : Date.now() + 5 * 60 * 1000; + // The jti is deliberately burned here, before token issuance: if issuance + // failed transiently the client must fetch a fresh ID-JAG from the IdP, + // which is preferable to leaving a validated grant replayable. if (isReplayedJti(`${config.idpIssuer}:${jti}`, expiresAtMs)) { throw new IdJagValidationError("invalid_grant", "ID-JAG has already been used"); } diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 1693e44..75f5946 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -175,10 +175,11 @@ export function createEnterpriseAuthRouter( "/.well-known/oauth-authorization-server", createAuthServerMetadataHandler(deps) ); - // RFC 9728 allows path-suffixed metadata URLs for resources with a path - // component (our resource identifier ends in /mcp), so serve both. + // RFC 9728 prescribes path-suffixed metadata URLs for resources with a + // path component (e.g. .../oauth-protected-resource/mcp). Serve the bare + // URL plus any suffix so custom ENTERPRISE_MCP_RESOURCE paths work too. router.get( - ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], + ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/*resourcePath"], createProtectedResourceMetadataHandler(deps) ); router.post( diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index d84bb10..4d1f7fb 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -42,17 +42,23 @@ function unauthorized( config: EnterpriseAuthConfig, options: { error?: string; description?: string } = {} ): void { - // INVARIANT: error/description are interpolated into the WWW-Authenticate - // header unescaped — callers must pass constants, never request-derived text. + // Callers pass constants, but escape defensively anyway so a future + // request-derived value cannot break out of the quoted header parameter. + const headerParam = (value: string) => value.replace(/["\\\r\n]/g, ""); const challengeParts: string[] = []; if (options.error) { - challengeParts.push(`error="${options.error}"`); + challengeParts.push(`error="${headerParam(options.error)}"`); } if (options.description) { - challengeParts.push(`error_description="${options.description}"`); + challengeParts.push(`error_description="${headerParam(options.description)}"`); } + // RFC 9728 path-suffixed metadata URL for a resource with a path component + // (e.g. .../oauth-protected-resource/mcp). The bare URL is also served. + const resourcePath = new URL(config.resource).pathname; challengeParts.push( - `resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"` + `resource_metadata="${config.issuer}/.well-known/oauth-protected-resource${ + resourcePath === "/" ? "" : resourcePath + }"` ); res.set("WWW-Authenticate", `Bearer ${challengeParts.join(", ")}`); // Body mirrors the RFC 6749 §5.2 shape used by /token so programmatic @@ -80,10 +86,16 @@ function resolveSkyflowCredentials( const headerValue = Array.isArray(skyflowHeader) ? skyflowHeader[0] : skyflowHeader; if (headerValue && headerValue.trim().length > 0) { - const normalized = headerValue.startsWith("Bearer ") - ? headerValue - : `Bearer ${headerValue}`; - const result = extractCredentials(normalized, undefined); + // Accept the credential bare or Bearer-prefixed in any casing (RFC 7235 + // auth schemes are case-insensitive), tolerating surrounding whitespace. + const trimmed = headerValue.trim(); + const value = /^bearer(\s|$)/i.test(trimmed) + ? trimmed.replace(/^bearer\s*/i, "") + : trimmed; + const result = extractCredentials( + value ? `Bearer ${value}` : undefined, + undefined + ); if (!result.isPresent || !result.credentials) { unauthorized(res, config, { error: "invalid_request", diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index 7bab610..5062c92 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -159,7 +159,7 @@ describe("enterprise auth HTTP flow (integration)", () => { const res = await callMcp({ jsonrpc: "2.0", method: "tools/list", id: 1 }); expect(res.status).toBe(401); expect(res.wwwAuthenticate).toContain( - `resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` + `resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp"` ); expect(res.body.error).toBe("unauthorized"); }); diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts index 3517f92..742c548 100644 --- a/tests/unit/middleware/enterpriseAuth.test.ts +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -90,8 +90,9 @@ describe("enterprise auth middleware", () => { const { next, captured } = await runMiddleware(enabledEnv(), req); expect(next).not.toHaveBeenCalled(); expect(captured.statusCode).toBe(401); + // RFC 9728 path-suffixed metadata URL (resource has a /mcp path) expect(captured.headers["www-authenticate"]).toContain( - `resource_metadata="${TEST_ISSUER}/.well-known/oauth-protected-resource"` + `resource_metadata="${TEST_ISSUER}/.well-known/oauth-protected-resource/mcp"` ); // Body follows the RFC 6749 §5.2 shape for programmatic clients expect(captured.jsonBody.error).toBe("unauthorized"); @@ -160,6 +161,30 @@ describe("enterprise auth middleware", () => { expect(req.skyflowCredentials).toEqual({ apiKey: "sky-abc123-def456" }); // gitleaks:allow }); + it("strips a lowercase bearer prefix (RFC 7235 schemes are case-insensitive)", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: "bearer sky-per-user-key", + }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ apiKey: "sky-per-user-key" }); + }); + + it("tolerates surrounding whitespace around a Bearer-prefixed JWT", async () => { + const req = createMockRequest({ + headers: { + authorization: `Bearer ${await validToken()}`, + [SKYFLOW_AUTH_HEADER]: ` Bearer ${SKYFLOW_JWT} `, + }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + expect(req.skyflowCredentials).toEqual({ token: SKYFLOW_JWT }); + }); + it("rejects a malformed X-Skyflow-Authorization header", async () => { const req = createMockRequest({ headers: { From 1d2b83019dd64f49437fbe4c964a798030bc197c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:18:33 +0000 Subject: [PATCH 08/18] Shorten default token TTL to 15m; louder operational logging - Default ENTERPRISE_TOKEN_TTL_SECONDS lowered from 3600 to 900: clients re-exchange ID-JAGs without user interaction, so short lifetimes cost nothing and bound the exposure window of a leaked bearer token - Startup log now spells out the consequences of required mode and warns when enterprise auth is enabled without SKYFLOW_API_KEY while anonymous mode is configured (silent degradation path) - Comment the optional-mode routing intent: a token claiming this server's issuer but failing verification is rejected, never demoted to the Skyflow credential path - Add a token-limiter test isolating distinct client IPs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 2 +- src/lib/auth/config.ts | 4 ++- src/lib/middleware/enterpriseAuth.ts | 5 +++- src/server.ts | 15 +++++++++++ tests/unit/auth/config.test.ts | 2 +- tests/unit/auth/routes.test.ts | 2 +- tests/unit/middleware/rateLimiter.test.ts | 32 +++++++++++++++++++++++ 7 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 06c97b7..183646f 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -54,7 +54,7 @@ All three return 404 when the feature is disabled. Unauthenticated `/mcp` reques | `ENTERPRISE_IDP_AUDIENCE` | no | Expected `aud` of ID-JAGs, if your IdP is configured with an audience other than `ENTERPRISE_AUTH_ISSUER`. | | `ENTERPRISE_MCP_RESOURCE` | no | RFC 9728 resource identifier of the MCP endpoint. Defaults to `{ENTERPRISE_AUTH_ISSUER}/mcp`. Issued tokens are audience-restricted to this value. | | `ENTERPRISE_ALLOWED_CLIENT_IDS` | no | Comma-separated allowlist of MCP client IDs (matched against the ID-JAG `client_id` claim). Empty = any client the IdP authorizes. | -| `ENTERPRISE_TOKEN_TTL_SECONDS` | no | Lifetime of issued access tokens. Default 3600. | +| `ENTERPRISE_TOKEN_TTL_SECONDS` | no | Lifetime of issued access tokens. Default 900 (15 minutes) — clients re-exchange their ID-JAG without user interaction, so short lifetimes cost nothing and bound the exposure of a leaked token. | | `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` | no | Max `/token` requests per client IP per window. Default 30. | | `ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS` | no | `/token` rate limit window in milliseconds. Default 60000. | | `SKYFLOW_API_KEY` | no | Server-side Skyflow service credential used for vault access on requests authenticated via enterprise auth (see below). | diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts index 4a173bc..33b54d9 100644 --- a/src/lib/auth/config.ts +++ b/src/lib/auth/config.ts @@ -121,7 +121,9 @@ export function loadEnterpriseAuthConfig( ); } - const tokenTtlSeconds = parseInt(env.ENTERPRISE_TOKEN_TTL_SECONDS || "3600", 10); + // Short default: leaked bearer tokens stay usable until expiry, and + // clients can re-exchange an ID-JAG without user interaction anyway. + const tokenTtlSeconds = parseInt(env.ENTERPRISE_TOKEN_TTL_SECONDS || "900", 10); if (isNaN(tokenTtlSeconds) || tokenTtlSeconds <= 0) { throw new EnterpriseAuthConfigError( "ENTERPRISE_TOKEN_TTL_SECONDS must be a positive integer" diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index 4d1f7fb..4bf5c3f 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -171,7 +171,10 @@ export function createEnterpriseAuthMiddleware( } // In optional mode, bearer values not issued by this server (Skyflow - // JWTs, API keys) flow through to the ordinary credential chain. + // JWTs, API keys) flow through to the ordinary credential chain. A token + // whose iss CLAIMS to be this server but fails verification is + // deliberately rejected below rather than falling through — a spoofed + // issuer must never demote into the Skyflow credential path. if (config.mode === "optional" && !looksLikeEnterpriseToken(token, config)) { return next(); } diff --git a/src/server.ts b/src/server.ts index ebf8413..24790a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -226,6 +226,21 @@ try { console.log( `Enterprise-managed authorization enabled (${enterpriseConfig.mode} mode, IdP: ${enterpriseConfig.idpIssuer})` ); + if (enterpriseConfig.mode === "required") { + console.log( + "Enterprise auth mode is 'required': /mcp requests carrying direct Skyflow credentials " + + "in the Authorization header will be rejected with 401. Set ENTERPRISE_AUTH_MODE=optional " + + "to keep accepting them alongside enterprise tokens." + ); + } + if (!process.env.SKYFLOW_API_KEY && process.env.ANON_MODE_API_KEY) { + console.warn( + "Enterprise auth is enabled without SKYFLOW_API_KEY while anonymous mode is configured: " + + "enterprise-authenticated requests that carry no Skyflow credentials will degrade to " + + "anonymous mode (non-persisted tokens, re-identify unavailable). Set SKYFLOW_API_KEY " + + "to give enterprise users real vault access." + ); + } } } catch (error) { console.error( diff --git a/tests/unit/auth/config.test.ts b/tests/unit/auth/config.test.ts index d0a8adb..31b295f 100644 --- a/tests/unit/auth/config.test.ts +++ b/tests/unit/auth/config.test.ts @@ -37,7 +37,7 @@ describe("loadEnterpriseAuthConfig()", () => { expect(config!.resource).toBe(`${TEST_ISSUER}/mcp`); expect(config!.idpAudience).toBe(TEST_ISSUER); expect(config!.mode).toBe("required"); - expect(config!.tokenTtlSeconds).toBe(3600); + expect(config!.tokenTtlSeconds).toBe(900); expect(config!.allowedClientIds).toEqual([]); expect(config!.idpJwksUri).toBeUndefined(); }); diff --git a/tests/unit/auth/routes.test.ts b/tests/unit/auth/routes.test.ts index 0ea03a5..b4cc398 100644 --- a/tests/unit/auth/routes.test.ts +++ b/tests/unit/auth/routes.test.ts @@ -212,7 +212,7 @@ describe("token endpoint", () => { expect(mock.statusCode).toBe(200); expect(mock.jsonBody.token_type).toBe("Bearer"); - expect(mock.jsonBody.expires_in).toBe(3600); + expect(mock.jsonBody.expires_in).toBe(900); expect(mock.jsonBody.scope).toBe("de-identify re-identify"); expect(mock.headers["cache-control"]).toBe("no-store"); diff --git a/tests/unit/middleware/rateLimiter.test.ts b/tests/unit/middleware/rateLimiter.test.ts index 8446374..654ad37 100644 --- a/tests/unit/middleware/rateLimiter.test.ts +++ b/tests/unit/middleware/rateLimiter.test.ts @@ -412,6 +412,38 @@ describe("Token Endpoint Rate Limiter", () => { ); }); + it("should isolate distinct client IPs from each other", () => { + const rateLimiter = createTokenEndpointRateLimiter({ + maxRequests: 1, + windowMs: 60000, + }); + + // Exhaust the limit for the first client + rateLimiter( + createMockRequest({ ip: "10.0.0.1" }) as Request, + createMockResponse().res as Response, + vi.fn() + ); + const blocked = createMockResponse(); + const blockedNext = vi.fn(); + rateLimiter( + createMockRequest({ ip: "10.0.0.1" }) as Request, + blocked.res as Response, + blockedNext + ); + expect(blockedNext).not.toHaveBeenCalled(); + expect(blocked.statusCode).toBe(429); + + // A different client IP is unaffected + const next = vi.fn(); + rateLimiter( + createMockRequest({ ip: "10.0.0.2" }) as Request, + createMockResponse().res as Response, + next + ); + expect(next).toHaveBeenCalled(); + }); + it("should track clients independently of the anonymous limiter", () => { const tokenLimiter = createTokenEndpointRateLimiter({ maxRequests: 1, From 8e22dc54989ebb798a0f6a897bccf8fd0bc93101 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:24:33 +0000 Subject: [PATCH 09/18] Comma-split repeated X-Forwarded-For headers; note per-instance limits - getClientId now normalizes the array form of X-Forwarded-For (repeated headers) by comma-splitting every entry before taking the rightmost address; previously an array entry like 'a, b' was used verbatim as the rate-limit key - Document that the /token rate limiter, like the jti replay cache, is per-instance in-memory state: best-effort on horizontally scaled or serverless deployments, with edge/proxy or shared-store enforcement recommended for hard limits Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 2 +- src/lib/middleware/rateLimiter.ts | 13 +++++++++---- tests/unit/middleware/rateLimiter.test.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 183646f..5790f8a 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -151,7 +151,7 @@ curl -X POST https://mcp.example.com/mcp \ - **Audience restriction**: issued access tokens carry `aud = ENTERPRISE_MCP_RESOURCE` and `typ: at+jwt`; they are only accepted by this deployment. - **Algorithm pinning**: ID-JAGs must be asymmetrically signed (RS/PS/ES/EdDSA); symmetric algorithms are rejected to prevent key-confusion attacks. Issued tokens are pinned to HS256. - **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. -- **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. +- **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. Like the `jti` replay cache, the rate limiter's state is in-memory and per-instance — on horizontally scaled or serverless deployments treat both as best-effort, and enforce hard limits at your edge/proxy or back them with a shared store (e.g. Redis) if your threat model requires it. - **Rate limiting and proxies**: the client IP is taken from the rightmost `X-Forwarded-For` entry (the hop that reached the trusted proxy — resistant to spoofing on single-proxy platforms like Vercel). If you self-host behind multiple proxy hops, verify what your outermost proxy puts there: if the rightmost entry is a shared internal proxy IP, distinct callers collapse into one rate-limit bucket and you should raise `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` or enforce limits at the proxy instead. - **`resource` claim**: an ID-JAG without a `resource` claim is accepted — the extension makes the token-exchange `resource` parameter optional and only constrains the claim "if present". The `aud` check still binds every grant to this authorization server, which serves exactly one resource. - **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. It must be a high-entropy random value — the server enforces a minimum length, but length alone is not enough (a long dictionary phrase is forgeable). Generate it with `openssl rand -base64 32`, store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. diff --git a/src/lib/middleware/rateLimiter.ts b/src/lib/middleware/rateLimiter.ts index 9e3fb01..9111f62 100644 --- a/src/lib/middleware/rateLimiter.ts +++ b/src/lib/middleware/rateLimiter.ts @@ -28,10 +28,15 @@ export function getClientId(req: Request): string { // Left-most IPs are client-controlled and can be spoofed to bypass rate limiting. const forwarded = req.headers["x-forwarded-for"]; if (forwarded) { - const ips = Array.isArray(forwarded) - ? forwarded[forwarded.length - 1] - : forwarded.split(",").pop()!; - return ips.trim(); + // Normalize both forms — a repeated header (array) and a comma-delimited + // list — so the rightmost entry is always a single address. + const entries = (Array.isArray(forwarded) ? forwarded : [forwarded]).flatMap( + (value) => value.split(",") + ); + const rightmost = entries[entries.length - 1]?.trim(); + if (rightmost) { + return rightmost; + } } // Fall back to direct IP diff --git a/tests/unit/middleware/rateLimiter.test.ts b/tests/unit/middleware/rateLimiter.test.ts index 654ad37..c80ecb1 100644 --- a/tests/unit/middleware/rateLimiter.test.ts +++ b/tests/unit/middleware/rateLimiter.test.ts @@ -87,6 +87,15 @@ describe("Anonymous Rate Limiter", () => { expect(getClientId(req)).toBe("150.172.238.178"); }); + it("should comma-split repeated X-Forwarded-For headers (array form)", () => { + const req = createMockRequest({ + headers: { + "x-forwarded-for": ["1.1.1.1, 2.2.2.2", "3.3.3.3, 4.4.4.4"], + } as Request["headers"], + }); + expect(getClientId(req as Request)).toBe("4.4.4.4"); + }); + it("should trim whitespace from extracted IP", () => { const req = createMockRequest({ headers: { "x-forwarded-for": " 203.0.113.195 " }, From 6b11ea2e60d4a7933bfffbec70bcfe0aecc7c138 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:31:20 +0000 Subject: [PATCH 10/18] Require exp/iat/sub/jti claims on ID-JAGs; strict numeric env parsing - jose only validates exp/iat when the claims are present, so an ID-JAG minted without exp would never expire. jwtVerify now runs with requiredClaims [exp, iat, sub, jti] for ID-JAGs and [exp, sub] for issued access tokens (defensive; issuance always sets them) - ENTERPRISE_TOKEN_TTL_SECONDS and ENTERPRISE_TOKEN_RATE_LIMIT_* now reject values with trailing garbage ('900abc') instead of parseInt silently truncating them, matching the fail-closed posture - Document that a directly exposed server (no proxy) has fully attacker-controlled X-Forwarded-For, defeating per-IP buckets - Comment that the client allowlist binds at issuance only, with the short TTL bounding revocation staleness Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 2 +- src/lib/auth/accessTokens.ts | 7 +++++++ src/lib/auth/config.ts | 4 +++- src/lib/auth/idJag.ts | 3 +++ src/lib/middleware/rateLimiter.ts | 13 +++++++------ tests/unit/auth/accessTokens.test.ts | 17 ++++++++++++++++- tests/unit/auth/config.test.ts | 8 ++++++++ tests/unit/auth/idJag.test.ts | 18 ++++++++++++++++++ tests/unit/middleware/rateLimiter.test.ts | 8 ++++++++ 9 files changed, 71 insertions(+), 9 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 5790f8a..b5bf368 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -152,7 +152,7 @@ curl -X POST https://mcp.example.com/mcp \ - **Algorithm pinning**: ID-JAGs must be asymmetrically signed (RS/PS/ES/EdDSA); symmetric algorithms are rejected to prevent key-confusion attacks. Issued tokens are pinned to HS256. - **Replay detection** for ID-JAG `jti` values is in-memory and therefore best-effort on serverless/multi-instance deployments; ID-JAGs are short-lived (typically 5 minutes), which bounds the window. Use a shared store if your threat model requires strict single-use. - **Token endpoint hardening**: `/token` is unauthenticated by design (clients present ID-JAGs), so it is rate-limited per client IP (`ENTERPRISE_TOKEN_RATE_LIMIT_*`), and the OIDC discovery request to the IdP carries a 5-second timeout so a hung IdP cannot stall requests. Like the `jti` replay cache, the rate limiter's state is in-memory and per-instance — on horizontally scaled or serverless deployments treat both as best-effort, and enforce hard limits at your edge/proxy or back them with a shared store (e.g. Redis) if your threat model requires it. -- **Rate limiting and proxies**: the client IP is taken from the rightmost `X-Forwarded-For` entry (the hop that reached the trusted proxy — resistant to spoofing on single-proxy platforms like Vercel). If you self-host behind multiple proxy hops, verify what your outermost proxy puts there: if the rightmost entry is a shared internal proxy IP, distinct callers collapse into one rate-limit bucket and you should raise `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` or enforce limits at the proxy instead. +- **Rate limiting and proxies**: the client IP is taken from the rightmost `X-Forwarded-For` entry (the hop that reached the trusted proxy — resistant to spoofing on single-proxy platforms like Vercel). If you self-host behind multiple proxy hops, verify what your outermost proxy puts there: if the rightmost entry is a shared internal proxy IP, distinct callers collapse into one rate-limit bucket and you should raise `ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS` or enforce limits at the proxy instead. If you expose the server **directly with no proxy at all**, the header is entirely attacker-controlled (a caller can rotate it per request to escape per-IP buckets) — put a proxy or CDN in front, or enforce limits there. - **`resource` claim**: an ID-JAG without a `resource` claim is accepted — the extension makes the token-exchange `resource` parameter optional and only constrains the claim "if present". The `aud` check still binds every grant to this authorization server, which serves exactly one resource. - **Signing key hygiene**: `ENTERPRISE_AUTH_SIGNING_KEY` is a bearer-token-minting secret. It must be a high-entropy random value — the server enforces a minimum length, but length alone is not enough (a long dictionary phrase is forgeable). Generate it with `openssl rand -base64 32`, store it in your platform's secret manager, rotate it periodically (rotation invalidates outstanding access tokens, forcing a silent re-exchange), and never commit it. - The enterprise identity (`sub`, `email`, `scope`, `client_id`) of a verified request is available to request handling as `req.enterpriseAuth`, with the `sub` claim as the stable identifier for account linking per the spec. diff --git a/src/lib/auth/accessTokens.ts b/src/lib/auth/accessTokens.ts index 042472f..ce67237 100644 --- a/src/lib/auth/accessTokens.ts +++ b/src/lib/auth/accessTokens.ts @@ -76,6 +76,11 @@ export async function issueAccessToken( * Verify an enterprise access token presented on an /mcp request. * Checks signature, issuer, audience (MCP resource identifier), typ, and expiry. * + * The client allowlist is deliberately enforced only at issuance (/token) — + * an already-issued token stays valid until expiry even if its client is + * removed from ENTERPRISE_ALLOWED_CLIENT_IDS; the short default TTL bounds + * that staleness window. + * * @throws jose errors when the token is invalid */ export async function verifyAccessToken( @@ -88,6 +93,8 @@ export async function verifyAccessToken( typ: ACCESS_TOKEN_TYP, algorithms: ["HS256"], clockTolerance: CLOCK_TOLERANCE_SECONDS, + // Defensive: issued tokens always carry these, but never accept one without + requiredClaims: ["exp", "sub"], }); if (typeof payload.sub !== "string" || payload.sub.length === 0) { diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts index 33b54d9..37306c6 100644 --- a/src/lib/auth/config.ts +++ b/src/lib/auth/config.ts @@ -123,7 +123,9 @@ export function loadEnterpriseAuthConfig( // Short default: leaked bearer tokens stay usable until expiry, and // clients can re-exchange an ID-JAG without user interaction anyway. - const tokenTtlSeconds = parseInt(env.ENTERPRISE_TOKEN_TTL_SECONDS || "900", 10); + // Strict digits-only parse: parseInt would silently accept "900abc". + const ttlRaw = (env.ENTERPRISE_TOKEN_TTL_SECONDS || "900").trim(); + const tokenTtlSeconds = /^\d+$/.test(ttlRaw) ? parseInt(ttlRaw, 10) : NaN; if (isNaN(tokenTtlSeconds) || tokenTtlSeconds <= 0) { throw new EnterpriseAuthConfigError( "ENTERPRISE_TOKEN_TTL_SECONDS must be a positive integer" diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 1fb5bb2..e4b3885 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -234,6 +234,9 @@ export async function validateIdJag( typ: ID_JAG_TYP, algorithms: ALLOWED_ID_JAG_ALGORITHMS, clockTolerance: CLOCK_TOLERANCE_SECONDS, + // jose only validates exp/iat when present — require them so a grant + // without an expiry can never pass as eternally valid. + requiredClaims: ["exp", "iat", "sub", "jti"], }); payload = result.payload; } catch (error) { diff --git a/src/lib/middleware/rateLimiter.ts b/src/lib/middleware/rateLimiter.ts index 9111f62..22a1c5f 100644 --- a/src/lib/middleware/rateLimiter.ts +++ b/src/lib/middleware/rateLimiter.ts @@ -168,13 +168,14 @@ export function createTokenEndpointRateLimiter(config: RateLimiterConfig) { export function getTokenEndpointRateLimitConfig( env: NodeJS.ProcessEnv = process.env ): RateLimiterConfig { - const maxRequests = parseInt( - env.ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS || "30", - 10 + // Strict digits-only parse: parseInt would silently accept "30abc" + const parseStrict = (raw: string): number => + /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN; + const maxRequests = parseStrict( + (env.ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS || "30").trim() ); - const windowMs = parseInt( - env.ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS || "60000", - 10 + const windowMs = parseStrict( + (env.ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS || "60000").trim() ); if (isNaN(maxRequests) || maxRequests <= 0) { diff --git a/tests/unit/auth/accessTokens.test.ts b/tests/unit/auth/accessTokens.test.ts index 07d94b4..edf4ab4 100644 --- a/tests/unit/auth/accessTokens.test.ts +++ b/tests/unit/auth/accessTokens.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { decodeJwt, decodeProtectedHeader } from "jose"; +import { decodeJwt, decodeProtectedHeader, SignJWT } from "jose"; import { issueAccessToken, verifyAccessToken, @@ -120,6 +120,21 @@ describe("enterprise access tokens", () => { verifyAccessToken("sky-api-key-123", testConfig()) ).rejects.toThrow(); }); + + it("rejects a token without an exp claim", async () => { + // Hand-crafted with the right secret/typ/iss/aud but no expiry + const secret = new TextEncoder().encode(testConfig().signingKey); + const eternal = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256", typ: ACCESS_TOKEN_TYP }) + .setIssuer(testConfig().issuer) + .setAudience(testConfig().resource) + .setSubject("user-1") + .setIssuedAt() + .sign(secret); + await expect( + verifyAccessToken(eternal, testConfig()) + ).rejects.toThrow(/exp/); + }); }); describe("looksLikeEnterpriseToken()", () => { diff --git a/tests/unit/auth/config.test.ts b/tests/unit/auth/config.test.ts index 31b295f..fc2ac58 100644 --- a/tests/unit/auth/config.test.ts +++ b/tests/unit/auth/config.test.ts @@ -133,6 +133,14 @@ describe("loadEnterpriseAuthConfig()", () => { ) ).toThrow(/positive integer/); }); + + it("throws on a token TTL with trailing garbage", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_TOKEN_TTL_SECONDS: "900abc" }) + ) + ).toThrow(/positive integer/); + }); }); describe("overrides", () => { diff --git a/tests/unit/auth/idJag.test.ts b/tests/unit/auth/idJag.test.ts index 007cee5..c3fec89 100644 --- a/tests/unit/auth/idJag.test.ts +++ b/tests/unit/auth/idJag.test.ts @@ -117,6 +117,24 @@ describe("validateIdJag()", () => { ); }); + it("rejects an ID-JAG without an exp claim (would otherwise never expire)", async () => { + const assertion = await idp.signIdJag({ exp: undefined }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /exp/ + ); + }); + + it("rejects an ID-JAG without an iat claim", async () => { + const assertion = await idp.signIdJag({ iat: undefined }); + await expectOAuthError( + validateIdJag(assertion, testConfig(), idp.keyResolver), + "invalid_grant", + /iat/ + ); + }); + it("rejects an expired ID-JAG", async () => { const now = Math.floor(Date.now() / 1000); const assertion = await idp.signIdJag({ iat: now - 600, exp: now - 300 }); diff --git a/tests/unit/middleware/rateLimiter.test.ts b/tests/unit/middleware/rateLimiter.test.ts index c80ecb1..f87d416 100644 --- a/tests/unit/middleware/rateLimiter.test.ts +++ b/tests/unit/middleware/rateLimiter.test.ts @@ -393,6 +393,14 @@ describe("Token Endpoint Rate Limiter", () => { } as NodeJS.ProcessEnv) ).toThrow(/positive integer/); }); + + it("should throw on values with trailing garbage", () => { + expect(() => + getTokenEndpointRateLimitConfig({ + ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: "30abc", + } as NodeJS.ProcessEnv) + ).toThrow(/positive integer/); + }); }); describe("createTokenEndpointRateLimiter()", () => { From 0f62a0a813125125cf9592b0c0cff7fa361f0f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:37:03 +0000 Subject: [PATCH 11/18] Simplify unreachable exp fallback; pin no-credentials 401 path - Drop the dead ternary in validateIdJag: requiredClaims guarantees a numeric exp before the replay check runs - Document that tools/list is not filtered by scope (shared tool registry); enforcement is at invocation time - Integration test pinning the documented hard-401 path: valid enterprise token, but no Skyflow credentials from any source Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 1 + src/lib/auth/idJag.ts | 6 ++---- .../enterpriseAuth.integration.test.ts | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index b5bf368..59d8317 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -68,6 +68,7 @@ Scopes let IdP administrators control *which tools* a user or client may invoke, - **Scope values name tools**: grant `de-identify` and/or `re-identify`. - **A token with a `scope` claim** may only invoke the named tools; other tools return an `insufficient_scope` error result. - **A token without a `scope` claim** is unrestricted — access is gated at the connection level only. +- **`tools/list` is not filtered by scope**: every tool stays visible to every connection (the tool registry is shared across requests); enforcement happens at invocation time with a clear `insufficient_scope` error. For example, an Okta policy granting the `de-identify` scope to the "support" group lets those users redact text but never restore original PII, while the "compliance" group gets both scopes. diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index e4b3885..7155638 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -286,10 +286,8 @@ export async function validateIdJag( if (typeof jti !== "string" || jti.length === 0) { throw new IdJagValidationError("invalid_grant", "ID-JAG is missing a jti claim"); } - const expiresAtMs = - typeof payload.exp === "number" - ? payload.exp * 1000 + CLOCK_TOLERANCE_SECONDS * 1000 - : Date.now() + 5 * 60 * 1000; + // exp is guaranteed present and numeric by requiredClaims + jose validation + const expiresAtMs = (payload.exp as number) * 1000 + CLOCK_TOLERANCE_SECONDS * 1000; // The jti is deliberately burned here, before token issuance: if issuance // failed transiently the client must fetch a fresh ID-JAG from the IdP, // which is preferable to leaving a validated grant replayable. diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index 5062c92..c2510fe 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -230,6 +230,25 @@ describe("enterprise auth HTTP flow (integration)", () => { expect(denied.body?.result?.structuredContent?.error).toBe("insufficient_scope"); }); + it("returns 401 when enterprise auth passes but no Skyflow credentials resolve", async () => { + // Without SKYFLOW_API_KEY, X-Skyflow-Authorization, apiKey param, or + // anonymous mode, the documented hard-failure path is a credentials 401. + const { body } = await exchangeToken(await signIdJag()); + delete process.env.SKYFLOW_API_KEY; + try { + const res = await callMcp( + { jsonrpc: "2.0", method: "tools/list", id: 7 }, + body.access_token + ); + expect(res.status).toBe(401); + // The failure comes from Skyflow credential resolution, not the + // enterprise token (which was valid and consumed) + expect(res.wwwAuthenticate).toBeNull(); + } finally { + process.env.SKYFLOW_API_KEY = "sky-integration-dummy-key"; + } + }); + it("lets legacy Skyflow credentials fall through in optional mode", async () => { process.env.ENTERPRISE_AUTH_MODE = "optional"; try { From 0eba532fd408f752b62b173068eae8d1f4437784 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:44:43 +0000 Subject: [PATCH 12/18] Don't demote enterprise requests with placeholder vault params - The placeholder-fallback branch in the /mcp handler ran after enterprise auth resolved credentials, so an enterprise client whose URL template left ${...} vault params unsubstituted was silently demoted to anonymous mode (or 400) even when the deployment had a server-side vault configuration. Enterprise-authenticated requests with VAULT_ID/VAULT_URL configured now use the env vault config and ignore the placeholders, with an integration test pinning it - Allow [::1] alongside localhost/127.0.0.1 for http URLs in config validation and discovered jwks_uri checks Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- src/lib/auth/config.ts | 4 +++- src/lib/auth/idJag.ts | 4 +++- src/server.ts | 16 ++++++++++++- .../enterpriseAuth.integration.test.ts | 23 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts index 37306c6..2e9998b 100644 --- a/src/lib/auth/config.ts +++ b/src/lib/auth/config.ts @@ -82,7 +82,9 @@ function requireUrl(name: string, value: string | undefined): string { throw new EnterpriseAuthConfigError(`${name} must be a valid URL, got: ${trimmed}`); } const isLocalhost = - parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "[::1]"; if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) { throw new EnterpriseAuthConfigError( `${name} must use https (http is only allowed for localhost), got: ${trimmed}` diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 7155638..0a8df99 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -117,7 +117,9 @@ async function discoverJwksUri(idpIssuer: string): Promise { throw new Error(`IdP discovery document has an invalid jwks_uri: ${metadata.jwks_uri}`); } const isLocalhost = - jwksUrl.hostname === "localhost" || jwksUrl.hostname === "127.0.0.1"; + jwksUrl.hostname === "localhost" || + jwksUrl.hostname === "127.0.0.1" || + jwksUrl.hostname === "[::1]"; if (jwksUrl.protocol !== "https:" && !(jwksUrl.protocol === "http:" && isLocalhost)) { throw new Error( `IdP discovery document has a non-https jwks_uri: ${metadata.jwks_uri}` diff --git a/src/server.ts b/src/server.ts index 24790a6..fd8d4b6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -278,7 +278,16 @@ app.post("/mcp", createEnterpriseAuthMiddleware(), authenticateBearer, anonymous const hasPlaceholderParams = looksLikePlaceholder(queryVaultId) || looksLikePlaceholder(queryVaultUrl); - if (hasPlaceholderParams && !req.isAnonymousMode) { + // Enterprise-authenticated requests with a server-side vault configuration + // ignore unsubstituted placeholder params instead of demoting to anonymous + // mode: the deployment's env vault config is authoritative for them. + const usesEnvVaultFallback = + hasPlaceholderParams && + req.enterpriseAuth !== undefined && + !!process.env.VAULT_ID && + !!process.env.VAULT_URL; + + if (hasPlaceholderParams && !req.isAnonymousMode && !usesEnvVaultFallback) { // Query params contain placeholders - check if anonymous mode is available as fallback const anonApiKey = process.env.ANON_MODE_API_KEY; const anonVaultId = process.env.ANON_MODE_VAULT_ID; @@ -304,6 +313,11 @@ app.post("/mcp", createEnterpriseAuthMiddleware(), authenticateBearer, anonymous // Use anonymous mode configuration vaultId = req.anonVaultConfig.vaultId; vaultUrl = req.anonVaultConfig.vaultUrl; + } else if (usesEnvVaultFallback) { + // Placeholder query params from an enterprise client: use the + // server-side vault configuration instead + vaultId = process.env.VAULT_ID; + vaultUrl = process.env.VAULT_URL; } else { // Use client-provided or environment configuration vaultId = (req.query.vaultId as string) || process.env.VAULT_ID; diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index c2510fe..3242945 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -230,6 +230,29 @@ describe("enterprise auth HTTP flow (integration)", () => { expect(denied.body?.result?.structuredContent?.error).toBe("insufficient_scope"); }); + it("ignores placeholder vault params for enterprise requests with env vault config", async () => { + // An enterprise client whose URL template left ${...} placeholders + // unsubstituted must use the server-side vault config, not demote to + // anonymous mode or fail + const { body } = await exchangeToken(await signIdJag()); + const params = new URLSearchParams({ + vaultId: "${SKYFLOW_VAULT_ID}", + vaultUrl: "${SKYFLOW_VAULT_URL}", + }); + const res = await fetch(`${baseUrl}/mcp?${params}`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: `Bearer ${body.access_token}`, + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 8 }), + }); + expect(res.status).toBe(200); + const listBody = await res.json(); + expect(listBody?.result?.tools?.length).toBeGreaterThan(0); + }); + it("returns 401 when enterprise auth passes but no Skyflow credentials resolve", async () => { // Without SKYFLOW_API_KEY, X-Skyflow-Authorization, apiKey param, or // anonymous mode, the documented hard-failure path is a credentials 401. From f9d35054c75c6cc43fa12738b1672fd32427824a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:49:58 +0000 Subject: [PATCH 13/18] Document scope-naming assumption to verify against live IdPs Scope enforcement matches tool names exactly; IdPs that emit namespaced scope strings (e.g. mcp:de-identify) will deny tools until configured to issue bare names. Flagged in the setup guide as an item to validate when wiring up a live Okta org. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 59d8317..d51256c 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -72,6 +72,8 @@ Scopes let IdP administrators control *which tools* a user or client may invoke, For example, an Okta policy granting the `de-identify` scope to the "support" group lets those users redact text but never restore original PII, while the "compliance" group gets both scopes. +> **Known assumption to verify against your IdP:** scope values must match tool names *exactly* — configure your IdP to issue the literal scopes `de-identify` and `re-identify`. Some IdPs default to namespaced or audience-qualified scope strings (e.g. `mcp:de-identify`); those will not match and will deny the tool. If your IdP cannot issue bare scope names, omit the scope grant entirely (unrestricted) until a mapping layer is added. This is one of the items to validate when wiring up a live Okta org. + ## Skyflow vault credentials under enterprise auth The `Authorization` header now carries the enterprise access token, so Skyflow vault credentials are resolved separately, in order of precedence: From 7f0df24207c9c6f2e34e27c807113474911f6f3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:57:22 +0000 Subject: [PATCH 14/18] Required mode never demotes to anonymous mode An enterprise-authenticated request in required mode with no Skyflow credentials from any source (X-Skyflow-Authorization, SKYFLOW_API_KEY, apiKey query param) now returns 401 missing_skyflow_credentials instead of silently falling back to the anonymous demo vault when ANON_MODE_* is configured. Required mode is the strict posture; serving SSO users non-persisted demo tokens contradicted operator intent. Optional mode keeps the documented anonymous fallback as a demo path for mixed deployments. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 5 ++- src/lib/middleware/enterpriseAuth.ts | 34 +++++++++++++++---- .../enterpriseAuth.integration.test.ts | 1 + tests/unit/middleware/enterpriseAuth.test.ts | 29 ++++++++++++++-- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index d51256c..0671844 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -82,7 +82,10 @@ The `Authorization` header now carries the enterprise access token, so Skyflow v 2. **`SKYFLOW_API_KEY` environment variable** — a server-wide service credential. The typical setup for enterprise deployments: employees authenticate with SSO only and never handle Skyflow credentials. 3. **Existing fallbacks** — the `apiKey` query parameter, then anonymous mode if configured. -Note the last fallback: an enterprise-authenticated request with no Skyflow credentials at all degrades to [anonymous mode](../README.md#anonymous-mode-try-before-you-buy) when `ANON_MODE_*` is configured (responses are clearly marked with `anonymousMode: true`), and receives a 401 otherwise. This is deliberate — it gives SSO users a working demo path before vault credentials are provisioned. Deployments that want enterprise users to always hit a real vault should set `SKYFLOW_API_KEY`; deployments that want a hard failure instead should leave `ANON_MODE_*` unset. +What happens when none of these yield credentials depends on the mode: + +- **`required` mode returns 401** (`missing_skyflow_credentials`) — a deployment that demands SSO on every request is never silently served from the anonymous demo vault, even when `ANON_MODE_*` is configured. +- **`optional` mode degrades to [anonymous mode](../README.md#anonymous-mode-try-before-you-buy)** when `ANON_MODE_*` is configured (responses are clearly marked with `anonymousMode: true`), and 401s otherwise. This is deliberate — a working demo path for mixed deployments before vault credentials are provisioned. Set `SKYFLOW_API_KEY` if enterprise users should always hit a real vault. ## Deployment scenario 1: Skyflow-hosted endpoint + Skyflow Okta diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index 4bf5c3f..a0add16 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -8,7 +8,9 @@ * * 1. X-Skyflow-Authorization header (per-user Skyflow bearer token/API key) * 2. SKYFLOW_API_KEY environment variable (server-wide service credential) - * 3. Existing fallbacks in authenticateBearer (apiKey query param, anonymous mode) + * 3. apiKey query parameter (consumed downstream by authenticateBearer) + * 4. Anonymous mode — in `optional` mode only. `required` mode returns 401 + * instead of demoting an SSO-authenticated user to the demo vault. * * When enterprise auth is disabled this middleware is a no-op. */ @@ -116,12 +118,30 @@ function resolveSkyflowCredentials( return true; } - // Leave credentials unresolved: authenticateBearer will fall back to the - // apiKey query parameter or anonymous mode. Deliberate trade-off: an - // enterprise-authenticated user without Skyflow credentials degrades to - // anonymous mode (clearly marked via anonymousMode:true in tool responses) - // rather than being rejected. Deployments that don't want this should set - // SKYFLOW_API_KEY or leave ANON_MODE_* unconfigured (yielding a 401). + if (config.mode === "required") { + // Strict posture: never demote an enterprise-authenticated request to + // anonymous mode. The apiKey query parameter is still honored (consumed + // downstream by authenticateBearer); anything less is a hard 401. + const apiKeyParam = req.query?.apiKey; + if (typeof apiKeyParam === "string" && apiKeyParam.trim().length > 0) { + return true; + } + res.status(401).json({ + error: "missing_skyflow_credentials", + error_description: + "Enterprise authorization succeeded, but no Skyflow credentials were provided. " + + "Send them in the X-Skyflow-Authorization header, configure SKYFLOW_API_KEY on the server, " + + "or pass the apiKey query parameter.", + }); + return false; + } + + // Optional mode: leave credentials unresolved so authenticateBearer falls + // back to the apiKey query parameter or anonymous mode. Deliberate + // trade-off: an enterprise-authenticated user without Skyflow credentials + // degrades to anonymous mode (clearly marked via anonymousMode:true in + // tool responses) rather than being rejected — a demo path for mixed + // deployments. Set SKYFLOW_API_KEY to avoid it. return true; } diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index 3242945..f75d4e5 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -267,6 +267,7 @@ describe("enterprise auth HTTP flow (integration)", () => { // The failure comes from Skyflow credential resolution, not the // enterprise token (which was valid and consumed) expect(res.wwwAuthenticate).toBeNull(); + expect(res.body.error).toBe("missing_skyflow_credentials"); } finally { process.env.SKYFLOW_API_KEY = "sky-integration-dummy-key"; } diff --git a/tests/unit/middleware/enterpriseAuth.test.ts b/tests/unit/middleware/enterpriseAuth.test.ts index 742c548..cf2c944 100644 --- a/tests/unit/middleware/enterpriseAuth.test.ts +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -127,7 +127,8 @@ describe("enterprise auth middleware", () => { const req = createMockRequest({ headers: { authorization: `Bearer ${await validToken()}` }, }); - const { next } = await runMiddleware(enabledEnv(), req); + const env = enabledEnv({ SKYFLOW_API_KEY: "service-api-key" }); + const { next } = await runMiddleware(env, req); expect(next).toHaveBeenCalled(); expect(req.enterpriseAuth).toEqual(identity); // The enterprise token must not leak downstream as a Skyflow credential @@ -208,12 +209,36 @@ describe("enterprise auth middleware", () => { expect(req.isAnonymousMode).toBe(false); }); - it("leaves credentials unresolved for downstream fallbacks when none provided", async () => { + it("rejects required-mode requests with no Skyflow credentials (no anonymous demotion)", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const { next, captured } = await runMiddleware(enabledEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + expect(captured.jsonBody.error).toBe("missing_skyflow_credentials"); + }); + + it("lets the apiKey query parameter through in required mode", async () => { const req = createMockRequest({ headers: { authorization: `Bearer ${await validToken()}` }, + query: { apiKey: "sky-param-key" }, }); const { next } = await runMiddleware(enabledEnv(), req); expect(next).toHaveBeenCalled(); + // Left unresolved: authenticateBearer consumes the query parameter + expect(req.skyflowCredentials).toBeUndefined(); + }); + + it("leaves credentials unresolved for downstream fallbacks in optional mode", async () => { + const req = createMockRequest({ + headers: { authorization: `Bearer ${await validToken()}` }, + }); + const { next } = await runMiddleware( + enabledEnv({ ENTERPRISE_AUTH_MODE: "optional" }), + req + ); + expect(next).toHaveBeenCalled(); expect(req.skyflowCredentials).toBeUndefined(); }); From ad0cceacc1d5637f9f7c1e62022889cd0583629f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:02:22 +0000 Subject: [PATCH 15/18] Surface serverless best-effort caveat in README; consistent 404 body Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- README.md | 2 ++ src/lib/auth/routes.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 76651b9..741012a 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ The server supports the MCP [Enterprise-Managed Authorization extension](https:/ The feature is off by default and enabled via `ENTERPRISE_AUTH_ENABLED=true`. It supports a `required` mode (every request needs SSO-derived auth — for self-hosted enterprise deployments) and an `optional` mode (SSO tokens accepted alongside ordinary Skyflow credentials — for shared endpoints). See [docs/enterprise-managed-auth.md](docs/enterprise-managed-auth.md) for the full flow, environment variable reference, and IdP setup for both deployment scenarios. +> **Serverless note:** ID-JAG replay detection and `/token` rate limiting use in-memory, per-instance state. On horizontally scaled or serverless platforms (Vercel included) they are best-effort only — if your threat model requires strict single-use grants or hard rate limits, back them with a shared store or enforce at your edge. Details in the [security notes](docs/enterprise-managed-auth.md#security-notes). + ## Installation ```bash diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 75f5946..80de0d0 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -58,7 +58,10 @@ function configForRequest( throw error; } if (!config) { - res.status(404).json({ error: "not_found" }); + res.status(404).json({ + error: "not_found", + error_description: "Enterprise-managed authorization is not enabled on this server", + }); return null; } return config; From 1ae5946ff9071b05d8e00f476145ddc7d85876f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:07:43 +0000 Subject: [PATCH 16/18] Document discovery-cache lifetime; e2e test for required-mode apiKey param Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- src/lib/auth/idJag.ts | 7 ++++++- .../enterpriseAuth.integration.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/auth/idJag.ts b/src/lib/auth/idJag.ts index 0a8df99..ee75d9c 100644 --- a/src/lib/auth/idJag.ts +++ b/src/lib/auth/idJag.ts @@ -78,7 +78,12 @@ let cachedRemoteJwks: { uri: string; resolver: JWTVerifyGetKey } | null = null; /** * Discover the IdP's JWKS URI from its OIDC discovery document. - * Results are cached for the lifetime of the process. + * + * Results are cached for the lifetime of the process: key ROTATION happens + * at a stable jwks_uri (createRemoteJWKSet refetches keys as needed), while + * relocating the jwks_uri itself is a rare IdP reconfiguration — set + * ENTERPRISE_IDP_JWKS_URI explicitly or restart if that ever happens. + * Serverless instance recycling bounds the staleness in practice. */ async function discoverJwksUri(idpIssuer: string): Promise { const cached = discoveredJwksUris.get(idpIssuer); diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index f75d4e5..54cb110 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -273,6 +273,27 @@ describe("enterprise auth HTTP flow (integration)", () => { } }); + it("honors the apiKey query parameter in required mode (no SKYFLOW_API_KEY)", async () => { + // Locks in the handoff: the enterprise middleware leaves credentials + // unresolved and authenticateBearer consumes the query parameter + const { body } = await exchangeToken(await signIdJag()); + delete process.env.SKYFLOW_API_KEY; + try { + const res = await fetch(`${baseUrl}/mcp?apiKey=sky-param-key`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: `Bearer ${body.access_token}`, + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 9 }), + }); + expect(res.status).toBe(200); + } finally { + process.env.SKYFLOW_API_KEY = "sky-integration-dummy-key"; + } + }); + it("lets legacy Skyflow credentials fall through in optional mode", async () => { process.env.ENTERPRISE_AUTH_MODE = "optional"; try { From aca737ec842d42a38bc88458babd79ecbc940c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:15:13 +0000 Subject: [PATCH 17/18] Pair env vault fallback only with the service credential - The placeholder-params fallback to VAULT_ID/VAULT_URL now applies only when enterprise auth resolved credentials from SKYFLOW_API_KEY: the server's credential and vault belong together, but per-user credentials from X-Skyflow-Authorization must not be paired with a vault the server guesses at - Enterprise-authenticated requests never take the anonymous placeholder fallback: broken vault template + per-user credentials is a clear 400, consistent with required-mode strictness - Middleware records the credential source (header vs env) for the handler's decision; docs describe the precedence, and a doc note warns that /token requires form-urlencoded bodies per RFC 6749 - Integration test covers the per-user-credentials + placeholder 400 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 3 +++ src/lib/middleware/enterpriseAuth.ts | 2 ++ src/server.ts | 21 +++++++++++++--- .../enterpriseAuth.integration.test.ts | 24 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 0671844..07501d1 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -82,6 +82,8 @@ The `Authorization` header now carries the enterprise access token, so Skyflow v 2. **`SKYFLOW_API_KEY` environment variable** — a server-wide service credential. The typical setup for enterprise deployments: employees authenticate with SSO only and never handle Skyflow credentials. 3. **Existing fallbacks** — the `apiKey` query parameter, then anonymous mode if configured. +If a client's URL template leaves unsubstituted `${...}` placeholders in the `vaultId`/`vaultUrl` query parameters, an enterprise request using the **server's service credential** (`SKYFLOW_API_KEY`) falls back to the server's `VAULT_ID`/`VAULT_URL` — the deployment's credential and vault belong together. A request carrying **per-user credentials** (`X-Skyflow-Authorization`) gets a 400 instead: the server won't guess which vault those credentials belong to, and enterprise requests are never demoted to the anonymous vault via the placeholder path. + What happens when none of these yield credentials depends on the mode: - **`required` mode returns 401** (`missing_skyflow_credentials`) — a deployment that demands SSO on every request is never silently served from the anonymous demo vault, even when `ANON_MODE_*` is configured. @@ -138,6 +140,7 @@ Simulating what an enterprise-enabled MCP client does after SSO (you need a real curl https://mcp.example.com/.well-known/oauth-protected-resource/mcp # 2. Exchange the ID-JAG for an access token +# (must be application/x-www-form-urlencoded per RFC 6749 — JSON bodies are not parsed) curl -X POST https://mcp.example.com/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ diff --git a/src/lib/middleware/enterpriseAuth.ts b/src/lib/middleware/enterpriseAuth.ts index a0add16..6991465 100644 --- a/src/lib/middleware/enterpriseAuth.ts +++ b/src/lib/middleware/enterpriseAuth.ts @@ -107,6 +107,7 @@ function resolveSkyflowCredentials( } // SECURITY: req.skyflowCredentials contains secrets — never log or serialize the request object. req.skyflowCredentials = result.credentials; + req.skyflowCredentialsSource = "header"; req.isAnonymousMode = false; return true; } @@ -114,6 +115,7 @@ function resolveSkyflowCredentials( if (env.SKYFLOW_API_KEY) { // SECURITY: req.skyflowCredentials contains secrets — never log or serialize the request object. req.skyflowCredentials = { apiKey: env.SKYFLOW_API_KEY }; + req.skyflowCredentialsSource = "env"; req.isAnonymousMode = false; return true; } diff --git a/src/server.ts b/src/server.ts index fd8d4b6..29ad35a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -262,6 +262,7 @@ declare global { isAnonymousMode: boolean; // Always set by authenticateBearer middleware anonVaultConfig?: { vaultId: string; vaultUrl: string }; enterpriseAuth?: EnterpriseIdentity; // Set when enterprise auth verified the request + skyflowCredentialsSource?: "header" | "env"; // Where enterprise auth resolved Skyflow credentials from } } } @@ -278,16 +279,30 @@ app.post("/mcp", createEnterpriseAuthMiddleware(), authenticateBearer, anonymous const hasPlaceholderParams = looksLikePlaceholder(queryVaultId) || looksLikePlaceholder(queryVaultUrl); - // Enterprise-authenticated requests with a server-side vault configuration - // ignore unsubstituted placeholder params instead of demoting to anonymous - // mode: the deployment's env vault config is authoritative for them. + // Enterprise requests using the SERVER's service credential ignore + // unsubstituted placeholder params in favor of the env vault config — the + // deployment's credential and vault belong together. Per-user credentials + // (X-Skyflow-Authorization) are NOT paired with the server's vault: we + // don't know which vault they belong to. const usesEnvVaultFallback = hasPlaceholderParams && req.enterpriseAuth !== undefined && + req.skyflowCredentialsSource === "env" && !!process.env.VAULT_ID && !!process.env.VAULT_URL; if (hasPlaceholderParams && !req.isAnonymousMode && !usesEnvVaultFallback) { + if (req.enterpriseAuth) { + // Enterprise-authenticated requests never demote to the anonymous + // vault via the placeholder path; a broken vault template alongside + // per-user credentials is a client configuration error. + return res.status(400).json({ + error: + "Configuration error: vaultId/vaultUrl query parameters contain unsubstituted placeholders " + + "(e.g. ${SKYFLOW_VAULT_ID}). Fix the client's URL template or configure VAULT_ID/VAULT_URL " + + "with SKYFLOW_API_KEY on the server.", + }); + } // Query params contain placeholders - check if anonymous mode is available as fallback const anonApiKey = process.env.ANON_MODE_API_KEY; const anonVaultId = process.env.ANON_MODE_VAULT_ID; diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts index 54cb110..5f9c617 100644 --- a/tests/integration/enterpriseAuth.integration.test.ts +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -253,6 +253,30 @@ describe("enterprise auth HTTP flow (integration)", () => { expect(listBody?.result?.tools?.length).toBeGreaterThan(0); }); + it("rejects placeholder vault params when per-user credentials are supplied", async () => { + // Per-user credentials must not be paired with the server's vault, and + // enterprise requests never demote to the anonymous vault: broken client + // template + X-Skyflow-Authorization is a clear 400 + const { body } = await exchangeToken(await signIdJag()); + const params = new URLSearchParams({ + vaultId: "${SKYFLOW_VAULT_ID}", + vaultUrl: "${SKYFLOW_VAULT_URL}", + }); + const res = await fetch(`${baseUrl}/mcp?${params}`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: `Bearer ${body.access_token}`, + "x-skyflow-authorization": "Bearer sky-per-user-key", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 10 }), + }); + expect(res.status).toBe(400); + const errBody = await res.json(); + expect(errBody.error).toContain("unsubstituted placeholders"); + }); + it("returns 401 when enterprise auth passes but no Skyflow credentials resolve", async () => { // Without SKYFLOW_API_KEY, X-Skyflow-Authorization, apiKey param, or // anonymous mode, the documented hard-failure path is a credentials 401. From fa8dff288858bdbd7c5f0f10ad92d6bf43884e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:23:09 +0000 Subject: [PATCH 18/18] Ignore OIDC identity scopes in tool enforcement; validate PRM path suffix - Standard OIDC identity scopes (openid, profile, email, address, phone, offline_access) carry identity semantics, not tool policy: an IdP's default 'openid profile email' grant no longer denies every tool. A scope claim of only identity scopes behaves like no claim; mixed claims keep just the tool scopes; unrecognized custom scopes still fail closed with the granted scopes named in the error - The protected-resource metadata route now 404s for path suffixes that don't match the configured resource path (RFC 9728 conformance) instead of returning identical metadata for any suffix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsKnZeLzmfh5mQoGaFYd5P --- docs/enterprise-managed-auth.md | 5 +++-- src/lib/auth/routes.ts | 14 ++++++++++++++ src/lib/auth/scopes.ts | 30 ++++++++++++++++++++++++++++-- tests/unit/auth/routes.test.ts | 33 ++++++++++++++++++++++++++++++++- tests/unit/auth/scopes.test.ts | 18 ++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/enterprise-managed-auth.md b/docs/enterprise-managed-auth.md index 07501d1..147e2f8 100644 --- a/docs/enterprise-managed-auth.md +++ b/docs/enterprise-managed-auth.md @@ -66,13 +66,14 @@ Misconfiguration fails **closed**: if the feature is enabled but required variab Scopes let IdP administrators control *which tools* a user or client may invoke, not just whether they can connect. The IdP grants scopes in the ID-JAG (per admin policy), the `/token` endpoint copies them into the access token, and the server enforces them per tool call: - **Scope values name tools**: grant `de-identify` and/or `re-identify`. -- **A token with a `scope` claim** may only invoke the named tools; other tools return an `insufficient_scope` error result. +- **A token with tool scopes** may only invoke the named tools; other tools return an `insufficient_scope` error result naming the granted scopes. - **A token without a `scope` claim** is unrestricted — access is gated at the connection level only. +- **Standard OIDC identity scopes are ignored** (`openid`, `profile`, `email`, `address`, `phone`, `offline_access`): they describe identity data, not tool policy, so an IdP's default `openid profile email` grant does not deny every tool. A claim of *only* identity scopes behaves like no claim at all. - **`tools/list` is not filtered by scope**: every tool stays visible to every connection (the tool registry is shared across requests); enforcement happens at invocation time with a clear `insufficient_scope` error. For example, an Okta policy granting the `de-identify` scope to the "support" group lets those users redact text but never restore original PII, while the "compliance" group gets both scopes. -> **Known assumption to verify against your IdP:** scope values must match tool names *exactly* — configure your IdP to issue the literal scopes `de-identify` and `re-identify`. Some IdPs default to namespaced or audience-qualified scope strings (e.g. `mcp:de-identify`); those will not match and will deny the tool. If your IdP cannot issue bare scope names, omit the scope grant entirely (unrestricted) until a mapping layer is added. This is one of the items to validate when wiring up a live Okta org. +> **Known assumption to verify against your IdP:** tool scope values must match tool names *exactly* — configure your IdP to issue the literal scopes `de-identify` and `re-identify`. Standard OIDC identity scopes are filtered out harmlessly, but namespaced or audience-qualified custom scopes (e.g. `mcp:de-identify`) will not match and will deny tools (fail closed, with the granted scopes named in the error). If your IdP cannot issue bare scope names, omit custom scope grants entirely (unrestricted) until a mapping layer is added. This is one of the items to validate when wiring up a live Okta org. ## Skyflow vault credentials under enterprise auth diff --git a/src/lib/auth/routes.ts b/src/lib/auth/routes.ts index 80de0d0..c3c3591 100644 --- a/src/lib/auth/routes.ts +++ b/src/lib/auth/routes.ts @@ -93,6 +93,20 @@ export function createProtectedResourceMetadataHandler( return (req: Request, res: Response) => { const config = configForRequest(res, deps.env ?? process.env); if (!config) return; + // RFC 9728: the path suffix must correspond to the resource's path + // component — the bare URL and the configured resource path are the + // only valid metadata locations. + const resourcePath = new URL(config.resource).pathname; + const validPaths = new Set([ + "/.well-known/oauth-protected-resource", + `/.well-known/oauth-protected-resource${resourcePath === "/" ? "" : resourcePath}`, + ]); + if (!validPaths.has(req.path)) { + return res.status(404).json({ + error: "not_found", + error_description: "No protected resource metadata at this path", + }); + } res.json({ resource: config.resource, authorization_servers: [config.issuer], diff --git a/src/lib/auth/scopes.ts b/src/lib/auth/scopes.ts index f301726..22bd19b 100644 --- a/src/lib/auth/scopes.ts +++ b/src/lib/auth/scopes.ts @@ -11,9 +11,30 @@ * exactly the named tools. */ +/** + * Standard OIDC identity scopes. IdPs commonly include these in every grant + * (they describe identity data, not tool policy), so they are ignored when + * deciding tool access — otherwise a default "openid profile email" claim + * would silently deny every tool. + */ +const OIDC_IDENTITY_SCOPES = new Set([ + "openid", + "profile", + "email", + "address", + "phone", + "offline_access", +]); + /** * Parse the space-delimited scope claim from an enterprise access token. - * Returns undefined when no scope claim was present (= unrestricted). + * + * - No scope claim → undefined (unrestricted) + * - Claim with only standard OIDC identity scopes → undefined (they carry + * no tool policy; connection-level gating was intended) + * - Empty claim ("") → [] (explicit deny-all) + * - Anything else → the non-identity scope values; unrecognized custom + * scopes deny tools (fail closed) with an error naming what was granted */ export function parseGrantedScopes( scope: string | undefined @@ -21,7 +42,12 @@ export function parseGrantedScopes( if (scope === undefined) { return undefined; } - return scope.split(" ").filter((s) => s.length > 0); + const values = scope.split(" ").filter((s) => s.length > 0); + const toolScopes = values.filter((s) => !OIDC_IDENTITY_SCOPES.has(s)); + if (values.length > 0 && toolScopes.length === 0) { + return undefined; + } + return toolScopes; } /** diff --git a/tests/unit/auth/routes.test.ts b/tests/unit/auth/routes.test.ts index b4cc398..23a91d5 100644 --- a/tests/unit/auth/routes.test.ts +++ b/tests/unit/auth/routes.test.ts @@ -22,7 +22,13 @@ import { } from "./helpers"; function createMockRequest(overrides: Partial = {}): Request { - return { headers: {}, query: {}, body: {}, ...overrides } as Request; + return { + headers: {}, + query: {}, + body: {}, + path: "/.well-known/oauth-protected-resource", + ...overrides, + } as Request; } interface MockResponse { @@ -125,6 +131,31 @@ describe("protected resource metadata endpoint", () => { bearer_methods_supported: ["header"], }); }); + + it("serves the RFC 9728 path-suffixed metadata URL for the resource path", () => { + const mock = createMockResponse(); + createProtectedResourceMetadataHandler({ env: enabledEnv() })( + createMockRequest({ + path: "/.well-known/oauth-protected-resource/mcp", + } as Partial), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(200); + expect(mock.jsonBody.resource).toBe(TEST_RESOURCE); + }); + + it("returns 404 for a path suffix that is not the resource path", () => { + const mock = createMockResponse(); + createProtectedResourceMetadataHandler({ env: enabledEnv() })( + createMockRequest({ + path: "/.well-known/oauth-protected-resource/wrong", + } as Partial), + mock.res, + vi.fn() + ); + expect(mock.statusCode).toBe(404); + }); }); describe("createEnterpriseAuthRouter()", () => { diff --git a/tests/unit/auth/scopes.test.ts b/tests/unit/auth/scopes.test.ts index f131702..5d10fa3 100644 --- a/tests/unit/auth/scopes.test.ts +++ b/tests/unit/auth/scopes.test.ts @@ -22,6 +22,24 @@ describe("enterprise scope enforcement", () => { expect(parseGrantedScopes("")).toEqual([]); expect(parseGrantedScopes(" ")).toEqual([]); }); + + it("ignores standard OIDC identity scopes (no tool policy implied)", () => { + // A default "openid profile email" grant must not deny every tool + expect(parseGrantedScopes("openid profile email")).toBeUndefined(); + expect(parseGrantedScopes("openid offline_access")).toBeUndefined(); + }); + + it("strips identity scopes but keeps tool scopes when mixed", () => { + expect(parseGrantedScopes("openid profile de-identify")).toEqual([ + "de-identify", + ]); + }); + + it("keeps unrecognized custom scopes (fail closed at enforcement)", () => { + expect(parseGrantedScopes("openid mcp:de-identify")).toEqual([ + "mcp:de-identify", + ]); + }); }); describe("isToolPermitted()", () => {