sh-mcp/auth/pre-token-gen/test/handler.test.ts
Adam Moussa b5e604dabe
Some checks failed
deploy / deploy (push) Has been cancelled
Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4)
* Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas

Stands up the real AWS auth broker the servers already validate against
(SH_MCP_ENV=aws), surface-agnostic. Nothing deployed yet (gated on Google
secrets); CI synthesizes the stack.

infra/ — root CDK app, stack sh-mcp-auth:
  - Cognito user pool, ESSENTIALS feature plan (required for the V2 pre-token
    trigger), Google external OIDC IdP (client_id/secret resolved from Secrets
    Manager at deploy via CFN dynamic reference, never inlined).
  - Resource servers + per-tier app clients whose AllowedOAuthScopes ARE the
    trust-tier boundary: ops=(read,tasks), exec=(ops+gmail/calendar, NO finance),
    finance=(finance:read ONLY, 15-min access TTL). offline refresh 30d.
  - Cognito groups sh-mcp-ops/-assistant/-finance/-admin.
  - sync-state + deny-list DynamoDB tables (overrideLogicalId pinned so a future
    refactor cannot replace+drop them; deny-list TTL attr 'expiresAt').
  - Least-priv IAM (no wildcard action/resource; Google SA secret grant scoped to
    the one secret), arm64 Lambdas, explicit 60-day log groups, alarms on the
    seahaven-alarm-topics CMK (ALARM-state actions only, two-alarm group-sync).

auth/pre-token-gen — SUPPRESS-ONLY V2 Lambda. Maps Cognito group entitlement to
  scopesToSuppress; NEVER scopesToAdd a tier scope (AllowedOAuthScopes stays the
  ceiling). Reads last_successful_sync; fail-closed to base ops:read when stale.

auth/group-sync — mirrors Google Group membership into Cognito groups every 5 min
  (jose-signed SA JWT -> Directory API, no googleapis dep); writes the freshness
  marker ONLY on full success so a partial failure keeps the pre-token Lambda
  failing closed.

37 new tests (suppress-only policy, fail-closed, reconcile diff, 16 CDK
assertions incl. Essentials/V2/per-client-scope/no-wildcard-IAM). 448 total pass;
tsc -b + infra typecheck + cdk synth + prettier clean; CI run-cdk-synth re-enabled.

App-client callback URLs are a context placeholder pending the surface decision.
Confluence map (1540098) + project memory updates owed once this deploys.

* Phase 2a: harden auth substrate per security-review + IAM cross-review

Both mandatory gates run on the 2a diff. GPT-4.1 IAM/Lambda cross-review: the
suppress-only invariant is now an executable fail-closed guard (a future edit
that sets scopesToAdd throws → no token minted). /sh-security-review fan-out +
proof-or-kill verifier: PASS (0 confirmed critical/high). The verifier refuted
the two "high" candidates (the email-case revocation "bypass" is symmetric — the
add path uses the same lowercasing filter, so an un-removable user could never
have been added; the empty-directory purge is a non-200 throw → stale marker →
fail closed). Three confirmed findings remediated:

- C2 (deny-list was inert): the sh-mcp-deny-list table was provisioned and
  documented as "hard revocation" but no code read it. The pre-token Lambda now
  reads it on every mint (DENY_LIST_TABLE env + grantReadData) and strips a
  deny-listed sub to NO tier scopes, ahead of the next group sync. Fail-OPEN on
  a DDB read error (logs deny_list_read_failed) so a blip can't lock everyone
  out — group membership + its fail-closed 30-min window stay authoritative.
- C5 (finance 30-day refresh nullified the 15-min access TTL): refresh window is
  now per-tier; finance caps at 8h, ops/exec keep 30d.
- C7 (nested Google-group members silently dropped): listGroupMembers now sets
  includeDerivedMembership and skips non-USER rows, honoring the documented
  "nested resolved" contract instead of pushing a phantom group address.

Also corrects the sync.ts comment that overstated fail-closed as instantaneous
(it is bounded by MAX_SYNC_AGE_MS). +8 tests (deny-list unit, hard-revocation
handler path, finance refresh window, deny-list env wiring); 456 pass. tsc -b,
cdk synth, prettier, eslint all clean.
2026-06-26 14:33:46 -04:00

