mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-02 11:53:17 +00:00
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, LocalPrincipal>;
|
||
|
|
/** 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<string, LocalPrincipal> {
|
||
|
|
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<string, LocalPrincipal>;
|
||
|
|
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<AuthContext> {
|
||
|
|
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 };
|
||
|
|
}
|
||
|
|
}
|