/** * Local development AuthProvider. * * Lets the servers run end-to-end WITHOUT Cognito (design.md §6 phase-1 goal, * build-plan §3). It maps a small set of static dev bearer tokens to fully-formed * `AuthContext`s so tool-hiding, tiering, audience binding, and finance redaction * are all exercisable locally and in tests. * * SAFETY (build-plan §3, §5 "Local-auth safety"): this provider MUST refuse to * construct unless `SH_MCP_ENV === 'local'`, so it can never run in production. * * It also enforces audience binding the same way the real provider does: a * principal whose `aud` does not match this server's configured `audience` is * rejected with an `AuthError('client_not_allowed')`, mirroring the Cognito * client_id allow-list boundary (design.md §2.5). That keeps the local and AWS * paths behaviourally aligned for the audience-binding tests. */ import { AuthError, extractBearerToken } from './cognito-auth.js'; import type { AuthProvider } from './auth.js'; import type { AuthContext, Scope } from './types.js'; /** A dev principal: the identity a dev bearer token resolves to. */ export interface LocalPrincipal { sub: string; scopes: Scope[]; /** Audience this principal's token is "minted" for (e.g. "sh-mcp-ops"). */ aud: string; } export interface LocalAuthConfig { /** This server's audience; a principal with a different `aud` is rejected. */ audience: string; /** token → principal map (dev bearer tokens). */ principals: Record; /** The value of `SH_MCP_ENV`; the provider refuses to build unless 'local'. */ env: string | undefined; } /** Resource-server identifiers for the two launch tiers. */ export const OPS_AUDIENCE = 'sh-mcp-ops'; export const FINANCE_AUDIENCE = 'sh-mcp-finance'; /** * Build the default dev-principal map (build-plan §3). Tokens are deliberately * obvious dev strings; they grant exactly the scopes named so tests can assert * tool-hiding and tiering. * * Audiences are FIXED to each principal's home tier (NOT auto-set to the server * that loads them) so audience binding is demonstrable locally: the same global * map is given to both servers, and an ops principal presented to the finance * server is rejected because its `aud` is `sh-mcp-ops` (design.md §2.5). Each * tier also gets its own admin principal so an admin can reach its own server. */ export function defaultLocalPrincipals(): Record { return { 'dev-ops-only': { sub: 'ops-only@seahavenind.com', scopes: ['ops:read', 'ops:tasks'], aud: OPS_AUDIENCE, }, 'dev-assistant': { sub: 'lauren@seahavenind.com', scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'], aud: OPS_AUDIENCE, }, 'dev-ops-admin': { sub: 'adam@seahavenind.com', scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'], aud: OPS_AUDIENCE, }, 'dev-finance': { sub: 'accounting@seahavenind.com', scopes: ['ops:read', 'finance:read'], aud: FINANCE_AUDIENCE, }, 'dev-finance-admin': { sub: 'adam@seahavenind.com', scopes: ['ops:read', 'finance:read', 'finance:admin'], aud: FINANCE_AUDIENCE, }, }; } /** * Resolves dev bearer tokens to `AuthContext`s. Only constructible in * `SH_MCP_ENV=local`. */ export class LocalAuthProvider implements AuthProvider { private readonly principals: Record; private readonly audience: string; constructor(config: LocalAuthConfig) { if (config.env !== 'local') { throw new Error( 'LocalAuthProvider may only be constructed when SH_MCP_ENV=local ' + `(got SH_MCP_ENV=${JSON.stringify(config.env)}). It must never run in production.`, ); } // Defense in depth, independent of the env flag: refuse to run in a real AWS // runtime. The env check above can be defeated by a misconfiguration that // resolves env to 'local' in a deployed context; this positive prod signal // (set by Lambda / the AWS runtime) cannot. Static dev tokens must never // authenticate anywhere AWS is executing this code. if (process.env['AWS_LAMBDA_FUNCTION_NAME'] || process.env['AWS_EXECUTION_ENV']) { throw new Error( 'LocalAuthProvider refuses to run inside an AWS Lambda/execution context ' + '(AWS_LAMBDA_FUNCTION_NAME / AWS_EXECUTION_ENV present). Dev auth is local-only.', ); } this.audience = config.audience; this.principals = config.principals; } async authenticate(req: unknown): Promise { const token = extractBearerToken(req); // throws AuthError('missing_token') const principal = this.principals[token]; if (!principal) { throw new AuthError('invalid_token', 'Unknown dev bearer token.'); } // Audience binding: an ops principal presented to the finance server (or // vice versa) is rejected — same boundary the Cognito client_id allow-list // enforces in AWS mode (design.md §2.5). if (principal.aud !== this.audience) { throw new AuthError( 'client_not_allowed', `Dev principal audience "${principal.aud}" is not permitted for "${this.audience}".`, ); } return { sub: principal.sub, scopes: [...principal.scopes], aud: this.audience }; } }