/** * Cognito V2 pre-token-generation Lambda — SUPPRESS-ONLY scope enforcement. * * On every access-token mint it strips the tier scopes the caller's groups do * not grant (and falls back to base `ops:read` when group state is stale). It * NEVER adds a scope: `AllowedOAuthScopes` on each app client remains the real * per-tier ceiling (design.md §2.3; memory: Cognito spike gotcha 3). * * Requires Cognito user pool feature plan ESSENTIALS or higher — Lite silently * ignores the V2 trigger (memory: Cognito spike gotcha 1). */ import { computeScopesToSuppress, ALL_TIER_SCOPES, BASE_SCOPES } from './scopes.js'; import { readLastSuccessfulSyncMs, isSyncFresh } from './sync-state.js'; import { isSubDenied } from './deny-list.js'; /** Minimal shape of the V2/V3 pre-token event fields we read/write. */ interface PreTokenEvent { callerContext?: { clientId?: string }; userName?: string; request?: { groupConfiguration?: { groupsToOverride?: string[] }; userAttributes?: { sub?: string }; }; response?: Record; } export async function handler(event: PreTokenEvent): Promise { const groups = event.request?.groupConfiguration?.groupsToOverride ?? []; // Read env per-invocation (not at module load) so the Lambda picks up its // configured tables and so the policy is exercisable in tests. const syncStateTable = process.env['SYNC_STATE_TABLE']; const denyListTable = process.env['DENY_LIST_TABLE']; // Hard-revocation overlay: a deny-listed `sub` is dropped to NO tier scopes // immediately, ahead of the next group sync. Checked first — it short-circuits // entitlement entirely. (Fail-open on read error; see deny-list.ts.) const sub = event.request?.userAttributes?.sub; if (denyListTable && (await isSubDenied(denyListTable, sub))) { console.warn( JSON.stringify({ event: 'pre_token_deny_listed', clientId: event.callerContext?.clientId }), ); return writeSuppression(event, [...ALL_TIER_SCOPES]); } // Determine group-sync freshness. A missing table env, an unreadable marker, or // a stale timestamp all resolve to "not fresh" → fail closed to base scopes. let syncFresh = false; if (syncStateTable) { const lastSyncMs = await readLastSuccessfulSyncMs(syncStateTable); syncFresh = isSyncFresh(lastSyncMs, Date.now()); } if (!syncFresh) { // Structured log so a CloudWatch metric filter can alarm on fail-closed events // (a sustained outage means group revocations aren't propagating). console.warn( JSON.stringify({ event: 'pre_token_fail_closed', reason: syncStateTable ? 'group_sync_stale_or_unreadable' : 'sync_state_table_unset', clientId: event.callerContext?.clientId, droppedTo: BASE_SCOPES, }), ); } return writeSuppression(event, computeScopesToSuppress(groups, syncFresh)); } /** * Write the suppress-only override onto the event. Centralizes the single place a * response is constructed so the SUPPRESS-ONLY invariant is enforced once. */ function writeSuppression(event: PreTokenEvent, scopesToSuppress: string[]): PreTokenEvent { // SUPPRESS-ONLY: scopesToAdd is intentionally omitted. Do not ever set it. const accessTokenGeneration: { scopesToSuppress: string[] } = { scopesToSuppress }; // Executable enforcement of the load-bearing invariant: a future edit that // ever introduces `scopesToAdd` must fail the invocation (no token minted → // PreTokenErrorsAlarm fires) rather than silently widen a caller's scope. if ('scopesToAdd' in accessTokenGeneration) { throw new Error('pre-token policy violated SUPPRESS-ONLY invariant: scopesToAdd is set'); } event.response = { ...event.response, claimsAndScopeOverrideDetails: { accessTokenGeneration, }, }; return event; } /** Exported for tests: the full issued scope set this policy governs. */ export { ALL_TIER_SCOPES };