From 5983bd7a5192158298ac240c06548fe2f2d3dd45 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 20:08:54 -0400 Subject: [PATCH] Complete Cognito auth provider + Phase 1 build brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/build-plan-phase-1.md | 320 ++++++++++++++++++ package-lock.json | 12 + .../test/fixtures/password-reset.html | 8 + .../test/fixtures/pto-policy.html | 8 + .../test/fixtures/return-policy.html | 8 + packages/shared/package.json | 3 + packages/shared/src/auth.ts | 34 +- packages/shared/src/cognito-auth.test.ts | 263 ++++++++++++++ packages/shared/src/cognito-auth.ts | 302 +++++++++++++++++ packages/shared/src/index.ts | 11 + 10 files changed, 946 insertions(+), 23 deletions(-) create mode 100644 docs/build-plan-phase-1.md create mode 100644 packages/knowledge-base/test/fixtures/password-reset.html create mode 100644 packages/knowledge-base/test/fixtures/pto-policy.html create mode 100644 packages/knowledge-base/test/fixtures/return-policy.html create mode 100644 packages/shared/src/cognito-auth.test.ts create mode 100644 packages/shared/src/cognito-auth.ts diff --git a/docs/build-plan-phase-1.md b/docs/build-plan-phase-1.md new file mode 100644 index 0000000..597e658 --- /dev/null +++ b/docs/build-plan-phase-1.md @@ -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 { 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`). + - `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 " 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. diff --git a/package-lock.json b/package-lock.json index 9163795..504a4f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/knowledge-base/test/fixtures/password-reset.html b/packages/knowledge-base/test/fixtures/password-reset.html new file mode 100644 index 0000000..cd8caa0 --- /dev/null +++ b/packages/knowledge-base/test/fixtures/password-reset.html @@ -0,0 +1,8 @@ + + +Password Reset + +

How to Reset Your Password

+

Go to the login page and click Forgot Password. 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.

+ + diff --git a/packages/knowledge-base/test/fixtures/pto-policy.html b/packages/knowledge-base/test/fixtures/pto-policy.html new file mode 100644 index 0000000..5c15f53 --- /dev/null +++ b/packages/knowledge-base/test/fixtures/pto-policy.html @@ -0,0 +1,8 @@ + + +PTO Policy + +

Paid Time Off Policy

+

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.

+ + diff --git a/packages/knowledge-base/test/fixtures/return-policy.html b/packages/knowledge-base/test/fixtures/return-policy.html new file mode 100644 index 0000000..6df31d4 --- /dev/null +++ b/packages/knowledge-base/test/fixtures/return-policy.html @@ -0,0 +1,8 @@ + + +Return Policy + +

Return Policy

+

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.

+ + diff --git a/packages/shared/package.json b/packages/shared/package.json index 24638ef..8328108 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -25,5 +25,8 @@ "@vitest/coverage-v8": "^2.0.0", "typescript": "^5.5.0", "vitest": "^2.0.0" + }, + "dependencies": { + "jose": "^6.2.3" } } diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index e4b8905..aff9d92 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -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//.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'; diff --git a/packages/shared/src/cognito-auth.test.ts b/packages/shared/src/cognito-auth.test.ts new file mode 100644 index 0000000..9a2889c --- /dev/null +++ b/packages/shared/src/cognito-auth.test.ts @@ -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>; + +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 { + return { + issuer: ISSUER, + audience: 'sh-mcp-ops', + allowedClientIds: [OPS_CLIENT], + scopePrefix: 'sh-mcp-ops', + jwks, + ...overrides, + }; +} + +function financeConfig(overrides: Partial = {}): 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 { + 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); + }); +}); diff --git a/packages/shared/src/cognito-auth.ts b/packages/shared/src/cognito-auth.ts new file mode 100644 index 0000000..a4b0c5c --- /dev/null +++ b/packages/shared/src/cognito-auth.ts @@ -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; +} + +/** + * 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 `"/"`, 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; + 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 { + 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 }; + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 64af138..f7b270d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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';