98 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the sync-state + deny-list DDB reads so the handler test exercises pure
// orchestration. vi.hoisted keeps the mock fns available to the hoisted factories.
const { readMock, denyMock } = vi.hoisted(() => ({ readMock: vi.fn(), denyMock: vi.fn() }));
vi.mock('../src/sync-state.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/sync-state.js')>();
return { ...actual, readLastSuccessfulSyncMs: readMock };
});
vi.mock('../src/deny-list.js', () => ({ isSubDenied: denyMock }));
import { handler } from '../src/index.js';
import { FINANCE_READ, FINANCE_ADMIN, OPS_READ, ALL_TIER_SCOPES } from '../src/scopes.js';
function event(groups: string[], clientId = 'sh-agentforce-finance', sub = 'user-sub-1') {
return {
callerContext: { clientId },
request: { groupConfiguration: { groupsToOverride: groups }, userAttributes: { sub } },
response: {},
};
}
function accessGen(res: Awaited<ReturnType<typeof handler>>) {
const d = res.response?.['claimsAndScopeOverrideDetails'] as
| { accessTokenGeneration?: { scopesToSuppress?: string[]; scopesToAdd?: string[] } }
| undefined;
return d?.accessTokenGeneration ?? {};
}
describe('pre-token handler', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-26T18:00:00Z'));
process.env['SYNC_STATE_TABLE'] = 'sh-mcp-sync-state';
process.env['DENY_LIST_TABLE'] = 'sh-mcp-deny-list';
readMock.mockReset();
denyMock.mockReset();
denyMock.mockResolvedValue(false); // not denied unless a test says otherwise
});
afterEach(() => vi.useRealTimers());
it('HARD REVOCATION: a deny-listed sub is stripped to NO tier scopes, ignoring groups', async () => {
readMock.mockResolvedValue(Date.now()); // fresh sync — irrelevant once denied
denyMock.mockResolvedValue(true);
const res = await handler(event(['sh-mcp-admin']));
const gen = accessGen(res);
expect(gen.scopesToAdd).toBeUndefined();
// Every issued tier scope is suppressed → the principal keeps none.
for (const s of ALL_TIER_SCOPES) expect(gen.scopesToSuppress).toContain(s);
});
it('deny-list is skipped when DENY_LIST_TABLE is unset (never blocks issuance on missing config)', async () => {
delete process.env['DENY_LIST_TABLE'];
readMock.mockResolvedValue(Date.now());
const res = await handler(event(['sh-mcp-finance']));
expect(denyMock).not.toHaveBeenCalled();
expect(accessGen(res).scopesToSuppress).not.toContain(FINANCE_READ);
});
it('NEVER emits scopesToAdd, on any path', async () => {
readMock.mockResolvedValue(Date.now()); // fresh
for (const groups of [[], ['sh-mcp-ops'], ['sh-mcp-admin']]) {
const res = await handler(event(groups));
expect(accessGen(res).scopesToAdd).toBeUndefined();
}
});
it('fresh sync: finance group keeps finance:read, loses finance:admin', async () => {
readMock.mockResolvedValue(Date.now() - 60_000); // 1 min old → fresh
const res = await handler(event(['sh-mcp-finance']));
const suppress = accessGen(res).scopesToSuppress ?? [];
expect(suppress).toContain(FINANCE_ADMIN);
expect(suppress).not.toContain(FINANCE_READ);
expect(suppress).not.toContain(OPS_READ);
});
it('FAIL CLOSED: stale sync drops an admin to base ops:read', async () => {
readMock.mockResolvedValue(Date.now() - 60 * 60_000); // 60 min old → stale
const res = await handler(event(['sh-mcp-admin']));
const suppress = accessGen(res).scopesToSuppress ?? [];
expect(suppress).toContain(FINANCE_READ);
expect(suppress).toContain(FINANCE_ADMIN);
expect(suppress).not.toContain(OPS_READ);
});
it('FAIL CLOSED: missing sync marker (null) drops to base', async () => {
readMock.mockResolvedValue(null);
const res = await handler(event(['sh-mcp-finance']));
expect(accessGen(res).scopesToSuppress).toContain(FINANCE_READ);
});
it('FAIL CLOSED: unset sync-state table never reads DDB and drops to base', async () => {
delete process.env['SYNC_STATE_TABLE'];
const res = await handler(event(['sh-mcp-admin']));
expect(readMock).not.toHaveBeenCalled();
expect(accessGen(res).scopesToSuppress).toContain(FINANCE_ADMIN);
});
});