/** * Authorization interface and scope-enforcement guard. * * 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 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. * * 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'; // --------------------------------------------------------------------------- // ScopeError // --------------------------------------------------------------------------- /** * Thrown by `requireScope()` when the caller's AuthContext does not include * the required scope. * * Transport adapters (OpenAPI handler, MCP dispatcher) should catch this and * return an appropriate 403 / permission-denied response. */ export class ScopeError extends Error { /** The scope that was required but absent. */ readonly requiredScope: Scope; /** The `sub` from the AuthContext that triggered the error. */ readonly sub: string; constructor(sub: string, requiredScope: Scope) { super(`User "${sub}" does not have the required scope "${requiredScope}".`); this.name = 'ScopeError'; this.requiredScope = requiredScope; this.sub = sub; // Maintain proper prototype chain for `instanceof` checks. Object.setPrototypeOf(this, new.target.prototype); } } // --------------------------------------------------------------------------- // requireScope // --------------------------------------------------------------------------- /** * Assert that `ctx` contains `scope`. Throws `ScopeError` if it does not. * * Call at the top of every tool handler before touching any input or reaching * any downstream service: * * ```ts * handler: async (input, ctx) => { * requireScope(ctx, 'finance:read'); * // ... safe to proceed * }, * ``` * * NOTE: Server-side enforcement is authoritative. Tool-hiding in the agent UI * is a convenience only (design.md §2.5). This guard enforces independently. * * NOTE: JWT signature/issuer/aud/client_id validation is NOT done here — see * the TODO above. By the time `handler` is called, the `AuthProvider` has * already validated the token and populated `ctx`. */ export function requireScope(ctx: AuthContext, scope: Scope): void { if (!ctx.scopes.includes(scope)) { throw new ScopeError(ctx.sub, scope); } } // --------------------------------------------------------------------------- // AuthProvider interface (deferred 0a-gated auth layer contract) // --------------------------------------------------------------------------- /** * Contract that each server's concrete auth layer must implement. * * The server's request pipeline calls `authenticate(req)` once per inbound * request and passes the resolved `AuthContext` into every tool handler. * * `req` is typed as `unknown` so this interface stays transport-agnostic * (works for an Express `Request`, a raw `IncomingMessage`, a Lambda event, * or a test-double). The concrete implementation casts to the appropriate type. * * TODO(auth-layer-0a): Implement this interface in the deferred auth layer. * A concrete implementation lives in `servers/sh-mcp-ops/src/auth.ts` and * `servers/sh-mcp-finance/src/auth.ts` once that layer is built. */ export interface AuthProvider { /** * Extract and validate the inbound token from `req`, returning a fully * populated AuthContext on success. * * Throws (or rejects) on any validation failure: * - Missing / malformed Authorization header * - Invalid JWT signature * - Wrong issuer, audience, or client_id * - Expired token * - User on the deny-list */ authenticate(req: unknown): Promise; }