diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4b8f2..e0f4f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### 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, 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 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; 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. + - **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..84ccf35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,18 @@ 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` +- `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** - Registers two active tools: `de-identify` and `re-identify` @@ -127,14 +139,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`, `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): -- `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 +300,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 +347,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..741012a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ 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. + +> **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 @@ -159,7 +167,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..147e2f8 --- /dev/null +++ b/docs/enterprise-managed-auth.md @@ -0,0 +1,168 @@ +# 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 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). | + +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 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:** 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 + +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. + +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. +- **`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 + +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 +# (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" \ + -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. +- **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. 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/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..ce67237 --- /dev/null +++ b/src/lib/auth/accessTokens.ts @@ -0,0 +1,127 @@ +/** + * 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 }), + // 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 }) + .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. + * + * 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( + 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, + // Defensive: issued tokens always carry these, but never accept one without + requiredClaims: ["exp", "sub"], + }); + + 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..2e9998b --- /dev/null +++ b/src/lib/auth/config.ts @@ -0,0 +1,161 @@ +/** + * 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" || + 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}` + ); + } + 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}` + ); + } + + // Short default: leaked bearer tokens stay usable until expiry, and + // clients can re-exchange an ID-JAG without user interaction anyway. + // 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" + ); + } + + 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..ee75d9c --- /dev/null +++ b/src/lib/auth/idJag.ts @@ -0,0 +1,312 @@ +/** + * 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; + +/** 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" + | "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: 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); + if (cached) return cached; + + const discoveryUrl = `${idpIssuer}/.well-known/openid-configuration`; + let response: Response; + try { + response = await fetch(discoveryUrl, { + signal: AbortSignal.timeout(IDP_HTTP_TIMEOUT_MS), + }); + } 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`); + } + + // 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" || + 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}` + ); + } + + 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, + // 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; +} + +/** 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(); + +/** 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; + } + 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, + // 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) { + 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. + // 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) + ? 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"); + } + // 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. + 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..c3c3591 --- /dev/null +++ b/src/lib/auth/routes.ts @@ -0,0 +1,251 @@ +/** + * 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"; +import { + createTokenEndpointRateLimiter, + getTokenEndpointRateLimitConfig, +} from "../middleware/rateLimiter.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", + error_description: "Enterprise-managed authorization is not enabled on this server", + }); + 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; + // 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], + 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, + // 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) { + 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 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/*resourcePath"], + createProtectedResourceMetadataHandler(deps) + ); + router.post( + "/token", + 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..22bd19b --- /dev/null +++ b/src/lib/auth/scopes.ts @@ -0,0 +1,84 @@ +/** + * 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. + */ + +/** + * 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. + * + * - 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 +): string[] | undefined { + if (scope === undefined) { + return undefined; + } + 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; +} + +/** + * 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/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..6991465 --- /dev/null +++ b/src/lib/middleware/enterpriseAuth.ts @@ -0,0 +1,223 @@ +/** + * 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. 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. + */ +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 { + // 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="${headerParam(options.error)}"`); + } + if (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${ + resourcePath === "/" ? "" : resourcePath + }"` + ); + 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: 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.", + }); +} + +/** + * 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) { + // 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", + 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.skyflowCredentialsSource = "header"; + 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.skyflowCredentialsSource = "env"; + req.isAnonymousMode = false; + return true; + } + + 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; +} + +/** + * 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: "server_error", + error_description: "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. 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(); + } + + 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/lib/middleware/rateLimiter.ts b/src/lib/middleware/rateLimiter.ts index d1f522a..22a1c5f 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 @@ -56,23 +61,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 +110,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 +126,72 @@ 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 { + // 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 = parseStrict( + (env.ENTERPRISE_TOKEN_RATE_LIMIT_WINDOW_MS || "60000").trim() + ); + + 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/src/server.ts b/src/server.ts index 81923ed..29ad35a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,15 @@ 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 { + parseGrantedScopes, + isToolPermitted, + buildScopeDenial, +} from "./lib/auth/scopes.js"; import { createAnonymousRateLimiter, getAnonymousRateLimitConfig, @@ -33,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(); @@ -59,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", @@ -125,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) }], @@ -159,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) }], @@ -169,11 +204,51 @@ registerAppTool( ); const app: Express = express(); + +// 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. 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 { + const enterpriseConfig = loadEnterpriseAuthConfig(); + if (enterpriseConfig) { + 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( + "Enterprise-managed authorization is misconfigured:", + error instanceof Error ? error.message : error + ); +} + // Create rate limiter for anonymous mode const anonymousRateLimiter = createAnonymousRateLimiter( getAnonymousRateLimitConfig() @@ -186,11 +261,13 @@ 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 + skyflowCredentialsSource?: "header" | "env"; // Where enterprise auth resolved Skyflow credentials from } } } -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; @@ -202,7 +279,30 @@ app.post("/mcp", authenticateBearer, anonymousRateLimiter, async (req, res) => { const hasPlaceholderParams = looksLikePlaceholder(queryVaultId) || looksLikePlaceholder(queryVaultUrl); - if (hasPlaceholderParams && !req.isAnonymousMode) { + // 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; @@ -228,6 +328,11 @@ app.post("/mcp", authenticateBearer, anonymousRateLimiter, async (req, res) => { // 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; @@ -287,6 +392,7 @@ app.post("/mcp", authenticateBearer, anonymousRateLimiter, async (req, res) => { skyflow: skyflowInstance, vaultId: validatedVaultId, isAnonymousMode: useAnonymousMode, + enterpriseScopes: parseGrantedScopes(req.enterpriseAuth?.scope), }, async () => { await server.connect(transport); diff --git a/tests/integration/enterpriseAuth.integration.test.ts b/tests/integration/enterpriseAuth.integration.test.ts new file mode 100644 index 0000000..5f9c617 --- /dev/null +++ b/tests/integration/enterpriseAuth.integration.test.ts @@ -0,0 +1,333 @@ +/** + * 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/mcp"` + ); + 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("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("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. + 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(); + expect(res.body.error).toBe("missing_skyflow_credentials"); + } finally { + process.env.SKYFLOW_API_KEY = "sky-integration-dummy-key"; + } + }); + + 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 { + 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 new file mode 100644 index 0000000..edf4ab4 --- /dev/null +++ b/tests/unit/auth/accessTokens.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { decodeJwt, decodeProtectedHeader, SignJWT } 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("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, + 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(); + }); + + 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()", () => { + 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..fc2ac58 --- /dev/null +++ b/tests/unit/auth/config.test.ts @@ -0,0 +1,197 @@ +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(900); + 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/); + }); + + it("throws on a token TTL with trailing garbage", () => { + expect(() => + loadEnterpriseAuthConfig( + enabledEnv({ ENTERPRISE_TOKEN_TTL_SECONDS: "900abc" }) + ) + ).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..c3fec89 --- /dev/null +++ b/tests/unit/auth/idJag.test.ts @@ -0,0 +1,278 @@ +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 { + 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 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 }); + 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("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 }); + 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..23a91d5 --- /dev/null +++ b/tests/unit/auth/routes.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import type { Request, Response } from "express"; +import { + createAuthServerMetadataHandler, + createProtectedResourceMetadataHandler, + createTokenHandler, + createEnterpriseAuthRouter, +} 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: {}, + path: "/.well-known/oauth-protected-resource", + ...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"], + }); + }); + + 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()", () => { + 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; + + 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(900); + 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("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({ + 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/auth/scopes.test.ts b/tests/unit/auth/scopes.test.ts new file mode 100644 index 0000000..5d10fa3 --- /dev/null +++ b/tests/unit/auth/scopes.test.ts @@ -0,0 +1,75 @@ +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([]); + }); + + 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()", () => { + 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"); + }); + }); +}); 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..cf2c944 --- /dev/null +++ b/tests/unit/middleware/enterpriseAuth.test.ts @@ -0,0 +1,311 @@ +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); + // 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/mcp"` + ); + // 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 () => { + 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"'); + expect(captured.jsonBody.error).toBe("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 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 + 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", // gitleaks:allow + }, + }); + const { next } = await runMiddleware(enabledEnv(), req); + expect(next).toHaveBeenCalled(); + 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: { + 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("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(); + }); + + 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" }) // gitleaks:allow + ); + const req = createMockRequest({ + headers: { authorization: `Bearer ${issued.accessToken}` }, + }); + const { next, captured } = await runMiddleware(optionalEnv(), req); + expect(next).not.toHaveBeenCalled(); + expect(captured.statusCode).toBe(401); + }); + }); +}); diff --git a/tests/unit/middleware/rateLimiter.test.ts b/tests/unit/middleware/rateLimiter.test.ts index b77883b..f87d416 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, @@ -85,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 " }, @@ -354,3 +365,124 @@ 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/); + }); + + it("should throw on values with trailing garbage", () => { + expect(() => + getTokenEndpointRateLimitConfig({ + ENTERPRISE_TOKEN_RATE_LIMIT_REQUESTS: "30abc", + } 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 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, + 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(); + }); + }); +}); 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: {