sh-mcp/packages/shared/src/cognito-auth.test.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
* Phase 0b slice: monorepo scaffold + shared core + integration packages

The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku
scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers.

- Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate),
  eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu).
- @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry,
  redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider
  interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2.
- 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base,
  gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind
  injected client interfaces; finance handlers call redact().

Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally
deferred until the 0a spike resolves it (G16/§0.4).

* 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).

* ci: disable cdk synth for Phase 0b (no CDK app yet)

The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but
the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails
with '--app is required'. Disable it here; Phase 1 re-enables it with the
server CDK stubs.
2026-06-26 12:42:17 -04:00

263 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});