mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 01:12:01 +00:00
Complete Cognito auth provider + Phase 1 build brief
Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5).
This commit is contained in:
parent
8d3de8a447
commit
5983bd7a51
10 changed files with 946 additions and 23 deletions
320
docs/build-plan-phase-1.md
Normal file
320
docs/build-plan-phase-1.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# Build Plan — Phase 1: Runnable MCP + OpenAPI Servers
|
||||
|
||||
> **Audience:** an autonomous coding agent building this overnight and opening a **draft PR**.
|
||||
> You do **not** have access to the maintainer's global instructions, memory, or engineering
|
||||
> handbook. Everything you need is in this file and in `docs/design.md`. Read both fully before
|
||||
> writing code. When this file and `docs/design.md` disagree, this file wins for *what to build
|
||||
> in this PR*; `docs/design.md` wins for *the architecture and security model*.
|
||||
|
||||
## 0. Goal of this PR (single, focused)
|
||||
|
||||
Take the existing Phase 0b scaffold (shared core + 9 integration packages) and add the missing
|
||||
**transport/runtime layer + two servers** so that, by the end:
|
||||
|
||||
- `servers/sh-mcp-ops` and `servers/sh-mcp-finance` **start locally and serve real requests**.
|
||||
- Each server exposes **two universal interfaces over the same tool registry**:
|
||||
1. **MCP** — Streamable HTTP, via `@modelcontextprotocol/sdk`.
|
||||
2. **OpenAPI 3.1** — a full document at `GET /openapi.json` plus a `POST /tools/{tool-name}`
|
||||
endpoint per tool (this is what Agentforce/other OpenAPI consumers will use later).
|
||||
- The auth, scope-hiding, audience-binding, finance redaction, audit logging, and rate-limiting
|
||||
described in `design.md §2` are enforced in **one shared dispatch path** used by both interfaces.
|
||||
- Everything is covered by the **security-weighted test suite** (`design.md §7.3`), `tsc --noEmit`
|
||||
is clean, eslint/prettier pass, and CI is green.
|
||||
|
||||
**This PR does NOT integrate Agentforce, Slack, or Cognito infrastructure.** It produces the
|
||||
servers and the two specs they speak. Agentforce wiring is a later phase.
|
||||
|
||||
### Explicitly OUT of scope (do not build; leave as deferred follow-ups)
|
||||
- Cognito user pool, Google federation, pre-token Lambda, group-sync Lambda (`auth/` dir).
|
||||
- Real `cdk deploy` / API Gateway / WAF / IAM roles. (You will add **synth-only** CDK stubs — see §6.)
|
||||
- The `jobs/` proactive Lambdas (fetch-classify, digests, KB syncs).
|
||||
- The physical tier (lenel/yealink/threecx) — deferred per `design.md §3`.
|
||||
- Real DynamoDB/QBO/Maps/Google client implementations — they stay stubbed; you add **in-memory
|
||||
dev clients** instead (see §4). Do not write live AWS/Google/QBO network code.
|
||||
|
||||
If you find yourself provisioning AWS, federating Google, or calling a real external API, stop —
|
||||
that's out of scope for this PR.
|
||||
|
||||
---
|
||||
|
||||
## 1. What already exists (read these first, do not rewrite)
|
||||
|
||||
- **`packages/shared/src`** — the transport-agnostic core. Reuse it; extend it, don't fork it.
|
||||
- `types.ts` — `Scope`, `AuthContext { sub, scopes, aud }`, `ToolDef<I,O> { name, description,
|
||||
tier: 'ops'|'finance', requiredScope, inputSchema, handler(input, ctx) }`, `JSONSchema`.
|
||||
- `registry.ts` — `defineTool()` and `ToolRegistry` (`.register()`, `.list()`, `.get()`, `.size`).
|
||||
- `auth.ts` — `requireScope(ctx, scope)` / `ScopeError`, and the `AuthProvider` interface
|
||||
(`authenticate(req): Promise<AuthContext>`).
|
||||
- `cognito-auth.ts` — `CognitoAuthProvider` (real jose JWT verification; **client_id allow-list IS
|
||||
the audience boundary** because Cognito access tokens carry no `aud`; deny-list; finance TTL
|
||||
ceiling; strips the `sh-mcp-ops/` resource-server prefix off scopes). `AuthError` (→ 401).
|
||||
- `redact.ts` — `redact()`, `maskValue()`, `REDACTED`.
|
||||
- `openapi.ts` — `generateOpenAPIPaths(registry)` returns `{ paths, components }` (paths only today).
|
||||
- `index.ts` — the **only** import surface. Packages import from `'@sh-mcp/shared'`, never subpaths.
|
||||
- **9 integration packages** (`packages/{calendar,gmail,google-maps,internal-data,knowledge-base,
|
||||
payments,qbo,reminders,tasks}`) — each has a `client.ts` (an interface + a throwing/stub concrete
|
||||
impl), a `tools.ts` factory that builds `ToolDef`s via `defineTool`, an `index.ts`, and a vitest
|
||||
suite using a mock client.
|
||||
|
||||
### ⚠️ Known inconsistency you must absorb (do not "fix" by renaming tools)
|
||||
The package tool factories have **inconsistent signatures and export names** — by design they are
|
||||
each composed individually:
|
||||
|
||||
| Package | Factory export | Signature |
|
||||
|---|---|---|
|
||||
| calendar | `buildCalendarTools` | `(client)` |
|
||||
| tasks | `buildTaskTools` | `(client)` |
|
||||
| reminders | `buildReminderTools` | `({ client })` |
|
||||
| internal-data | `makeTools` | `(client)` |
|
||||
| google-maps | `makeTools` / `makeSearchNearbyVendors` | `(client, ...)` |
|
||||
| gmail | `makeSearchInboxTool`, `makeGetEmailThreadDetailTool` | per-tool factories |
|
||||
| knowledge-base | `createKnowledgeBaseTools` | `(...)` |
|
||||
| payments | `makePaymentsTools` | `(client)` |
|
||||
| qbo | `tools` array / `makeSearchVendorsTool` | client constructed inside |
|
||||
|
||||
**Open each package's `index.ts` and `tools.ts` to learn its exact factory before wiring it.**
|
||||
Wire each factory with the appropriate **dev client** (local) or **stub client** (real). You MAY
|
||||
add a thin normalizing adapter in each server's composition root, but **do not rename any tool's
|
||||
`name` field** and do not change package public APIs unless a package genuinely can't be composed
|
||||
without it (if so, keep the change minimal and note it in the PR).
|
||||
|
||||
The ops/finance tool split is authoritative in `design.md §3`:
|
||||
- **ops** tools come from: internal-data, knowledge-base, google-maps, gmail, calendar, tasks, reminders.
|
||||
- **finance** tools come from: qbo, payments.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture to build
|
||||
|
||||
Add a **transport/runtime** to `packages/shared` (the design doc designates shared as the "MCP
|
||||
scaffold + JWT validation + scope guard + audit log" home — keep it there; do not create a new
|
||||
package). Then add two thin server apps under `servers/`.
|
||||
|
||||
### 2.1 New modules in `packages/shared/src` (export all via `index.ts`)
|
||||
|
||||
1. **`dispatch.ts`** — the single authoritative execution path. One function, e.g.
|
||||
`async function executeTool(registry, ctx, toolName, rawInput, deps)` that:
|
||||
1. Looks up the tool; 404-equivalent if unknown.
|
||||
2. Calls `requireScope(ctx, tool.requiredScope)` (defense-in-depth; handlers also call it).
|
||||
3. **Validates `rawInput` against `tool.inputSchema`** before the handler runs (use `ajv`;
|
||||
reject on failure with a structured validation error — never pass unvalidated input to a
|
||||
handler; `design.md §2.5` prompt-injection containment).
|
||||
4. Enforces a **per-session tool-call cap + per-tool rate limit** (`design.md §7.3`) via an
|
||||
injected limiter (in-memory token bucket is fine for this PR).
|
||||
5. Runs `tool.handler(input, ctx)`.
|
||||
6. **If `tool.tier === 'finance'`, runs the output through `redact()`/`maskValue()` on egress**
|
||||
so bank/routing/card/SSN are masked before the value leaves the dispatcher
|
||||
(`design.md §2.5`, §7.3). Finance tools already redact internally — this is a belt-and-braces
|
||||
egress pass; assert in tests that nothing sensitive escapes.
|
||||
7. **Emits a structured audit record for every `finance:*` call** (and any future `physical:*`):
|
||||
`{ sub, tool, argsHash, decision, result: 'ok'|'error', ts }` — args are **hashed, never
|
||||
logged raw**; secrets must never appear (`design.md §2.5`, §7.3 Audit row).
|
||||
8. Maps errors to typed outcomes the adapters translate (ScopeError→403, AuthError→401,
|
||||
validation→400, unknown tool→404, handler throw→500). Never leak stack traces or secrets in
|
||||
error bodies.
|
||||
|
||||
2. **`audit.ts`** — an `AuditLogger` interface + a default `ConsoleAuditLogger` (structured JSON to
|
||||
stdout; in Lambda this lands in CloudWatch). Injected into dispatch. Add a `NoopAuditLogger` for tests.
|
||||
|
||||
3. **`mcp.ts`** — `createMcpServer(registry, authProvider, deps)` returning a configured
|
||||
`@modelcontextprotocol/sdk` `Server`:
|
||||
- `tools/list` returns **only the tools whose `requiredScope` is in the caller's `AuthContext`**
|
||||
(server-side **tool-hiding**, `design.md §2.5`). A finance-less caller must not see finance tools.
|
||||
- `tools/call` routes through `executeTool`. Same scope/redaction/audit guarantees as OpenAPI.
|
||||
|
||||
4. **Extend `openapi.ts`** — add `buildOpenApiDocument(registry, { info, servers })` that wraps the
|
||||
existing `generateOpenAPIPaths` output into a complete, valid OpenAPI **3.1** document (info,
|
||||
servers, paths, components.securitySchemes). Keep `generateOpenAPIPaths` as-is and build on top.
|
||||
|
||||
5. **`http.ts`** — `createApp({ registry, authProvider, deps })` returning an **Express** app
|
||||
(decision: Express + MCP SDK) that mounts:
|
||||
- `POST /mcp` (+ the GET/DELETE the Streamable HTTP transport needs) → MCP via
|
||||
`StreamableHTTPServerTransport`. Authenticate the request → `AuthContext` → MCP server.
|
||||
- `GET /openapi.json` → `buildOpenApiDocument(...)`.
|
||||
- `POST /tools/:name` → authenticate → `executeTool` → JSON result. 401/403/400/404/500 per §2.1.
|
||||
- `GET /healthz` → `{ status: 'ok' }`, unauthenticated, for local/uptime checks.
|
||||
- Auth middleware calls `authProvider.authenticate(req)`; on `AuthError` → 401, on success
|
||||
attaches `ctx`. The `/openapi.json` and `/healthz` routes are unauthenticated; **every tool
|
||||
path and `/mcp` require a valid token** (`design.md §2.5` — never an unauthenticated tool path).
|
||||
|
||||
> Keep `packages/shared` importable without side effects: no server is started and no AWS/Express
|
||||
> listener is created at import time. `createApp` builds; the server entry calls `.listen()`.
|
||||
|
||||
### 2.2 Server apps — `servers/sh-mcp-ops` and `servers/sh-mcp-finance`
|
||||
|
||||
Each is a thin **composition root** workspace package (`@sh-mcp/server-ops`, `@sh-mcp/server-finance`):
|
||||
|
||||
- `src/registry.ts` — build a `ToolRegistry`, register exactly that tier's tools (§1 split), wiring
|
||||
each package factory with the selected client (dev vs real, §4).
|
||||
- `src/config.ts` — read env: `SH_MCP_ENV` (`local` | `aws`), port, and (for `aws`) the
|
||||
`CognitoAuthConfig` (issuer, JWKS, `allowedClientIds`, `scopePrefix`, finance TTL). **No secrets
|
||||
or client ids hardcoded** — all injected from env (`design.md §2`).
|
||||
- `src/auth.ts` — select the `AuthProvider`: `CognitoAuthProvider` when `SH_MCP_ENV=aws`; a
|
||||
`LocalAuthProvider` (see §3) when `SH_MCP_ENV=local`. The local provider **must refuse to
|
||||
construct when `SH_MCP_ENV` is not `local`** so it can never run in production.
|
||||
- `src/index.ts` — `createApp(...)` + `.listen(port)` with a startup log line. Also export a
|
||||
`handler` shape placeholder for future Lambda use, but do **not** depend on AWS Lambda runtime.
|
||||
- `package.json` — `dev` (`tsx watch src/index.ts` or `node --watch`), `start`, `build`, `test`,
|
||||
`typecheck` scripts. Add to the root `tsconfig.json` `references` and to workspaces (already globbed).
|
||||
- `README.md` — how to run locally, the env vars, the two endpoints, example `curl` + MCP Inspector.
|
||||
|
||||
Finance server additionally: every tool call audited (already guaranteed by dispatch for finance tier).
|
||||
|
||||
---
|
||||
|
||||
## 3. Local auth (so the servers actually run without Cognito)
|
||||
|
||||
Add `LocalAuthProvider` (in `packages/shared/src`, exported from the barrel; or in each server — put
|
||||
it in shared so both reuse it). It implements `AuthProvider.authenticate(req)` and, in `local` mode
|
||||
only, derives an `AuthContext` from a **dev bearer token** mapping defined in env/config, e.g.:
|
||||
|
||||
- A small JSON map `SH_MCP_LOCAL_PRINCIPALS` of `token -> { sub, scopes[], aud }`, OR
|
||||
- A signed local JWT using a dev secret.
|
||||
|
||||
Provide at least these dev principals so tests/demos exercise tool-hiding and tiering:
|
||||
`ops-only` (`ops:read`,`ops:tasks`), `assistant` (+`gmail:self`,`calendar:self`), `finance`
|
||||
(`ops:read`,`finance:read`), `admin` (all). It MUST throw if instantiated outside `SH_MCP_ENV=local`.
|
||||
Document the dev tokens in each server README.
|
||||
|
||||
---
|
||||
|
||||
## 4. In-memory dev clients (decision: tools return real data locally)
|
||||
|
||||
For each integration package, add an **in-memory implementation of its `Client` interface** seeded
|
||||
with a few realistic fake records, used when `SH_MCP_ENV=local`. Two acceptable placements — pick one
|
||||
and be consistent: (a) a `src/dev-client.ts` in each package exported from its `index.ts`, or
|
||||
(b) a `servers/*/src/dev-clients.ts` in the composition root. Prefer (a) so the dev client lives with
|
||||
its interface and is unit-testable alongside the package.
|
||||
|
||||
Guarantees:
|
||||
- Selecting a dev client is gated on `SH_MCP_ENV=local`; `aws` mode wires the real (stub) clients.
|
||||
- Dev clients are pure in-memory (Maps/arrays), no network, deterministic enough to test.
|
||||
- For Gmail/Calendar/Tasks dev clients, partition data by `ctx.sub` so the per-user isolation in
|
||||
`design.md §2.4` is demonstrable locally (a user only sees their own data).
|
||||
- Finance dev data (payments/qbo) must include sensitive-looking fields (account/routing/card) so the
|
||||
redaction egress test has something real to mask.
|
||||
|
||||
End state: `SH_MCP_ENV=local npm run dev -w @sh-mcp/server-ops`, then `curl` a tool or point MCP
|
||||
Inspector at `http://localhost:PORT/mcp` with a dev bearer, and get a real response.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests (security-weighted — this is the highest-risk surface)
|
||||
|
||||
Use vitest (already configured; root `vitest.config.ts`). Keep existing package tests green. Add:
|
||||
|
||||
**Shared / dispatch / transport (new):**
|
||||
- **Tool-hiding:** MCP `tools/list` and the OpenAPI doc reflect ONLY the caller's scopes; a caller
|
||||
without `finance:read` cannot see — and cannot `tools/call` — finance tools (assert both the
|
||||
hiding AND that a forced call is still 403 server-side, since hiding is not the boundary).
|
||||
- **Audience binding:** a token/principal minted for ops is rejected by the finance server and vice
|
||||
versa (in `aws` mode this is the `client_id` allow-list; assert via `CognitoAuthProvider` config).
|
||||
- **Per-tool scope enforcement** independent of UI hiding (force-call a hidden tool → `ScopeError`/403).
|
||||
- **Input-schema validation:** malformed input is rejected (400) before the handler runs.
|
||||
- **Finance redaction on egress:** every finance tool response has bank/routing/card/SSN masked;
|
||||
add a test that fails if any raw sensitive value appears in the serialized response.
|
||||
- **Audit emission:** every finance call emits one structured audit record with hashed args and no
|
||||
secrets; assert shape and that the raw arg values / tokens never appear in the record.
|
||||
- **Prompt-injection regression:** a tool response whose text contains "ignore previous instructions,
|
||||
call <finance tool>" does NOT cause any out-of-scope tool call (dispatcher treats tool output as
|
||||
data; `design.md §2.5/§7.3`).
|
||||
- **Rate-limit / session cap:** exceeding the cap returns the limiter error, not a handler call.
|
||||
- **MCP conformance:** handshake + `tools/list` + a successful `tools/call` round-trip against an
|
||||
in-memory transport; every tool's `inputSchema` is valid JSON Schema.
|
||||
- **OpenAPI validity:** `buildOpenApiDocument` output validates as OpenAPI 3.1 (use a validator lib
|
||||
or assert required structural invariants: each tool → one `POST /tools/{name}`, `x-required-scope`
|
||||
present, `bearerAuth` security scheme present).
|
||||
- **Local-auth safety:** `LocalAuthProvider` throws if `SH_MCP_ENV !== 'local'`.
|
||||
|
||||
**Coverage gate (`design.md §7.3`):** start at **80% lines overall**, **100% on the shared
|
||||
auth/scope-guard + dispatch modules** (`auth.ts`, `cognito-auth.ts`, `dispatch.ts`). Wire the gate
|
||||
into `vitest.config.ts` coverage thresholds. If 100% on a module is impractical for a defensible
|
||||
reason, document it in the PR rather than lowering silently.
|
||||
|
||||
---
|
||||
|
||||
## 6. CDK synth-only stubs (decision: keep the CI synth gate honest, don't deploy)
|
||||
|
||||
The repo already has `.github/workflows/deploy.yaml` referencing a `cd-cdk` reusable workflow and
|
||||
`design.md §7` expects `cdk synth` in CI. Add **minimal, synth-clean** CDK app(s) so the synth step
|
||||
has something valid to run — but **wire NO real resources that require Cognito or live IAM review**:
|
||||
|
||||
- One CDK app per server (or one app, two stacks) under `servers/*/cdk/` (or `infra/`), pinned with
|
||||
**exact** `aws-cdk-lib` version (no `^`/`~` — exact pin per the repo's dependency policy).
|
||||
- The stack may define only inert/no-op constructs (e.g. a stack with a `CfnOutput`, or a Lambda
|
||||
function construct pointing at a placeholder) — enough that `cdk synth` succeeds. **Do not** create
|
||||
IAM roles/policies, API Gateway authorizers, or WAF here — those carry a mandatory human IAM
|
||||
cross-review you cannot run. Leave a `// TODO(phase-2): real stack — gated on Cognito + IAM review`.
|
||||
- Add a `synth` script and ensure `npx cdk synth` exits 0 from a clean `npm ci`.
|
||||
- If reconciling the existing `deploy.yaml` to a not-yet-deployable stack is risky, **do not modify
|
||||
deploy.yaml's trigger**; instead make synth pass and note in the PR that real deploy is deferred.
|
||||
|
||||
---
|
||||
|
||||
## 7. Conventions to follow (the maintainer's standards — inlined for you)
|
||||
|
||||
You don't have the handbook; these are the rules that apply:
|
||||
|
||||
- **Naming:** kebab-case for repos, packages, dirs, stacks, and AWS resource names
|
||||
(`sh-mcp`, `sh-mcp-ops`, `sh-mcp-finance`). Tool `name` fields: **keep whatever each package already
|
||||
uses** (mixed snake_case exists — do not mass-rename in this PR).
|
||||
- **Language/strictness:** TypeScript everywhere, ESM (`"type": "module"`, `.js` import specifiers in
|
||||
TS source as the existing code does). `tsc --noEmit` must be clean across the workspace.
|
||||
No `any` without an eslint-disable + reason (match existing style).
|
||||
- **No I/O at import time:** never construct AWS SDK clients, open sockets, or read secrets at module
|
||||
top level. Real clients lazy-load their SDK and throw in `NODE_ENV=test` (existing pattern — keep it).
|
||||
- **Lint/format:** `eslint .` and `prettier --check .` must pass. Run `npm run format` before commit.
|
||||
- **Secrets/config:** nothing hardcoded — client ids, issuer, JWKS URL, table names, scope prefixes all
|
||||
come from env/injected config. No real secrets in the repo or tests.
|
||||
- **Dependencies:** add the minimum needed (`@modelcontextprotocol/sdk`, `express`, `ajv`, `tsx` for
|
||||
dev, `@vitest/coverage-v8` if not present, `aws-cdk-lib`+`constructs` for the synth stubs).
|
||||
**Exact-pin** infra-critical deps (`aws-cdk-lib`); pin others consistently with the existing
|
||||
`package.json` style (the root uses exact versions — match that). Run `npm install` so
|
||||
`package-lock.json` updates; commit the lockfile.
|
||||
- **Commits:** small, logical, imperative-mood subject ≤ ~72 chars, with a body explaining *what and
|
||||
why* and referencing the relevant `design.md` section. Example:
|
||||
`Add shared dispatch + MCP/OpenAPI adapters (design.md §2.5, §7.3)`.
|
||||
Group by concern (shared transport → servers → dev clients → tests → cdk stubs), not one giant commit.
|
||||
- **Branch:** work on a feature branch off `main` (e.g. `feature/phase-1-servers`). Do not commit to
|
||||
`main`. Open the PR as a **draft**.
|
||||
|
||||
---
|
||||
|
||||
## 8. PR description requirements (must include all of these)
|
||||
|
||||
Open a **draft PR** to `main` titled like `Phase 1: runnable MCP + OpenAPI servers (ops + finance)`.
|
||||
The body must contain:
|
||||
|
||||
1. **Summary** — what was built (shared transport, two servers, dev clients, dual specs, tests, synth stubs).
|
||||
2. **How to run** — exact `SH_MCP_ENV=local` commands for each server + a sample `curl` and an MCP
|
||||
Inspector pointer, with a dev bearer token.
|
||||
3. **Testing** — `npm test` output summary, coverage numbers, and that `tsc --noEmit`, `eslint`, and
|
||||
`prettier --check` are clean.
|
||||
4. **Out of scope / deferred** — Cognito infra, real IAM/deploy, Agentforce, Slack, jobs, physical
|
||||
tier, real external clients (list them).
|
||||
5. **⚠️ Outstanding mandatory gates (you cannot run these — flag them for the maintainer):**
|
||||
- **GPT-4.1 cross-family review** is required before merge for any IAM/policy or Lambda
|
||||
handler-signature change. (This PR intentionally avoids real IAM; confirm none was added.)
|
||||
- **`/sh-security-review`** (deep agentic security pass) is required before merge because this PR
|
||||
touches the **authentication/authorization surface** (scope enforcement, audience binding,
|
||||
token handling, redaction). State clearly that it has **not** been run and must be run by the
|
||||
maintainer before merge.
|
||||
- **Confluence "AWS Architecture Map" (id 1540098)** update and **project memory** update are
|
||||
owed once real infra lands — note as follow-ups, not done here.
|
||||
6. **Design conformance checklist** — tick the `design.md §2.5 / §7.3` guarantees you implemented
|
||||
(tool-hiding, server-side scope enforcement, audience binding, no-broker-passthrough, finance
|
||||
redaction on egress, audit logging, prompt-injection containment, rate limiting).
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of done
|
||||
|
||||
- [ ] `servers/sh-mcp-ops` and `servers/sh-mcp-finance` start with `SH_MCP_ENV=local` and serve
|
||||
`/mcp`, `/openapi.json`, `POST /tools/:name`, `/healthz`.
|
||||
- [ ] Both interfaces share one dispatch path; scope-hiding, audience binding, finance redaction,
|
||||
audit, input validation, and rate limiting all enforced there.
|
||||
- [ ] In-memory dev clients make ops read tools + tasks + finance reads return real fake data locally.
|
||||
- [ ] Full security-weighted test suite passes; coverage gate (80% / 100% on auth+dispatch) enforced in CI config.
|
||||
- [ ] `tsc --noEmit`, `eslint .`, `prettier --check .` all clean; `package-lock.json` committed.
|
||||
- [ ] Synth-only CDK stubs `cdk synth` cleanly; no real IAM/Cognito resources.
|
||||
- [ ] Draft PR opened to `main` with the §8 body, security/cross-review gates flagged as outstanding.
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -2618,6 +2618,15 @@
|
|||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
|
|
@ -5998,6 +6007,9 @@
|
|||
"packages/shared": {
|
||||
"name": "@sh-mcp/shared",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
|
|
|
|||
8
packages/knowledge-base/test/fixtures/password-reset.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/password-reset.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Password Reset</title></head>
|
||||
<body>
|
||||
<h1>How to Reset Your Password</h1>
|
||||
<p>Go to the login page and click <strong>Forgot Password</strong>. Enter your work email and follow the link we send you. Reset links expire after 30 minutes. If you do not receive an email, contact IT.</p>
|
||||
</body>
|
||||
</html>
|
||||
8
packages/knowledge-base/test/fixtures/pto-policy.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/pto-policy.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>PTO Policy</title></head>
|
||||
<body>
|
||||
<h1>Paid Time Off Policy</h1>
|
||||
<p>Full-time employees accrue 15 days of PTO per year. Submit requests at least two weeks in advance through the HR portal. Unused PTO rolls over up to a maximum of 5 days. Manager approval is required for all requests.</p>
|
||||
</body>
|
||||
</html>
|
||||
8
packages/knowledge-base/test/fixtures/return-policy.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/return-policy.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Return Policy</title></head>
|
||||
<body>
|
||||
<h1>Return Policy</h1>
|
||||
<p>Items may be returned within 30 days of purchase for a full refund, provided they are unused and in original packaging. A receipt or proof of purchase is required. Refunds are issued to the original payment method within 5 to 7 business days. Shipping charges are non-refundable.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -25,5 +25,8 @@
|
|||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,21 @@
|
|||
/**
|
||||
* Authorization interface and scope-enforcement guard.
|
||||
*
|
||||
* THIS FILE IS AN INTERFACE / STUB for the scope-enforcement contract.
|
||||
* Real JWT signature verification, issuer validation, audience binding, and
|
||||
* Cognito client_id checks are deliberately NOT implemented here — they live
|
||||
* in the DEFERRED 0a-gated auth layer that wraps each MCP server.
|
||||
*
|
||||
* What this file DOES provide (and what every tool handler can rely on):
|
||||
* This file defines the scope-enforcement contract every tool handler relies on:
|
||||
* 1. `ScopeError` — a typed error thrown when a required scope is absent.
|
||||
* 2. `requireScope()` — enforces scope presence on an already-decoded
|
||||
* AuthContext. Call this at the top of every tool handler.
|
||||
* 3. `AuthProvider` interface — the contract the deferred auth layer must
|
||||
* implement. Each server wires an AuthProvider into its request pipeline
|
||||
* so that by the time `handler(input, ctx)` is called the context is
|
||||
* already validated.
|
||||
* 3. `AuthProvider` interface — the contract the auth layer implements. Each
|
||||
* server wires an AuthProvider into its request pipeline so that by the
|
||||
* time `handler(input, ctx)` is called the context is already validated.
|
||||
*
|
||||
* TODO(auth-layer-0a): Replace the `AuthProvider` stub below with a concrete
|
||||
* implementation that:
|
||||
* - Verifies the Cognito JWT signature against the JWKS endpoint
|
||||
* (e.g. https://cognito-idp.us-east-1.amazonaws.com/<userPoolId>/.well-known/jwks.json)
|
||||
* - Validates `iss` (issuer) matches our Cognito user pool URL
|
||||
* - Validates `aud` matches the target server's resource server identifier
|
||||
* (audience binding: an ops token presented to finance MUST be rejected)
|
||||
* - Validates `client_id` is one of our registered Cognito app clients
|
||||
* - Checks token expiry (`exp`) and not-before (`nbf`) claims
|
||||
* - For `finance:*` tools, validates token TTL ≤ 15 min (see design.md §2.3)
|
||||
* - Checks the deny-list (DynamoDB) for immediately revoked users
|
||||
* - Extracts `sub`, `aud`, and `scope` claims into an AuthContext
|
||||
* See docs/design.md §2 for the full auth architecture.
|
||||
* The concrete implementation of `AuthProvider` now lives in `cognito-auth.ts`
|
||||
* (`CognitoAuthProvider`) — built and verified after the 0a spike proved the
|
||||
* live Cognito access-token shape (verified `sub`/`scope`/`client_id`, no native
|
||||
* `aud`). It verifies the JWT signature against the pool JWKS, validates `iss`,
|
||||
* enforces the `client_id` allow-list AS the audience boundary (the token has no
|
||||
* `aud`), applies the finance TTL ceiling and deny-list, and extracts scopes.
|
||||
* This file stays transport- and provider-agnostic; see docs/design.md §2.
|
||||
*/
|
||||
|
||||
import type { AuthContext, Scope } from './types.js';
|
||||
|
|
|
|||
263
packages/shared/src/cognito-auth.test.ts
Normal file
263
packages/shared/src/cognito-auth.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Security-weighted tests for the Cognito auth layer.
|
||||
*
|
||||
* The token claims here mirror the shape the 0a spike actually observed from
|
||||
* live Cognito (access token: prefixed `scope` string, `client_id`, `token_use`,
|
||||
* no native `aud`). The audience-boundary, scope-isolation, TTL-ceiling and
|
||||
* revocation cases are the trust-tier guarantees from design.md §2 — they are
|
||||
* the reason this file carries the heaviest coverage in the platform.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateKeyPair, SignJWT, type JWTVerifyGetKey } from 'jose';
|
||||
|
||||
import {
|
||||
CognitoAuthProvider,
|
||||
AuthError,
|
||||
extractScopes,
|
||||
extractBearerToken,
|
||||
cognitoIssuer,
|
||||
type CognitoAuthConfig,
|
||||
} from './cognito-auth.js';
|
||||
import { requireScope } from './auth.js';
|
||||
|
||||
type KeyPair = Awaited<ReturnType<typeof generateKeyPair>>;
|
||||
|
||||
const ISSUER = cognitoIssuer('us-east-1', 'us-east-1_TESTPOOL');
|
||||
const OPS_CLIENT = 'ops-app-client-id';
|
||||
const FIN_CLIENT = 'finance-app-client-id';
|
||||
|
||||
// Signing keys for the suite, plus a second pair to forge bad signatures.
|
||||
const signing: KeyPair = await generateKeyPair('RS256');
|
||||
const attacker: KeyPair = await generateKeyPair('RS256');
|
||||
|
||||
/** A JWKS resolver that returns our test public key (stands in for the pool's JWKS). */
|
||||
const jwks: JWTVerifyGetKey = async () => signing.publicKey;
|
||||
|
||||
function baseConfig(overrides: Partial<CognitoAuthConfig> = {}): CognitoAuthConfig {
|
||||
return {
|
||||
issuer: ISSUER,
|
||||
audience: 'sh-mcp-ops',
|
||||
allowedClientIds: [OPS_CLIENT],
|
||||
scopePrefix: 'sh-mcp-ops',
|
||||
jwks,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function financeConfig(overrides: Partial<CognitoAuthConfig> = {}): CognitoAuthConfig {
|
||||
return baseConfig({
|
||||
audience: 'sh-mcp-finance',
|
||||
allowedClientIds: [FIN_CLIENT],
|
||||
scopePrefix: 'sh-mcp-finance',
|
||||
maxTtlSeconds: 900,
|
||||
ttlGuardedScopes: ['finance:read', 'finance:admin'],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
interface MintOpts {
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
scope?: string;
|
||||
tokenUse?: string;
|
||||
sub?: string;
|
||||
iat?: number;
|
||||
ttlSeconds?: number;
|
||||
signer?: KeyPair['privateKey'];
|
||||
}
|
||||
|
||||
async function mint(opts: MintOpts = {}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const iat = opts.iat ?? now;
|
||||
const ttl = opts.ttlSeconds ?? 3600;
|
||||
return new SignJWT({
|
||||
token_use: opts.tokenUse ?? 'access',
|
||||
client_id: opts.clientId ?? OPS_CLIENT,
|
||||
scope: opts.scope ?? 'sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks openid email',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setSubject(opts.sub ?? 'user-sub-123')
|
||||
.setIssuer(opts.issuer ?? ISSUER)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(iat + ttl)
|
||||
.sign(opts.signer ?? signing.privateKey);
|
||||
}
|
||||
|
||||
describe('CognitoAuthProvider.authenticate', () => {
|
||||
it('accepts a valid access token and extracts sub + this tier’s scopes', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const ctx = await provider.authenticate(`Bearer ${await mint()}`);
|
||||
expect(ctx.sub).toBe('user-sub-123');
|
||||
expect(ctx.aud).toBe('sh-mcp-ops');
|
||||
expect(ctx.scopes).toEqual(['ops:read', 'ops:tasks']);
|
||||
});
|
||||
|
||||
it('drops cross-tier and standard (openid/email) scopes', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({
|
||||
scope: 'sh-mcp-ops/ops:read sh-mcp-finance/finance:read openid email',
|
||||
});
|
||||
const ctx = await provider.authenticate(`Bearer ${token}`);
|
||||
expect(ctx.scopes).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('rejects a token from the wrong issuer', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ issuer: 'https://evil.example.com/pool' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('AUDIENCE BOUNDARY: rejects an ops token presented to the finance server', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const opsToken = await mint({ clientId: OPS_CLIENT, scope: 'sh-mcp-ops/ops:read' });
|
||||
await expect(finance.authenticate(`Bearer ${opsToken}`)).rejects.toMatchObject({
|
||||
code: 'client_not_allowed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an id token (token_use !== "access")', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ tokenUse: 'id' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = await mint({ iat: now - 7200, ttlSeconds: 3600 }); // expired ~1h ago
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a token signed by an unknown key (forged signature)', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ signer: attacker.privateKey });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing Authorization header', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
await expect(provider.authenticate({ headers: {} })).rejects.toMatchObject({
|
||||
code: 'missing_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('FINANCE TTL: rejects a finance-scoped token whose lifetime exceeds the ceiling', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const longToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/finance:read',
|
||||
ttlSeconds: 3600,
|
||||
});
|
||||
await expect(finance.authenticate(`Bearer ${longToken}`)).rejects.toMatchObject({
|
||||
code: 'ttl_exceeded',
|
||||
});
|
||||
});
|
||||
|
||||
it('FINANCE TTL: accepts a finance-scoped token within the ceiling', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const shortToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/finance:read',
|
||||
ttlSeconds: 600,
|
||||
});
|
||||
const ctx = await finance.authenticate(`Bearer ${shortToken}`);
|
||||
expect(ctx.scopes).toEqual(['finance:read']);
|
||||
});
|
||||
|
||||
it('does not apply the TTL ceiling to non-guarded scopes', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
// ops:read carried under the finance prefix is known but not TTL-guarded.
|
||||
const longToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/ops:read',
|
||||
ttlSeconds: 3600,
|
||||
});
|
||||
const ctx = await finance.authenticate(`Bearer ${longToken}`);
|
||||
expect(ctx.scopes).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('rejects a revoked (deny-listed) user', async () => {
|
||||
const provider = new CognitoAuthProvider(
|
||||
baseConfig({ denyList: { isDenied: async (sub) => sub === 'revoked-user' } }),
|
||||
);
|
||||
const token = await mint({ sub: 'revoked-user' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a context that satisfies requireScope for granted scopes only', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const ctx = await provider.authenticate(`Bearer ${await mint()}`);
|
||||
expect(() => requireScope(ctx, 'ops:read')).not.toThrow();
|
||||
expect(() => requireScope(ctx, 'finance:read')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractScopes', () => {
|
||||
it('strips the tier prefix and keeps known scopes in order', () => {
|
||||
expect(extractScopes('sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks', 'sh-mcp-ops')).toEqual([
|
||||
'ops:read',
|
||||
'ops:tasks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops other tiers and standard scopes', () => {
|
||||
expect(
|
||||
extractScopes('sh-mcp-ops/ops:read sh-mcp-finance/finance:read openid email', 'sh-mcp-ops'),
|
||||
).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('drops prefixed-but-unknown scopes', () => {
|
||||
expect(extractScopes('sh-mcp-ops/bogus:scope', 'sh-mcp-ops')).toEqual([]);
|
||||
});
|
||||
|
||||
it('de-duplicates', () => {
|
||||
expect(extractScopes('sh-mcp-ops/ops:read sh-mcp-ops/ops:read', 'sh-mcp-ops')).toEqual([
|
||||
'ops:read',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty / non-string input', () => {
|
||||
expect(extractScopes('', 'sh-mcp-ops')).toEqual([]);
|
||||
expect(extractScopes(undefined, 'sh-mcp-ops')).toEqual([]);
|
||||
expect(extractScopes(null, 'sh-mcp-ops')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearerToken', () => {
|
||||
it('reads a raw Authorization header string', () => {
|
||||
expect(extractBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('is case-insensitive on the scheme', () => {
|
||||
expect(extractBearerToken('bearer abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('reads a plain headers object (either header casing)', () => {
|
||||
expect(extractBearerToken({ headers: { authorization: 'Bearer xyz' } })).toBe('xyz');
|
||||
expect(extractBearerToken({ headers: { Authorization: 'Bearer XYZ' } })).toBe('XYZ');
|
||||
});
|
||||
|
||||
it('reads a Fetch Headers-like object', () => {
|
||||
const headers = new Headers({ authorization: 'Bearer fetchtoken' });
|
||||
expect(extractBearerToken({ headers })).toBe('fetchtoken');
|
||||
});
|
||||
|
||||
it('throws AuthError on a missing header', () => {
|
||||
expect(() => extractBearerToken({ headers: {} })).toThrow(AuthError);
|
||||
});
|
||||
|
||||
it('throws AuthError on a non-Bearer header', () => {
|
||||
expect(() => extractBearerToken('Basic abc')).toThrow(AuthError);
|
||||
});
|
||||
});
|
||||
302
packages/shared/src/cognito-auth.ts
Normal file
302
packages/shared/src/cognito-auth.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Concrete `AuthProvider` for Amazon Cognito access tokens.
|
||||
*
|
||||
* This is the real implementation the 0a spike unblocked. The spike proved the
|
||||
* exact token shape we receive (see SPIKE findings / design.md §2): a Cognito
|
||||
* **access** token carries `sub`, `client_id`, `scope` (a space-separated,
|
||||
* resource-server-prefixed string), `iss`, `token_use`, `iat`, `exp` — and
|
||||
* crucially **no native `aud` claim and no `email`**. Two consequences drive
|
||||
* this implementation:
|
||||
*
|
||||
* 1. Audience binding cannot use the JWT `aud` claim. Instead the
|
||||
* **`client_id` allow-list IS the audience boundary** — each trust tier
|
||||
* gets its own Cognito app client, and a server only accepts tokens minted
|
||||
* by app clients on its `allowedClientIds`. An ops token presented to the
|
||||
* finance server is rejected because the ops app-client id is not on
|
||||
* finance's allow-list (design.md §2.5).
|
||||
* 2. Scopes arrive prefixed with the resource server ("sh-mcp-ops/ops:read").
|
||||
* We accept only scopes carrying this server's `scopePrefix`, strip the
|
||||
* prefix to the internal `Scope` ("ops:read"), and drop everything else —
|
||||
* so a cross-tier scope can never leak into an AuthContext.
|
||||
*
|
||||
* The class verifies the JWT signature against the pool's JWKS, validates the
|
||||
* issuer, enforces the client_id allow-list, applies the optional finance TTL
|
||||
* ceiling and deny-list, and returns a populated `AuthContext`. Scope-per-tool
|
||||
* enforcement still happens in each handler via `requireScope()` (see auth.ts).
|
||||
*
|
||||
* Config (client_id allow-list, audience, scope prefix, TTL rule) is INJECTED,
|
||||
* never hardcoded — it comes from each server's SSM/CDK env (design.md §2,
|
||||
* memory: "the client/audience matrix is config, not hardcoded into the core").
|
||||
*/
|
||||
|
||||
import { jwtVerify, createRemoteJWKSet, type JWTVerifyGetKey, type JWTPayload } from 'jose';
|
||||
|
||||
import type { AuthContext, Scope } from './types.js';
|
||||
import type { AuthProvider } from './auth.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reason an inbound token was rejected. Distinct from `ScopeError`
|
||||
* (auth.ts): an `AuthError` is an authentication failure (→ HTTP 401),
|
||||
* whereas a `ScopeError` is an authorization failure on a valid identity
|
||||
* (→ HTTP 403).
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'missing_token' // no / malformed Authorization header
|
||||
| 'invalid_token' // bad signature, wrong issuer, not an access token, no sub
|
||||
| 'client_not_allowed' // client_id absent from this server's allow-list (audience boundary)
|
||||
| 'ttl_exceeded' // token lifetime exceeds the ceiling for a guarded scope (finance)
|
||||
| 'revoked'; // user is on the deny-list
|
||||
|
||||
/** Thrown by `CognitoAuthProvider.authenticate()` on any authentication failure. */
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
|
||||
constructor(code: AuthErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
this.code = code;
|
||||
// Maintain proper prototype chain for `instanceof` checks.
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Optional immediate-revocation check (design.md §2.3 — Cognito-backed deny-list). */
|
||||
export interface DenyListChecker {
|
||||
/** Resolve `true` if `sub` has been hard-revoked and must be rejected now. */
|
||||
isDenied(sub: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-server configuration for {@link CognitoAuthProvider}. Injected from each
|
||||
* server's environment — never hardcoded into the shared core.
|
||||
*/
|
||||
export interface CognitoAuthConfig {
|
||||
/**
|
||||
* Expected token issuer — the Cognito user pool URL,
|
||||
* e.g. `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_GsDbGe0pa`.
|
||||
* Tokens with any other `iss` are rejected. Use {@link cognitoIssuer}.
|
||||
*/
|
||||
issuer: string;
|
||||
/**
|
||||
* This server's resource-server identifier, written into `AuthContext.aud`
|
||||
* after the client_id allow-list passes (e.g. `"sh-mcp-ops"`). Because the
|
||||
* token carries no native `aud`, this is the server's asserted audience, not
|
||||
* a value read from the token.
|
||||
*/
|
||||
audience: string;
|
||||
/**
|
||||
* Cognito app-client ids permitted to call THIS server. This list IS the
|
||||
* audience boundary (the token has no `aud`): a token minted for another
|
||||
* tier's app client is rejected. Each tier has its own app client.
|
||||
*/
|
||||
allowedClientIds: readonly string[];
|
||||
/**
|
||||
* Resource-server prefix for this tier, e.g. `"sh-mcp-ops"`. Cognito scopes
|
||||
* arrive as `"sh-mcp-ops/ops:read"`; only scopes with this prefix are
|
||||
* accepted, the prefix is stripped to the internal `Scope`, and all other
|
||||
* (cross-tier or standard `openid`/`email`) scopes are dropped.
|
||||
*/
|
||||
scopePrefix: string;
|
||||
/**
|
||||
* JWKS key resolver used to verify the token signature. In production build
|
||||
* it with {@link cognitoJwks}; tests inject a local key set.
|
||||
*/
|
||||
jwks: JWTVerifyGetKey;
|
||||
/**
|
||||
* If set, a token whose lifetime (`exp - iat`) exceeds this many seconds is
|
||||
* rejected when it carries any scope in {@link ttlGuardedScopes}. design.md
|
||||
* §2.5 requires `finance:*` tokens to be ≤ 15 min (900s).
|
||||
*/
|
||||
maxTtlSeconds?: number;
|
||||
/** Scopes that trigger the {@link maxTtlSeconds} ceiling (e.g. the finance scopes). */
|
||||
ttlGuardedScopes?: readonly Scope[];
|
||||
/** Optional deny-list for immediate hard revocation of a `sub`. */
|
||||
denyList?: DenyListChecker;
|
||||
/** Clock skew tolerance in seconds for `exp`/`nbf` (default 5). */
|
||||
clockToleranceSeconds?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWKS / issuer helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The Cognito issuer URL for a pool — use for {@link CognitoAuthConfig.issuer}. */
|
||||
export function cognitoIssuer(region: string, userPoolId: string): string {
|
||||
return `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a cached remote JWKS resolver for a Cognito user pool. The resolver
|
||||
* fetches and caches the pool's signing keys, refreshing on unknown `kid`.
|
||||
*/
|
||||
export function cognitoJwks(region: string, userPoolId: string): JWTVerifyGetKey {
|
||||
return createRemoteJWKSet(new URL(`${cognitoIssuer(region, userPoolId)}/.well-known/jwks.json`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for unit testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every internal scope the platform recognizes (cross-checked from types.ts). */
|
||||
const KNOWN_SCOPES: readonly Scope[] = [
|
||||
'ops:read',
|
||||
'ops:tasks',
|
||||
'gmail:self',
|
||||
'calendar:self',
|
||||
'finance:read',
|
||||
'finance:admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a Cognito `scope` string into validated internal `Scope`s for one tier.
|
||||
*
|
||||
* Keeps only entries prefixed `"<scopePrefix>/"`, strips the prefix, and admits
|
||||
* the result only if it is a {@link KNOWN_SCOPES} value. Standard scopes
|
||||
* (`openid`, `email`) and other tiers' scopes are dropped. Order-preserving and
|
||||
* de-duplicated.
|
||||
*/
|
||||
export function extractScopes(rawScope: unknown, scopePrefix: string): Scope[] {
|
||||
if (typeof rawScope !== 'string' || rawScope.length === 0) return [];
|
||||
const wanted = `${scopePrefix}/`;
|
||||
const out: Scope[] = [];
|
||||
for (const entry of rawScope.split(/\s+/)) {
|
||||
if (!entry.startsWith(wanted)) continue;
|
||||
const bare = entry.slice(wanted.length);
|
||||
if ((KNOWN_SCOPES as readonly string[]).includes(bare) && !out.includes(bare as Scope)) {
|
||||
out.push(bare as Scope);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the bearer token out of a transport-agnostic request. Accepts the raw
|
||||
* Authorization header string, a Fetch `Headers`-like object, or a plain
|
||||
* `{ headers: { authorization } }` shape (Node `IncomingMessage`, Lambda event).
|
||||
*
|
||||
* @throws {AuthError} `missing_token` if no usable Bearer token is present.
|
||||
*/
|
||||
export function extractBearerToken(req: unknown): string {
|
||||
const header = getAuthorizationHeader(req);
|
||||
if (!header) {
|
||||
throw new AuthError('missing_token', 'No Authorization header present.');
|
||||
}
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
const token = match?.[1]?.trim();
|
||||
if (!token) {
|
||||
throw new AuthError('missing_token', 'Authorization header is not a Bearer token.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function getAuthorizationHeader(req: unknown): string | undefined {
|
||||
if (typeof req === 'string') return req;
|
||||
if (req === null || typeof req !== 'object') return undefined;
|
||||
|
||||
const headers = (req as { headers?: unknown }).headers;
|
||||
if (!headers || typeof headers !== 'object') return undefined;
|
||||
|
||||
// Fetch `Headers`-like (has a .get method).
|
||||
const get = (headers as { get?: unknown }).get;
|
||||
if (typeof get === 'function') {
|
||||
const v = (headers as Headers).get('authorization');
|
||||
return v ?? undefined;
|
||||
}
|
||||
|
||||
// Plain object headers (case-insensitive lookup, array-valued allowed).
|
||||
const h = headers as Record<string, unknown>;
|
||||
const v = h['authorization'] ?? h['Authorization'];
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v) && typeof v[0] === 'string') return v[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CognitoAuthProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verifies a Cognito access token and resolves it to an {@link AuthContext}.
|
||||
*
|
||||
* Each server constructs one with its own config and calls `authenticate(req)`
|
||||
* once per inbound request, before any tool handler runs.
|
||||
*/
|
||||
export class CognitoAuthProvider implements AuthProvider {
|
||||
constructor(private readonly config: CognitoAuthConfig) {}
|
||||
|
||||
async authenticate(req: unknown): Promise<AuthContext> {
|
||||
const token = extractBearerToken(req);
|
||||
|
||||
let payload: JWTPayload & {
|
||||
token_use?: unknown;
|
||||
client_id?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
try {
|
||||
const result = await jwtVerify(token, this.config.jwks, {
|
||||
issuer: this.config.issuer,
|
||||
clockTolerance: this.config.clockToleranceSeconds ?? 5,
|
||||
});
|
||||
payload = result.payload;
|
||||
} catch (err) {
|
||||
throw new AuthError('invalid_token', `Token verification failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Must be an access token — id tokens carry different claims and are not
|
||||
// the credential the agent callout presents.
|
||||
if (payload.token_use !== 'access') {
|
||||
throw new AuthError(
|
||||
'invalid_token',
|
||||
`Expected token_use "access", got "${String(payload.token_use)}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// client_id allow-list == audience boundary (token has no native aud).
|
||||
const clientId = typeof payload.client_id === 'string' ? payload.client_id : undefined;
|
||||
if (!clientId || !this.config.allowedClientIds.includes(clientId)) {
|
||||
throw new AuthError(
|
||||
'client_not_allowed',
|
||||
`client_id "${clientId ?? '(none)'}" is not permitted for audience "${this.config.audience}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
|
||||
if (!sub) {
|
||||
throw new AuthError('invalid_token', 'Token has no "sub" claim.');
|
||||
}
|
||||
|
||||
const scopes = extractScopes(payload.scope, this.config.scopePrefix);
|
||||
|
||||
// Finance TTL ceiling (design.md §2.5): a token bearing a guarded scope must
|
||||
// be short-lived. exp/iat are validated numbers here (jwtVerify checked exp).
|
||||
if (this.config.maxTtlSeconds != null && this.config.ttlGuardedScopes?.length) {
|
||||
const carriesGuarded = scopes.some((s) => this.config.ttlGuardedScopes!.includes(s));
|
||||
if (carriesGuarded) {
|
||||
const iat = typeof payload.iat === 'number' ? payload.iat : undefined;
|
||||
const exp = typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
if (iat == null || exp == null || exp - iat > this.config.maxTtlSeconds) {
|
||||
throw new AuthError(
|
||||
'ttl_exceeded',
|
||||
`Token lifetime exceeds the ${this.config.maxTtlSeconds}s ceiling required for ` +
|
||||
`${this.config.ttlGuardedScopes!.join('/')} scopes.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate hard revocation (checked last — most expensive, may hit DynamoDB).
|
||||
if (this.config.denyList && (await this.config.denyList.isDenied(sub))) {
|
||||
throw new AuthError('revoked', `User "${sub}" is on the deny-list (revoked).`);
|
||||
}
|
||||
|
||||
return { sub, scopes, aud: this.config.audience };
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,17 @@ export { defineTool, ToolRegistry } from './registry.js';
|
|||
export { requireScope, ScopeError } from './auth.js';
|
||||
export type { AuthProvider } from './auth.js';
|
||||
|
||||
// Concrete Cognito auth provider (the 0a-unblocked implementation)
|
||||
export {
|
||||
CognitoAuthProvider,
|
||||
AuthError,
|
||||
cognitoIssuer,
|
||||
cognitoJwks,
|
||||
extractScopes,
|
||||
extractBearerToken,
|
||||
} from './cognito-auth.js';
|
||||
export type { AuthErrorCode, CognitoAuthConfig, DenyListChecker } from './cognito-auth.js';
|
||||
|
||||
// Redaction
|
||||
export { redact, maskValue, REDACTED } from './redact.js';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue