From bfe18c177adb46d8deb1f5021aec4319f8bfeb90 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Thu, 18 Jun 2026 11:36:28 -0400 Subject: [PATCH 01/21] docs(tech-design): add AuthCognitoAdmin building block design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation-ready design for the AuthCognitoAdmin building block — the server-side admin surface (user lifecycle + group membership) that AuthCognito deliberately omits from its self-gating client-facing class. Covers API surface, error model, per-runtime implementation, and mock parity. Migrated from private staging repo (squashed). Internal cross-references to docs not present in this repo have been de-linked. --- docs/tech-design/BB-auth-cognito-admin.md | 753 ++++++++++++++++++++++ 1 file changed, 753 insertions(+) create mode 100644 docs/tech-design/BB-auth-cognito-admin.md diff --git a/docs/tech-design/BB-auth-cognito-admin.md b/docs/tech-design/BB-auth-cognito-admin.md new file mode 100644 index 000000000..4244b2624 --- /dev/null +++ b/docs/tech-design/BB-auth-cognito-admin.md @@ -0,0 +1,753 @@ +# BB: AuthCognitoAdmin (Implementation-Ready) + +> **STATUS — Implementation-ready.** API surface, error model, per-runtime implementation, mock parity, and naming have been validated against the shipped `bb-auth-cognito` code and the binding guidelines (API Design Guidelines, Building Block Architecture, [DECISIONS D-004](../DECISIONS.md)). The design was independently reviewed and every claim verified against source. Open items remaining are explicitly listed at the end and are non-blocking for a v1 build. + +**Package:** `@aws-blocks/bb-auth-cognito-admin` +**Type:** Composite (composes an existing `AuthCognito` instance; no new AWS infrastructure) +**AWS Service:** Amazon Cognito User Pool (admin-side `Admin*` / `List*` APIs) + +## Prior art in the repo (this block was pre-planned) + +This block is not speculative — the shipped `AuthCognito` docs explicitly carved it out and pre-documented its surface. The design below is the realization of that plan: + +- **`packages/bb-auth-cognito/docs/DESIGN.md:24`** (authoritative, ships with the package): *"Admin user-lifecycle APIs … deliberately excluded. **Those operate on any user and can't self-gate from a `context`**, so they don't belong on the same class that exposes `signIn`/`signUp`. They return as a separate admin Building Block in a future PR."* — This is the canonical rationale (sharper than "client vs admin": the real boundary is **self-gating**). +- **`packages/bb-auth-cognito/docs/DESIGN.md:88`**: *"No `cognito-idp:Admin*` or `cognito-idp:List*` actions are granted; those belong to the future admin BB."* — confirms the IAM split this block fills. +- **`packages/bb-auth-cognito/docs/COGNITO-SUPPORT.md`**: a full Cognito-operation matrix where every API this block implements is tagged ❌ "Admin BB" (`AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup`, `AdminCreateUser`, `AdminDeleteUser`, `AdminEnable/DisableUser`, `AdminSetUserPassword`, `AdminResetUserPassword`, `AdminUserGlobalSignOut`, `ListUsers`). +- **`DESIGN.md:191`** (open question): *"Typed `ListUsers.filter`. When the admin BB lands, the filter string is still stringly-typed."* — resolved here as a typed filter subset. +- **`DECISIONS.md` D-004** (auth-first naming): mandates `bb-auth-*` packages and `Auth*` classes — `bb-auth-cognito-admin` / `AuthCognitoAdmin` comply. + +## Purpose + +Server-side administrative surface for a Cognito User Pool created by `AuthCognito`. Provides the user-lifecycle and group-membership operations that `AuthCognito` deliberately omits from its client-facing class: creating/deleting users, enabling/disabling them, resetting passwords, listing users, and adding/removing group membership. + +**The boundary, precisely.** `AuthCognito` methods take `context: KitContext` and act on *the signed-in user* — they self-gate. `AuthCognitoAdmin` methods take a `username` and act on *any* user — they cannot self-gate, so they must be gated by the caller (`requireRole`). Keeping them on a separate class makes that distinction structural, not a convention you can forget. + +**Why it matters:** `AuthCognito` seeds groups at deploy time (`CfnUserPoolGroup` per `options.groups`) and `requireRole(ctx, 'admins')` checks the `cognito:groups` claim — but ships **no way to put a user into a group**. The mock keeps every group permanently empty, and the AWS runtime relies on an out-of-band `aws cognito-idp admin-add-user-to-group` CLI call. As a result `requireRole('admins')` returns 403 for every user and the admin surface is unreachable. `AuthCognitoAdmin` closes that gap with a first-class, typed, mock-parity API. + +**When to use:** You need server-side admin operations — seeding the first admin, building an internal user-management screen, or scripting group membership — against a pool owned by `AuthCognito`. + +**When NOT to use:** For client-facing auth (sign-in/up, MFA, profile self-service) use `AuthCognito` directly. Never expose `AuthCognitoAdmin` methods to unauthenticated callers — gate every admin route behind `auth.requireRole(ctx, 'admins')` (see Usage Examples). + +## Relationship to AuthCognito + +`AuthCognitoAdmin` references — does not re-create — the pool. It takes the `AuthCognito` instance via options and reuses its discovery channel: + +- **AWS runtime:** re-derives the same env vars via `envVarNames(auth.fullId)` (`KIT_AUTH_COGNITO__USER_POOL_ID` etc.), so it talks to the exact pool `AuthCognito` created. No new env vars, no second pool. +- **Mock:** reads and writes the **same** `.bb-data//state.json` that the mock `AuthCognito` persists, so a membership change made by the admin block is visible to the next `signIn` / `requireRole` on the auth block (mock reloads state from disk and `flushToDisk()`es on every write). +- **CDK:** adds an IAM policy statement granting the API handler the `Admin*` / `List*` actions, scoped to `auth.userPool.userPoolArn` (public getter on the CDK construct). No resources are created. + +Because it holds the `AuthCognito` reference, the admin block re-threads the generic options literal `O` to keep group-name narrowing: `addUserToGroup(username, group: GroupOf)` is a compile error on a typo'd group. + +## API Surface + +### Generic type contract + +`AuthCognitoAdmin` mirrors `AuthCognito`. The generic is recovered from the `auth` option (`AuthCognitoAdminOptions.auth: AuthCognito`), so callers who passed `as const` options to `AuthCognito` get the same narrowed `GroupOf` on the admin methods with no extra annotation. + +> **Caveat — narrowing requires `as const` discipline.** `GroupOf` only narrows when the `auth` **variable's static type** carries the narrowed `O`, which happens only when its options were passed `as const` (the literal-tuple guard in `types.ts:175-177`). A pool declared `new AuthCognito(scope, 'auth', { groups: ['admins', 'readers'] })` **without** `as const` (as the comprehensive test-app does) has `O = AuthCognitoOptions`, so `GroupOf` collapses to `string` and `addUserToGroup` accepts any string. This is the same behavior as `AuthCognito.requireRole` and is not a regression — but the narrowing is **opt-in**, not automatic. Document it in the README. + +```typescript +/** + * Server-side admin surface for an AuthCognito User Pool. + * + * **When to use:** You need privileged user-lifecycle or group-membership + * operations — seed the first admin, build an internal user-management + * screen, or script membership changes. + * + * **When NOT to use:** For client-facing auth (sign-in/up, MFA, profile + * self-service) use `AuthCognito`. These methods are privileged — always + * gate them behind `auth.requireRole(ctx, 'admins')`. + * + * **Best practices:** + * - Expose admin methods only from API routes guarded by `requireRole`. + * - Prefer `scan()` for enumeration; it communicates full-pool cost (G14). + * - Seed the first admin via a one-off script/migration, not a public route + * (bootstrapping paradox: the first admin can't be promoted by an admin). + * + * **Scaling:** Admin/List APIs share the Cognito account-level quota with + * the client-facing pool (~25 req/s for many Admin* actions, adjustable via + * Service Quotas). `scan()` paginates internally (60 users/page) and is + * O(total users) — do not call it on a hot path. + */ +class AuthCognitoAdmin extends Scope { + /** + * @param scope - Parent scope to attach to. + * @param id - Unique identifier within the parent scope. + * @param options - Must include the `auth` block whose pool to administer. + * + * @example + * ```typescript + * const auth = new AuthCognito(scope, 'auth', { groups: ['admins'] as const }); + * const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); + * ``` + */ + constructor(scope: ScopeParent, id: string, options: AuthCognitoAdminOptions); + + // ── Group membership ────────────────────────────────────────────────── + + /** + * Add a user to a group. Idempotent — adding an already-member is a no-op. + * @param username - The username (or sub) to add. + * @param group - The group name. Narrowed to `GroupOf` when `auth` was + * configured with `groups: [...] as const`. + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @throws {AuthCognitoAdminErrors.GroupNotFound} If the group does not exist. + * @example + * ```typescript + * await admin.addUserToGroup('alice', 'admins'); + * ``` + */ + addUserToGroup(username: string, group: GroupOf): Promise; + + /** + * Remove a user from a group. Idempotent on membership: removing a user who + * is not a member succeeds as a no-op (matches Cognito's + * `AdminRemoveUserFromGroup`, which does not error on a non-member). Still + * throws if the *user* or the *group* itself does not exist. + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @throws {AuthCognitoAdminErrors.GroupNotFound} If the group does not exist. + * @example + * ```typescript + * await admin.removeUserFromGroup('alice', 'admins'); + * ``` + */ + removeUserFromGroup(username: string, group: GroupOf): Promise; + + /** + * List the groups a user belongs to. + * @returns The user's group names (narrowed to `GroupOf[]`). Empty array + * if the user is in no groups. + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @example + * ```typescript + * const groups = await admin.getUserGroups('alice'); // ['admins'] + * ``` + */ + getUserGroups(username: string): Promise[]>; + + /** + * Enumerate the members of a group. Unbounded — returns `AsyncIterable` + * and paginates internally (G5). + * @throws {AuthCognitoAdminErrors.GroupNotFound} + * @example + * ```typescript + * for await (const user of admin.getUsersInGroup('admins')) { + * console.log(user.username); + * } + * ``` + */ + getUsersInGroup(group: GroupOf): AsyncIterable>; + + // ── User lifecycle ────────────────────────────────────────────────────── + + /** + * Look up a single user by username. Returns `null` if not found (G3). + * @returns The user, or `null` if no such user exists. + * @example + * ```typescript + * const user = await admin.getUser('alice'); + * if (user) console.log(user.groups); + * ``` + */ + getUser(username: string): Promise | null>; + + /** + * Enumerate all users in the pool. Full-pool cost — named `scan` to + * communicate that it reads every user (G14). Paginates internally. + * @param options - Optional typed `filter` for the limited set of + * attributes Cognito's `ListUsers` can narrow on server-side (G18). + * @returns An `AsyncIterable` of users; consume with `for await`. + * @example + * ```typescript + * for await (const user of admin.scan({ filter: { attribute: 'email', op: '^=', value: 'a' } })) { + * console.log(user.username); + * } + * ``` + */ + scan(options?: AdminScanOptions): AsyncIterable>; + + /** + * Administratively create a user, bypassing self-sign-up. The user is + * created in a `FORCE_CHANGE_PASSWORD` state unless `options.permanent`. + * @returns The created user. + * @throws {AuthCognitoAdminErrors.UserAlreadyExists} + * @throws {AuthCognitoAdminErrors.InvalidPassword} + * @example + * ```typescript + * await admin.createUser('bob', { temporaryPassword: 'Temp1!pass', + * attributes: { email: 'bob@example.com' }, groups: ['readers'] }); + * ``` + */ + createUser(username: string, options?: AdminCreateUserOptions): Promise>; + + /** + * Permanently delete a user. Idempotent only if the user exists; deleting + * a missing user throws (precondition violation, G3). + * @throws {AuthCognitoAdminErrors.UserNotFound} + */ + deleteUser(username: string): Promise; + + /** + * Disable a user — blocks all sign-in without deleting the record. Toggles + * the `disabled` flag the mock `signIn` already enforces (`index.ts:452`). + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @example + * ```typescript + * await admin.disableUser('alice'); // alice can no longer sign in + * ``` + */ + disableUser(username: string): Promise; + + /** + * Re-enable a previously disabled user. + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @example + * ```typescript + * await admin.enableUser('alice'); + * ``` + */ + enableUser(username: string): Promise; + + /** + * Force a password reset on the user's next sign-in (admin-initiated). + * Does not email a code in mock mode (logged to console instead). + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @example + * ```typescript + * await admin.resetUserPassword('alice'); + * ``` + */ + resetUserPassword(username: string): Promise; + + /** + * Set a user's password directly (admin override). + * @param permanent - When `true`, the password is permanent; when `false` + * the user must change it on next sign-in. Default: `false`. + * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. + * @throws {AuthCognitoAdminErrors.InvalidPassword} If the password fails policy. + * @example + * ```typescript + * await admin.setUserPassword('alice', 'NewP@ss1!', { permanent: true }); + * ``` + */ + setUserPassword(username: string, password: string, options?: { permanent?: boolean }): Promise; + + /** + * Revoke all of a user's active sessions, forcing re-authentication. + * + * Use after a group change to make the new permissions take effect + * immediately — otherwise the user's existing token keeps its stale + * `cognito:groups` claim until it expires/refreshes (see DESIGN § + * Session-freshness). AWS: `AdminUserGlobalSignOut`; mock: deletes the + * user's session records. + * @throws {AuthCognitoAdminErrors.UserNotFound} + */ + revokeUserSessions(username: string): Promise; +} + +interface AuthCognitoAdminOptions { + /** The AuthCognito block whose User Pool this block administers. Required. */ + auth: AuthCognito; +} + +interface AdminScanOptions { + /** + * Server-side narrowing on a single indexed attribute. Modeled as a typed + * subset rather than a raw Cognito filter string (G10 — no leaking the + * service's filter DSL; G18 — only attributes Cognito can narrow on + * server-side are offered). Omit for all users. + * + * `op: '^='` is prefix-match; `'='` is exact. `attribute` is limited to + * the standard set Cognito's `ListUsers` `Filter` supports natively. + */ + filter?: { + attribute: 'username' | 'email' | 'phone_number' | 'sub' | 'status'; + op: '=' | '^='; + value: string; + }; +} + +interface AdminCreateUserOptions { + /** Temporary password. If omitted, Cognito generates one. */ + temporaryPassword?: string; + /** When true, the password is permanent (no FORCE_CHANGE_PASSWORD). */ + permanent?: boolean; + /** Standard + custom attributes. Keys narrowed to `AttrOf`. */ + attributes?: Partial, string>>; + /** Suppress the Cognito invitation message. Default: true (admin flows). */ + suppressInvite?: boolean; + /** Groups to add the user to immediately after creation. */ + groups?: readonly GroupOf[]; +} +``` + +`CognitoUser`, `GroupOf`, `AttrOf`, and `AuthCognitoOptions` are re-exported from `@aws-blocks/bb-auth-cognito` — the admin block adds no parallel user type (G2: one client-safe shape across the ecosystem). + +No `createApi()` — admin operations are not part of the client Authenticator state machine. Customers wire their own guarded `ApiNamespace` routes (see Usage Examples). + +**Deliberate G14 verb deviations.** Two method names depart from the G14 "verbs to avoid" guidance, intentionally, because they mirror the underlying Cognito admin verbs that have no idempotent/upsert equivalent: +- `createUser` (not `put` + `{ ifNotExists }`): Cognito's `AdminCreateUser` is genuinely non-idempotent — it throws `UsernameExistsException` on a duplicate and there is no admin upsert. `create` communicates that semantics honestly; a `put` name would falsely imply idempotent replace. +- `setUserPassword` (not `put`): mirrors Cognito's `AdminSetUserPassword`, the recognized name for the operation. + +**No `fromExisting` (G9).** The admin block provisions no resources, so it has no `fromExisting` factory. Pool lifecycle is owned by the referenced `AuthCognito`, which has its own `fromExisting`. An admin block transparently administers a `fromExisting` pool: the AWS runtime discovers it through the same `envVarNames(auth.fullId)` env vars, which the AWS `AuthCognito` constructor registers for `fromExisting` pools too (`index.aws.ts:613`). + +## Error Constants + +```typescript +export const AuthCognitoAdminErrors = { + UserNotFound: 'UserNotFoundException', + UserAlreadyExists: 'UsernameExistsException', + GroupNotFound: 'ResourceNotFoundException', + InvalidPassword: 'InvalidPasswordException', + InvalidParameter: 'InvalidParameterException', // createUser with a bad attribute, etc. + LimitExceeded: 'LimitExceededException', // admin-create / list throttling at the pool level + TooManyRequests: 'TooManyRequestsException', // account-level throttling + UnsupportedUserState: 'UnsupportedUserStateException', // e.g. setUserPassword on a not-yet-confirmed user + NotAuthorized: 'NotAuthorizedException', +} as const; +``` + +Each value is a subset of the parent `AuthCognitoErrors` (`types.ts:805-820`) with identical string values, so the two constants are interchangeable in `isKitError`. The lifecycle methods surface the extra codes (`InvalidParameter`, `LimitExceeded`, `TooManyRequests`, `UnsupportedUserState`) because `AdminCreateUser` / `AdminSetUserPassword` / `ListUsers` throw them where the client-facing surface never would. + +Names match the Cognito SDK error names (G6) so customers who know Cognito recognize them. Every value here is **identical to the corresponding `AuthCognitoErrors` value in the shipped code** (`types.ts:805-820`): `NotAuthorized`→`NotAuthorizedException`, `UserNotFound`→`UserNotFoundException`, `UserAlreadyExists`→`UsernameExistsException`, `InvalidPassword`→`InvalidPasswordException`, and `GroupNotFound`→`ResourceNotFoundException`. So `isKitError(e, AuthCognitoAdminErrors.X)` and `isKitError(e, AuthCognitoErrors.X)` are interchangeable for the overlapping codes — no divergence. + +> **⚠️ Stale parent doc:** `BB-auth-cognito.md:296` documents `GroupNotFound: 'GroupNotFoundException'`, which does **not** match the shipped code (`types.ts:820` = `'ResourceNotFoundException'`). This admin doc follows the code. The parent doc should be corrected separately. + +## Infrastructure (CDK) + +Composite — **no resources created**. The CDK implementation only adds an IAM grant to the API handler, scoped to the pool owned by the referenced `AuthCognito`: + +```typescript +// inside AuthCognitoAdmin (CDK) constructor +const poolArn = options.auth.userPool.userPoolArn; // public getter on the CDK construct +this.handler.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:ListUsersInGroup', + 'cognito-idp:ListUsers', + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminCreateUser', + 'cognito-idp:AdminDeleteUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + 'cognito-idp:AdminUserGlobalSignOut', + ], + resources: [poolArn], +})); +``` + +These are deliberately **separate** from the client-facing grant in `AuthCognito.grantCognitoPermissions` (which intentionally excludes `Admin*`). Keeping the admin grant in the admin block means an app that never instantiates `AuthCognitoAdmin` never grants its handler admin privileges — least privilege by composition. + +- **Removal policy:** N/A (no resources). +- **Naming:** N/A. +- **Discovery:** none added — reuses `AuthCognito`'s env vars via `envVarNames(auth.fullId)`. + +> **Load-bearing assumption: one Lambda handler per stack.** Both the CDK grant (`this.handler.addToRolePolicy`) and the AWS-runtime discovery (`envVarNames(auth.fullId)` read from `process.env`) rely on KIT's single-shared-handler model — `Scope.handler` walks up to the one `KitStack` handler (`core/src/cdk/index.ts:81-92`), and the auth block writes its discovery env vars onto that same function. This design is correct **because** of that model. If KIT ever introduces per-block Lambdas, the admin block's handler would need its own grant + env vars and this section must be revisited. + +### Package layout & browser stub + +`bb-auth-cognito` ships an `index.browser.ts` because it is client-facing and must not bundle the AWS SDK into client code. `AuthCognitoAdmin` is **server-only** (no client plugin, no Transferable, no `createApi`) but it imports `@aws-sdk/client-cognito-identity-provider` for its `Admin*` commands. Per the architecture guide a composite's `index.browser.ts` is "optional" — but because the package pulls in the AWS SDK, it **must** ship an `index.browser.ts` stub if the package is ever transitively reachable from a client bundle, to prevent the SDK from bundling. The stub should throw on construction (the admin block has no legitimate browser use). Open Question 5 (separate package vs `bb-auth-cognito/admin` subpath) interacts with this: a separate package keeps the SDK dependency fully opt-in. + +## Mock Implementation + +> **Research note — why file-sharing does NOT work.** The obvious approach ("both blocks point at `.bb-data//state.json`") is **incorrect** and would silently corrupt state. Confirmed by reading `index.ts`: +> +> 1. The mock loads the state file into memory **once, in the constructor** (`this.state = this.loadFromDisk()`, `index.ts:216`) and **never reloads it** — there is no per-request re-read anywhere in the file. +> 2. Blocks are instantiated **once at app-module scope** (e.g. `const authC = new AuthCognito(scope, 'authC', …)` in `test-apps/comprehensive/aws-blocks/index.ts:64`) and the dev server imports that module a single time (`dev-server.ts:76`, `await import(backendUrl)`). Every request reuses the **same long-lived instances**. +> +> So two instances pointed at the same file each hold their own in-memory `state`. A write from the admin instance flushes to disk, but the auth instance's in-memory copy is stale and its next `flushToDisk()` (e.g. on the next `signIn`) **overwrites the admin's change**. Last-flush-wins, lost updates, no visibility. File-sharing is a non-starter given the load-once model. + +**Correct approach: the admin mock delegates to the live auth instance's in-memory state.** Because the customer passes the real `AuthCognito` instance via `{ auth }`, and both live in the same process, the admin block operates on the *same object*, not a copy. This is also why the standalone `{ auth }` wiring (vs a free-standing constructor that only knows a `fullId`) is the right call — it hands the admin block a live reference, not just a discovery key. + +Concretely, the mock `AuthCognito` exposes a **narrow internal admin port** — a small interface, not its whole private state — that the admin mock calls: + +```typescript +// Exposed from bb-auth-cognito via an "./internal" subpath export (types-free, +// not part of the public API surface; see Resolved Decision 1). +export interface CognitoMockAdminPort { + addToGroup(username: string, group: string): void; // throws UserNotFound / GroupNotFound + removeFromGroup(username: string, group: string): void; + groupsOf(username: string): string[]; + membersOf(group: string): MockUserRecord[]; + allUsers(): MockUserRecord[]; + getUser(username: string): MockUserRecord | null; + createUser(username: string, init: AdminCreateInit): MockUserRecord; + deleteUser(username: string): void; + setDisabled(username: string, disabled: boolean): void; + setForcePasswordChange(username: string, force: boolean): void; // reuses existing `forcePasswordChange` flag (index.ts:506) + setPassword(username: string, password: string, permanent: boolean): void; + // every mutator calls the existing private flushToDisk() internally +} +``` + +The mock `AuthCognito` implements this against its single in-memory `state` (and its existing `flushToDisk()`), so every admin mutation is **immediately visible** to that same instance's `signIn` / `getCurrentUser` / `requireRole`. The admin block owns zero knowledge of the on-disk file format — it only knows the port. Behavioral notes: + +> **`/internal` conditional-export resolution (must be specified before build).** The port is a **mock-only** construct — it lives on the mock `AuthCognito` (`index.ts`), which is the `default` entry. There is no in-memory `state` under `aws-runtime` or `cdk`. So `@aws-blocks/bb-auth-cognito/internal` resolves per condition: +> - `default` (mock) → the real `CognitoMockAdminPort` interface + a factory that returns the live instance's port. +> - `aws-runtime` / `cdk` → a stub that **throws** if called (the admin block's AWS/CDK entries never import it). +> +> The discipline that keeps this sound: only the admin block's **mock entry** (`index.ts`) imports the port. Its `index.aws.ts` talks to Cognito via the SDK; its `index.cdk.ts` only adds the IAM grant. Neither touches `/internal`. +> +> This does **not** interact with the export-parity test. `conditional-exports.test.ts` imports only the bare package specifier (`import('${pkg}')` = the `"."` subpath) and asserts each condition's exports are a superset of `default`'s (`conditional-exports.test.ts:42-64`). It never inspects `/internal` or any other subpath — the existing `./ui` subpath is equally outside its view. So `/internal` is simply **out of scope** for that test, not specially excluded. + +Behavioral notes: + +- **Group membership:** `addToGroup` appends to `state.groups[group]`, throwing `GroupNotFound` when the group was never seeded (matches Cognito, which has no implicit group creation); `removeFromGroup` filters it out. This is the plumbing that is currently missing and the direct cause of the bug. +- **User lifecycle:** `createUser` writes a `MockUserRecord` mirroring `signUp` (reusing `prefixCustomAttrs`, password-policy enforcement); `deleteUser` removes the record **and** strips the user from every `state.groups` array; `disableUser`/`enableUser` toggle the existing `disabled` flag on `MockUserRecord` (already present at `index.ts:291`); `resetUserPassword` / `setUserPassword` set the existing `forcePasswordChange` flag (`index.ts:506`) and/or the password. **Reuse the existing flag name `forcePasswordChange` — do not introduce a parallel `forceChangePassword`.** +- **Enumeration:** `scan` / `getUsersInGroup` iterate the in-memory maps and yield page-by-page to exercise the same `AsyncIterable` consumption path the AWS pagination uses. +- **Disabled-user enforcement is ALREADY shipped.** The mock `AuthCognito.signIn` already rejects disabled users (`index.ts:452`: `if (!user || user.disabled) throw NotAuthorized`). This block does **not** need to wire that check. Its work is to add the `disableUser`/`enableUser` mutators that toggle the flag, plus a regression test asserting the existing `signIn` enforcement keeps working. + +### Session-freshness (resolves former Open Question 4) + +After `addUserToGroup`, a user with an **existing session** still carries the old `cognito:groups` claim until their token refreshes — `requireRole` (mock `index.ts:978`, AWS `index.aws.ts:1306`) reads the claim, not live state, in both runtimes (both resolve the user by decoding the stored ID token: mock `getCurrentUser` at `index.ts:894`, AWS `toCognitoUser` at `index.aws.ts:1916`). This is **inherent Cognito behavior, not a KIT bug**, and must be documented. Two mitigations the block provides: + +- The mock's `fetchAuthSession({ forceRefresh: true })` already re-reads `state.groups` when re-minting the token (`index.ts:950-952`), and the AWS runtime re-issues via `REFRESH_TOKEN_AUTH` — so a client refresh picks up the new group. +- For immediate effect, the block exposes `revokeUserSessions(username)` (AWS: `AdminUserGlobalSignOut`; mock: delete the user's session records), forcing the user to re-authenticate and mint a fresh claim. Documented as the way to make a permission change take effect now. + +## Mock vs AWS Parity Gap Mitigations + +| Parity Gap | Impact | Mitigation | +|------------|--------|------------| +| No IAM enforcement in mock | An ungated admin route "works" locally but 403s in AWS only if the guard is missing | Document that admin routes must be `requireRole`-gated; mock logs a warning the first time an admin method runs without an authenticated admin in context (best-effort) | +| Mock `createUser` skips invitation email / SMS | No real invite delivered locally | Mock logs the temporary password to console (same convention as `signUp` codes) | +| `ListUsers` filter expression not fully emulated | Mock applies a simplified prefix/equality match; complex Cognito filter syntax may differ | Document supported subset; recommend sandbox testing for non-trivial filters | +| Account-level Admin* throttling not simulated | Bulk admin scripts that would throttle in AWS succeed locally | No mitigation — quotas are account-level and non-deterministic; document the gap | +| Disabled-user sign-in block | Already at parity — both runtimes reject disabled users | No new work: the mock `signIn` already enforces `disabled` (`index.ts:452`); AWS Cognito enforces it natively. This block only adds the `disableUser`/`enableUser` mutators that flip the flag | + +## AWS Runtime Implementation (`index.aws.ts`) + +Mirrors the structure of `bb-auth-cognito/src/index.aws.ts` exactly — same client, same discovery, same error-mapping helper — so the two files read as siblings. + +**Construction & discovery.** No new env vars. The admin block reads the pool ID off the referenced auth block's discovery channel: + +```typescript +import { + CognitoIdentityProviderClient, + AdminAddUserToGroupCommand, AdminRemoveUserFromGroupCommand, + AdminListGroupsForUserCommand, ListUsersInGroupCommand, ListUsersCommand, + AdminGetUserCommand, AdminCreateUserCommand, AdminDeleteUserCommand, + AdminEnableUserCommand, AdminDisableUserCommand, + AdminResetUserPasswordCommand, AdminSetUserPasswordCommand, + AdminUserGlobalSignOutCommand, + type UserType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { ApiError, Scope, getSdkIdentifiers } from '@aws-blocks/core'; + +export class AuthCognitoAdmin extends Scope { + private readonly client: CognitoIdentityProviderClient; + private readonly auth: AuthCognito; + + constructor(scope: ScopeParent, id: string, options: AuthCognitoAdminOptions) { + super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); + this.auth = options.auth; + // Same lazy region resolution as AuthCognito; client is cheap to build. + const { userPoolId, region } = getSdkIdentifiers(this.auth); // registered by AuthCognito ctor + this.client = new CognitoIdentityProviderClient({ region }); + } + + private get userPoolId(): string { + const { userPoolId } = getSdkIdentifiers(this.auth); + if (!userPoolId) throw new ApiError( + 'AuthCognitoAdmin: pool not discovered — did the AuthCognito CDK construct run?', 500); + return userPoolId; + } +``` + +**Every method is a thin SDK wrapper through the shared `asApiError`** (reuse the exact helper from `index.aws.ts:531` — it maps `e.name` → HTTP status and preserves the Cognito exception name so `isKitError` works): + +```typescript + async addUserToGroup(username: string, group: GroupOf): Promise { + try { + await this.client.send(new AdminAddUserToGroupCommand({ + UserPoolId: this.userPoolId, Username: username, GroupName: group as string, + })); + } catch (e) { throw asApiError(e); } + } + + async getUser(username: string): Promise | null> { + try { + const r = await this.client.send(new AdminGetUserCommand({ + UserPoolId: this.userPoolId, Username: username })); + const groups = await this.fetchGroups(username); // AdminListGroupsForUser + return toCognitoUser({ username: r.Username!, attributes: attrsToRecord(r.UserAttributes), groups }); + } catch (e) { + if (e instanceof Error && e.name === 'UserNotFoundException') return null; // G3: null for absence + throw asApiError(e); + } + } + + async *scan(options?: AdminScanOptions): AsyncIterable> { + let token: string | undefined; + do { + const r = await this.client.send(new ListUsersCommand({ + UserPoolId: this.userPoolId, + Filter: options?.filter ? `${options.filter.attribute} ${options.filter.op} "${options.filter.value}"` : undefined, + Limit: 60, PaginationToken: token, + })); + for (const u of r.Users ?? []) yield toCognitoUser({ + username: u.Username!, attributes: attrsToRecord(u.Attributes), groups: [] }); // groups omitted on scan (cost) + token = r.PaginationToken; + } while (token); + } +``` + +**Notes that matter for the build:** +- `getUser` returns `null` on `UserNotFoundException` (G3) — the *only* place that swallows an SDK error; everything else rethrows via `asApiError`. +- `scan` and `getUsersInGroup` use the **identical pagination loop** as `fetchDevices` (`index.aws.ts:1555-1580`): `Limit: 60`, `do { … } while (PaginationToken)`. `scan` yields users *without* resolving each one's groups — doing a per-user `AdminListGroupsForUser` inside a full-pool scan would be O(users) extra calls (a G18 cost trap). `getUser`/`getUsersInGroup` resolve groups because they're bounded. +- `createUser` maps `permanent` → a follow-up `AdminSetUserPassword({ Permanent: true })` after `AdminCreateUser` (Cognito has no single "create with permanent password" call), and `suppressInvite` → `MessageAction: 'SUPPRESS'`. Reuse `attrsToList` (`index.aws.ts:150`) + `prefixCustomAttrs` for attribute marshalling. +- `revokeUserSessions` → `AdminUserGlobalSignOutCommand`. Note it invalidates Cognito refresh tokens but the customer's **server-side KVStore session record still exists** until its next access-token expiry; document that the user is fully locked out only after the access token TTL, or pair with deleting the session record if immediate lockout is required. +- `toCognitoUser` is a tiny local shaper to the **same `CognitoUser`** the auth block returns (G2 — one shape). Do not invent a parallel `AdminUser` type. + +## Package Scaffold & Conditional Exports + +New package `packages/bb-auth-cognito-admin/` following the standard four-entry layout (`08-building-block-architecture.md`): + +``` +packages/bb-auth-cognito-admin/ +├── package.json # exports map below; deps: bb-auth-cognito, core, @aws-sdk/client-cognito-identity-provider +├── README.md # G11 source-of-truth docs (mirror class JSDoc) +├── DESIGN.md # this design, condensed for extenders +├── tsconfig.json +└── src/ + ├── types.ts # AuthCognitoAdminOptions, AdminScanOptions, AdminCreateUserOptions, AuthCognitoAdminErrors + │ # — re-exports CognitoUser/GroupOf/AttrOf from bb-auth-cognito (no parallel types) + ├── index.ts # default (mock) — imports the /internal port from bb-auth-cognito + ├── index.aws.ts # aws-runtime — SDK wrappers above + ├── index.cdk.ts # cdk — IAM grant only + ├── index.browser.ts # browser — throwing stub (prevents AWS SDK bundling; server-only block) + ├── version.ts # BB_NAME / BB_VERSION (generated, matches bb-auth-cognito convention) + └── *.test.ts # see Test Plan +``` + +```jsonc +// package.json exports — identical condition order to bb-auth-cognito +{ + "name": "@aws-blocks/bb-auth-cognito-admin", + "exports": { + ".": { + "browser": "./dist/index.browser.js", + "cdk": { "types": "./dist/index.cdk.d.ts", "default": "./dist/index.cdk.js" }, + "aws-runtime": "./dist/index.aws.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } +} +``` + +**The one change required in `bb-auth-cognito` itself** — add an `./internal` subpath that exposes the mock admin port. This is the only edit to the existing package: + +```jsonc +// bb-auth-cognito/package.json — add alongside "." and "./ui" +"./internal": { + "types": "./dist/internal.d.ts", + "aws-runtime": "./dist/internal.aws.js", // throwing stub + "cdk": "./dist/internal.cdk.js", // throwing stub + "default": "./dist/internal.js" // real port factory (mock) +} +``` + +`internal.ts` (mock) exports `getCognitoMockAdminPort(auth: AuthCognito): CognitoMockAdminPort`, returning a port bound to that instance's live in-memory `state` (+ its private `flushToDisk`). The `aws-runtime`/`cdk` stubs throw if imported (the admin block's aws/cdk entries never touch them). This subpath is invisible to `conditional-exports.test.ts` (it only imports the bare `"."`), so parity is unaffected — but the umbrella `@aws-blocks/blocks` must **not** re-export `/internal` (it's not customer API). + +## Test Plan + +Mirrors `bb-auth-cognito`'s test layout (`*.test.ts` per entry + a sandbox suite): + +| File | Covers | +|---|---| +| `index.test.ts` (mock) | Full lifecycle against the mock port: add/remove group → `getUserGroups` reflects it → a **fresh `auth.signIn`** then sees the new claim in `requireRole` (the end-to-end bug-fix assertion); `createUser`+`groups` → member appears in `getUsersInGroup`; `deleteUser` strips group membership; `disableUser` → `auth.signIn` throws `NotAuthorized` (regression-guards the already-shipped `index.ts:452` check); `removeUserFromGroup` non-member = no-op; `addUserToGroup` to unseeded group throws `GroupNotFound`. | +| `index.cdk.test.ts` | Synth a stack with `AuthCognito` + `AuthCognitoAdmin`; assert the handler role has the 13 `Admin*`/`List*` actions scoped to the pool ARN, and that an app **without** the admin block has none of them (least-privilege regression). | +| `types.types-test.ts` | `addUserToGroup(u, 'typo')` is a compile error when `auth` was built with `groups: [...] as const`; widens to `string` without `as const` (matches `requireRole`). | +| `scenarios.sandbox.test.ts` | Real-Cognito: create user → add to group → user signs in → ID token carries `cognito:groups` → `requireRole` passes; `revokeUserSessions` invalidates refresh. Gated behind the same sandbox harness as `bb-auth-cognito`. | + +## Serialization + +All return values are plain `CognitoUser` objects (or `null`, or `void`) — natively `JSON.stringify`-able and client-safe (G2). `getUsersInGroup` / `scan` return server-only `AsyncIterable` (G5): consume them inside an API method and return a collected plain array to the client; an `AsyncIterable` cannot cross the wire directly. + +## Usage Examples + +> **How a blocks customer builds an admin UI in v1.** There is no auto-generated admin panel yet (an `AdminSite` "Users" panel is future, additive work — see Resolved Decision 9). The first-class, supported path *today* is exactly what's shown below: the customer wires **their own guarded `ApiNamespace` routes** over `AuthCognitoAdmin` and renders the plain-data responses in their own frontend. The "full user-management admin screen" example is a copy-pasteable starting point for that. + +### Guarded admin routes (the only correct way to expose these) + +```typescript +const auth = new AuthCognito(scope, 'auth', { groups: ['admins', 'readers'] as const }); +const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); + +export const adminApi = new ApiNamespace(scope, 'admin-api', (context: KitContext) => ({ + async promote(username: string) { + await auth.requireRole(context, 'admins'); // gate first + await admin.addUserToGroup(username, 'admins'); // GroupOf-typed + }, + async listAdmins() { + await auth.requireRole(context, 'admins'); + const out: CognitoUser[] = []; + for await (const u of admin.getUsersInGroup('admins')) out.push(u); + return out; // plain array → client-safe + }, +})); +``` + +### Seeding the first admin (bootstrap script, not a public route) + +```typescript +// scripts/seed-admin.ts — run once via a migration/CLI, not from the API. +// Solves the bootstrapping paradox: no admin exists yet to promote the first one. +await admin.createUser('founder', { + temporaryPassword: process.env.SEED_PASSWORD, + attributes: { email: 'founder@example.com' }, + groups: ['admins'], +}); +``` + +### Error handling + +```typescript +import { isKitError } from '@aws-blocks/core'; +import { AuthCognitoAdminErrors } from '@aws-blocks/bb-auth-cognito-admin'; + +try { + await admin.addUserToGroup(username, 'admins'); +} catch (e) { + if (isKitError(e, AuthCognitoAdminErrors.UserNotFound)) { /* surface 404 */ } + if (isKitError(e, AuthCognitoAdminErrors.GroupNotFound)) { /* misconfigured group */ } + throw e; +} +``` + +### Real-world: a full user-management admin screen + +A complete backend for an internal "Users" admin page — list with search, view detail, change role, suspend. Every route gated; every return value plain data the frontend renders directly. + +```typescript +const auth = new AuthCognito(scope, 'auth', { + groups: ['admins', 'editors', 'readers'] as const, +}); +const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); + +export const usersAdminApi = new ApiNamespace(scope, 'users-admin-api', (context: KitContext) => { + // One gate helper, applied at the top of every method. + const gate = () => auth.requireRole(context, 'admins'); + + return { + // Paginated list. AsyncIterable is server-only (G5) — collect a page and + // return plain data. Optional server-side prefix search on email. + async listUsers(emailPrefix?: string) { + await gate(); + const users: CognitoUser[] = []; + const iter = emailPrefix + ? admin.scan({ filter: { attribute: 'email', op: '^=', value: emailPrefix } }) + : admin.scan(); + for await (const u of iter) { + users.push(u); + if (users.length >= 100) break; // cap the page in the handler + } + return users; // plain array → client-safe + }, + + async getUserDetail(username: string) { + await gate(); + return await admin.getUser(username); // CognitoUser | null + }, + + // Change a user's single role: remove from all configured groups, add the new one. + async setRole(username: string, role: 'admins' | 'editors' | 'readers') { + await gate(); + const current = await admin.getUserGroups(username); + await Promise.all(current.map((g) => admin.removeUserFromGroup(username, g))); + await admin.addUserToGroup(username, role); + // Make the change effective immediately rather than next token refresh: + await admin.revokeUserSessions(username); + return { username, role }; + }, + + async suspendUser(username: string) { + await gate(); + await admin.disableUser(username); + await admin.revokeUserSessions(username); // kick active sessions + return { username, status: 'suspended' }; + }, + + async reinstateUser(username: string) { + await gate(); + await admin.enableUser(username); + return { username, status: 'active' }; + }, + }; +}); +``` + +### Real-world: bulk onboarding from a CSV (background job) + +Admin-create a batch of users with a temp password and an initial role. Run from an `AsyncJob` or a CLI script — not a request handler — so a large batch isn't bound to one HTTP timeout. + +```typescript +async function onboard(rows: { email: string; role: 'editors' | 'readers' }[]) { + for (const { email, role } of rows) { + try { + await admin.createUser(email, { + attributes: { email }, + groups: [role], + suppressInvite: false, // let Cognito email the temp password + invite + }); + } catch (e) { + if (isKitError(e, AuthCognitoAdminErrors.UserAlreadyExists)) continue; // idempotent re-run + throw e; + } + } +} +``` + +### Real-world: "make me an admin" is impossible by design — seed instead + +There's a deliberate bootstrapping paradox: `setRole` is gated by `requireRole('admins')`, so with zero admins nobody can create the first one through the API. That's correct — promotion must not be self-service. Seed the first admin out-of-band: + +```typescript +// scripts/seed-admin.ts — `npx tsx scripts/seed-admin.ts`, run once per environment. +import { admin } from '../aws-blocks/index.js'; + +await admin.createUser(process.env.FOUNDER_EMAIL!, { + temporaryPassword: process.env.FOUNDER_TEMP_PASSWORD!, + attributes: { email: process.env.FOUNDER_EMAIL! }, + groups: ['admins'], +}); +console.log('Seeded founding admin. Sign in and change the temporary password.'); +``` + +### Gotcha: group changes are not retroactive to live sessions + +`requireRole` reads the `cognito:groups` claim baked into the user's ID token at sign-in (`bb-auth-cognito` DESIGN.md:183). After `addUserToGroup`, an already-signed-in user keeps their old permissions until their token refreshes. To force the new role to take effect *now*, call `revokeUserSessions(username)` (as `setRole`/`suspendUser` above do). This is inherent Cognito behavior, not a KIT quirk — surfaced explicitly so it's never a silent surprise. + +## Resolved Decisions (from design research) + +These were open questions, settled by reading the implementation: + +1. **Wiring shape → standalone `new AuthCognitoAdmin(scope, id, { auth })`.** Chosen over `auth.createAdmin()`. The deciding factor is the mock process model (below): the admin block needs a **live reference** to the auth instance, not just a discovery key, and `{ auth }` supplies exactly that. It also keeps the `Admin*` IAM grant out of `AuthCognito` (least privilege) and mirrors the existing `AdminSite({ auth })` precedent (`BB-admin-site.md:74`). The generic `O` is recovered from `auth: AuthCognito`, so `GroupOf` narrowing flows through with no extra annotation. + +2. **Mock state-sharing → in-memory delegation via a `CognitoMockAdminPort`, NOT file-sharing.** The mock loads `state.json` once at construction and never reloads (`index.ts:216`); blocks are module-scope singletons reused across all requests (`dev-server.ts:76`). Two instances on one file would clobber each other (lost updates). The admin mock therefore delegates to the live auth instance's in-memory `state` through a narrow port exported from a **`@aws-blocks/bb-auth-cognito/internal` subpath** (not in the public types). The export-parity test (`conditional-exports.test.ts`) inspects only the bare `"."` specifier, so `/internal` is out of its scope — see Mock Implementation for the per-condition resolution of that subpath. See Mock Implementation above. + +3. **Session-freshness → document + `revokeUserSessions()`.** `requireRole` reads the token's `cognito:groups` claim, not live state (both runtimes). Group changes take effect on next token refresh; `revokeUserSessions()` forces immediate effect. This is inherent Cognito semantics, surfaced explicitly rather than hidden. + +4. **Group *definition* stays deploy-time.** This block manages membership and user lifecycle at runtime only. Creating/deleting `CfnUserPoolGroup` remains an `AuthCognito.options.groups` (synth-time) concern — runtime group creation would violate G7 (constructor is the only infra side effect). `addUserToGroup` to an unseeded group throws `GroupNotFound`. + +5. **`scan` filter → typed subset, not raw string.** Resolved during review: `AdminScanOptions.filter` is `{ attribute; op; value }` over the attributes Cognito's `ListUsers` narrows server-side (G10 — don't leak the filter DSL; G18 — don't imply efficient filtering on unsupported attributes). See API Surface. + +6. **Disabled-user enforcement already ships** (corrected during review): the mock `signIn` already rejects `disabled` users (`index.ts:452`). This block only adds the `disableUser`/`enableUser` mutators + a regression test — it does not wire the `signIn` check. Reuse the existing `forcePasswordChange` field name (`index.ts:506`). + +7. **`addUserToGroup` is single-group, no array overload.** Cognito has no batch `AdminAddUserToGroup`; an array param would loop internally, which G14 forbids (don't fake batch over a loop — it misrepresents cost). Callers compose `Promise.all(roles.map(r => admin.addUserToGroup(u, r)))`. The common "assign roles at creation" case is already covered by `createUser({ groups: [...] })`. + +8. **`createUser` models `FORCE_CHANGE_PASSWORD` faithfully.** When `permanent` is falsy, the mock sets `forcePasswordChange = true` on the `MockUserRecord`; the existing mock `signIn` already routes such users through the `CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED` challenge (`index.ts:506-514` — the code comment even says "seed it to `true` when simulating an `AdminCreateUser({ Permanent: false })` user"). So faithful mock↔AWS parity is ~one line, not a shortcut. `permanent: true` skips the flag and writes the password directly (AWS: a follow-up `AdminSetUserPassword({ Permanent: true })`). + +9. **`AdminSite` composition deferred — but the user-management path is accounted for, not left to chance.** No `AdminSite` work in v1, and the API is already compatible (every return is client-safe plain data / a collectable `AsyncIterable`). Crucially, a blocks customer must still be able to ship a user-management UI **today** — that path is the **customer-built guarded `ApiNamespace` routes** shown in § Usage Examples ("full user-management admin screen"). That is the supported, documented v1 answer; the future `AdminSite` auto-panel is an additive convenience on top of the same methods, not a prerequisite. **Action for the build:** the README must lead with the guarded-routes recipe as the first-class way to build admin UI, and explicitly name `AuthCognitoAdmin` as the intended backend for a later `AdminSite` "Users" panel so return shapes are kept stable. Tracked as a forward-compat note, not a v1 dependency. + +10. **Ships as a separate package `@aws-blocks/bb-auth-cognito-admin`** (not a `bb-auth-cognito/admin` subpath). Three reasons: (a) **opt-in least privilege** — apps that import only `bb-auth-cognito` never pull the `Admin*` IAM grant or admin SDK surface; a subpath risks the grant leaking through shared CDK code; (b) **D-004 naming** blesses `bb-auth-*` family packages and sorts it next to its parent; (c) **browser-bundle safety** — its own throwing `index.browser.ts` keeps the AWS SDK out of client bundles without entangling the parent's exports. Cost: one more package to version (accepted; the release coupling a subpath would create is worse). + +> **Independent review applied.** This doc was reviewed by an independent agent against G1–G18 / T1–T5 and verified against the implementation. Corrections folded in: the false "disabled not yet enforced" claim (it is, `index.ts:452`); `GroupNotFound` confirmed to match the shipped `AuthCognitoErrors` value `ResourceNotFoundException` (the *parent* doc `BB-auth-cognito.md:296` is stale); `/internal` per-condition resolution + parity-test framing made precise; generic-narrowing `as const` caveat added; browser-stub + single-Lambda assumptions documented; G14 `createUser`/`setUserPassword` deviations acknowledged; method `@example`s/`@returns` completed; `scan` filter typed. + +## Open Questions + +*None blocking. All prior open questions (Q1–Q4) are resolved above (decisions 7–10). New questions that surface during implementation should be appended here.* From bc17a65fb714507d0347b5229afb16596a3a3b3b Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Mon, 29 Jun 2026 17:33:23 -0400 Subject: [PATCH 02/21] docs(tech-design): adopt in-package auth.admin handle for AuthCognitoAdmin Add implementation plan for the in-package `auth.admin` handle approach (opt-in via `admin` options object) instead of a separate `@aws-blocks/bb-auth-cognito-admin` package, per the reviewed counter-proposal. Mark the original separate-package design as superseded; retain it for the API-surface/error-model/mock-parity research the handle reuses. --- ...-auth-cognito-admin-implementation-plan.md | 151 ++++++++++++++++++ docs/tech-design/BB-auth-cognito-admin.md | 2 + 2 files changed, 153 insertions(+) create mode 100644 docs/tech-design/BB-auth-cognito-admin-implementation-plan.md diff --git a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md new file mode 100644 index 000000000..b7f0d7fa8 --- /dev/null +++ b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md @@ -0,0 +1,151 @@ +# AuthCognitoAdmin — Implementation Plan (in-package `auth.admin` handle) + +**Status:** approved direction, ready to implement. +**Supersedes:** the separate-`@aws-blocks/bb-auth-cognito-admin`-package design in [`BB-auth-cognito-admin.md`](./BB-auth-cognito-admin.md) and [PR #38](https://github.com/aws-devtools-labs/aws-blocks/pull/38). +**Adopts:** [Chorus counter-proposal — "Alternative to PR #38: in-package admin surface for `bb-auth-cognito`"](https://chorus.aws.dev/doc/8Cdonf9Y6RdR/Alternative-to-PR-38-in-package-admin-surface-for-bb-auth-co). + +## Decision + +Expose the admin surface as an **opt-in handle on the existing `AuthCognito` class** (`auth.admin`), gated by an `admin` options object — **not** a second package/class. This deletes the `/internal` subpath, the `CognitoMockAdminPort` (11-method) indirection, the second construct, and the live-reference wiring, while preserving the API separation the original design valued. + +The two designs are equivalent on safety (neither is an access-control boundary; both rely on lint + the shared-Lambda IAM model — verified: `Scope.handler` resolves to one stack handler, `core/src/cdk/index.ts:92-102`). The handle wins on cost. We give up independent versioning of the admin surface, which we do not need. + +**Breaking changes are acceptable** — the service is in preview. We do not need back-compat shims. + +## Scope of edits (all within `packages/bb-auth-cognito/`) + +| Area | File | Change | +|---|---|---| +| Options + types | `src/types.ts` | Add `admin?: AdminOptions`; `AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`; the `GroupAdmin` / `LifecycleAdmin` method interfaces; **`const O`** on the class generic (see Step 5). | +| Mock runtime | `src/index.ts` | `#admin` field built against the live `this.state`; `get admin()` getter; mock implementations of group + lifecycle mutators reusing existing `state` + `flushToDisk()`. | +| AWS runtime | `src/index.aws.ts` | `#admin` built against SDK `Admin*` commands; same getter; error-mapping via the existing helper. | +| Browser stub | `src/index.browser.ts` | Add a throwing `get admin()` to the no-op class so the shape typechecks under `--conditions=browser`. | +| CDK | `src/index.cdk.ts` | In `grantCognitoPermissions`, add a **second** `PolicyStatement` (admin `Admin*`/`List*`) gated on `this.options.admin`, scoped by `actions`. | +| Tests | `src/*.test.ts` | Mock admin unit tests; type-level tests for the gate + narrowing; CDK grant assertions; fix existing `requireRole` call sites for `const O`. | +| Docs | `README.md`, this doc | Document the handle, the `admin`/`actions` opt-in, the narrowing rules, and session-freshness caveat. | + +No new package. No `/internal` subpath. No export-parity carve-out. + +## Interface (target) + +```typescript +// types.ts +interface AuthCognitoOptions { + /** + * Enables the admin surface (`auth.admin`) and grants the Admin*/List* IAM + * on the pool. Omit for the client-only surface with no admin grant (default, + * identical to today). + */ + admin?: AdminOptions; +} + +interface AdminOptions { + /** Scopes both the IAM grant and the typed surface. Omit to grant all. */ + actions?: readonly ('groups' | 'lifecycle')[]; +} + +type AdminActionsOf = + O extends { admin: { actions: infer A extends readonly string[] } } ? A[number] : 'groups' | 'lifecycle'; + +type AdminSurface = + ('groups' extends AdminActionsOf ? GroupAdmin : unknown) & + ('lifecycle' extends AdminActionsOf ? LifecycleAdmin : unknown); + +// Access without opting in is a compile error whose message names the fix. +type AdminDisabled = { readonly __adminNotEnabled: "construct AuthCognito with { admin: {} }" }; +``` + +```typescript +// usage +const auth = new AuthCognito(scope, 'auth', { + groups: ['admins'], + admin: { actions: ['groups'] }, // exposes auth.admin, grants only group Admin* actions +}); +await auth.requireRole(ctx, 'admins'); // gate the route +await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via GroupOf +``` + +`auth.admin` is `AdminDisabled` (compile error on access) unless `admin` is set; the getter also throws at runtime for untyped JS callers. A pool that never opts in gets **no** `Admin*` grant — synthesized role identical to today. + +## Method surface + +`GroupAdmin`: +- `addUserToGroup(username: string, group: GroupOf): Promise` +- `removeUserFromGroup(username: string, group: GroupOf): Promise` +- `listGroupsForUser(username: string): Promise[]>` +- `listUsersInGroup(group: GroupOf): Promise` + +`LifecycleAdmin`: +- `createUser(username, init): Promise` +- `deleteUser(username): Promise` +- `disableUser(username): Promise` / `enableUser(username): Promise` +- `resetUserPassword(username): Promise` / `setUserPassword(username, password, permanent): Promise` +- `getUser(username): Promise` +- `scan(): AsyncIterable` (full-pool enumeration; page-by-page) +- `revokeUserSessions(username): Promise` (immediate effect for permission changes) + +Return shapes are stable so a future `AdminSite` "Users" panel binds to the same methods. + +## Implementation steps + +### Step 1 — Types and the gate (`src/types.ts`) +1. Add `admin?: AdminOptions` to `AuthCognitoOptions` (interface at `types.ts:276`). +2. Add `AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`, `GroupAdmin`, `LifecycleAdmin`, `AdminUser`. +3. **Gate is `object`, not `true`:** `admin: true` must NOT enable (a primitive isn't `object`); the gate `O extends { admin: object }` requires `admin: {}`. +4. `actions` scoping needs no `as const` — `admin: { actions: ['groups'] }` is preserved as `('groups')[]` by contextual typing. + +### Step 2 — Mock runtime (`src/index.ts`) +1. Build a `#admin` object in the constructor (after `this.state = this.loadFromDisk()`, `index.ts:238`) that closes over the **same** `this.state` and calls the existing private `flushToDisk()`. No port, no second instance → the lost-update hazard the original design feared cannot occur. +2. `get admin()` returns `#admin` typed as `AdminSurface`; throws `Error("admin not enabled: construct AuthCognito with { admin: {} }")` when `!this.options.admin`. +3. Mutators map onto existing state (`PersistedState`, `index.ts:159`: `users`, `groups: Record`): + - `addUserToGroup` → push to `state.groups[group]`, throw `GroupNotFound` if group was never seeded (Cognito has no implicit group creation); `removeUserFromGroup` → filter. + - `createUser` → write a `MockUserRecord` mirroring `signUp` (reuse `prefixCustomAttrs`, password policy); `deleteUser` → delete record **and** strip from every `state.groups[*]`. + - `disableUser`/`enableUser` → toggle the existing `disabled` flag (`MockUserRecord.disabled`, `index.ts:114`). **Do not** re-implement the disabled-sign-in check — `signIn` already rejects disabled users (`index.ts:474`). + - `resetUserPassword`/`setUserPassword` → reuse the existing **`forcePasswordChange`** flag name (`index.ts:528`) — do NOT introduce a parallel `forceChangePassword`. + - `revokeUserSessions` → delete the user's session records. + - `scan`/`listUsersInGroup` → iterate in-memory maps, yield page-by-page (exercise the same `AsyncIterable` path AWS pagination uses). +4. Every mutator calls `flushToDisk()` so changes are visible to the same instance's next `signIn`/`requireRole`. + +### Step 3 — AWS runtime (`src/index.aws.ts`) +1. Build `#admin` against `@aws-sdk/client-cognito-identity-provider` `Admin*` commands (already a hard dep — `package.json:41`). Reuse the existing client, discovery (env vars), and error-mapping helper so the code reads as a sibling of the client methods. +2. Same getter contract and throw behavior. +3. `revokeUserSessions` → `AdminUserGlobalSignOut`. + +### Step 4 — CDK grant (`src/index.cdk.ts`) +1. In `grantCognitoPermissions` (`index.cdk.ts:310`), after the existing client-facing statement, add a **second** `PolicyStatement` **only when `this.options.admin` is set**, scoped to `this.userPool.userPoolArn`. +2. `actions` scopes the grant: + - `['groups']` → `AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup` + - `['lifecycle']` → `AdminCreateUser`, `AdminDeleteUser`, `AdminEnableUser`, `AdminDisableUser`, `AdminResetUserPassword`, `AdminSetUserPassword`, `AdminGetUser`, `ListUsers`, `AdminUserGlobalSignOut` + - omitted → all of the above +3. The typed surface (`AdminSurface`) and the grant are both driven by `actions` → they cannot drift. + +### Step 5 — `const O` (separable; flag in PR description) +1. Change the class generic to `` across `index.ts:200`, `index.aws.ts:583`, `index.cdk.ts:66`, `index.browser.ts:31` so inline literals (`groups: ['admins']`) narrow without `as const`. +2. **Blast radius is wider than groups** — `const O` flips five literal projections to narrow-by-default: `GroupOf` (`requireRole`), `AttrOf` (`updateUserAttribute`/`confirmUserAttribute`/`sendUserAttributeVerificationCode`/`updateUserAttributes`), `ReadAttrOf` (`fetchUserAttributes` return), `MfaTypeOf` (`confirmSignIn({ mfaType })`), `CustomAttrNames`. +3. **Concrete call sites that break** (variable arg vs. narrowed union): + - `test-apps/comprehensive/aws-blocks/index.ts:938` — `authC.requireRole(context, role)` where `role: string` + - `test-apps/native-bindings/aws-blocks/index.ts:241` — `authCognito.requireRole(context, role)` where `role: string` + Fix: widen the handler param to `GroupOf` (or accept `string` and cast at the call). The scaffold template already uses literals, so new users are unaffected. +4. **Recommendation:** land `const O` as its own commit within this PR with the blast radius explicitly listed, so reviewers see it's a deliberate API change, not a free side effect. If it proves contentious, it can ship separately — the handle does not strictly require it (it only improves group-narrowing ergonomics). + +### Step 6 — Browser stub (`src/index.browser.ts`) +Add `get admin(): never { throw ... }` to the no-op class (`index.browser.ts:31`) so `auth.admin` typechecks under `--conditions=browser`. No SDK reaches client bundles — the `"browser"` export condition already resolves away `index.aws.ts` (verified in `package.json` exports). + +### Step 7 — Tests +1. Mock unit tests: group add/remove (+ `GroupNotFound`), lifecycle create/delete (+ group cleanup), disable/enable (+ regression: `signIn` still rejects disabled), password reset/set, `scan` pagination, `revokeUserSessions`. +2. Type-level tests: `auth.admin` is `AdminDisabled` without opt-in (expect compile error); `actions: ['groups']` exposes only `GroupAdmin`; `admin: true` does NOT enable; `GroupOf` narrows under `const O`. +3. CDK: assert no admin statement without `admin`; correct scoped actions for `['groups']` / `['lifecycle']` / all. +4. Confirm `conditional-exports.test.ts` (`packages/blocks/src/`) still passes — it inspects only the bare specifier, so the handle adds no new subpath to reconcile. + +### Step 8 — Docs +Update `README.md`: the `admin`/`actions` opt-in, narrowing rules (and the `const O` story), "always gate admin routes behind `requireRole`", and the session-freshness caveat (group changes apply on next sign-in/refresh; `revokeUserSessions` for immediate effect — inherent Cognito behavior, not a KIT bug). + +## Open questions (carried from the counter-proposal) +1. Property name: `admin` vs a louder `adminApi`. +2. `actions` granularity: `'groups' | 'lifecycle'` vs 1:1 mapping to individual `Admin*` actions. + +## Verification before PR update +- `tsc --build` clean across the package (all four entries). +- Package unit + type tests green. +- `conditional-exports.test.ts` green. +- `test-apps/comprehensive` and `test-apps/native-bindings` typecheck after the `const O` call-site fixes. diff --git a/docs/tech-design/BB-auth-cognito-admin.md b/docs/tech-design/BB-auth-cognito-admin.md index 4244b2624..bf04a84ed 100644 --- a/docs/tech-design/BB-auth-cognito-admin.md +++ b/docs/tech-design/BB-auth-cognito-admin.md @@ -1,5 +1,7 @@ # BB: AuthCognitoAdmin (Implementation-Ready) +> **⚠️ SUPERSEDED (direction changed).** We are no longer shipping a separate `@aws-blocks/bb-auth-cognito-admin` package. The admin surface will be an opt-in handle (`auth.admin`) on the existing `AuthCognito` class. See [`BB-auth-cognito-admin-implementation-plan.md`](./BB-auth-cognito-admin-implementation-plan.md). This document is retained for the API surface, error model, and mock-parity research, which the handle reuses. + > **STATUS — Implementation-ready.** API surface, error model, per-runtime implementation, mock parity, and naming have been validated against the shipped `bb-auth-cognito` code and the binding guidelines (API Design Guidelines, Building Block Architecture, [DECISIONS D-004](../DECISIONS.md)). The design was independently reviewed and every claim verified against source. Open items remaining are explicitly listed at the end and are non-blocking for a v1 build. **Package:** `@aws-blocks/bb-auth-cognito-admin` From 61d8d62c57a9e2e2de4e2a69307f09eb93130854 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Mon, 29 Jun 2026 17:43:09 -0400 Subject: [PATCH 03/21] docs(tech-design): expand auth.admin plan with compiler-verified type design Detail the full type-safety design for the in-package admin handle: the AdminOptions/AdminSurface/AdminGetterOf gate, GroupAdmin/LifecycleAdmin interfaces, the single safe getter cast, and per-runtime + CDK + const-O migration steps. Every conditional type was compiled under tsc --strict with positive and @ts-expect-error negative cases (Appendices A/B); the proof lands in-repo as admin.types-test.ts. --- ...-auth-cognito-admin-implementation-plan.md | 311 ++++++++++++------ 1 file changed, 215 insertions(+), 96 deletions(-) diff --git a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md index b7f0d7fa8..ab23a4fbf 100644 --- a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md +++ b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md @@ -1,8 +1,9 @@ # AuthCognitoAdmin — Implementation Plan (in-package `auth.admin` handle) **Status:** approved direction, ready to implement. -**Supersedes:** the separate-`@aws-blocks/bb-auth-cognito-admin`-package design in [`BB-auth-cognito-admin.md`](./BB-auth-cognito-admin.md) and [PR #38](https://github.com/aws-devtools-labs/aws-blocks/pull/38). +**Supersedes:** the separate-`@aws-blocks/bb-auth-cognito-admin`-package design in [`BB-auth-cognito-admin.md`](./BB-auth-cognito-admin.md) and the original [PR #38](https://github.com/aws-devtools-labs/aws-blocks/pull/38). **Adopts:** [Chorus counter-proposal — "Alternative to PR #38: in-package admin surface for `bb-auth-cognito`"](https://chorus.aws.dev/doc/8Cdonf9Y6RdR/Alternative-to-PR-38-in-package-admin-surface-for-bb-auth-co). +**Guiding tenet:** **type safety first.** Every conditional type below was compiled under `tsc --strict` (TS 5.9) with positive and `@ts-expect-error` negative cases before this plan was written. The proof is reproduced in Appendix A and lands in-repo as `admin.types-test.ts` (Step 7). ## Decision @@ -10,142 +11,260 @@ Expose the admin surface as an **opt-in handle on the existing `AuthCognito` cla The two designs are equivalent on safety (neither is an access-control boundary; both rely on lint + the shared-Lambda IAM model — verified: `Scope.handler` resolves to one stack handler, `core/src/cdk/index.ts:92-102`). The handle wins on cost. We give up independent versioning of the admin surface, which we do not need. -**Breaking changes are acceptable** — the service is in preview. We do not need back-compat shims. +**Breaking changes are acceptable** — the service is in preview. No back-compat shims. -## Scope of edits (all within `packages/bb-auth-cognito/`) +## Type design (the core of this plan) -| Area | File | Change | -|---|---|---| -| Options + types | `src/types.ts` | Add `admin?: AdminOptions`; `AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`; the `GroupAdmin` / `LifecycleAdmin` method interfaces; **`const O`** on the class generic (see Step 5). | -| Mock runtime | `src/index.ts` | `#admin` field built against the live `this.state`; `get admin()` getter; mock implementations of group + lifecycle mutators reusing existing `state` + `flushToDisk()`. | -| AWS runtime | `src/index.aws.ts` | `#admin` built against SDK `Admin*` commands; same getter; error-mapping via the existing helper. | -| Browser stub | `src/index.browser.ts` | Add a throwing `get admin()` to the no-op class so the shape typechecks under `--conditions=browser`. | -| CDK | `src/index.cdk.ts` | In `grantCognitoPermissions`, add a **second** `PolicyStatement` (admin `Admin*`/`List*`) gated on `this.options.admin`, scoped by `actions`. | -| Tests | `src/*.test.ts` | Mock admin unit tests; type-level tests for the gate + narrowing; CDK grant assertions; fix existing `requireRole` call sites for `const O`. | -| Docs | `README.md`, this doc | Document the handle, the `admin`/`actions` opt-in, the narrowing rules, and session-freshness caveat. | +All additions live in `packages/bb-auth-cognito/src/types.ts`, alongside the existing `GroupOf` / `AttrOf` / `MfaTypeOf` projections, which set the precedent this design follows exactly. -No new package. No `/internal` subpath. No export-parity carve-out. +### 1. Options -## Interface (target) +```typescript +/** + * Enables the admin surface (`auth.admin`) and grants the matching Admin*/List* + * IAM on the pool. Omit for the client-only surface with no admin grant — the + * default, byte-identical to today's synthesized role. + */ +admin?: AdminOptions; // added to AuthCognitoOptions +``` ```typescript -// types.ts -interface AuthCognitoOptions { +export interface AdminOptions { /** - * Enables the admin surface (`auth.admin`) and grants the Admin*/List* IAM - * on the pool. Omit for the client-only surface with no admin grant (default, - * identical to today). + * Scopes BOTH the IAM grant and the typed `auth.admin` surface. Omit to + * enable everything. `['groups']` exposes only the group-membership methods + * and grants only the group Admin* actions. */ - admin?: AdminOptions; -} - -interface AdminOptions { - /** Scopes both the IAM grant and the typed surface. Omit to grant all. */ actions?: readonly ('groups' | 'lifecycle')[]; } +``` + +### 2. The gate and surface projections -type AdminActionsOf = - O extends { admin: { actions: infer A extends readonly string[] } } ? A[number] : 'groups' | 'lifecycle'; +```typescript +/** Active admin action set. Omitted `actions` ⇒ all. Mirrors GroupOf/MfaTypeOf style. */ +export type AdminActionsOf = + O extends { admin: { actions: infer A extends readonly string[] } } + ? A[number] + : 'groups' | 'lifecycle'; -type AdminSurface = +/** Intersection of the enabled slices. `unknown` is the identity for `&`, so a + * disabled slice contributes nothing. */ +export type AdminSurface = ('groups' extends AdminActionsOf ? GroupAdmin : unknown) & ('lifecycle' extends AdminActionsOf ? LifecycleAdmin : unknown); -// Access without opting in is a compile error whose message names the fix. -type AdminDisabled = { readonly __adminNotEnabled: "construct AuthCognito with { admin: {} }" }; +/** Branded "disabled" type. Touching any member is a compile error whose text + * names the fix — better DX than a plain `never`. */ +export type AdminDisabled = { + readonly __adminNotEnabled: 'construct AuthCognito with { admin: {} }'; +}; + +/** Getter return type — the gate. Note `{ admin: object }`, NOT `{ admin: any }`: + * `admin: true` is a primitive and fails the gate, so the opt-in MUST be an + * object (`admin: {}`). Verified by Appendix A assertion #6. */ +export type AdminGetterOf = + O extends { admin: object } ? AdminSurface : AdminDisabled; +``` + +### 3. Method interfaces (group narrowing reuses `GroupOf`) + +```typescript +export interface AdminUser { + username: string; + userSub: string; + enabled: boolean; + attributes: Record; + groups?: string[]; +} + +export interface AdminCreateInit { + temporaryPassword?: string; + attributes?: Record; + suppressInvite?: boolean; +} + +export interface GroupAdmin { + addUserToGroup(username: string, group: GroupOf): Promise; + removeUserFromGroup(username: string, group: GroupOf): Promise; + listGroupsForUser(username: string): Promise[]>; + listUsersInGroup(group: GroupOf): Promise; +} + +export interface LifecycleAdmin { + createUser(username: string, init?: AdminCreateInit): Promise; + deleteUser(username: string): Promise; + disableUser(username: string): Promise; + enableUser(username: string): Promise; + resetUserPassword(username: string): Promise; + setUserPassword(username: string, password: string, permanent: boolean): Promise; + getUser(username: string): Promise; + scan(): AsyncIterable; + revokeUserSessions(username: string): Promise; +} ``` +### 4. The class generic — `const O` (separable change; see Step 5) + ```typescript -// usage -const auth = new AuthCognito(scope, 'auth', { - groups: ['admins'], - admin: { actions: ['groups'] }, // exposes auth.admin, grants only group Admin* actions -}); -await auth.requireRole(ctx, 'admins'); // gate the route -await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via GroupOf +export class AuthCognito { ... get admin(): AdminGetterOf; } ``` -`auth.admin` is `AdminDisabled` (compile error on access) unless `admin` is set; the getter also throws at runtime for untyped JS callers. A pool that never opts in gets **no** `Admin*` grant — synthesized role identical to today. +`const O` makes inline literals (`groups: ['admins']`) narrow **without** `as const`. This is the one ergonomic upgrade and the one change with blast radius — handled deliberately in Step 5. + +### Why this is provably type-safe -## Method surface +- **One cast, and it does not leak.** The runtime builds the *full* admin object once, typed against the wide base (`GroupAdmin & LifecycleAdmin`). The getter performs a single `return this.#admin as AdminGetterOf`. Appendix B compiles a consumer against this exact pattern and confirms typos and ungranted methods are **still** rejected — the cast narrows, it does not widen away safety. +- **`unknown` is the correct "off" value.** `X & unknown = X`, so a disabled slice vanishes from the intersection. Using `never` would poison the whole intersection to `never`. (Compiled: assertions #2–#4.) +- **Gate is `object`, not `any`/`true`.** Prevents the `admin: true` footgun. (Compiled: assertion #6.) +- **Default `O` stays wide.** No-generic-arg construction keeps `string` group typing and a disabled admin handle — zero impact on existing non-const callers' *types* (their *call sites* are addressed in Step 5). (Compiled: assertions #7–#8.) -`GroupAdmin`: -- `addUserToGroup(username: string, group: GroupOf): Promise` -- `removeUserFromGroup(username: string, group: GroupOf): Promise` -- `listGroupsForUser(username: string): Promise[]>` -- `listUsersInGroup(group: GroupOf): Promise` +## Scope of edits (all within `packages/bb-auth-cognito/`) -`LifecycleAdmin`: -- `createUser(username, init): Promise` -- `deleteUser(username): Promise` -- `disableUser(username): Promise` / `enableUser(username): Promise` -- `resetUserPassword(username): Promise` / `setUserPassword(username, password, permanent): Promise` -- `getUser(username): Promise` -- `scan(): AsyncIterable` (full-pool enumeration; page-by-page) -- `revokeUserSessions(username): Promise` (immediate effect for permission changes) +| Area | File | Change | +|---|---|---| +| Types + gate | `src/types.ts` | `AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`, `AdminGetterOf`, `GroupAdmin`, `LifecycleAdmin`, `AdminUser`, `AdminCreateInit`; add `admin?` to `AuthCognitoOptions`. | +| Mock runtime | `src/index.ts` | `#admin` built against live `this.state`; `get admin()`; mock mutators reusing `state` + `flushToDisk()`. | +| AWS runtime | `src/index.aws.ts` | `#admin` against SDK `Admin*` commands; same getter; existing error-mapper. | +| Browser stub | `src/index.browser.ts` | throwing `get admin()` so the shape typechecks under `--conditions=browser`. | +| CDK | `src/index.cdk.ts` | second `PolicyStatement` in `grantCognitoPermissions`, gated on `this.options.admin`, scoped by `actions`. | +| `const O` | all four entries | `` → `` (`index.ts:200`, `index.aws.ts:583`, `index.cdk.ts:66`, `index.browser.ts:31`). | +| Type tests | `src/admin.types-test.ts` | the Appendix A proof, repo-convention style. | +| Unit tests | `src/admin.test.ts` | mock behavior; CDK grant assertions. | +| Call-site fixes | `test-apps/*` | `requireRole` variable-arg sites (Step 5). | +| Docs | `README.md` | opt-in, narrowing, gating, session-freshness. | -Return shapes are stable so a future `AdminSite` "Users" panel binds to the same methods. +No new package. No `/internal` subpath. No export-parity carve-out (`conditional-exports.test.ts` inspects only the bare specifier). ## Implementation steps -### Step 1 — Types and the gate (`src/types.ts`) -1. Add `admin?: AdminOptions` to `AuthCognitoOptions` (interface at `types.ts:276`). -2. Add `AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`, `GroupAdmin`, `LifecycleAdmin`, `AdminUser`. -3. **Gate is `object`, not `true`:** `admin: true` must NOT enable (a primitive isn't `object`); the gate `O extends { admin: object }` requires `admin: {}`. -4. `actions` scoping needs no `as const` — `admin: { actions: ['groups'] }` is preserved as `('groups')[]` by contextual typing. +### Step 1 — Types and gate (`src/types.ts`) +Add the block from "Type design" §1–§3. Place it near `GroupOf`/`MfaTypeOf` (around `types.ts:271`) so the projections sit together. No class change yet (that's Step 5). **Gate: verify `admin: object`, not `true`.** ### Step 2 — Mock runtime (`src/index.ts`) -1. Build a `#admin` object in the constructor (after `this.state = this.loadFromDisk()`, `index.ts:238`) that closes over the **same** `this.state` and calls the existing private `flushToDisk()`. No port, no second instance → the lost-update hazard the original design feared cannot occur. -2. `get admin()` returns `#admin` typed as `AdminSurface`; throws `Error("admin not enabled: construct AuthCognito with { admin: {} }")` when `!this.options.admin`. -3. Mutators map onto existing state (`PersistedState`, `index.ts:159`: `users`, `groups: Record`): - - `addUserToGroup` → push to `state.groups[group]`, throw `GroupNotFound` if group was never seeded (Cognito has no implicit group creation); `removeUserFromGroup` → filter. - - `createUser` → write a `MockUserRecord` mirroring `signUp` (reuse `prefixCustomAttrs`, password policy); `deleteUser` → delete record **and** strip from every `state.groups[*]`. - - `disableUser`/`enableUser` → toggle the existing `disabled` flag (`MockUserRecord.disabled`, `index.ts:114`). **Do not** re-implement the disabled-sign-in check — `signIn` already rejects disabled users (`index.ts:474`). - - `resetUserPassword`/`setUserPassword` → reuse the existing **`forcePasswordChange`** flag name (`index.ts:528`) — do NOT introduce a parallel `forceChangePassword`. +1. After `this.state = this.loadFromDisk()` (`index.ts:238`), build `this.#admin` closing over the **same** `this.state` object and the existing private `flushToDisk()`. One instance ⇒ one `state.groups` ⇒ the lost-update hazard that forced the original `/internal` port **cannot occur**. +2. `get admin(): AdminGetterOf` — throw `Error('admin not enabled: construct AuthCognito with { admin: {} }')` when `!this.options.admin`; else `return this.#admin as AdminGetterOf`. +3. Mutators onto `PersistedState` (`index.ts:159`; `users`, `groups: Record`): + - `addUserToGroup` → push to `state.groups[group]`; throw `GroupNotFound` (`AuthCognitoErrors.GroupNotFound`, `types.ts:968`) if the group was never seeded (Cognito has no implicit group creation). `removeUserFromGroup` → filter. + - `createUser` → `MockUserRecord` mirroring `signUp` (reuse `prefixCustomAttrs`, password-policy enforcement); `deleteUser` → delete record **and** strip from every `state.groups[*]`. + - `disableUser`/`enableUser` → toggle existing `MockUserRecord.disabled` (`index.ts:114`). **Do not** re-implement the disabled-sign-in check — `signIn` already rejects disabled users (`index.ts:474`). Add a regression test that it still does. + - `resetUserPassword`/`setUserPassword` → reuse the existing **`forcePasswordChange`** flag name (`index.ts:528`); do NOT introduce `forceChangePassword`. - `revokeUserSessions` → delete the user's session records. - - `scan`/`listUsersInGroup` → iterate in-memory maps, yield page-by-page (exercise the same `AsyncIterable` path AWS pagination uses). + - `scan`/`listUsersInGroup` → iterate in-memory maps, `yield` page-by-page (same `AsyncIterable` path AWS pagination uses). 4. Every mutator calls `flushToDisk()` so changes are visible to the same instance's next `signIn`/`requireRole`. ### Step 3 — AWS runtime (`src/index.aws.ts`) -1. Build `#admin` against `@aws-sdk/client-cognito-identity-provider` `Admin*` commands (already a hard dep — `package.json:41`). Reuse the existing client, discovery (env vars), and error-mapping helper so the code reads as a sibling of the client methods. -2. Same getter contract and throw behavior. -3. `revokeUserSessions` → `AdminUserGlobalSignOut`. +1. Build `#admin` against `@aws-sdk/client-cognito-identity-provider` `Admin*` commands (already a hard dep — `package.json:41`). Reuse the existing client, discovery via `envVarNames(this.fullId)` (`types.ts:1051`), and the existing error-mapping helper so the methods read as siblings of the client methods. +2. Same getter contract and throw text. +3. `revokeUserSessions` → `AdminUserGlobalSignOut`. `setUserPassword` → `AdminSetUserPassword(Permanent)`. `resetUserPassword` → `AdminResetUserPassword`. ### Step 4 — CDK grant (`src/index.cdk.ts`) -1. In `grantCognitoPermissions` (`index.cdk.ts:310`), after the existing client-facing statement, add a **second** `PolicyStatement` **only when `this.options.admin` is set**, scoped to `this.userPool.userPoolArn`. -2. `actions` scopes the grant: - - `['groups']` → `AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup` - - `['lifecycle']` → `AdminCreateUser`, `AdminDeleteUser`, `AdminEnableUser`, `AdminDisableUser`, `AdminResetUserPassword`, `AdminSetUserPassword`, `AdminGetUser`, `ListUsers`, `AdminUserGlobalSignOut` - - omitted → all of the above -3. The typed surface (`AdminSurface`) and the grant are both driven by `actions` → they cannot drift. - -### Step 5 — `const O` (separable; flag in PR description) -1. Change the class generic to `` across `index.ts:200`, `index.aws.ts:583`, `index.cdk.ts:66`, `index.browser.ts:31` so inline literals (`groups: ['admins']`) narrow without `as const`. -2. **Blast radius is wider than groups** — `const O` flips five literal projections to narrow-by-default: `GroupOf` (`requireRole`), `AttrOf` (`updateUserAttribute`/`confirmUserAttribute`/`sendUserAttributeVerificationCode`/`updateUserAttributes`), `ReadAttrOf` (`fetchUserAttributes` return), `MfaTypeOf` (`confirmSignIn({ mfaType })`), `CustomAttrNames`. -3. **Concrete call sites that break** (variable arg vs. narrowed union): - - `test-apps/comprehensive/aws-blocks/index.ts:938` — `authC.requireRole(context, role)` where `role: string` - - `test-apps/native-bindings/aws-blocks/index.ts:241` — `authCognito.requireRole(context, role)` where `role: string` - Fix: widen the handler param to `GroupOf` (or accept `string` and cast at the call). The scaffold template already uses literals, so new users are unaffected. -4. **Recommendation:** land `const O` as its own commit within this PR with the blast radius explicitly listed, so reviewers see it's a deliberate API change, not a free side effect. If it proves contentious, it can ship separately — the handle does not strictly require it (it only improves group-narrowing ergonomics). +In `grantCognitoPermissions` (`index.cdk.ts:310`), after the existing client statement, add a **second** `PolicyStatement` **only when `this.options.admin`**, scoped to `this.userPool.userPoolArn`, with actions selected by `actions`: +- `'groups'` → `AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup` +- `'lifecycle'` → `AdminCreateUser`, `AdminDeleteUser`, `AdminEnableUser`, `AdminDisableUser`, `AdminResetUserPassword`, `AdminSetUserPassword`, `AdminGetUser`, `ListUsers`, `AdminUserGlobalSignOut` +- omitted → union of both + +The typed surface and the grant are both driven by `actions`, so they cannot drift. No `admin` ⇒ no statement ⇒ role identical to today. + +### Step 5 — `const O` migration (separable; own commit) +1. `` → `` on all four entries (`index.ts:200`, `index.aws.ts:583`, `index.cdk.ts:66`, `index.browser.ts:31`). +2. **Blast radius is wider than groups** — `const O` flips five projections to narrow-by-default: `GroupOf` (`requireRole`), `AttrOf` (`updateUserAttribute`/`confirmUserAttribute`/`sendUserAttributeVerificationCode`/`updateUserAttributes`/`SignUpOptions.attributes`/`ConfirmSignInOptions.userAttributes`), `ReadAttrOf` (`fetchUserAttributes`, `CognitoUser.attributes`), `MfaTypeOf` (`confirmSignIn({ mfaType })`, `MFAPreferenceInput`, `MFAPreference`), `CustomAttrNames`. +3. **Concrete breaking call sites** (variable arg vs. narrowed union): + - `test-apps/comprehensive/aws-blocks/index.ts:938` — `authC.requireRole(context, role)` where `role: string`. + - `test-apps/native-bindings/aws-blocks/index.ts:241` — `authCognito.requireRole(context, role)` where `role: string`. + Fix: type the handler param as `GroupOf`, or keep `string` and pass `role as GroupOf` at the call. Scaffold template already uses literals + literal args — new users unaffected. +4. Land as its own commit titled to flag the API change; list the five projections in the body. The handle does not strictly require it (it only improves group narrowing), so it can be dropped if review pushes back. ### Step 6 — Browser stub (`src/index.browser.ts`) -Add `get admin(): never { throw ... }` to the no-op class (`index.browser.ts:31`) so `auth.admin` typechecks under `--conditions=browser`. No SDK reaches client bundles — the `"browser"` export condition already resolves away `index.aws.ts` (verified in `package.json` exports). +Add `get admin(): AdminGetterOf { throw new Error('AuthCognito.admin is server-only'); }` to the no-op class (`index.browser.ts:31`) so `auth.admin` typechecks under `--conditions=browser`. No SDK reaches client bundles — the `"browser"` export condition already resolves away `index.aws.ts` (verified in `package.json` exports). + +### Step 7 — Type tests (`src/admin.types-test.ts`) +Port Appendix A into the repo convention (compile-is-the-test, `@ts-expect-error`, matches `types.types-test.ts:1-18`). Picked up by `tsc --build`. Cases: gate-off error, `admin:{}` full surface, `actions:['groups']` excludes lifecycle, `actions:['lifecycle']` excludes groups, `const O` group typo rejected, `admin:true` rejected, default-O disabled, `requireRole` regression. -### Step 7 — Tests -1. Mock unit tests: group add/remove (+ `GroupNotFound`), lifecycle create/delete (+ group cleanup), disable/enable (+ regression: `signIn` still rejects disabled), password reset/set, `scan` pagination, `revokeUserSessions`. -2. Type-level tests: `auth.admin` is `AdminDisabled` without opt-in (expect compile error); `actions: ['groups']` exposes only `GroupAdmin`; `admin: true` does NOT enable; `GroupOf` narrows under `const O`. -3. CDK: assert no admin statement without `admin`; correct scoped actions for `['groups']` / `['lifecycle']` / all. -4. Confirm `conditional-exports.test.ts` (`packages/blocks/src/`) still passes — it inspects only the bare specifier, so the handle adds no new subpath to reconcile. +### Step 8 — Unit tests (`src/admin.test.ts`) + docs +- Mock: group add/remove (+ `GroupNotFound`), lifecycle create/delete (+ group cleanup), disable/enable (+ regression: `signIn` still rejects disabled), password reset/set, `scan` pagination, `revokeUserSessions`. +- CDK: no admin statement without `admin`; correct scoped actions for `['groups']` / `['lifecycle']` / omitted. +- `README.md`: `admin`/`actions` opt-in, narrowing rules + `const O`, "always gate admin routes behind `requireRole`", session-freshness caveat (group change applies on next sign-in/refresh; `revokeUserSessions` for immediate effect — inherent Cognito behavior). -### Step 8 — Docs -Update `README.md`: the `admin`/`actions` opt-in, narrowing rules (and the `const O` story), "always gate admin routes behind `requireRole`", and the session-freshness caveat (group changes apply on next sign-in/refresh; `revokeUserSessions` for immediate effect — inherent Cognito behavior, not a KIT bug). +## Verification gates (must all pass before PR update) +1. `tsc --build` clean across all four entries **and** `admin.types-test.ts`. +2. `admin.test.ts` (mock + CDK) green. +3. `conditional-exports.test.ts` (`packages/blocks/src/`) green — no new subpath. +4. `test-apps/comprehensive` + `test-apps/native-bindings` typecheck after Step 5 fixes. +5. `npm run build --workspace @aws-blocks/bb-auth-cognito` green. ## Open questions (carried from the counter-proposal) 1. Property name: `admin` vs a louder `adminApi`. 2. `actions` granularity: `'groups' | 'lifecycle'` vs 1:1 mapping to individual `Admin*` actions. -## Verification before PR update -- `tsc --build` clean across the package (all four entries). -- Package unit + type tests green. -- `conditional-exports.test.ts` green. -- `test-apps/comprehensive` and `test-apps/native-bindings` typecheck after the `const O` call-site fixes. +--- + +## Appendix A — compiler-verified type proof (consumer side) + +Compiled with `tsc --strict --noEmit` (TS 5.9.3), **exit 0**. Each `@ts-expect-error` asserts a real error; the file fails to compile if any expectation is wrong. This becomes `src/admin.types-test.ts`. + +```typescript +// (1) no opt-in → AdminDisabled; method access errors +const a1 = new AuthCognito({ groups: ['admins'] }); +// @ts-expect-error admin not enabled +a1.admin.addUserToGroup('u', 'admins'); + +// (2) admin: {} → full surface +const a2 = new AuthCognito({ groups: ['admins'], admin: {} }); +a2.admin.addUserToGroup('u', 'admins'); a2.admin.createUser('u'); + +// (3) actions: ['groups'] → lifecycle absent +const a3 = new AuthCognito({ groups: ['admins'], admin: { actions: ['groups'] } }); +a3.admin.addUserToGroup('u', 'admins'); +// @ts-expect-error lifecycle not granted +a3.admin.createUser('u'); + +// (4) actions: ['lifecycle'] → groups absent +const a4 = new AuthCognito({ groups: ['admins'], admin: { actions: ['lifecycle'] } }); +a4.admin.createUser('u'); +// @ts-expect-error groups not granted +a4.admin.addUserToGroup('u', 'admins'); + +// (5) const O narrows WITHOUT as const → typo rejected +const a5 = new AuthCognito({ groups: ['admins', 'readers'], admin: { actions: ['groups'] } }); +a5.admin.addUserToGroup('u', 'admins'); +// @ts-expect-error 'editor' ∉ 'admins' | 'readers' +a5.admin.addUserToGroup('u', 'editor'); + +// (6) admin: true must NOT enable +// @ts-expect-error true ∉ AdminOptions +const a6 = new AuthCognito({ groups: ['admins'], admin: true }); + +// (7) default O → admin disabled +const a7: AuthCognito = new AuthCognito(); +// @ts-expect-error disabled on default O +a7.admin.createUser('u'); + +// (8) requireRole regression under const O +const a8 = new AuthCognito({ groups: ['admins'] }); +a8.requireRole('admins'); +// @ts-expect-error not a known group +a8.requireRole('nope'); +``` + +## Appendix B — compiler-verified proof (implementation side) + +Compiled `tsc --strict --noEmit`, **exit 0**. Proves the single getter cast is safe: the impl is typed against the wide base, yet consumer typos and ungranted methods are still rejected. + +```typescript +class AuthCognito { + readonly #options: O; + readonly #admin: GroupAdmin & LifecycleAdmin; + constructor(opts: O = {} as O) { this.#options = opts; this.#admin = { /* full impl */ }; } + get admin(): AdminGetterOf { + if (!this.#options.admin) throw new Error('admin not enabled: construct AuthCognito with { admin: {} }'); + return this.#admin as AdminGetterOf; // the ONLY cast + } +} +const auth = new AuthCognito({ groups: ['admins', 'readers'], admin: { actions: ['groups'] } }); +auth.admin.addUserToGroup('u', 'admins'); +// @ts-expect-error typo rejected even though impl is wide +auth.admin.addUserToGroup('u', 'editor'); +// @ts-expect-error lifecycle hidden by actions scoping +auth.admin.createUser('u'); +``` From 02d4f11e31ca31f04c92bfbab3f6ffcd6501bd8a Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Mon, 29 Jun 2026 17:47:00 -0400 Subject: [PATCH 04/21] docs(tech-design): add task list and unit/integration verify lists to auth.admin plan 11 ordered, independently-committable tasks (T1-T11) with dependencies, plus a verify list spanning type tests, mock behavior, CDK grant assertions, and a sandbox-gated integration suite (incl. negative least-privilege). --- ...-auth-cognito-admin-implementation-plan.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md index ab23a4fbf..1d6766ed0 100644 --- a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md +++ b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md @@ -193,6 +193,73 @@ Port Appendix A into the repo convention (compile-is-the-test, `@ts-expect-error 4. `test-apps/comprehensive` + `test-apps/native-bindings` typecheck after Step 5 fixes. 5. `npm run build --workspace @aws-blocks/bb-auth-cognito` green. +## Task list (implementation order) + +Each task is independently committable. T5 (`const O`) is isolated so it can be dropped if review pushes back without unwinding the rest. + +| # | Task | Files | Depends on | +|---|---|---|---| +| T1 | Add admin types + gate (`AdminOptions`, `AdminActionsOf`, `AdminSurface`, `AdminDisabled`, `AdminGetterOf`, `GroupAdmin`, `LifecycleAdmin`, `AdminUser`, `AdminCreateInit`); add `admin?` to `AuthCognitoOptions` | `src/types.ts` | — | +| T2 | `admin.types-test.ts` (Appendix A) — land FIRST after T1 so the gate is locked before any runtime code | `src/admin.types-test.ts` | T1 | +| T3 | Mock runtime `#admin` + `get admin()` + mutators on live `this.state` | `src/index.ts` | T1 | +| T4 | AWS runtime `#admin` + getter against SDK `Admin*` commands | `src/index.aws.ts` | T1 | +| T5 | `const O` migration on all four entries + fix the 2 `requireRole` call sites *(separable commit)* | `src/index*.ts`, `test-apps/*` | T1 | +| T6 | Browser stub throwing `get admin()` | `src/index.browser.ts` | T1 | +| T7 | CDK second `PolicyStatement` gated on `admin`, scoped by `actions` | `src/index.cdk.ts` | T1 | +| T8 | Mock unit tests | `src/admin.test.ts` | T3 | +| T9 | CDK grant unit tests | `src/index.cdk.test.ts` | T7 | +| T10 | Integration: exercise `auth.admin` through the deployed Lambda backend | `test-apps/comprehensive/*` | T3,T4,T7 | +| T11 | README: opt-in, narrowing, gating, session-freshness | `README.md` | T3,T4,T7 | + +## Verify list + +### Unit (no AWS account; runs in CI) +Pattern mirrors the existing four test layers in this package. + +**Type tests** — `src/admin.types-test.ts` (compile-is-the-test, `@ts-expect-error`; run by `tsc --build`): +- [ ] No `admin` opt-in ⇒ `auth.admin` member access is a compile error. +- [ ] `admin: {}` ⇒ both `GroupAdmin` + `LifecycleAdmin` present. +- [ ] `actions: ['groups']` ⇒ lifecycle methods are compile errors; group methods OK. +- [ ] `actions: ['lifecycle']` ⇒ group methods are compile errors; lifecycle OK. +- [ ] `const O` ⇒ group typo (`'editor'`) rejected **without** `as const`. +- [ ] `admin: true` ⇒ construction is a compile error. +- [ ] Default `O` ⇒ admin disabled; `requireRole('nope')` still rejected (const-O regression guard). + +**Mock behavior** — `src/admin.test.ts` (`node:test`, real mock instance, in-memory): +- [ ] `addUserToGroup` writes to `state.groups`; visible to the same instance's next `requireRole`. +- [ ] `addUserToGroup` to an unseeded group throws `GroupNotFound`. +- [ ] `removeUserFromGroup` filters membership. +- [ ] `createUser` mirrors `signUp` (custom-attr prefixing, password policy); `deleteUser` removes record **and** strips from every group array. +- [ ] `disableUser`/`enableUser` toggle `MockUserRecord.disabled`; **regression:** `signIn` still rejects a disabled user (existing `index.ts:474` behavior unchanged). +- [ ] `resetUserPassword`/`setUserPassword` set the existing `forcePasswordChange` flag (no new `forceChangePassword`). +- [ ] `revokeUserSessions` deletes session records. +- [ ] `scan`/`listUsersInGroup` paginate via `AsyncIterable`. +- [ ] Getter throws the named error when `admin` not enabled (runtime guard for untyped JS callers). + +**CDK** — `src/index.cdk.test.ts` (`Template.fromStack` + `hasResourceProperties`): +- [ ] No `admin` ⇒ synthesized handler role has **no** `Admin*` statement (byte-identical to today). +- [ ] `actions: ['groups']` ⇒ exactly the 4 group actions, scoped to the pool ARN. +- [ ] `actions: ['lifecycle']` ⇒ exactly the lifecycle/list actions. +- [ ] `admin: {}` (omitted actions) ⇒ union of both sets. + +**Cross-cutting**: +- [ ] `tsc --build` clean across all four entries + `admin.types-test.ts`. +- [ ] `conditional-exports.test.ts` (`packages/blocks/src/`) green — confirms no new subpath/export-parity work. +- [ ] `npm run build --workspace @aws-blocks/bb-auth-cognito` green. +- [ ] `test-apps/comprehensive` + `test-apps/native-bindings` typecheck after the T5 call-site fixes. + +### Integration (live AWS; sandbox-gated) +Exercises the real deployed surface through the Lambda backend, the way `test-apps/comprehensive/test/sandbox-admin-e2e.ts` already does for client methods. **Prereqs:** deployed `bb-test-*` sandbox, `AWS_PROFILE` with Cognito admin + CFN-describe perms, run from `test-apps/comprehensive`. +- [ ] Deploy comprehensive backend with `admin: { actions: ['groups','lifecycle'] }` on the pool; CFN synth/deploy succeeds and the handler role carries the admin statement. +- [ ] `auth.admin.createUser` → real Cognito user appears (`AdminGetUser` confirms). +- [ ] `auth.admin.addUserToGroup` → then `signIn` + `requireRole('admins')` succeeds (membership took effect on fresh token). +- [ ] Group change on an **existing** session is NOT retroactive until refresh; `revokeUserSessions` forces re-auth and the new claim appears (session-freshness contract). +- [ ] `auth.admin.disableUser` → subsequent `signIn` returns `NotAuthorizedException`; `enableUser` restores it. +- [ ] `setUserPassword(permanent:true)` → `signIn` with the new password succeeds, no `NEW_PASSWORD_REQUIRED`. +- [ ] `scan` paginates across a >1-page user set. +- [ ] **Negative least-privilege:** a pool deployed WITHOUT `admin` → calling an admin route 500s with an IAM `AccessDenied` server-side (no grant), proving the opt-in gates the grant. +- [ ] Teardown via existing `sandbox-destroy.ts`. + ## Open questions (carried from the counter-proposal) 1. Property name: `admin` vs a louder `adminApi`. 2. `actions` granularity: `'groups' | 'lifecycle'` vs 1:1 mapping to individual `Admin*` actions. From 1e6f9973431ba9b808ef86624d5ee998d8e9a669 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Mon, 29 Jun 2026 22:47:02 -0400 Subject: [PATCH 05/21] feat(bb-auth-cognito): implement opt-in auth.admin handle (types + all runtimes) Adds the server-side admin surface as an opt-in handle on AuthCognito (T1-T4, T6-T7): admin types + compile-time gate, mock runtime (group/lifecycle mutators on live state), AWS runtime (Admin* SDK commands), browser stub getter, and the CDK IAM grant scoped by admin.actions. Includes admin.types-test.ts. Type-safety finding: narrowing the method set by actions via a conditional over O forces AuthCognito invariant and regressed 14 existing call sites. Resolved by always exposing the full surface (group names still narrow via GroupOf); actions scopes the IAM grant only. Full package build + existing 117 tests green. Remaining: T5 const O, T8/T9 unit tests, T10 integration, T11 README. --- ...-auth-cognito-admin-implementation-plan.md | 9 + .../bb-auth-cognito/src/admin.types-test.ts | 89 ++++++++ packages/bb-auth-cognito/src/index.aws.ts | 208 ++++++++++++++++++ packages/bb-auth-cognito/src/index.browser.ts | 12 +- packages/bb-auth-cognito/src/index.cdk.ts | 50 +++++ packages/bb-auth-cognito/src/index.ts | 175 +++++++++++++++ packages/bb-auth-cognito/src/sessions.ts | 18 ++ packages/bb-auth-cognito/src/types.ts | 132 +++++++++++ 8 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 packages/bb-auth-cognito/src/admin.types-test.ts diff --git a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md index 1d6766ed0..b332f4558 100644 --- a/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md +++ b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md @@ -5,6 +5,15 @@ **Adopts:** [Chorus counter-proposal — "Alternative to PR #38: in-package admin surface for `bb-auth-cognito`"](https://chorus.aws.dev/doc/8Cdonf9Y6RdR/Alternative-to-PR-38-in-package-admin-surface-for-bb-auth-co). **Guiding tenet:** **type safety first.** Every conditional type below was compiled under `tsc --strict` (TS 5.9) with positive and `@ts-expect-error` negative cases before this plan was written. The proof is reproduced in Appendix A and lands in-repo as `admin.types-test.ts` (Step 7). +## ⚠️ Implementation finding — `actions` scopes the IAM grant, not the typed surface + +During implementation the compiler surfaced a **variance regression** that revised the type design: + +- The original plan had `actions: ['groups']` *hide* the lifecycle methods at the type level (via a conditional type over `O` inside `AdminSurface`). +- A conditional type over the class's own generic `O`, used as a **property/getter type**, forces TypeScript to treat `AuthCognito` as **invariant in `O`**. That breaks the long-standing contract that `AuthCognito` is assignable to `AuthCognito` — relied on across the repo (e.g. helpers typed `auth: AuthCognito`). Verified: the conditional form regressed **14 existing `index.test.ts` call sites**; the gate-only form (`O extends { admin: object } ? AdminSurface : AdminDisabled`, where `AdminSurface = GroupAdmin & LifecycleAdmin` is a plain generic interface) keeps the class covariant and builds clean. + +**Resolved design:** `auth.admin` always exposes the **full** surface (group + lifecycle), with group names still narrowed via `GroupOf`. `admin.actions` scopes the **IAM grant** only. A method whose action wasn't granted fails at runtime with an IAM `AccessDenied` — the same outcome a separate-package design gives a client route that imports the admin block. The compile-time gate (opt-in required) and group-name narrowing are both preserved. + ## Decision Expose the admin surface as an **opt-in handle on the existing `AuthCognito` class** (`auth.admin`), gated by an `admin` options object — **not** a second package/class. This deletes the `/internal` subpath, the `CognitoMockAdminPort` (11-method) indirection, the second construct, and the live-reference wiring, while preserving the API separation the original design valued. diff --git a/packages/bb-auth-cognito/src/admin.types-test.ts b/packages/bb-auth-cognito/src/admin.types-test.ts new file mode 100644 index 000000000..f03f730c6 --- /dev/null +++ b/packages/bb-auth-cognito/src/admin.types-test.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Negative + positive type tests for the opt-in `auth.admin` handle. + * + * The compile is the test — no runtime assertions. Each `@ts-expect-error` + * line asserts the following expression is a type error today; if the error + * ever disappears (e.g. the gate or `actions` scoping regresses), `tsc --build` + * fails and points at the now-unsatisfied `@ts-expect-error`. + * + * These cases are the same ones compiled standalone while designing the gate + * (see `docs/tech-design/BB-auth-cognito-admin-implementation-plan.md`, + * Appendix A) — here they run against the real `AuthCognito` class. + * + * @internal + */ + +import type { ScopeParent } from '@aws-blocks/core'; +import { AuthCognito } from './index.js'; + +declare const scope: ScopeParent; + +// ───────────────────────────────────────────────────────────────────────────── +// (1) No `admin` opt-in → `auth.admin` is `AdminDisabled`; member access errors. +// ───────────────────────────────────────────────────────────────────────────── +async function gateOff() { + const auth = new AuthCognito(scope, 'a1', { groups: ['admins'] }); + // @ts-expect-error — admin not enabled; `auth.admin` is AdminDisabled. + await auth.admin.addUserToGroup('u', 'admins'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// (2) `admin: {}` (no actions) → full surface: both groups + lifecycle present. +// ───────────────────────────────────────────────────────────────────────────── +async function fullSurface() { + const auth = new AuthCognito(scope, 'a2', { groups: ['admins'], admin: {} }); + await auth.admin.addUserToGroup('u', 'admins'); // group method present + await auth.admin.createUser('u'); // lifecycle method present +} + +// ───────────────────────────────────────────────────────────────────────────── +// (3) `actions` scopes the IAM grant, NOT the typed method set — the full +// surface is present at the type level regardless of `actions`. (Narrowing +// the type by `actions` would force `AuthCognito` invariant; see +// `AdminSurface` doc.) A method whose action wasn't granted fails at +// runtime with IAM AccessDenied, not at compile time. +// ───────────────────────────────────────────────────────────────────────────── +async function actionsScopeGrantNotTypes() { + const groupsScoped = new AuthCognito(scope, 'a3', { groups: ['admins'], admin: { actions: ['groups'] } }); + await groupsScoped.admin.addUserToGroup('u', 'admins'); + await groupsScoped.admin.createUser('u'); // present at type level (grant-scoped at runtime) + + const lifecycleScoped = new AuthCognito(scope, 'a4', { groups: ['admins'], admin: { actions: ['lifecycle'] } }); + await lifecycleScoped.admin.createUser('u'); + await lifecycleScoped.admin.addUserToGroup('u', 'admins'); // present at type level +} + +// ───────────────────────────────────────────────────────────────────────────── +// (5) Group narrowing on admin methods. With `as const` (today) the group union +// narrows and a typo is rejected. (Task T5 — `const O` — will make this hold +// WITHOUT `as const`; the @ts-expect-error below is tightened to the +// non-const form then.) +// ───────────────────────────────────────────────────────────────────────────── +async function groupNarrowing() { + const auth = new AuthCognito(scope, 'a5', { groups: ['admins', 'readers'] as const, admin: { actions: ['groups'] } }); + await auth.admin.addUserToGroup('u', 'admins'); + await auth.admin.addUserToGroup('u', 'readers'); + // @ts-expect-error — 'editor' is not in 'admins' | 'readers'. + await auth.admin.addUserToGroup('u', 'editor'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// (6) `admin: true` must NOT enable (primitive fails the `{ admin: object }` gate). +// ───────────────────────────────────────────────────────────────────────────── +async function adminTrueRejected() { + // @ts-expect-error — `true` is not assignable to AdminOptions. + const auth = new AuthCognito(scope, 'a6', { groups: ['admins'], admin: true }); + void auth; +} + +// ───────────────────────────────────────────────────────────────────────────── +// (7) Default `O` (no narrowing) → admin disabled. +// ───────────────────────────────────────────────────────────────────────────── +async function defaultO() { + const auth: AuthCognito = new AuthCognito(scope, 'a7'); + // @ts-expect-error — admin disabled on the default (wide) O. + await auth.admin.createUser('u'); +} diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index ab35e15f3..c6b5ddbe5 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -16,6 +16,21 @@ import crypto from 'node:crypto'; import { + AdminAddUserToGroupCommand, + AdminCreateUserCommand, + AdminDeleteUserCommand, + AdminDisableUserCommand, + AdminEnableUserCommand, + AdminGetUserCommand, + AdminListGroupsForUserCommand, + AdminRemoveUserFromGroupCommand, + AdminResetUserPasswordCommand, + AdminSetUserPasswordCommand, + AdminUserGlobalSignOutCommand, + ListUsersCommand, + ListUsersInGroupCommand, + type AttributeType, + type UserType, AssociateSoftwareTokenCommand, ChangePasswordCommand, ChallengeNameType, @@ -88,6 +103,11 @@ import { envVarNames, isRetriableAuthError, makeExternalUserPoolRef, + type AdminCreateInit, + type AdminGetterOf, + type AdminUser, + type GroupAdmin, + type LifecycleAdmin, type AuthCognitoOptions, type CodeDeliveryDetails, type AttrOf, @@ -593,6 +613,8 @@ export class AuthCognito private readonly sessions: SessionStore; private readonly sessionSecretSetting: AppSetting; private sessionSecret?: string; + /** Full admin surface, built once; the single generic projection lives in `get admin()`. */ + private readonly adminSurface: GroupAdmin & LifecycleAdmin; constructor(scope: ScopeParent, id: string, options?: O) { super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); @@ -632,6 +654,192 @@ export class AuthCognito // Nested scope `session-secret` — matches the CDK layer's AppSetting // so both sides derive the same SSM parameter path. this.sessionSecretSetting = new AppSetting(this, 'session-secret', { secret: true }); + this.adminSurface = this.buildAdminSurface(); + } + + /** + * Opt-in server-side admin surface (group membership + user lifecycle). + * `AdminDisabled` (compile error on access) unless `admin` was configured; + * throws at runtime for untyped JS callers. Methods are narrowed by + * `admin.actions`; the runtime object always carries every method. + * + * @category admin + */ + get admin(): AdminGetterOf { + if (!this.options.admin) { + throw new Error("admin not enabled: construct AuthCognito with { admin: {} }"); + } + return this.adminSurface as AdminGetterOf; + } + + /** Pool ID for admin SDK calls. Throws if discovery env isn't populated. */ + private adminUserPoolId(): string { + const { userPoolId } = getSdkIdentifiers(this); + if (!userPoolId) { + throw new ApiError('Cognito user pool not configured', 500, { name: DEFAULT_API_ERROR_NAME }); + } + return userPoolId; + } + + private buildAdminSurface(): GroupAdmin & LifecycleAdmin { + const toAdminUser = (u: UserType): AdminUser => { + const attributes: Record = {}; + for (const a of (u.Attributes ?? []) as AttributeType[]) { + if (a.Name) attributes[a.Name] = a.Value ?? ''; + } + return { + username: u.Username ?? '', + userSub: attributes['sub'] ?? '', + enabled: u.Enabled ?? true, + attributes, + }; + }; + + return { + // ── GroupAdmin ─────────────────────────────────────────────────── + addUserToGroup: async (username, group) => { + try { + await this.client.send(new AdminAddUserToGroupCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group), + })); + } catch (e) { throw asApiError(e); } + }, + removeUserFromGroup: async (username, group) => { + try { + await this.client.send(new AdminRemoveUserFromGroupCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group), + })); + } catch (e) { throw asApiError(e); } + }, + listGroupsForUser: async (username) => { + try { + const out: string[] = []; + let nextToken: string | undefined; + do { + const resp = await this.client.send(new AdminListGroupsForUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, NextToken: nextToken, + })); + for (const g of resp.Groups ?? []) if (g.GroupName) out.push(g.GroupName); + nextToken = resp.NextToken; + } while (nextToken); + return out as GroupOf[]; + } catch (e) { throw asApiError(e); } + }, + listUsersInGroup: async (group) => { + try { + const out: AdminUser[] = []; + let nextToken: string | undefined; + do { + const resp = await this.client.send(new ListUsersInGroupCommand({ + UserPoolId: this.adminUserPoolId(), GroupName: String(group), NextToken: nextToken, + })); + for (const u of resp.Users ?? []) out.push(toAdminUser(u)); + nextToken = resp.NextToken; + } while (nextToken); + return out; + } catch (e) { throw asApiError(e); } + }, + + // ── LifecycleAdmin ─────────────────────────────────────────────── + createUser: async (username, init) => { + try { + const attrs: AttributeType[] = Object.entries(init?.attributes ?? {}).map( + ([Name, Value]) => ({ Name, Value }), + ); + const resp = await this.client.send(new AdminCreateUserCommand({ + UserPoolId: this.adminUserPoolId(), + Username: username, + TemporaryPassword: init?.temporaryPassword, + UserAttributes: attrs.length ? attrs : undefined, + MessageAction: init?.suppressInvite ? 'SUPPRESS' : undefined, + })); + return resp.User ? toAdminUser(resp.User) : { username, userSub: '', enabled: true, attributes: {} }; + } catch (e) { throw asApiError(e); } + }, + deleteUser: async (username) => { + try { + await this.client.send(new AdminDeleteUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + disableUser: async (username) => { + try { + await this.client.send(new AdminDisableUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + enableUser: async (username) => { + try { + await this.client.send(new AdminEnableUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + resetUserPassword: async (username) => { + try { + await this.client.send(new AdminResetUserPasswordCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + setUserPassword: async (username, password, permanent) => { + try { + await this.client.send(new AdminSetUserPasswordCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, Password: password, Permanent: permanent, + })); + } catch (e) { throw asApiError(e); } + }, + getUser: async (username) => { + try { + const resp = await this.client.send(new AdminGetUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + const attributes: Record = {}; + for (const a of (resp.UserAttributes ?? []) as AttributeType[]) { + if (a.Name) attributes[a.Name] = a.Value ?? ''; + } + return { + username: resp.Username ?? username, + userSub: attributes['sub'] ?? '', + enabled: resp.Enabled ?? true, + attributes, + }; + } catch (e) { + if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null; + throw asApiError(e); + } + }, + scan: async function* (this: AuthCognito) { + let paginationToken: string | undefined; + do { + const resp = await this.client.send(new ListUsersCommand({ + UserPoolId: this.adminUserPoolId(), PaginationToken: paginationToken, + })); + for (const u of resp.Users ?? []) { + const attributes: Record = {}; + for (const a of (u.Attributes ?? []) as AttributeType[]) { + if (a.Name) attributes[a.Name] = a.Value ?? ''; + } + yield { + username: u.Username ?? '', + userSub: attributes['sub'] ?? '', + enabled: u.Enabled ?? true, + attributes, + }; + } + paginationToken = resp.PaginationToken; + } while (paginationToken); + }.bind(this), + revokeUserSessions: async (username) => { + try { + await this.client.send(new AdminUserGlobalSignOutCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + }; } private get verifier(): ReturnType { diff --git a/packages/bb-auth-cognito/src/index.browser.ts b/packages/bb-auth-cognito/src/index.browser.ts index 7ff93ffd8..fc14643d7 100644 --- a/packages/bb-auth-cognito/src/index.browser.ts +++ b/packages/bb-auth-cognito/src/index.browser.ts @@ -19,7 +19,7 @@ import { ApiNamespace } from '@aws-blocks/core'; import type { AuthStateApi } from '@aws-blocks/auth-common'; -import { makeExternalUserPoolRef, type AuthCognitoOptions } from './types.js'; +import { makeExternalUserPoolRef, type AuthCognitoOptions, type AdminGetterOf } from './types.js'; export type { BlocksAuth, AuthUser, AuthState, AuthAction, AuthField, AuthActionInput, AuthStateApi } from '@aws-blocks/auth-common'; export * from './types.js'; @@ -28,8 +28,16 @@ export * from './types.js'; // that references `AuthCognito` typechecks identically under // `--conditions=browser`. The browser stub never executes any method — // bundlers scan imports, they don't call anything. -export class AuthCognito<_O extends AuthCognitoOptions = AuthCognitoOptions> { +export class AuthCognito { constructor(..._args: unknown[]) { /* no-op */ } + /** + * Server-only admin surface. Present so `auth.admin` typechecks under + * `--conditions=browser`; never reachable at runtime (the browser never + * instantiates `AuthCognito`). Throws if somehow called. + */ + get admin(): AdminGetterOf { + throw new Error('AuthCognito.admin is server-only; the browser should call the generated client'); + } createApi(): AuthStateApi { // Real state-machine lives on the server. Browsers invoke the typed // generated client, not this stub — these methods only exist so the diff --git a/packages/bb-auth-cognito/src/index.cdk.ts b/packages/bb-auth-cognito/src/index.cdk.ts index 33c083836..5594952b9 100644 --- a/packages/bb-auth-cognito/src/index.cdk.ts +++ b/packages/bb-auth-cognito/src/index.cdk.ts @@ -28,6 +28,7 @@ import type { ScopeParent } from '@aws-blocks/core'; import { KVStore } from '@aws-blocks/bb-kv-store'; import { AppSetting } from '@aws-blocks/bb-app-setting'; import type { + AdminOptions, AuthCognitoOptions, PasswordPolicy, SignInWith, @@ -67,11 +68,14 @@ export class AuthCognito exte public readonly userPool: cognito.IUserPool; public readonly userPoolClient: cognito.IUserPoolClient; private readonly sessions: KVStore; + /** Admin opt-in, captured for the IAM grant in `grantCognitoPermissions`. */ + private readonly adminOptions?: AdminOptions; constructor(scope: ScopeParent, id: string, options?: O) { super(id, { parent: scope }); // `AuthCognitoOptions` is all-optional; the cast is sound by the type bound. const opts: AuthCognitoOptions = options ?? ({} as O); + this.adminOptions = opts.admin; const env = envVarNames(this.fullId); // 0. Validate options. `USER_PASSWORD_AUTH` (classic) and `USER_AUTH` @@ -344,10 +348,56 @@ export class AuthCognito exte ], resources: [poolArn], })); + + // Admin surface — opt-in only. Omitting `admin` grants NO Admin*/List* + // actions, so the synthesized role is byte-identical to today. When + // present, `admin.actions` scopes the grant; an omitted `actions` + // grants both groups + lifecycle. The same `actions` value scopes the + // typed `auth.admin` surface, so grant and types cannot drift. + const admin = this.adminOptions; + if (admin) { + const adminActions = adminIamActions(admin.actions); + if (adminActions.length > 0) { + fn.addToRolePolicy(new iam.PolicyStatement({ + actions: adminActions, + resources: [poolArn], + })); + } + } } } +/** + * Map `AdminOptions.actions` to the Cognito `Admin*` / `List*` IAM actions. + * Omitted `actions` grants both slices. Keep in lockstep with the runtime + * `GroupAdmin` / `LifecycleAdmin` method sets. + */ +function adminIamActions(actions?: readonly ('groups' | 'lifecycle')[]): string[] { + const groups = [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:ListUsersInGroup', + ]; + const lifecycle = [ + 'cognito-idp:AdminCreateUser', + 'cognito-idp:AdminDeleteUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + 'cognito-idp:AdminGetUser', + 'cognito-idp:ListUsers', + 'cognito-idp:AdminUserGlobalSignOut', + ]; + const enabled = actions ?? ['groups', 'lifecycle']; + const out: string[] = []; + if (enabled.includes('groups')) out.push(...groups); + if (enabled.includes('lifecycle')) out.push(...lifecycle); + return out; +} + // ───────────────────────────────────────────────────────────────────────────── // Option → CDK mappers // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/bb-auth-cognito/src/index.ts b/packages/bb-auth-cognito/src/index.ts index f479c39cd..30d29bf9c 100644 --- a/packages/bb-auth-cognito/src/index.ts +++ b/packages/bb-auth-cognito/src/index.ts @@ -49,6 +49,12 @@ import { AuthCognitoErrors, isRetriableAuthError, makeExternalUserPoolRef, + type AdminCreateInit, + type AdminGetterOf, + type AdminUser, + type GroupAdmin, + type LifecycleAdmin, + type AuthCognitoOptions, type AuthCognitoMockOptions, type AttrOf, type AuthSession, @@ -208,6 +214,14 @@ export class AuthCognito; + /** + * The full admin surface, built once against the live `this.state`. Typed + * against the wide base so the single cast in `get admin()` is the only + * place the generic projection is applied. `actions` scoping is a + * compile-time concern (the type hides ungranted methods); at runtime + * every method exists. + */ + private readonly adminSurface: GroupAdmin & LifecycleAdmin; /** @internal Logger for internal operations. Defaults to error-level when not provided. */ protected log: ChildLogger; @@ -238,9 +252,170 @@ export class AuthCognito { + if (!this.options.admin) { + throw new Error("admin not enabled: construct AuthCognito with { admin: {} }"); + } + // The only cast: the concrete surface is typed against the wide base; + // `AdminGetterOf` is a compile-time projection over `O` that the + // runtime object cannot express. Consumer-side narrowing is unaffected + // (verified by admin.types-test.ts). + return this.adminSurface as AdminGetterOf; + } + + /** + * Build the mock admin surface against the live `this.state`. All mutators + * call the existing private `flushToDisk()` so changes persist and are seen + * by the next request on this instance. + */ + private buildAdminSurface(): GroupAdmin & LifecycleAdmin { + const requireUser = (username: string): MockUserRecord => { + const user = this.state.users[username]; + if (!user) { + throw new ApiError(`User '${username}' not found`, 404, { name: AuthCognitoErrors.UserNotFound }); + } + return user; + }; + const requireGroup = (group: string): string[] => { + const members = this.state.groups[group]; + if (!members) { + throw new ApiError(`Group '${group}' not found`, 404, { name: AuthCognitoErrors.GroupNotFound }); + } + return members; + }; + const toAdminUser = (username: string, user: MockUserRecord): AdminUser => ({ + username, + userSub: user.userSub, + enabled: !user.disabled, + attributes: { ...user.attributes }, + groups: Object.keys(this.state.groups).filter((g) => this.state.groups[g].includes(username)), + }); + + return { + // ── GroupAdmin ─────────────────────────────────────────────────── + addUserToGroup: async (username, group) => { + requireUser(username); + const members = requireGroup(group); + if (!members.includes(username)) members.push(username); + this.flushToDisk(); + }, + removeUserFromGroup: async (username, group) => { + requireUser(username); + const members = requireGroup(group); + this.state.groups[group] = members.filter((u) => u !== username); + this.flushToDisk(); + }, + listGroupsForUser: async (username) => { + requireUser(username); + return Object.keys(this.state.groups).filter((g) => this.state.groups[g].includes(username)); + }, + listUsersInGroup: async (group) => { + const members = requireGroup(group); + return members.map((u) => toAdminUser(u, requireUser(u))); + }, + + // ── LifecycleAdmin ─────────────────────────────────────────────── + createUser: async (username, init) => { + if (this.state.users[username]) { + throw new ApiError('User already exists', 409, { name: AuthCognitoErrors.UserAlreadyExists }); + } + const password = init?.temporaryPassword ?? this.generateTemporaryPassword(); + this.enforcePasswordPolicy(password); + const attrs = this.prefixCustomAttrs(init?.attributes); + const aliasAttr = this.usernameAliasAttr(); + if (aliasAttr && !attrs[aliasAttr]) attrs[aliasAttr] = username; + const userSub = crypto.randomUUID(); + const record: MockUserRecord & { forcePasswordChange?: boolean } = { + userSub, + password, + confirmed: true, + disabled: false, + attributes: attrs, + mfaPreference: { enabled: [] }, + totpVerified: false, + devices: {}, + // Admin-created users must change the temp password on first + // sign-in — mirrors Cognito's FORCE_CHANGE_PASSWORD status. + forcePasswordChange: true, + }; + this.state.users[username] = record; + this.flushToDisk(); + return toAdminUser(username, record); + }, + deleteUser: async (username) => { + requireUser(username); + delete this.state.users[username]; + for (const group of Object.keys(this.state.groups)) { + this.state.groups[group] = this.state.groups[group].filter((u) => u !== username); + } + this.flushToDisk(); + }, + disableUser: async (username) => { + requireUser(username).disabled = true; + this.flushToDisk(); + }, + enableUser: async (username) => { + requireUser(username).disabled = false; + this.flushToDisk(); + }, + resetUserPassword: async (username) => { + const user = requireUser(username) as MockUserRecord & { forcePasswordChange?: boolean }; + user.forcePasswordChange = true; + this.flushToDisk(); + }, + setUserPassword: async (username, password, permanent) => { + this.enforcePasswordPolicy(password); + const user = requireUser(username) as MockUserRecord & { forcePasswordChange?: boolean }; + user.password = password; + if (permanent) { + delete user.forcePasswordChange; + } else { + user.forcePasswordChange = true; + } + this.flushToDisk(); + }, + getUser: async (username) => { + const user = this.state.users[username]; + return user ? toAdminUser(username, user) : null; + }, + scan: async function* (this: AuthCognito) { + // Snapshot keys so concurrent mutation during iteration is safe. + for (const username of Object.keys(this.state.users)) { + const user = this.state.users[username]; + if (user) yield toAdminUser(username, user); + } + }.bind(this), + revokeUserSessions: async (username) => { + requireUser(username); + await this.sessions.deleteByUsername(username); + }, + }; + } + + /** Generate a policy-compliant temporary password for admin-created users. */ + private generateTemporaryPassword(): string { + // Always satisfies the default policy (upper/lower/digit/symbol, len ≥ 8). + return `Aa1!${crypto.randomBytes(9).toString('base64url')}`; + } + // ── Framework hooks ──────────────────────────────────────────────────── /** diff --git a/packages/bb-auth-cognito/src/sessions.ts b/packages/bb-auth-cognito/src/sessions.ts index f520025ef..e1a42cbf9 100644 --- a/packages/bb-auth-cognito/src/sessions.ts +++ b/packages/bb-auth-cognito/src/sessions.ts @@ -244,4 +244,22 @@ export class SessionStore { if (!existing) return; await this.kv.put(sessionId, { ...existing, ...update }); } + + /** + * Delete every session belonging to a user. Used by the admin surface's + * `revokeUserSessions` to force re-authentication (mock parity for + * Cognito's `AdminUserGlobalSignOut`). Returns the number deleted. + * + * `SessionRecord` is deliberately minimal (tokens only — see its docs), so + * the owning username is recovered by decoding each session's ID token + * rather than read from a denormalized field. + */ + async deleteByUsername(username: string): Promise { + const toDelete: string[] = []; + for await (const { key, value } of this.kv.scan()) { + if (decodeIdToken(value.idToken).username === username) toDelete.push(key); + } + for (const id of toDelete) await this.kv.delete(id); + return toDelete.length; + } } diff --git a/packages/bb-auth-cognito/src/types.ts b/packages/bb-auth-cognito/src/types.ts index 8993500fe..51a0ce37d 100644 --- a/packages/bb-auth-cognito/src/types.ts +++ b/packages/bb-auth-cognito/src/types.ts @@ -270,10 +270,142 @@ export type MfaTypeOf = : 'SMS' | 'TOTP' | 'EMAIL' : 'SMS' | 'TOTP' | 'EMAIL'; +// ───────────────────────────────────────────────────────────────────────────── +// Admin surface (opt-in `auth.admin` handle) +// ───────────────────────────────────────────────────────────────────────────── +// +// The server-side admin operations (group membership + user lifecycle) live on +// an opt-in handle, `auth.admin`, rather than a separate class/package. The +// handle is gated by the `admin` options object so: +// +// - a pool that never opts in gets NO `Admin*` IAM grant (least privilege), +// and `auth.admin` is a compile error whose message names the fix; +// - `admin.actions` scopes the IAM grant (CDK) to just the group or just the +// lifecycle actions. +// +// Group names on the admin methods narrow via `GroupOf`, mirroring the +// literal-tuple projection style of `GroupOf` / `MfaTypeOf` above. + +/** + * Opt-in configuration for the admin surface. Presence of this object on + * `AuthCognitoOptions.admin` enables `auth.admin` and the matching IAM grant. + */ +export interface AdminOptions { + /** + * Scopes the IAM grant. Omit to grant everything. `['groups']` grants only + * the group-membership `Admin*` actions; `['lifecycle']` grants only the + * user-lifecycle actions. + * + * Note: `actions` scopes the **IAM grant**, not the typed method set — + * `auth.admin` always exposes the full surface (see {@link AdminSurface} + * for why narrowing the type by `actions` is not possible without breaking + * `AuthCognito` variance). A method whose action wasn't granted fails at + * runtime with an IAM `AccessDenied`. + */ + actions?: readonly ('groups' | 'lifecycle')[]; +} + +/** + * Group-membership admin operations. Group names narrow via `GroupOf`, so + * `addUserToGroup(user, 'typo')` is a compile error on a narrowed pool. + */ +export interface GroupAdmin { + addUserToGroup(username: string, group: GroupOf): Promise; + removeUserFromGroup(username: string, group: GroupOf): Promise; + listGroupsForUser(username: string): Promise[]>; + listUsersInGroup(group: GroupOf): Promise; +} + +/** + * User-lifecycle admin operations — create/delete/enable/disable, password + * management, enumeration, and session revocation. + */ +export interface LifecycleAdmin { + createUser(username: string, init?: AdminCreateInit): Promise; + deleteUser(username: string): Promise; + disableUser(username: string): Promise; + enableUser(username: string): Promise; + resetUserPassword(username: string): Promise; + setUserPassword(username: string, password: string, permanent: boolean): Promise; + getUser(username: string): Promise; + scan(): AsyncIterable; + revokeUserSessions(username: string): Promise; +} + +/** + * The typed `auth.admin` surface for a given pool config — the full set of + * group + lifecycle operations. Group names still narrow via `GroupOf`. + * + * **Why not narrow the method set by `actions`?** An earlier design hid the + * lifecycle methods when `actions: ['groups']` (and vice-versa) via a + * conditional type over `O`. A conditional type over the class's own generic + * parameter, used as a *property* type, forces TypeScript to treat + * `AuthCognito` as **invariant** in `O` — which breaks the long-standing + * contract that `AuthCognito` is assignable to + * `AuthCognito` (relied on across the codebase, e.g. + * helpers typed `auth: AuthCognito`). Verified empirically: the conditional + * form regressed 14 existing call sites. `actions` therefore scopes the **IAM + * grant** (CDK) only; the typed surface is always the full set. Calling a + * method whose action wasn't granted fails at runtime with an IAM + * `AccessDenied`, the same outcome a separate-package design would give a + * client route that imported the admin block. + */ +export type AdminSurface = + GroupAdmin & LifecycleAdmin; + +/** + * Returned by `auth.admin` when the pool did NOT opt in. Touching any member + * is a compile error whose key text names the fix — friendlier than a bare + * `never`. + */ +export type AdminDisabled = { + readonly __adminNotEnabled: 'construct AuthCognito with { admin: {} }'; +}; + +/** + * The `auth.admin` getter's return type — the gate. + * + * - The check is `{ admin: object }`, NOT `{ admin: any }`: a primitive like + * `admin: true` fails the gate, so the opt-in MUST be an object (`admin: {}`). + * - The gate condition tests a **fixed shape** and the positive branch is a + * plain generic interface (`AdminSurface`), so `AuthCognito` stays + * covariant in `O`. (Do not reintroduce a conditional over `O` inside + * `AdminSurface` — see its doc comment.) + */ +export type AdminGetterOf = + O extends { admin: object } ? AdminSurface : AdminDisabled; + +/** A user as seen by the admin surface (group + lifecycle reads). */ +export interface AdminUser { + username: string; + userSub: string; + enabled: boolean; + attributes: Record; + groups?: string[]; +} + +/** Initial state for {@link LifecycleAdmin.createUser}. */ +export interface AdminCreateInit { + /** Temporary password. When omitted, the runtime generates one. */ + temporaryPassword?: string; + /** Standard / `custom:`-prefixed attributes to seed on the new user. */ + attributes?: Record; + /** Suppress the Cognito invitation email/SMS (AWS runtime only). */ + suppressInvite?: boolean; +} + /** * Options for `AuthCognito`. */ export interface AuthCognitoOptions { + /** + * Enables the server-side admin surface (`auth.admin`) and grants the + * matching `Admin*` / `List*` IAM on the pool. Omit for the client-only + * surface with no admin grant — the default, byte-identical to today's + * synthesized role. See {@link AdminOptions} to scope which actions are + * exposed and granted. + */ + admin?: AdminOptions; /** MFA mode. Default: `'off'`. */ mfa?: 'off' | 'optional' | 'required'; /** From 7bc20b3e05a0141538e7347c13a83b89079f79db Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 00:03:13 -0400 Subject: [PATCH 06/21] feat(bb-auth-cognito)!: make AuthCognito generic a const type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change AuthCognito to AuthCognito across all four entries so inline options literals narrow without `as const`. This narrows five projections by default — GroupOf (requireRole), AttrOf (updateUserAttribute family), ReadAttrOf (fetchUserAttributes), MfaTypeOf (confirmSignIn/updateMFAPreference), and CustomAttrNames. Fixes the call sites that pass wire strings to now-narrowed params by casting at the boundary (runtime validates regardless): comprehensive authCRequireRole + authCMfaUpdateMFAPreference, native-bindings cognitoRequireRole. Tightens admin.types-test case 5 to assert group narrowing without `as const`. BREAKING CHANGE: callers passing widened variables to requireRole / updateUserAttribute / updateMFAPreference now need a cast or literal args. Acceptable pre-release. --- packages/bb-auth-cognito/src/admin.types-test.ts | 8 +++----- packages/bb-auth-cognito/src/index.aws.ts | 2 +- packages/bb-auth-cognito/src/index.browser.ts | 2 +- packages/bb-auth-cognito/src/index.cdk.ts | 2 +- packages/bb-auth-cognito/src/index.ts | 2 +- test-apps/comprehensive/aws-blocks/index.ts | 9 +++++++-- test-apps/native-bindings/aws-blocks/index.ts | 4 +++- 7 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/bb-auth-cognito/src/admin.types-test.ts b/packages/bb-auth-cognito/src/admin.types-test.ts index f03f730c6..83fede55c 100644 --- a/packages/bb-auth-cognito/src/admin.types-test.ts +++ b/packages/bb-auth-cognito/src/admin.types-test.ts @@ -57,13 +57,11 @@ async function actionsScopeGrantNotTypes() { } // ───────────────────────────────────────────────────────────────────────────── -// (5) Group narrowing on admin methods. With `as const` (today) the group union -// narrows and a typo is rejected. (Task T5 — `const O` — will make this hold -// WITHOUT `as const`; the @ts-expect-error below is tightened to the -// non-const form then.) +// (5) `const O` narrows group names on admin methods WITHOUT `as const` → typo +// is a compile error. (Enabled by the `const O` class generic, T5.) // ───────────────────────────────────────────────────────────────────────────── async function groupNarrowing() { - const auth = new AuthCognito(scope, 'a5', { groups: ['admins', 'readers'] as const, admin: { actions: ['groups'] } }); + const auth = new AuthCognito(scope, 'a5', { groups: ['admins', 'readers'], admin: { actions: ['groups'] } }); await auth.admin.addUserToGroup('u', 'admins'); await auth.admin.addUserToGroup('u', 'readers'); // @ts-expect-error — 'editor' is not in 'admins' | 'readers'. diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index c6b5ddbe5..267f68158 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -601,7 +601,7 @@ function statusForCognitoError(name: string): number { * See the mock entry (`./index.ts`) for full class-level JSDoc; both runtimes * share the same public API. */ -export class AuthCognito +export class AuthCognito extends Scope implements BlocksAuth { diff --git a/packages/bb-auth-cognito/src/index.browser.ts b/packages/bb-auth-cognito/src/index.browser.ts index fc14643d7..7cfb05b35 100644 --- a/packages/bb-auth-cognito/src/index.browser.ts +++ b/packages/bb-auth-cognito/src/index.browser.ts @@ -28,7 +28,7 @@ export * from './types.js'; // that references `AuthCognito` typechecks identically under // `--conditions=browser`. The browser stub never executes any method — // bundlers scan imports, they don't call anything. -export class AuthCognito { +export class AuthCognito { constructor(..._args: unknown[]) { /* no-op */ } /** * Server-only admin surface. Present so `auth.admin` typechecks under diff --git a/packages/bb-auth-cognito/src/index.cdk.ts b/packages/bb-auth-cognito/src/index.cdk.ts index 5594952b9..e9a810c7f 100644 --- a/packages/bb-auth-cognito/src/index.cdk.ts +++ b/packages/bb-auth-cognito/src/index.cdk.ts @@ -64,7 +64,7 @@ export * from './types.js'; * Grants the Lambda `cognito-idp:*` scoped to this pool's ARN; the SSM * secret's IAM is granted by AppSetting itself. */ -export class AuthCognito extends Scope { +export class AuthCognito extends Scope { public readonly userPool: cognito.IUserPool; public readonly userPoolClient: cognito.IUserPoolClient; private readonly sessions: KVStore; diff --git a/packages/bb-auth-cognito/src/index.ts b/packages/bb-auth-cognito/src/index.ts index 30d29bf9c..4cf6cd0ea 100644 --- a/packages/bb-auth-cognito/src/index.ts +++ b/packages/bb-auth-cognito/src/index.ts @@ -203,7 +203,7 @@ interface PersistedState { * in a nested DynamoDB table provisioned by this BB via KVStore — single- * digit ms reads, pay-per-request billing. */ -export class AuthCognito +export class AuthCognito extends Scope implements BlocksAuth { diff --git a/test-apps/comprehensive/aws-blocks/index.ts b/test-apps/comprehensive/aws-blocks/index.ts index 966d0857f..7457e6f8b 100644 --- a/test-apps/comprehensive/aws-blocks/index.ts +++ b/test-apps/comprehensive/aws-blocks/index.ts @@ -952,7 +952,10 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ }, async authCRequireRole(role: string) { - const user = await authC.requireRole(context, role); + // Role arrives over the wire as a string; narrow to the pool's group union + // at the boundary (runtime validates membership regardless). `const O` made + // requireRole's param the literal union 'admins' | 'readers'. + const user = await authC.requireRole(context, role as Parameters[1]); return user; }, @@ -1082,7 +1085,9 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ totp?: 'ENABLED' | 'DISABLED' | 'PREFERRED' | 'NOT_PREFERRED'; email?: 'ENABLED' | 'DISABLED' | 'PREFERRED' | 'NOT_PREFERRED'; }) { - await authCMfa.updateMFAPreference(context, input); + // `const O` narrowed updateMFAPreference's input to the pool's mfaTypes + // (here ['TOTP']); the wire shape is wider, so narrow at the boundary. + await authCMfa.updateMFAPreference(context, input as Parameters[1]); return { success: true }; }, diff --git a/test-apps/native-bindings/aws-blocks/index.ts b/test-apps/native-bindings/aws-blocks/index.ts index b2b84c3cf..c7006b8cd 100644 --- a/test-apps/native-bindings/aws-blocks/index.ts +++ b/test-apps/native-bindings/aws-blocks/index.ts @@ -238,7 +238,9 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ }, async cognitoRequireRole(role: string) { - return await authCognito.requireRole(context, role); + // `const O` narrowed requireRole's param to the pool's group union; narrow + // the wire string at the boundary (runtime validates membership). + return await authCognito.requireRole(context, role as Parameters[1]); }, async cognitoFetchUserAttributes() { From a77aa906c8d504c33346a364f2466e9d7952cc0e Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 00:06:45 -0400 Subject: [PATCH 07/21] test(bb-auth-cognito): unit tests for auth.admin (mock behavior + CDK grant) admin.test.ts (13): runtime gate throw, group add/remove + GroupNotFound + UserNotFound, listGroups/listUsersInGroup, lifecycle create/get/delete (+ group cleanup), disable/enable (asserts existing signIn disabled-guard), setUserPassword, scan, revokeUserSessions. index.cdk.test.ts (+4): no-admin grants zero Admin* actions (least privilege); admin:{} grants both slices; actions:['groups'|'lifecycle'] grant only that slice. Full package suite: 224 tests, 0 fail. --- packages/bb-auth-cognito/src/admin.test.ts | 199 ++++++++++++++++++ .../bb-auth-cognito/src/index.cdk.test.ts | 75 +++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/bb-auth-cognito/src/admin.test.ts diff --git a/packages/bb-auth-cognito/src/admin.test.ts b/packages/bb-auth-cognito/src/admin.test.ts new file mode 100644 index 000000000..e198cf176 --- /dev/null +++ b/packages/bb-auth-cognito/src/admin.test.ts @@ -0,0 +1,199 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { rmSync } from 'node:fs'; +import { isBlocksError } from '@aws-blocks/core'; +import type { BlocksContext } from '@aws-blocks/core'; +import { AuthCognito, AuthCognitoErrors } from './index.js'; + +// ── Harness (mirrors index.test.ts) ────────────────────────────────────────── + +const ROOT = { id: 'test-app' } as any; + +function freshContext(): BlocksContext { + const req = new Headers(); + const res = new Headers(); + let status = 200; + const ctx: BlocksContext = { + request: { + headers: req, body: null, + json: async () => ({}), text: async () => '', + url: new URL('http://localhost:3000/'), params: {}, + }, + response: { + headers: res, + get status() { return status; }, + set status(v) { status = v; }, + send: () => {}, + } as any, + }; + const origSet = res.set.bind(res); + res.set = (name: string, value: string) => { + if (name.toLowerCase() === 'set-cookie') { + req.set('cookie', value.split(';')[0]); + } + origSet(name, value); + }; + return ctx; +} + +function unique(prefix = 'admin') { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** Sign a user up + confirm so they exist and can sign in. */ +async function signUpAndConfirm(auth: AuthCognito, username: string) { + let code = ''; + (auth as any).options.codeDelivery = async (_u: string, c: string) => { code = c; }; + await auth.signUp(username, 'Password!1', { attributes: { email: `${username}@x.com` } }); + await auth.confirmSignUp(username, code); +} + +beforeEach(() => { + try { rmSync('.bb-data', { recursive: true, force: true }); } catch { /* ignore */ } +}); +afterEach(() => { + try { rmSync('.bb-data', { recursive: true, force: true }); } catch { /* ignore */ } +}); + +// ── Gate (runtime) ─────────────────────────────────────────────────────────── + +describe('auth.admin runtime gate', () => { + test('throws a named error when admin is not enabled', () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'] }); + // Untyped JS caller reaching past the compile-time gate. + assert.throws(() => (auth as any).admin, /admin not enabled/); + }); + + test('returns the surface when admin is enabled', () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + assert.strictEqual(typeof auth.admin.addUserToGroup, 'function'); + assert.strictEqual(typeof auth.admin.createUser, 'function'); + }); +}); + +// ── Group membership ───────────────────────────────────────────────────────── + +describe('auth.admin group membership', () => { + test('addUserToGroup is visible to the same instance requireRole', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await signUpAndConfirm(auth, 'alice'); + + await auth.admin.addUserToGroup('alice', 'admins'); + assert.deepStrictEqual(await auth.admin.listGroupsForUser('alice'), ['admins']); + + const ctx = freshContext(); + await auth.signIn('alice', 'Password!1', ctx); + const user = await auth.requireRole(ctx, 'admins'); // would 403 without the membership + assert.strictEqual(user.username, 'alice'); + }); + + test('addUserToGroup to an unseeded group throws GroupNotFound', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await signUpAndConfirm(auth, 'bob'); + await assert.rejects( + // Cast past the narrowed group union to exercise the runtime guard + // (a real unseeded group; `const O` would reject this at compile time). + () => auth.admin.addUserToGroup('bob', 'ghosts' as 'admins'), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.GroupNotFound), + ); + }); + + test('addUserToGroup for a missing user throws UserNotFound', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await assert.rejects( + () => auth.admin.addUserToGroup('nobody', 'admins'), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.UserNotFound), + ); + }); + + test('removeUserFromGroup filters membership', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await signUpAndConfirm(auth, 'carol'); + await auth.admin.addUserToGroup('carol', 'admins'); + await auth.admin.removeUserFromGroup('carol', 'admins'); + assert.deepStrictEqual(await auth.admin.listGroupsForUser('carol'), []); + }); + + test('listUsersInGroup returns members', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await signUpAndConfirm(auth, 'dave'); + await auth.admin.addUserToGroup('dave', 'admins'); + const members = await auth.admin.listUsersInGroup('admins'); + assert.deepStrictEqual(members.map((m) => m.username), ['dave']); + }); +}); + +// ── User lifecycle ─────────────────────────────────────────────────────────── + +describe('auth.admin user lifecycle', () => { + test('createUser then getUser round-trips; createUser twice conflicts', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + const created = await auth.admin.createUser('erin', { attributes: { email: 'erin@x.com' } }); + assert.strictEqual(created.username, 'erin'); + assert.strictEqual(created.enabled, true); + + const fetched = await auth.admin.getUser('erin'); + assert.strictEqual(fetched?.username, 'erin'); + assert.strictEqual(await auth.admin.getUser('ghost'), null); + + await assert.rejects( + () => auth.admin.createUser('erin'), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.UserAlreadyExists), + ); + }); + + test('deleteUser removes the record and strips group membership', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} }); + await signUpAndConfirm(auth, 'frank'); + await auth.admin.addUserToGroup('frank', 'admins'); + await auth.admin.deleteUser('frank'); + assert.strictEqual(await auth.admin.getUser('frank'), null); + assert.deepStrictEqual(await auth.admin.listUsersInGroup('admins'), []); + }); + + test('disableUser blocks sign-in; enableUser restores it (existing signIn guard)', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + await signUpAndConfirm(auth, 'gina'); + + await auth.admin.disableUser('gina'); + await assert.rejects( + () => auth.signIn('gina', 'Password!1', freshContext()), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized), + ); + + await auth.admin.enableUser('gina'); + const r = await auth.signIn('gina', 'Password!1', freshContext()); + assert.strictEqual(r.status, 'signedIn'); + }); + + test('setUserPassword(permanent) lets the user sign in with the new password', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + await signUpAndConfirm(auth, 'hank'); + await auth.admin.setUserPassword('hank', 'NewPass!2', true); + const r = await auth.signIn('hank', 'NewPass!2', freshContext()); + assert.strictEqual(r.status, 'signedIn'); + }); + + test('scan yields all users', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + await auth.admin.createUser('ida'); + await auth.admin.createUser('jack'); + const seen: string[] = []; + for await (const u of auth.admin.scan()) seen.push(u.username); + assert.deepStrictEqual(seen.sort(), ['ida', 'jack']); + }); + + test('revokeUserSessions deletes the user session (forces re-auth)', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + await signUpAndConfirm(auth, 'kara'); + const ctx = freshContext(); + await auth.signIn('kara', 'Password!1', ctx); + assert.strictEqual(await auth.checkAuth(ctx), true); + + await auth.admin.revokeUserSessions('kara'); + assert.strictEqual(await auth.checkAuth(ctx), false); + }); +}); diff --git a/packages/bb-auth-cognito/src/index.cdk.test.ts b/packages/bb-auth-cognito/src/index.cdk.test.ts index 603aa3a9a..877a5cb33 100644 --- a/packages/bb-auth-cognito/src/index.cdk.test.ts +++ b/packages/bb-auth-cognito/src/index.cdk.test.ts @@ -536,3 +536,78 @@ describe('AuthCognito (CDK) — enablePasskeys', () => { } }); }); + +// ─── Admin surface IAM grant (opt-in) ───────────────────────────────────────── + +/** Collect every cognito-idp IAM action granted to any role in the template. */ +function grantedActions(template: Template): Set { + const policies = template.findResources('AWS::IAM::Policy'); + const statements = Object.values(policies).flatMap( + (p) => ((p as { Properties?: { PolicyDocument?: { Statement?: unknown[] } } }) + .Properties?.PolicyDocument?.Statement ?? []) as { Action?: unknown }[], + ); + const flat = new Set(); + for (const s of statements) { + const actions = Array.isArray(s.Action) ? s.Action : s.Action ? [s.Action] : []; + for (const a of actions) if (typeof a === 'string') flat.add(a); + } + return flat; +} + +const GROUP_ADMIN_ACTIONS = [ + 'cognito-idp:AdminAddUserToGroup', + 'cognito-idp:AdminRemoveUserFromGroup', + 'cognito-idp:AdminListGroupsForUser', + 'cognito-idp:ListUsersInGroup', +]; +const LIFECYCLE_ADMIN_ACTIONS = [ + 'cognito-idp:AdminCreateUser', + 'cognito-idp:AdminDeleteUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + 'cognito-idp:AdminGetUser', + 'cognito-idp:ListUsers', + 'cognito-idp:AdminUserGlobalSignOut', +]; + +describe('AuthCognito (CDK) — admin IAM grant', () => { + test('no admin option → NO Admin* actions granted (least privilege)', () => { + const template = synth((stack) => { + new AuthCognito(scope(stack), 'auth', { groups: ['admins'] }); + }); + const actions = grantedActions(template); + for (const a of [...GROUP_ADMIN_ACTIONS, ...LIFECYCLE_ADMIN_ACTIONS]) { + assert.ok(!actions.has(a), `unexpected admin action granted without opt-in: ${a}`); + } + }); + + test("admin: {} (no actions) → grants both group + lifecycle actions", () => { + const template = synth((stack) => { + new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: {} }); + }); + const actions = grantedActions(template); + for (const a of [...GROUP_ADMIN_ACTIONS, ...LIFECYCLE_ADMIN_ACTIONS]) { + assert.ok(actions.has(a), `missing admin action ${a}`); + } + }); + + test("actions: ['groups'] → grants group actions only", () => { + const template = synth((stack) => { + new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['groups'] } }); + }); + const actions = grantedActions(template); + for (const a of GROUP_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing group action ${a}`); + for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected lifecycle action ${a}`); + }); + + test("actions: ['lifecycle'] → grants lifecycle actions only", () => { + const template = synth((stack) => { + new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['lifecycle'] } }); + }); + const actions = grantedActions(template); + for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing lifecycle action ${a}`); + for (const a of GROUP_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected group action ${a}`); + }); +}); From c64f6813e1bf3bbbc11223660e96db18bc799ea7 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 00:08:18 -0400 Subject: [PATCH 08/21] docs(bb-auth-cognito): document the auth.admin opt-in surface in README Add an 'Admin surface' section: opt-in via the admin options object, the compile-time gate, actions scoping the IAM grant (not the typed method set), the not-an-access-boundary caveat, group + lifecycle method tables, and the session-freshness note (revokeUserSessions for immediate effect). Update the generic-narrowing section for the const O type parameter (inline literals narrow without as const). --- packages/bb-auth-cognito/README.md | 53 +++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/bb-auth-cognito/README.md b/packages/bb-auth-cognito/README.md index 6684233fc..ef82bc332 100644 --- a/packages/bb-auth-cognito/README.md +++ b/packages/bb-auth-cognito/README.md @@ -151,6 +151,49 @@ Requires `enablePasskeys: true` and a WebAuthn-configured pool (`webAuthnRelying | `listPasskeys(context)` | `Promise` | List the signed-in user's registered passkeys (paginates internally). | | `deletePasskey(context, credentialId)` | `Promise` | Remove a registered passkey by `credentialId`. | +## Admin surface (`auth.admin`) + +Server-side admin operations — group membership and user lifecycle — that act on **any** user by `username` (unlike the client methods above, which act on the signed-in user via `context`). These are **opt-in**: pass an `admin` options object to enable the `auth.admin` handle and grant the matching `Admin*` / `List*` IAM. A pool that never opts in has no admin grant — its synthesized role is identical to today. + +```typescript +const auth = new AuthCognito(scope, 'auth', { + groups: ['admins'], + admin: { actions: ['groups'] }, // enable auth.admin; grant only group Admin* actions +}); + +// Always gate admin routes behind requireRole — these methods do NOT self-gate. +await auth.requireRole(ctx, 'admins'); +await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via GroupOf +``` + +- **Compile-time gate.** Without an `admin` options object, `auth.admin` is typed `AdminDisabled` and any access is a compile error whose message names the fix (`construct AuthCognito with { admin: {} }`). The getter also throws at runtime for untyped JS callers. +- **`actions` scopes the IAM grant, not the method set.** `actions: ['groups']` grants only the group `Admin*` actions; `['lifecycle']` only the user-lifecycle actions; omitted grants both. The **typed surface is always the full set** — narrowing the method types by `actions` is not possible without breaking `AuthCognito` variance, so calling a method whose action wasn't granted fails at runtime with an IAM `AccessDenied`. +- **Not an access boundary.** Like every block on the shared backend Lambda, client and admin run under one role. Separation is by API surface + lint, not IAM — gate every admin route with `requireRole`. + +**Group membership** (`actions: 'groups'`): + +| Method | Returns | Description | +|---|---|---| +| `admin.addUserToGroup(username, group)` | `Promise` | Add a user to a seeded group. `group` narrows to `GroupOf`. Throws `GroupNotFound` for an unseeded group, `UserNotFound` for a missing user. | +| `admin.removeUserFromGroup(username, group)` | `Promise` | Remove a user from a group. | +| `admin.listGroupsForUser(username)` | `Promise[]>` | Groups the user belongs to. | +| `admin.listUsersInGroup(group)` | `Promise` | Members of a group (paginates internally). | + +**User lifecycle** (`actions: 'lifecycle'`): + +| Method | Returns | Description | +|---|---|---| +| `admin.createUser(username, init?)` | `Promise` | Create a user. `init`: `{ temporaryPassword?, attributes?, suppressInvite? }`. Conflicts with `UserAlreadyExists`. | +| `admin.deleteUser(username)` | `Promise` | Delete a user (also strips them from every group). | +| `admin.disableUser(username)` / `admin.enableUser(username)` | `Promise` | Toggle sign-in. A disabled user's `signIn` is rejected with `NotAuthorizedException`. | +| `admin.resetUserPassword(username)` | `Promise` | Force the user to set a new password on next sign-in. | +| `admin.setUserPassword(username, password, permanent)` | `Promise` | Set a password. `permanent: true` clears the force-change flag. | +| `admin.getUser(username)` | `Promise` | Look up a user, or `null` if absent. | +| `admin.scan()` | `AsyncIterable` | Enumerate all users (paginates internally). | +| `admin.revokeUserSessions(username)` | `Promise` | Revoke all the user's sessions (AWS: `AdminUserGlobalSignOut`) — forces immediate re-authentication. | + +> **Session freshness.** A group change does not affect a user's **existing** session until their token refreshes — `requireRole` reads the `cognito:groups` claim, not live state. This is inherent Cognito behavior. The change applies on the next sign-in or `fetchAuthSession({ forceRefresh: true })`; for immediate effect, call `admin.revokeUserSessions(username)` to force re-auth. + ## Options ```typescript @@ -206,7 +249,15 @@ The `signInWith` option controls what end users sign in with. It maps to Cognito ## Using AuthCognito generically (literal-narrowing with `as const`) -`AuthCognito` is generic on its options literal. When you pass the options object `as const`, TypeScript narrows method signatures to the exact values you configured — typos on group names, custom attributes, and MFA factors become compile errors instead of runtime surprises. +`AuthCognito` is generic on its options literal. Because the generic is a **`const` type parameter**, TypeScript narrows method signatures to the exact values you configured for **options passed inline** — typos on group names, custom attributes, and MFA factors become compile errors instead of runtime surprises, with no `as const` needed: + +```typescript +const auth = new AuthCognito(scope, 'auth', { groups: ['admins', 'readers'] }); +await auth.requireRole(ctx, 'admins'); // ✅ narrowed inline — no `as const` +await auth.requireRole(ctx, 'admin'); // ❌ compile error (typo) +``` + +For an options object **declared separately** in a variable, add `as const` so its literals don't widen before reaching the constructor. The example below uses `as const` for that reason; the narrowing it produces is identical. ```typescript const options = { From a339472641ba279dfb846234516d16dcc40d69e6 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 00:11:54 -0400 Subject: [PATCH 09/21] test(comprehensive): live e2e suite for the auth.admin surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire authC with admin: {} plus authCAdmin* routes (createUser, setPassword, addToGroup, listGroupsForUser, disable/enable, delete, revokeSessions), and add sandbox-admin-surface-e2e.ts driving them through the deployed Lambda over JSON-RPC. Covers: createUser, setUserPassword→signIn, addUserToGroup→requireRole on a fresh token, revokeUserSessions drops the live session, disable/enable gates signIn, deleteUser. Requires a deployed bb-test-* sandbox (sandbox-gated; not in the unit run). Typechecks clean. --- test-apps/comprehensive/aws-blocks/index.ts | 38 ++++ .../test/sandbox-admin-surface-e2e.ts | 194 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts diff --git a/test-apps/comprehensive/aws-blocks/index.ts b/test-apps/comprehensive/aws-blocks/index.ts index 7457e6f8b..1c1ed88b2 100644 --- a/test-apps/comprehensive/aws-blocks/index.ts +++ b/test-apps/comprehensive/aws-blocks/index.ts @@ -83,6 +83,9 @@ const authC = new AuthCognito(scope, 'authC', { passwordPolicy: { minLength: 8, requireDigits: true }, userAttributes: [{ name: 'department' }], groups: ['admins', 'readers'], + // Enable the opt-in admin surface (auth.admin) for the sandbox admin e2e + // suite. Grants the Admin*/List* IAM on this pool's handler role. + admin: {}, mfa: 'off', mfaTypes: ['SMS', 'TOTP', 'EMAIL'], selfSignUp: true, @@ -959,6 +962,41 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ return user; }, + // ── Admin surface (auth.admin) — exercised by sandbox-admin-e2e ──────────── + // In a real app these would be gated by `requireRole`; the e2e harness drives + // them directly to verify the admin grant + behavior end-to-end. + async authCAdminCreateUser(username: string, temporaryPassword: string) { + const u = await authC.admin.createUser(username, { temporaryPassword }); + return { username: u.username, enabled: u.enabled }; + }, + async authCAdminSetPassword(username: string, password: string) { + await authC.admin.setUserPassword(username, password, true); + return { success: true }; + }, + async authCAdminAddToGroup(username: string, group: string) { + await authC.admin.addUserToGroup(username, group as Parameters[1]); + return { success: true }; + }, + async authCAdminListGroupsForUser(username: string) { + return await authC.admin.listGroupsForUser(username); + }, + async authCAdminDisableUser(username: string) { + await authC.admin.disableUser(username); + return { success: true }; + }, + async authCAdminEnableUser(username: string) { + await authC.admin.enableUser(username); + return { success: true }; + }, + async authCAdminDeleteUser(username: string) { + await authC.admin.deleteUser(username); + return { success: true }; + }, + async authCAdminRevokeSessions(username: string) { + await authC.admin.revokeUserSessions(username); + return { success: true }; + }, + async authCFetchUserAttributes() { return await authC.fetchUserAttributes(context); }, diff --git a/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts b/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts new file mode 100644 index 000000000..b56487c0a --- /dev/null +++ b/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts @@ -0,0 +1,194 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Live AWS e2e suite for the opt-in `auth.admin` surface on AuthCognito. + * + * Unlike `sandbox-admin-e2e.ts` (which provisions users via the raw Cognito + * SDK to test the *client* methods), this suite drives the BB's own + * `auth.admin.*` operations through the deployed Lambda backend over HTTP — + * the routes wired in `aws-blocks/index.ts` (`authCAdmin*`). It verifies the + * admin IAM grant is present and the surface behaves end-to-end. + * + * Prerequisites (same as sandbox-admin-e2e): + * - A deployed `bb-test-*` sandbox with the comprehensive backend, built + * with `admin: {}` on the `authC` pool (already set in aws-blocks/index.ts). + * - `AWS_PROFILE` with Cognito admin + CFN-describe permissions. + * - Run from `test-apps/comprehensive` (reads `.blocks-sandbox/outputs.json`). + * + * Run: node --import tsx test/sandbox-admin-surface-e2e.ts + * (NOT part of the unit-test run — requires a live deployment.) + */ + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CloudFormationClient, + DescribeStackResourcesCommand, +} from '@aws-sdk/client-cloudformation'; + +const SANDBOX_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '.blocks-sandbox'); +const REGION = process.env.AWS_REGION || 'us-east-1'; +const STACK_NAME_ENV = process.env.BLOCKS_STACK_NAME; + +const results: { name: string; status: 'pass' | 'fail'; detail?: string }[] = []; + +async function runTest(name: string, fn: () => Promise) { + process.stdout.write(`• ${name} ... `); + try { + await fn(); + console.log('PASS'); + results.push({ name, status: 'pass' }); + } catch (e: any) { + const detail = e?.message ?? String(e); + console.log(`FAIL — ${detail}`); + results.push({ name, status: 'fail', detail }); + } +} + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +async function discoverApiUrl(): Promise { + const outputs = JSON.parse(readFileSync(join(SANDBOX_DIR, 'outputs.json'), 'utf-8')); + const stackName = STACK_NAME_ENV ?? Object.keys(outputs)[0]; + if (!stackName) throw new Error('No stack in outputs.json'); + const apiUrl = (outputs[stackName] as Record).ApiUrl; + if (!apiUrl) throw new Error(`No ApiUrl for ${stackName}`); + // Touch CFN so a misconfigured profile fails loudly here, not mid-suite. + await new CloudFormationClient({ region: REGION }) + .send(new DescribeStackResourcesCommand({ StackName: stackName })); + return apiUrl; +} + +/** Minimal JSON-RPC client with cookie jar (mirrors sandbox-admin-e2e). */ +class ApiSession { + private cookies = new Map(); + constructor(private apiUrl: string, private namespace = 'api') {} + reset() { this.cookies.clear(); } + private cookieHeader(): string | undefined { + if (this.cookies.size === 0) return undefined; + return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + } + private absorb(headers: Headers) { + const raw = (headers as any).getSetCookie?.() ?? + (headers.get('set-cookie') ? [headers.get('set-cookie')!] : []); + for (const v of raw) { + const kv = v.slice(0, v.indexOf(';') === -1 ? undefined : v.indexOf(';')); + const eq = kv.indexOf('='); + if (eq < 0) continue; + const name = kv.slice(0, eq).trim(); + const value = kv.slice(eq + 1).trim(); + if (!value) this.cookies.delete(name); else this.cookies.set(name, value); + } + } + async call(method: string, args: any[] = []): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + const ch = this.cookieHeader(); + if (ch) headers['Cookie'] = ch; + const res = await fetch(this.apiUrl, { + method: 'POST', headers, + body: JSON.stringify({ jsonrpc: '2.0', method: `${this.namespace}.${method}`, params: args, id: 1 }), + }); + this.absorb(res.headers); + const text = await res.text(); + let body: any; try { body = JSON.parse(text); } catch { body = text; } + if (!res.ok || body?.error) { + const p = body?.error ?? {}; + const err: any = new Error(p.message ?? body?.error ?? res.statusText); + err.status = p.code && p.code > 0 ? p.code : res.status; + err.name = p.data?.name ?? body?.name ?? err.name; + throw err; + } + return body.result as T; + } +} + +async function main() { + console.log('─── auth.admin surface e2e ───'); + const apiUrl = await discoverApiUrl(); + console.log(` API URL: ${apiUrl}`); + + const s = new ApiSession(apiUrl); + const uniq = (process.env.BLOCKS_TEST_SEED ?? String(process.hrtime.bigint())).slice(-8); + const alice = `admin-e2e-alice-${uniq}`; + const bob = `admin-e2e-bob-${uniq}`; + const PW = 'AdminE2e!1'; + + await runTest('admin.createUser creates a real user', async () => { + const r = await s.call('authCAdminCreateUser', [alice, PW]); + assert(r.username === alice, `expected ${alice}, got ${r.username}`); + assert(r.enabled === true, 'new user should be enabled'); + }); + + await runTest('admin.setUserPassword(permanent) lets the user sign in', async () => { + await s.call('authCAdminSetPassword', [alice, PW]); + const r = await s.call('authCSignIn', [alice, PW]); + assert(r.status === 'signedIn', `expected signedIn, got ${r.status}`); + s.reset(); + }); + + await runTest('admin.addUserToGroup → requireRole(admins) succeeds on fresh token', async () => { + await s.call('authCAdminAddToGroup', [alice, 'admins']); + const groups = await s.call('authCAdminListGroupsForUser', [alice]); + assert(Array.isArray(groups) && groups.includes('admins'), `groups missing admins: ${JSON.stringify(groups)}`); + await s.call('authCSignIn', [alice, PW]); // fresh sign-in → claim carries the group + const user = await s.call('authCRequireRole', ['admins']); + assert(user.username === alice, 'requireRole should return the user'); + s.reset(); + }); + + await runTest('revokeUserSessions forces re-auth (existing session dropped)', async () => { + await s.call('authCSignIn', [alice, PW]); + assert((await s.call('authCCheckAuth', [])) === true, 'should be signed in'); + await s.call('authCAdminRevokeSessions', [alice]); + // Same cookie jar — server-side session is gone, so checkAuth is false. + assert((await s.call('authCCheckAuth', [])) === false, 'session should be revoked'); + s.reset(); + }); + + await runTest('admin.disableUser blocks sign-in; enableUser restores it', async () => { + await s.call('authCAdminDisableUser', [alice]); + let threw = false; + try { await s.call('authCSignIn', [alice, PW]); } catch (e: any) { + threw = true; + assert(e.name === 'NotAuthorizedException', `expected NotAuthorized, got ${e.name}`); + } + assert(threw, 'disabled user sign-in should throw'); + await s.call('authCAdminEnableUser', [alice]); + const r = await s.call('authCSignIn', [alice, PW]); + assert(r.status === 'signedIn', 'enabled user should sign in'); + s.reset(); + }); + + await runTest('admin.deleteUser removes the user and group membership', async () => { + await s.call('authCAdminCreateUser', [bob, PW]); + await s.call('authCAdminSetPassword', [bob, PW]); + await s.call('authCAdminAddToGroup', [bob, 'readers']); + await s.call('authCAdminDeleteUser', [bob]); + let threw = false; + try { await s.call('authCSignIn', [bob, PW]); } catch { threw = true; } + assert(threw, 'deleted user should not sign in'); + }); + + // Cleanup + await runTest('cleanup: delete alice', async () => { + await s.call('authCAdminDeleteUser', [alice]); + }); + + const pass = results.filter((r) => r.status === 'pass').length; + const fail = results.filter((r) => r.status === 'fail').length; + console.log(`\n=== Summary: ${pass} pass · ${fail} fail (${results.length} total) ===`); + if (fail > 0) { + console.log('\nFailures:'); + for (const r of results.filter((r) => r.status === 'fail')) console.log(` ✗ ${r.name}: ${r.detail}`); + } + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error(e); + process.exit(2); +}); From 89d8076e6c52b5f99530815fefae4ac95985f491 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 10:14:59 -0400 Subject: [PATCH 10/21] test(comprehensive): integrate auth.admin sandbox suite into e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the admin e2e to the (getApi) => void suite pattern wired into e2e.test.ts (auth-cognito-admin-sandbox.test.ts), replacing the standalone script so it runs under the unified deploy/teardown harness. Verified live against real Cognito (us-west-2): createUser→setPassword→signIn, addUserToGroup→requireRole on a fresh token, disable/enable gates signIn, deleteUser, and revokeUserSessions all pass (5/5). Live run surfaced a real mock-vs-AWS parity point: AdminUserGlobalSignOut revokes refresh tokens but does NOT invalidate an already-issued access token, so checkAuth does not flip immediately on AWS (the mock deletes the session record and does). Corrected the test assertion and the README accordingly. --- packages/bb-auth-cognito/README.md | 6 +- .../test/auth-cognito-admin-sandbox.test.ts | 114 ++++++++++ test-apps/comprehensive/test/e2e.test.ts | 4 + .../test/sandbox-admin-surface-e2e.ts | 194 ------------------ 4 files changed, 122 insertions(+), 196 deletions(-) create mode 100644 test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts delete mode 100644 test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts diff --git a/packages/bb-auth-cognito/README.md b/packages/bb-auth-cognito/README.md index ef82bc332..109927dc0 100644 --- a/packages/bb-auth-cognito/README.md +++ b/packages/bb-auth-cognito/README.md @@ -190,9 +190,11 @@ await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via Grou | `admin.setUserPassword(username, password, permanent)` | `Promise` | Set a password. `permanent: true` clears the force-change flag. | | `admin.getUser(username)` | `Promise` | Look up a user, or `null` if absent. | | `admin.scan()` | `AsyncIterable` | Enumerate all users (paginates internally). | -| `admin.revokeUserSessions(username)` | `Promise` | Revoke all the user's sessions (AWS: `AdminUserGlobalSignOut`) — forces immediate re-authentication. | +| `admin.revokeUserSessions(username)` | `Promise` | Revoke the user's refresh tokens (AWS: `AdminUserGlobalSignOut`; mock: delete session records). New tokens can no longer be minted; see the session-freshness note for when this takes effect. | -> **Session freshness.** A group change does not affect a user's **existing** session until their token refreshes — `requireRole` reads the `cognito:groups` claim, not live state. This is inherent Cognito behavior. The change applies on the next sign-in or `fetchAuthSession({ forceRefresh: true })`; for immediate effect, call `admin.revokeUserSessions(username)` to force re-auth. +> **Session freshness.** A group change does not affect a user's **existing** session until their token refreshes — `requireRole` reads the `cognito:groups` claim, not live state. This is inherent Cognito behavior. The change applies on the next sign-in or `fetchAuthSession({ forceRefresh: true })`. +> +> `admin.revokeUserSessions(username)` revokes the user's **refresh tokens** so no new access tokens can be minted, but it does **not** invalidate an already-issued access token — a Blocks session whose access token is still valid keeps passing `checkAuth` / `requireAuth` until that token expires (verified against live Cognito). It is not an instant kill-switch on AWS. (The mock deletes the session record outright, so it *does* flip immediately there — a known mock-vs-AWS parity difference.) For a hard cap, lower `sessionTtlSeconds` / the access-token validity. ## Options diff --git a/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts b/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts new file mode 100644 index 000000000..4c7ef08ea --- /dev/null +++ b/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts @@ -0,0 +1,114 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Sandbox e2e for the opt-in `auth.admin` surface, driven through the deployed + * Lambda backend over HTTP (the `authCAdmin*` routes in aws-blocks/index.ts). + * + * Runs only when BLOCKS_TEST_ENV=sandbox|production (the unified e2e harness + * deploys + tears down the stack). Verifies the admin IAM grant is wired and + * the surface behaves end-to-end against real Cognito. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { isBlocksError } from '@aws-blocks/core'; +import type { api as apiType } from 'aws-blocks'; + +const ENV = process.env.BLOCKS_TEST_ENV || 'local'; +const isSandbox = ENV === 'sandbox' || ENV === 'production'; + +const RUN_ID = Date.now().toString(36); +let counter = 0; +function uniqueUser() { + return `adm-${RUN_ID}-${(counter++).toString(36)}`; +} + +const PW = 'AdminE2e!1'; + +export function authCognitoAdminTests(getApi: () => typeof apiType) { + describe('AuthCognito admin surface (sandbox)', { skip: !isSandbox && 'sandbox not deployed' }, () => { + test('admin.createUser → setUserPassword → signIn works end-to-end', async () => { + const api = getApi(); + const u = uniqueUser(); + const created = await api.authCAdminCreateUser(u, PW); + assert.strictEqual(created.username, u); + assert.strictEqual(created.enabled, true); + + await api.authCAdminSetPassword(u, PW); + const r = await api.authCSignIn(u, PW); + assert.strictEqual(r.status, 'signedIn'); + + await api.authCAdminDeleteUser(u); + }); + + test('admin.addUserToGroup → requireRole(admins) succeeds on a fresh token', async () => { + const api = getApi(); + const u = uniqueUser(); + await api.authCAdminCreateUser(u, PW); + await api.authCAdminSetPassword(u, PW); + + await api.authCAdminAddToGroup(u, 'admins'); + const groups = await api.authCAdminListGroupsForUser(u); + assert.ok(groups.includes('admins'), `expected admins in ${JSON.stringify(groups)}`); + + await api.authCSignIn(u, PW); // fresh sign-in → claim carries the group + const user = await api.authCRequireRole('admins'); + assert.strictEqual(user.username, u); + + await api.authCAdminDeleteUser(u); + }); + + test('admin.revokeUserSessions revokes Cognito refresh tokens (succeeds end-to-end)', async () => { + const api = getApi(); + const u = uniqueUser(); + await api.authCAdminCreateUser(u, PW); + await api.authCAdminSetPassword(u, PW); + await api.authCSignIn(u, PW); + assert.strictEqual(await api.authCCheckAuth(), true); + + // AdminUserGlobalSignOut revokes the user's REFRESH tokens at Cognito. + // The Blocks session's already-issued ACCESS token stays valid until + // it expires, so `checkAuth` (which validates the access token) does + // NOT flip to false immediately — this differs from the mock, which + // deletes the server-side session record. The immediate-revocation + // guarantee is "no new tokens can be minted", not "current request + // 401s instantly". We assert the call succeeds end-to-end (the IAM + // grant + AdminUserGlobalSignOut path work); the forced-refresh + // failure is covered separately. + await api.authCAdminRevokeSessions(u); + + await api.authCAdminDeleteUser(u); + }); + + test('admin.disableUser blocks signIn; enableUser restores it', async () => { + const api = getApi(); + const u = uniqueUser(); + await api.authCAdminCreateUser(u, PW); + await api.authCAdminSetPassword(u, PW); + + await api.authCAdminDisableUser(u); + await assert.rejects( + () => api.authCSignIn(u, PW), + (e: unknown) => isBlocksError(e, 'NotAuthorizedException'), + ); + + await api.authCAdminEnableUser(u); + const r = await api.authCSignIn(u, PW); + assert.strictEqual(r.status, 'signedIn'); + + await api.authCAdminDeleteUser(u); + }); + + test('admin.deleteUser removes the user and its group membership', async () => { + const api = getApi(); + const u = uniqueUser(); + await api.authCAdminCreateUser(u, PW); + await api.authCAdminSetPassword(u, PW); + await api.authCAdminAddToGroup(u, 'readers'); + await api.authCAdminDeleteUser(u); + + await assert.rejects(() => api.authCSignIn(u, PW)); + }); + }); +} diff --git a/test-apps/comprehensive/test/e2e.test.ts b/test-apps/comprehensive/test/e2e.test.ts index 0faefdcd7..fbdd120fd 100644 --- a/test-apps/comprehensive/test/e2e.test.ts +++ b/test-apps/comprehensive/test/e2e.test.ts @@ -16,6 +16,7 @@ import { basicAuthTests } from './basic-auth.test.js'; import { authCookieAttrsTests } from './auth-cookie-attrs.test.js'; import { authCognitoTests } from './auth-cognito.test.js'; import { authCognitoSandboxTests } from './auth-cognito-sandbox.test.js'; +import { authCognitoAdminTests } from './auth-cognito-admin-sandbox.test.js'; import { oidcAuthTests } from './oidc-auth.test.js'; import { databaseTests } from './database.test.js'; import { dsqlTests } from './dsql.test.js'; @@ -213,6 +214,9 @@ authCognitoTests(() => api); // AuthCognito Sandbox tests (separate file) authCognitoSandboxTests(() => api); +// AuthCognito admin-surface sandbox tests (separate file) +authCognitoAdminTests(() => api); + // AuthOIDC tests (separate file) oidcAuthTests(() => api); diff --git a/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts b/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts deleted file mode 100644 index b56487c0a..000000000 --- a/test-apps/comprehensive/test/sandbox-admin-surface-e2e.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Live AWS e2e suite for the opt-in `auth.admin` surface on AuthCognito. - * - * Unlike `sandbox-admin-e2e.ts` (which provisions users via the raw Cognito - * SDK to test the *client* methods), this suite drives the BB's own - * `auth.admin.*` operations through the deployed Lambda backend over HTTP — - * the routes wired in `aws-blocks/index.ts` (`authCAdmin*`). It verifies the - * admin IAM grant is present and the surface behaves end-to-end. - * - * Prerequisites (same as sandbox-admin-e2e): - * - A deployed `bb-test-*` sandbox with the comprehensive backend, built - * with `admin: {}` on the `authC` pool (already set in aws-blocks/index.ts). - * - `AWS_PROFILE` with Cognito admin + CFN-describe permissions. - * - Run from `test-apps/comprehensive` (reads `.blocks-sandbox/outputs.json`). - * - * Run: node --import tsx test/sandbox-admin-surface-e2e.ts - * (NOT part of the unit-test run — requires a live deployment.) - */ - -import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - CloudFormationClient, - DescribeStackResourcesCommand, -} from '@aws-sdk/client-cloudformation'; - -const SANDBOX_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '.blocks-sandbox'); -const REGION = process.env.AWS_REGION || 'us-east-1'; -const STACK_NAME_ENV = process.env.BLOCKS_STACK_NAME; - -const results: { name: string; status: 'pass' | 'fail'; detail?: string }[] = []; - -async function runTest(name: string, fn: () => Promise) { - process.stdout.write(`• ${name} ... `); - try { - await fn(); - console.log('PASS'); - results.push({ name, status: 'pass' }); - } catch (e: any) { - const detail = e?.message ?? String(e); - console.log(`FAIL — ${detail}`); - results.push({ name, status: 'fail', detail }); - } -} - -function assert(cond: unknown, msg: string): asserts cond { - if (!cond) throw new Error(msg); -} - -async function discoverApiUrl(): Promise { - const outputs = JSON.parse(readFileSync(join(SANDBOX_DIR, 'outputs.json'), 'utf-8')); - const stackName = STACK_NAME_ENV ?? Object.keys(outputs)[0]; - if (!stackName) throw new Error('No stack in outputs.json'); - const apiUrl = (outputs[stackName] as Record).ApiUrl; - if (!apiUrl) throw new Error(`No ApiUrl for ${stackName}`); - // Touch CFN so a misconfigured profile fails loudly here, not mid-suite. - await new CloudFormationClient({ region: REGION }) - .send(new DescribeStackResourcesCommand({ StackName: stackName })); - return apiUrl; -} - -/** Minimal JSON-RPC client with cookie jar (mirrors sandbox-admin-e2e). */ -class ApiSession { - private cookies = new Map(); - constructor(private apiUrl: string, private namespace = 'api') {} - reset() { this.cookies.clear(); } - private cookieHeader(): string | undefined { - if (this.cookies.size === 0) return undefined; - return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); - } - private absorb(headers: Headers) { - const raw = (headers as any).getSetCookie?.() ?? - (headers.get('set-cookie') ? [headers.get('set-cookie')!] : []); - for (const v of raw) { - const kv = v.slice(0, v.indexOf(';') === -1 ? undefined : v.indexOf(';')); - const eq = kv.indexOf('='); - if (eq < 0) continue; - const name = kv.slice(0, eq).trim(); - const value = kv.slice(eq + 1).trim(); - if (!value) this.cookies.delete(name); else this.cookies.set(name, value); - } - } - async call(method: string, args: any[] = []): Promise { - const headers: Record = { 'Content-Type': 'application/json' }; - const ch = this.cookieHeader(); - if (ch) headers['Cookie'] = ch; - const res = await fetch(this.apiUrl, { - method: 'POST', headers, - body: JSON.stringify({ jsonrpc: '2.0', method: `${this.namespace}.${method}`, params: args, id: 1 }), - }); - this.absorb(res.headers); - const text = await res.text(); - let body: any; try { body = JSON.parse(text); } catch { body = text; } - if (!res.ok || body?.error) { - const p = body?.error ?? {}; - const err: any = new Error(p.message ?? body?.error ?? res.statusText); - err.status = p.code && p.code > 0 ? p.code : res.status; - err.name = p.data?.name ?? body?.name ?? err.name; - throw err; - } - return body.result as T; - } -} - -async function main() { - console.log('─── auth.admin surface e2e ───'); - const apiUrl = await discoverApiUrl(); - console.log(` API URL: ${apiUrl}`); - - const s = new ApiSession(apiUrl); - const uniq = (process.env.BLOCKS_TEST_SEED ?? String(process.hrtime.bigint())).slice(-8); - const alice = `admin-e2e-alice-${uniq}`; - const bob = `admin-e2e-bob-${uniq}`; - const PW = 'AdminE2e!1'; - - await runTest('admin.createUser creates a real user', async () => { - const r = await s.call('authCAdminCreateUser', [alice, PW]); - assert(r.username === alice, `expected ${alice}, got ${r.username}`); - assert(r.enabled === true, 'new user should be enabled'); - }); - - await runTest('admin.setUserPassword(permanent) lets the user sign in', async () => { - await s.call('authCAdminSetPassword', [alice, PW]); - const r = await s.call('authCSignIn', [alice, PW]); - assert(r.status === 'signedIn', `expected signedIn, got ${r.status}`); - s.reset(); - }); - - await runTest('admin.addUserToGroup → requireRole(admins) succeeds on fresh token', async () => { - await s.call('authCAdminAddToGroup', [alice, 'admins']); - const groups = await s.call('authCAdminListGroupsForUser', [alice]); - assert(Array.isArray(groups) && groups.includes('admins'), `groups missing admins: ${JSON.stringify(groups)}`); - await s.call('authCSignIn', [alice, PW]); // fresh sign-in → claim carries the group - const user = await s.call('authCRequireRole', ['admins']); - assert(user.username === alice, 'requireRole should return the user'); - s.reset(); - }); - - await runTest('revokeUserSessions forces re-auth (existing session dropped)', async () => { - await s.call('authCSignIn', [alice, PW]); - assert((await s.call('authCCheckAuth', [])) === true, 'should be signed in'); - await s.call('authCAdminRevokeSessions', [alice]); - // Same cookie jar — server-side session is gone, so checkAuth is false. - assert((await s.call('authCCheckAuth', [])) === false, 'session should be revoked'); - s.reset(); - }); - - await runTest('admin.disableUser blocks sign-in; enableUser restores it', async () => { - await s.call('authCAdminDisableUser', [alice]); - let threw = false; - try { await s.call('authCSignIn', [alice, PW]); } catch (e: any) { - threw = true; - assert(e.name === 'NotAuthorizedException', `expected NotAuthorized, got ${e.name}`); - } - assert(threw, 'disabled user sign-in should throw'); - await s.call('authCAdminEnableUser', [alice]); - const r = await s.call('authCSignIn', [alice, PW]); - assert(r.status === 'signedIn', 'enabled user should sign in'); - s.reset(); - }); - - await runTest('admin.deleteUser removes the user and group membership', async () => { - await s.call('authCAdminCreateUser', [bob, PW]); - await s.call('authCAdminSetPassword', [bob, PW]); - await s.call('authCAdminAddToGroup', [bob, 'readers']); - await s.call('authCAdminDeleteUser', [bob]); - let threw = false; - try { await s.call('authCSignIn', [bob, PW]); } catch { threw = true; } - assert(threw, 'deleted user should not sign in'); - }); - - // Cleanup - await runTest('cleanup: delete alice', async () => { - await s.call('authCAdminDeleteUser', [alice]); - }); - - const pass = results.filter((r) => r.status === 'pass').length; - const fail = results.filter((r) => r.status === 'fail').length; - console.log(`\n=== Summary: ${pass} pass · ${fail} fail (${results.length} total) ===`); - if (fail > 0) { - console.log('\nFailures:'); - for (const r of results.filter((r) => r.status === 'fail')) console.log(` ✗ ${r.name}: ${r.detail}`); - } - process.exit(fail > 0 ? 1 : 0); -} - -main().catch((e) => { - console.error(e); - process.exit(2); -}); From 91c574576e4de90d8b4b1070a97d40a5a28ac027 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 11:19:39 -0400 Subject: [PATCH 11/21] chore(bb-auth-cognito): update API report + add changeset for auth.admin Regenerate API.md to include the admin surface (AdminOptions, AdminSurface, GroupAdmin/LifecycleAdmin, AdminGetterOf, AdminUser, AdminCreateInit) and the const O class generic. Add a minor changeset documenting the feature + the const O breaking change. --- .changeset/auth-cognito-admin-handle.md | 16 +++++ packages/bb-auth-cognito/API.md | 78 ++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .changeset/auth-cognito-admin-handle.md diff --git a/.changeset/auth-cognito-admin-handle.md b/.changeset/auth-cognito-admin-handle.md new file mode 100644 index 000000000..3a9be310c --- /dev/null +++ b/.changeset/auth-cognito-admin-handle.md @@ -0,0 +1,16 @@ +--- +"@aws-blocks/bb-auth-cognito": minor +--- + +Add an opt-in `auth.admin` handle to `AuthCognito` for server-side group-membership and user-lifecycle administration. + +Enable it by passing an `admin` options object; `admin.actions` scopes the granted `Admin*` / `List*` IAM. Without it, `auth.admin` is a compile error and no admin IAM is granted (unchanged default). Group names on the admin methods narrow via `GroupOf`. + +```ts +const auth = new AuthCognito(scope, 'auth', { groups: ['admins'], admin: { actions: ['groups'] } }); +await auth.admin.addUserToGroup('alice', 'admins'); +``` + +The `AuthCognito` class generic is now a `const` type parameter, so inline options literals narrow without `as const`. + +BREAKING CHANGE: `const O` narrows the params of `requireRole`, `updateUserAttribute`, and `updateMFAPreference` for inline-literal options. Callers passing widened `string` variables to these now need a cast or literal arguments. diff --git a/packages/bb-auth-cognito/API.md b/packages/bb-auth-cognito/API.md index 830c4bdee..12c1c7162 100644 --- a/packages/bb-auth-cognito/API.md +++ b/packages/bb-auth-cognito/API.md @@ -12,6 +12,45 @@ import type { ChildLogger } from '@aws-blocks/bb-logger'; import { Scope } from '@aws-blocks/core'; import type { ScopeParent } from '@aws-blocks/core'; +// @public +export interface AdminCreateInit { + attributes?: Record; + suppressInvite?: boolean; + temporaryPassword?: string; +} + +// @public +export type AdminDisabled = { + readonly __adminNotEnabled: 'construct AuthCognito with { admin: {} }'; +}; + +// @public +export type AdminGetterOf = O extends { + admin: object; +} ? AdminSurface : AdminDisabled; + +// @public +export interface AdminOptions { + actions?: readonly ('groups' | 'lifecycle')[]; +} + +// @public +export type AdminSurface = GroupAdmin & LifecycleAdmin; + +// @public +export interface AdminUser { + // (undocumented) + attributes: Record; + // (undocumented) + enabled: boolean; + // (undocumented) + groups?: string[]; + // (undocumented) + username: string; + // (undocumented) + userSub: string; +} + // Warning: (ae-incompatible-release-tags) The symbol "AttrOf" is marked as @public, but its signature references "CustomAttrNames" which is marked as @internal // // @public @@ -20,8 +59,9 @@ export type AttrOf = O extends { } ? StandardUserAttributeKey | CustomAttrNames | `custom:${CustomAttrNames}` : string; // @public -export class AuthCognito extends Scope implements BlocksAuth { +export class AuthCognito extends Scope implements BlocksAuth { constructor(scope: ScopeParent, id: string, options?: O); + get admin(): AdminGetterOf; autoSignIn(context: BlocksContext): Promise>; checkAuth(context: BlocksContext): Promise; completePasskeyRegistration(context: BlocksContext, credential: string): Promise; @@ -114,6 +154,7 @@ export interface AuthCognitoMockOptions extends AuthCognitoOptions { // @public export interface AuthCognitoOptions { + admin?: AdminOptions; authFlowType?: AuthFlowType; crossDomain?: boolean; deviceTracking?: { @@ -270,6 +311,18 @@ export interface FetchAuthSessionOptions { forceRefresh?: boolean; } +// @public +export interface GroupAdmin { + // (undocumented) + addUserToGroup(username: string, group: GroupOf): Promise; + // (undocumented) + listGroupsForUser(username: string): Promise[]>; + // (undocumented) + listUsersInGroup(group: GroupOf): Promise; + // (undocumented) + removeUserFromGroup(username: string, group: GroupOf): Promise; +} + // @public export type GroupOf = O extends { groups: readonly [unknown, ...unknown[]]; @@ -292,6 +345,28 @@ export interface JWT { toString(): string; } +// @public +export interface LifecycleAdmin { + // (undocumented) + createUser(username: string, init?: AdminCreateInit): Promise; + // (undocumented) + deleteUser(username: string): Promise; + // (undocumented) + disableUser(username: string): Promise; + // (undocumented) + enableUser(username: string): Promise; + // (undocumented) + getUser(username: string): Promise; + // (undocumented) + resetUserPassword(username: string): Promise; + // (undocumented) + revokeUserSessions(username: string): Promise; + // (undocumented) + scan(): AsyncIterable; + // (undocumented) + setUserPassword(username: string, password: string, permanent: boolean): Promise; +} + // @public export function makeExternalUserPoolRef(userPoolId: string, clientId?: string): ExternalUserPoolRef; @@ -376,6 +451,7 @@ export interface SessionRecord { export class SessionStore { constructor(scope: ScopeParent, id?: string); createSession(record: SessionRecord): Promise; + deleteByUsername(username: string): Promise; deleteSession(sessionId: string): Promise; lookupSession(sessionId: string): Promise; // (undocumented) From 7941c3e569c666c8e1597814f9b3c25c00b6eb21 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Tue, 30 Jun 2026 11:25:52 -0400 Subject: [PATCH 12/21] chore(bb-agent): regenerate stale API report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bb-agent's committed API.md is out of date with its BedrockModels source on main (model-id renames + DEFAULT/BALANCED/SMART reordering). The repo-wide check:api gate regenerates and diffs ALL packages, so this pre-existing staleness blocks any PR. Regenerated to match source — no source change. Isolated in its own commit so it can be dropped if handled separately. --- packages/bb-agent/API.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/bb-agent/API.md b/packages/bb-agent/API.md index 904024ae8..8b90feb1e 100644 --- a/packages/bb-agent/API.md +++ b/packages/bb-agent/API.md @@ -102,29 +102,25 @@ export type AgentTool = ToolDefinition Date: Tue, 30 Jun 2026 11:45:13 -0400 Subject: [PATCH 13/21] chore: add changeset for bb-agent API report regeneration The repo-wide changeset-coverage gate requires a changeset for any package with file changes; the bb-agent API.md regen (stale-on-main fix) needs a patch entry. --- .changeset/bb-agent-api-report-sync.md | 5 +++++ packages/bb-agent/API.md | 18 +++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .changeset/bb-agent-api-report-sync.md diff --git a/.changeset/bb-agent-api-report-sync.md b/.changeset/bb-agent-api-report-sync.md new file mode 100644 index 000000000..7f35fedb2 --- /dev/null +++ b/.changeset/bb-agent-api-report-sync.md @@ -0,0 +1,5 @@ +--- +"@aws-blocks/bb-agent": patch +--- + +Regenerate the API report to match the current `BedrockModels` source (the committed `API.md` had drifted from the model-id constants). No source or runtime change. diff --git a/packages/bb-agent/API.md b/packages/bb-agent/API.md index 8b90feb1e..904024ae8 100644 --- a/packages/bb-agent/API.md +++ b/packages/bb-agent/API.md @@ -102,25 +102,29 @@ export type AgentTool = ToolDefinition Date: Fri, 3 Jul 2026 15:04:31 -0400 Subject: [PATCH 14/21] docs+fix(bb-auth-cognito): address PR review findings - DESIGN.md (must-fix): move the admin surface from Out to In (opt-in auth.admin handle), update the IAM section to the opt-in grant model, add an Admin surface section + Key Design Decision #7, cross-link the implementation plan. Resolves the shipped-doc-vs-code contradiction. - @aws-blocks/blocks (should-fix): re-export the admin types so umbrella consumers can name AdminOptions/AdminUser/GroupAdmin/etc.; regenerate blocks API.md; add changeset. - Superseded design doc (minor): reduce the 755-line BB-auth-cognito-admin.md to a redirect stub, removing KitContext/KIT_*/isKitError rebrand residue. - AWS admin scan() (minor): pin Limit: 60 to match the documented page-size contract and the other paginators. Full package suite: 224 pass, 0 fail. --- .changeset/blocks-admin-types-reexport.md | 5 + docs/tech-design/BB-auth-cognito-admin.md | 756 +--------------------- packages/bb-auth-cognito/DESIGN.md | 14 +- packages/bb-auth-cognito/src/index.aws.ts | 2 +- packages/blocks/API.md | 24 + packages/blocks/src/index.ts | 8 + 6 files changed, 56 insertions(+), 753 deletions(-) create mode 100644 .changeset/blocks-admin-types-reexport.md diff --git a/.changeset/blocks-admin-types-reexport.md b/.changeset/blocks-admin-types-reexport.md new file mode 100644 index 000000000..0ca86ee26 --- /dev/null +++ b/.changeset/blocks-admin-types-reexport.md @@ -0,0 +1,5 @@ +--- +"@aws-blocks/blocks": patch +--- + +Re-export the `auth.admin` types (`AdminOptions`, `AdminUser`, `AdminCreateInit`, `GroupAdmin`, `LifecycleAdmin`, `AdminSurface`, `AdminGetterOf`, `AdminDisabled`) from the umbrella package so consumers using `@aws-blocks/blocks` can name the values `auth.admin` returns and annotate `admin` options. diff --git a/docs/tech-design/BB-auth-cognito-admin.md b/docs/tech-design/BB-auth-cognito-admin.md index bf04a84ed..815d623c1 100644 --- a/docs/tech-design/BB-auth-cognito-admin.md +++ b/docs/tech-design/BB-auth-cognito-admin.md @@ -1,755 +1,11 @@ -# BB: AuthCognitoAdmin (Implementation-Ready) +# BB: AuthCognitoAdmin — SUPERSEDED -> **⚠️ SUPERSEDED (direction changed).** We are no longer shipping a separate `@aws-blocks/bb-auth-cognito-admin` package. The admin surface will be an opt-in handle (`auth.admin`) on the existing `AuthCognito` class. See [`BB-auth-cognito-admin-implementation-plan.md`](./BB-auth-cognito-admin-implementation-plan.md). This document is retained for the API surface, error model, and mock-parity research, which the handle reuses. - -> **STATUS — Implementation-ready.** API surface, error model, per-runtime implementation, mock parity, and naming have been validated against the shipped `bb-auth-cognito` code and the binding guidelines (API Design Guidelines, Building Block Architecture, [DECISIONS D-004](../DECISIONS.md)). The design was independently reviewed and every claim verified against source. Open items remaining are explicitly listed at the end and are non-blocking for a v1 build. - -**Package:** `@aws-blocks/bb-auth-cognito-admin` -**Type:** Composite (composes an existing `AuthCognito` instance; no new AWS infrastructure) -**AWS Service:** Amazon Cognito User Pool (admin-side `Admin*` / `List*` APIs) - -## Prior art in the repo (this block was pre-planned) - -This block is not speculative — the shipped `AuthCognito` docs explicitly carved it out and pre-documented its surface. The design below is the realization of that plan: - -- **`packages/bb-auth-cognito/docs/DESIGN.md:24`** (authoritative, ships with the package): *"Admin user-lifecycle APIs … deliberately excluded. **Those operate on any user and can't self-gate from a `context`**, so they don't belong on the same class that exposes `signIn`/`signUp`. They return as a separate admin Building Block in a future PR."* — This is the canonical rationale (sharper than "client vs admin": the real boundary is **self-gating**). -- **`packages/bb-auth-cognito/docs/DESIGN.md:88`**: *"No `cognito-idp:Admin*` or `cognito-idp:List*` actions are granted; those belong to the future admin BB."* — confirms the IAM split this block fills. -- **`packages/bb-auth-cognito/docs/COGNITO-SUPPORT.md`**: a full Cognito-operation matrix where every API this block implements is tagged ❌ "Admin BB" (`AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup`, `AdminCreateUser`, `AdminDeleteUser`, `AdminEnable/DisableUser`, `AdminSetUserPassword`, `AdminResetUserPassword`, `AdminUserGlobalSignOut`, `ListUsers`). -- **`DESIGN.md:191`** (open question): *"Typed `ListUsers.filter`. When the admin BB lands, the filter string is still stringly-typed."* — resolved here as a typed filter subset. -- **`DECISIONS.md` D-004** (auth-first naming): mandates `bb-auth-*` packages and `Auth*` classes — `bb-auth-cognito-admin` / `AuthCognitoAdmin` comply. - -## Purpose - -Server-side administrative surface for a Cognito User Pool created by `AuthCognito`. Provides the user-lifecycle and group-membership operations that `AuthCognito` deliberately omits from its client-facing class: creating/deleting users, enabling/disabling them, resetting passwords, listing users, and adding/removing group membership. - -**The boundary, precisely.** `AuthCognito` methods take `context: KitContext` and act on *the signed-in user* — they self-gate. `AuthCognitoAdmin` methods take a `username` and act on *any* user — they cannot self-gate, so they must be gated by the caller (`requireRole`). Keeping them on a separate class makes that distinction structural, not a convention you can forget. - -**Why it matters:** `AuthCognito` seeds groups at deploy time (`CfnUserPoolGroup` per `options.groups`) and `requireRole(ctx, 'admins')` checks the `cognito:groups` claim — but ships **no way to put a user into a group**. The mock keeps every group permanently empty, and the AWS runtime relies on an out-of-band `aws cognito-idp admin-add-user-to-group` CLI call. As a result `requireRole('admins')` returns 403 for every user and the admin surface is unreachable. `AuthCognitoAdmin` closes that gap with a first-class, typed, mock-parity API. - -**When to use:** You need server-side admin operations — seeding the first admin, building an internal user-management screen, or scripting group membership — against a pool owned by `AuthCognito`. - -**When NOT to use:** For client-facing auth (sign-in/up, MFA, profile self-service) use `AuthCognito` directly. Never expose `AuthCognitoAdmin` methods to unauthenticated callers — gate every admin route behind `auth.requireRole(ctx, 'admins')` (see Usage Examples). - -## Relationship to AuthCognito - -`AuthCognitoAdmin` references — does not re-create — the pool. It takes the `AuthCognito` instance via options and reuses its discovery channel: - -- **AWS runtime:** re-derives the same env vars via `envVarNames(auth.fullId)` (`KIT_AUTH_COGNITO__USER_POOL_ID` etc.), so it talks to the exact pool `AuthCognito` created. No new env vars, no second pool. -- **Mock:** reads and writes the **same** `.bb-data//state.json` that the mock `AuthCognito` persists, so a membership change made by the admin block is visible to the next `signIn` / `requireRole` on the auth block (mock reloads state from disk and `flushToDisk()`es on every write). -- **CDK:** adds an IAM policy statement granting the API handler the `Admin*` / `List*` actions, scoped to `auth.userPool.userPoolArn` (public getter on the CDK construct). No resources are created. - -Because it holds the `AuthCognito` reference, the admin block re-threads the generic options literal `O` to keep group-name narrowing: `addUserToGroup(username, group: GroupOf)` is a compile error on a typo'd group. - -## API Surface - -### Generic type contract - -`AuthCognitoAdmin` mirrors `AuthCognito`. The generic is recovered from the `auth` option (`AuthCognitoAdminOptions.auth: AuthCognito`), so callers who passed `as const` options to `AuthCognito` get the same narrowed `GroupOf` on the admin methods with no extra annotation. - -> **Caveat — narrowing requires `as const` discipline.** `GroupOf` only narrows when the `auth` **variable's static type** carries the narrowed `O`, which happens only when its options were passed `as const` (the literal-tuple guard in `types.ts:175-177`). A pool declared `new AuthCognito(scope, 'auth', { groups: ['admins', 'readers'] })` **without** `as const` (as the comprehensive test-app does) has `O = AuthCognitoOptions`, so `GroupOf` collapses to `string` and `addUserToGroup` accepts any string. This is the same behavior as `AuthCognito.requireRole` and is not a regression — but the narrowing is **opt-in**, not automatic. Document it in the README. - -```typescript -/** - * Server-side admin surface for an AuthCognito User Pool. - * - * **When to use:** You need privileged user-lifecycle or group-membership - * operations — seed the first admin, build an internal user-management - * screen, or script membership changes. - * - * **When NOT to use:** For client-facing auth (sign-in/up, MFA, profile - * self-service) use `AuthCognito`. These methods are privileged — always - * gate them behind `auth.requireRole(ctx, 'admins')`. - * - * **Best practices:** - * - Expose admin methods only from API routes guarded by `requireRole`. - * - Prefer `scan()` for enumeration; it communicates full-pool cost (G14). - * - Seed the first admin via a one-off script/migration, not a public route - * (bootstrapping paradox: the first admin can't be promoted by an admin). - * - * **Scaling:** Admin/List APIs share the Cognito account-level quota with - * the client-facing pool (~25 req/s for many Admin* actions, adjustable via - * Service Quotas). `scan()` paginates internally (60 users/page) and is - * O(total users) — do not call it on a hot path. - */ -class AuthCognitoAdmin extends Scope { - /** - * @param scope - Parent scope to attach to. - * @param id - Unique identifier within the parent scope. - * @param options - Must include the `auth` block whose pool to administer. - * - * @example - * ```typescript - * const auth = new AuthCognito(scope, 'auth', { groups: ['admins'] as const }); - * const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); - * ``` - */ - constructor(scope: ScopeParent, id: string, options: AuthCognitoAdminOptions); - - // ── Group membership ────────────────────────────────────────────────── - - /** - * Add a user to a group. Idempotent — adding an already-member is a no-op. - * @param username - The username (or sub) to add. - * @param group - The group name. Narrowed to `GroupOf` when `auth` was - * configured with `groups: [...] as const`. - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @throws {AuthCognitoAdminErrors.GroupNotFound} If the group does not exist. - * @example - * ```typescript - * await admin.addUserToGroup('alice', 'admins'); - * ``` - */ - addUserToGroup(username: string, group: GroupOf): Promise; - - /** - * Remove a user from a group. Idempotent on membership: removing a user who - * is not a member succeeds as a no-op (matches Cognito's - * `AdminRemoveUserFromGroup`, which does not error on a non-member). Still - * throws if the *user* or the *group* itself does not exist. - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @throws {AuthCognitoAdminErrors.GroupNotFound} If the group does not exist. - * @example - * ```typescript - * await admin.removeUserFromGroup('alice', 'admins'); - * ``` - */ - removeUserFromGroup(username: string, group: GroupOf): Promise; - - /** - * List the groups a user belongs to. - * @returns The user's group names (narrowed to `GroupOf[]`). Empty array - * if the user is in no groups. - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @example - * ```typescript - * const groups = await admin.getUserGroups('alice'); // ['admins'] - * ``` - */ - getUserGroups(username: string): Promise[]>; - - /** - * Enumerate the members of a group. Unbounded — returns `AsyncIterable` - * and paginates internally (G5). - * @throws {AuthCognitoAdminErrors.GroupNotFound} - * @example - * ```typescript - * for await (const user of admin.getUsersInGroup('admins')) { - * console.log(user.username); - * } - * ``` - */ - getUsersInGroup(group: GroupOf): AsyncIterable>; - - // ── User lifecycle ────────────────────────────────────────────────────── - - /** - * Look up a single user by username. Returns `null` if not found (G3). - * @returns The user, or `null` if no such user exists. - * @example - * ```typescript - * const user = await admin.getUser('alice'); - * if (user) console.log(user.groups); - * ``` - */ - getUser(username: string): Promise | null>; - - /** - * Enumerate all users in the pool. Full-pool cost — named `scan` to - * communicate that it reads every user (G14). Paginates internally. - * @param options - Optional typed `filter` for the limited set of - * attributes Cognito's `ListUsers` can narrow on server-side (G18). - * @returns An `AsyncIterable` of users; consume with `for await`. - * @example - * ```typescript - * for await (const user of admin.scan({ filter: { attribute: 'email', op: '^=', value: 'a' } })) { - * console.log(user.username); - * } - * ``` - */ - scan(options?: AdminScanOptions): AsyncIterable>; - - /** - * Administratively create a user, bypassing self-sign-up. The user is - * created in a `FORCE_CHANGE_PASSWORD` state unless `options.permanent`. - * @returns The created user. - * @throws {AuthCognitoAdminErrors.UserAlreadyExists} - * @throws {AuthCognitoAdminErrors.InvalidPassword} - * @example - * ```typescript - * await admin.createUser('bob', { temporaryPassword: 'Temp1!pass', - * attributes: { email: 'bob@example.com' }, groups: ['readers'] }); - * ``` - */ - createUser(username: string, options?: AdminCreateUserOptions): Promise>; - - /** - * Permanently delete a user. Idempotent only if the user exists; deleting - * a missing user throws (precondition violation, G3). - * @throws {AuthCognitoAdminErrors.UserNotFound} - */ - deleteUser(username: string): Promise; - - /** - * Disable a user — blocks all sign-in without deleting the record. Toggles - * the `disabled` flag the mock `signIn` already enforces (`index.ts:452`). - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @example - * ```typescript - * await admin.disableUser('alice'); // alice can no longer sign in - * ``` - */ - disableUser(username: string): Promise; - - /** - * Re-enable a previously disabled user. - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @example - * ```typescript - * await admin.enableUser('alice'); - * ``` - */ - enableUser(username: string): Promise; - - /** - * Force a password reset on the user's next sign-in (admin-initiated). - * Does not email a code in mock mode (logged to console instead). - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @example - * ```typescript - * await admin.resetUserPassword('alice'); - * ``` - */ - resetUserPassword(username: string): Promise; - - /** - * Set a user's password directly (admin override). - * @param permanent - When `true`, the password is permanent; when `false` - * the user must change it on next sign-in. Default: `false`. - * @throws {AuthCognitoAdminErrors.UserNotFound} If the user does not exist. - * @throws {AuthCognitoAdminErrors.InvalidPassword} If the password fails policy. - * @example - * ```typescript - * await admin.setUserPassword('alice', 'NewP@ss1!', { permanent: true }); - * ``` - */ - setUserPassword(username: string, password: string, options?: { permanent?: boolean }): Promise; - - /** - * Revoke all of a user's active sessions, forcing re-authentication. - * - * Use after a group change to make the new permissions take effect - * immediately — otherwise the user's existing token keeps its stale - * `cognito:groups` claim until it expires/refreshes (see DESIGN § - * Session-freshness). AWS: `AdminUserGlobalSignOut`; mock: deletes the - * user's session records. - * @throws {AuthCognitoAdminErrors.UserNotFound} - */ - revokeUserSessions(username: string): Promise; -} - -interface AuthCognitoAdminOptions { - /** The AuthCognito block whose User Pool this block administers. Required. */ - auth: AuthCognito; -} - -interface AdminScanOptions { - /** - * Server-side narrowing on a single indexed attribute. Modeled as a typed - * subset rather than a raw Cognito filter string (G10 — no leaking the - * service's filter DSL; G18 — only attributes Cognito can narrow on - * server-side are offered). Omit for all users. - * - * `op: '^='` is prefix-match; `'='` is exact. `attribute` is limited to - * the standard set Cognito's `ListUsers` `Filter` supports natively. - */ - filter?: { - attribute: 'username' | 'email' | 'phone_number' | 'sub' | 'status'; - op: '=' | '^='; - value: string; - }; -} - -interface AdminCreateUserOptions { - /** Temporary password. If omitted, Cognito generates one. */ - temporaryPassword?: string; - /** When true, the password is permanent (no FORCE_CHANGE_PASSWORD). */ - permanent?: boolean; - /** Standard + custom attributes. Keys narrowed to `AttrOf`. */ - attributes?: Partial, string>>; - /** Suppress the Cognito invitation message. Default: true (admin flows). */ - suppressInvite?: boolean; - /** Groups to add the user to immediately after creation. */ - groups?: readonly GroupOf[]; -} -``` - -`CognitoUser`, `GroupOf`, `AttrOf`, and `AuthCognitoOptions` are re-exported from `@aws-blocks/bb-auth-cognito` — the admin block adds no parallel user type (G2: one client-safe shape across the ecosystem). - -No `createApi()` — admin operations are not part of the client Authenticator state machine. Customers wire their own guarded `ApiNamespace` routes (see Usage Examples). - -**Deliberate G14 verb deviations.** Two method names depart from the G14 "verbs to avoid" guidance, intentionally, because they mirror the underlying Cognito admin verbs that have no idempotent/upsert equivalent: -- `createUser` (not `put` + `{ ifNotExists }`): Cognito's `AdminCreateUser` is genuinely non-idempotent — it throws `UsernameExistsException` on a duplicate and there is no admin upsert. `create` communicates that semantics honestly; a `put` name would falsely imply idempotent replace. -- `setUserPassword` (not `put`): mirrors Cognito's `AdminSetUserPassword`, the recognized name for the operation. - -**No `fromExisting` (G9).** The admin block provisions no resources, so it has no `fromExisting` factory. Pool lifecycle is owned by the referenced `AuthCognito`, which has its own `fromExisting`. An admin block transparently administers a `fromExisting` pool: the AWS runtime discovers it through the same `envVarNames(auth.fullId)` env vars, which the AWS `AuthCognito` constructor registers for `fromExisting` pools too (`index.aws.ts:613`). - -## Error Constants - -```typescript -export const AuthCognitoAdminErrors = { - UserNotFound: 'UserNotFoundException', - UserAlreadyExists: 'UsernameExistsException', - GroupNotFound: 'ResourceNotFoundException', - InvalidPassword: 'InvalidPasswordException', - InvalidParameter: 'InvalidParameterException', // createUser with a bad attribute, etc. - LimitExceeded: 'LimitExceededException', // admin-create / list throttling at the pool level - TooManyRequests: 'TooManyRequestsException', // account-level throttling - UnsupportedUserState: 'UnsupportedUserStateException', // e.g. setUserPassword on a not-yet-confirmed user - NotAuthorized: 'NotAuthorizedException', -} as const; -``` - -Each value is a subset of the parent `AuthCognitoErrors` (`types.ts:805-820`) with identical string values, so the two constants are interchangeable in `isKitError`. The lifecycle methods surface the extra codes (`InvalidParameter`, `LimitExceeded`, `TooManyRequests`, `UnsupportedUserState`) because `AdminCreateUser` / `AdminSetUserPassword` / `ListUsers` throw them where the client-facing surface never would. - -Names match the Cognito SDK error names (G6) so customers who know Cognito recognize them. Every value here is **identical to the corresponding `AuthCognitoErrors` value in the shipped code** (`types.ts:805-820`): `NotAuthorized`→`NotAuthorizedException`, `UserNotFound`→`UserNotFoundException`, `UserAlreadyExists`→`UsernameExistsException`, `InvalidPassword`→`InvalidPasswordException`, and `GroupNotFound`→`ResourceNotFoundException`. So `isKitError(e, AuthCognitoAdminErrors.X)` and `isKitError(e, AuthCognitoErrors.X)` are interchangeable for the overlapping codes — no divergence. - -> **⚠️ Stale parent doc:** `BB-auth-cognito.md:296` documents `GroupNotFound: 'GroupNotFoundException'`, which does **not** match the shipped code (`types.ts:820` = `'ResourceNotFoundException'`). This admin doc follows the code. The parent doc should be corrected separately. - -## Infrastructure (CDK) - -Composite — **no resources created**. The CDK implementation only adds an IAM grant to the API handler, scoped to the pool owned by the referenced `AuthCognito`: - -```typescript -// inside AuthCognitoAdmin (CDK) constructor -const poolArn = options.auth.userPool.userPoolArn; // public getter on the CDK construct -this.handler.addToRolePolicy(new iam.PolicyStatement({ - actions: [ - 'cognito-idp:AdminAddUserToGroup', - 'cognito-idp:AdminRemoveUserFromGroup', - 'cognito-idp:AdminListGroupsForUser', - 'cognito-idp:ListUsersInGroup', - 'cognito-idp:ListUsers', - 'cognito-idp:AdminGetUser', - 'cognito-idp:AdminCreateUser', - 'cognito-idp:AdminDeleteUser', - 'cognito-idp:AdminEnableUser', - 'cognito-idp:AdminDisableUser', - 'cognito-idp:AdminResetUserPassword', - 'cognito-idp:AdminSetUserPassword', - 'cognito-idp:AdminUserGlobalSignOut', - ], - resources: [poolArn], -})); -``` - -These are deliberately **separate** from the client-facing grant in `AuthCognito.grantCognitoPermissions` (which intentionally excludes `Admin*`). Keeping the admin grant in the admin block means an app that never instantiates `AuthCognitoAdmin` never grants its handler admin privileges — least privilege by composition. - -- **Removal policy:** N/A (no resources). -- **Naming:** N/A. -- **Discovery:** none added — reuses `AuthCognito`'s env vars via `envVarNames(auth.fullId)`. - -> **Load-bearing assumption: one Lambda handler per stack.** Both the CDK grant (`this.handler.addToRolePolicy`) and the AWS-runtime discovery (`envVarNames(auth.fullId)` read from `process.env`) rely on KIT's single-shared-handler model — `Scope.handler` walks up to the one `KitStack` handler (`core/src/cdk/index.ts:81-92`), and the auth block writes its discovery env vars onto that same function. This design is correct **because** of that model. If KIT ever introduces per-block Lambdas, the admin block's handler would need its own grant + env vars and this section must be revisited. - -### Package layout & browser stub - -`bb-auth-cognito` ships an `index.browser.ts` because it is client-facing and must not bundle the AWS SDK into client code. `AuthCognitoAdmin` is **server-only** (no client plugin, no Transferable, no `createApi`) but it imports `@aws-sdk/client-cognito-identity-provider` for its `Admin*` commands. Per the architecture guide a composite's `index.browser.ts` is "optional" — but because the package pulls in the AWS SDK, it **must** ship an `index.browser.ts` stub if the package is ever transitively reachable from a client bundle, to prevent the SDK from bundling. The stub should throw on construction (the admin block has no legitimate browser use). Open Question 5 (separate package vs `bb-auth-cognito/admin` subpath) interacts with this: a separate package keeps the SDK dependency fully opt-in. - -## Mock Implementation - -> **Research note — why file-sharing does NOT work.** The obvious approach ("both blocks point at `.bb-data//state.json`") is **incorrect** and would silently corrupt state. Confirmed by reading `index.ts`: -> -> 1. The mock loads the state file into memory **once, in the constructor** (`this.state = this.loadFromDisk()`, `index.ts:216`) and **never reloads it** — there is no per-request re-read anywhere in the file. -> 2. Blocks are instantiated **once at app-module scope** (e.g. `const authC = new AuthCognito(scope, 'authC', …)` in `test-apps/comprehensive/aws-blocks/index.ts:64`) and the dev server imports that module a single time (`dev-server.ts:76`, `await import(backendUrl)`). Every request reuses the **same long-lived instances**. +> **⚠️ SUPERSEDED.** The original design proposed a separate `@aws-blocks/bb-auth-cognito-admin` package. We instead ship the admin surface as an **opt-in `auth.admin` handle** on the existing `AuthCognito` class. > -> So two instances pointed at the same file each hold their own in-memory `state`. A write from the admin instance flushes to disk, but the auth instance's in-memory copy is stale and its next `flushToDisk()` (e.g. on the next `signIn`) **overwrites the admin's change**. Last-flush-wins, lost updates, no visibility. File-sharing is a non-starter given the load-once model. - -**Correct approach: the admin mock delegates to the live auth instance's in-memory state.** Because the customer passes the real `AuthCognito` instance via `{ auth }`, and both live in the same process, the admin block operates on the *same object*, not a copy. This is also why the standalone `{ auth }` wiring (vs a free-standing constructor that only knows a `fullId`) is the right call — it hands the admin block a live reference, not just a discovery key. - -Concretely, the mock `AuthCognito` exposes a **narrow internal admin port** — a small interface, not its whole private state — that the admin mock calls: - -```typescript -// Exposed from bb-auth-cognito via an "./internal" subpath export (types-free, -// not part of the public API surface; see Resolved Decision 1). -export interface CognitoMockAdminPort { - addToGroup(username: string, group: string): void; // throws UserNotFound / GroupNotFound - removeFromGroup(username: string, group: string): void; - groupsOf(username: string): string[]; - membersOf(group: string): MockUserRecord[]; - allUsers(): MockUserRecord[]; - getUser(username: string): MockUserRecord | null; - createUser(username: string, init: AdminCreateInit): MockUserRecord; - deleteUser(username: string): void; - setDisabled(username: string, disabled: boolean): void; - setForcePasswordChange(username: string, force: boolean): void; // reuses existing `forcePasswordChange` flag (index.ts:506) - setPassword(username: string, password: string, permanent: boolean): void; - // every mutator calls the existing private flushToDisk() internally -} -``` - -The mock `AuthCognito` implements this against its single in-memory `state` (and its existing `flushToDisk()`), so every admin mutation is **immediately visible** to that same instance's `signIn` / `getCurrentUser` / `requireRole`. The admin block owns zero knowledge of the on-disk file format — it only knows the port. Behavioral notes: - -> **`/internal` conditional-export resolution (must be specified before build).** The port is a **mock-only** construct — it lives on the mock `AuthCognito` (`index.ts`), which is the `default` entry. There is no in-memory `state` under `aws-runtime` or `cdk`. So `@aws-blocks/bb-auth-cognito/internal` resolves per condition: -> - `default` (mock) → the real `CognitoMockAdminPort` interface + a factory that returns the live instance's port. -> - `aws-runtime` / `cdk` → a stub that **throws** if called (the admin block's AWS/CDK entries never import it). -> -> The discipline that keeps this sound: only the admin block's **mock entry** (`index.ts`) imports the port. Its `index.aws.ts` talks to Cognito via the SDK; its `index.cdk.ts` only adds the IAM grant. Neither touches `/internal`. -> -> This does **not** interact with the export-parity test. `conditional-exports.test.ts` imports only the bare package specifier (`import('${pkg}')` = the `"."` subpath) and asserts each condition's exports are a superset of `default`'s (`conditional-exports.test.ts:42-64`). It never inspects `/internal` or any other subpath — the existing `./ui` subpath is equally outside its view. So `/internal` is simply **out of scope** for that test, not specially excluded. - -Behavioral notes: - -- **Group membership:** `addToGroup` appends to `state.groups[group]`, throwing `GroupNotFound` when the group was never seeded (matches Cognito, which has no implicit group creation); `removeFromGroup` filters it out. This is the plumbing that is currently missing and the direct cause of the bug. -- **User lifecycle:** `createUser` writes a `MockUserRecord` mirroring `signUp` (reusing `prefixCustomAttrs`, password-policy enforcement); `deleteUser` removes the record **and** strips the user from every `state.groups` array; `disableUser`/`enableUser` toggle the existing `disabled` flag on `MockUserRecord` (already present at `index.ts:291`); `resetUserPassword` / `setUserPassword` set the existing `forcePasswordChange` flag (`index.ts:506`) and/or the password. **Reuse the existing flag name `forcePasswordChange` — do not introduce a parallel `forceChangePassword`.** -- **Enumeration:** `scan` / `getUsersInGroup` iterate the in-memory maps and yield page-by-page to exercise the same `AsyncIterable` consumption path the AWS pagination uses. -- **Disabled-user enforcement is ALREADY shipped.** The mock `AuthCognito.signIn` already rejects disabled users (`index.ts:452`: `if (!user || user.disabled) throw NotAuthorized`). This block does **not** need to wire that check. Its work is to add the `disableUser`/`enableUser` mutators that toggle the flag, plus a regression test asserting the existing `signIn` enforcement keeps working. - -### Session-freshness (resolves former Open Question 4) - -After `addUserToGroup`, a user with an **existing session** still carries the old `cognito:groups` claim until their token refreshes — `requireRole` (mock `index.ts:978`, AWS `index.aws.ts:1306`) reads the claim, not live state, in both runtimes (both resolve the user by decoding the stored ID token: mock `getCurrentUser` at `index.ts:894`, AWS `toCognitoUser` at `index.aws.ts:1916`). This is **inherent Cognito behavior, not a KIT bug**, and must be documented. Two mitigations the block provides: - -- The mock's `fetchAuthSession({ forceRefresh: true })` already re-reads `state.groups` when re-minting the token (`index.ts:950-952`), and the AWS runtime re-issues via `REFRESH_TOKEN_AUTH` — so a client refresh picks up the new group. -- For immediate effect, the block exposes `revokeUserSessions(username)` (AWS: `AdminUserGlobalSignOut`; mock: delete the user's session records), forcing the user to re-authenticate and mint a fresh claim. Documented as the way to make a permission change take effect now. - -## Mock vs AWS Parity Gap Mitigations - -| Parity Gap | Impact | Mitigation | -|------------|--------|------------| -| No IAM enforcement in mock | An ungated admin route "works" locally but 403s in AWS only if the guard is missing | Document that admin routes must be `requireRole`-gated; mock logs a warning the first time an admin method runs without an authenticated admin in context (best-effort) | -| Mock `createUser` skips invitation email / SMS | No real invite delivered locally | Mock logs the temporary password to console (same convention as `signUp` codes) | -| `ListUsers` filter expression not fully emulated | Mock applies a simplified prefix/equality match; complex Cognito filter syntax may differ | Document supported subset; recommend sandbox testing for non-trivial filters | -| Account-level Admin* throttling not simulated | Bulk admin scripts that would throttle in AWS succeed locally | No mitigation — quotas are account-level and non-deterministic; document the gap | -| Disabled-user sign-in block | Already at parity — both runtimes reject disabled users | No new work: the mock `signIn` already enforces `disabled` (`index.ts:452`); AWS Cognito enforces it natively. This block only adds the `disableUser`/`enableUser` mutators that flip the flag | - -## AWS Runtime Implementation (`index.aws.ts`) - -Mirrors the structure of `bb-auth-cognito/src/index.aws.ts` exactly — same client, same discovery, same error-mapping helper — so the two files read as siblings. - -**Construction & discovery.** No new env vars. The admin block reads the pool ID off the referenced auth block's discovery channel: - -```typescript -import { - CognitoIdentityProviderClient, - AdminAddUserToGroupCommand, AdminRemoveUserFromGroupCommand, - AdminListGroupsForUserCommand, ListUsersInGroupCommand, ListUsersCommand, - AdminGetUserCommand, AdminCreateUserCommand, AdminDeleteUserCommand, - AdminEnableUserCommand, AdminDisableUserCommand, - AdminResetUserPasswordCommand, AdminSetUserPasswordCommand, - AdminUserGlobalSignOutCommand, - type UserType, -} from '@aws-sdk/client-cognito-identity-provider'; -import { ApiError, Scope, getSdkIdentifiers } from '@aws-blocks/core'; - -export class AuthCognitoAdmin extends Scope { - private readonly client: CognitoIdentityProviderClient; - private readonly auth: AuthCognito; - - constructor(scope: ScopeParent, id: string, options: AuthCognitoAdminOptions) { - super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); - this.auth = options.auth; - // Same lazy region resolution as AuthCognito; client is cheap to build. - const { userPoolId, region } = getSdkIdentifiers(this.auth); // registered by AuthCognito ctor - this.client = new CognitoIdentityProviderClient({ region }); - } - - private get userPoolId(): string { - const { userPoolId } = getSdkIdentifiers(this.auth); - if (!userPoolId) throw new ApiError( - 'AuthCognitoAdmin: pool not discovered — did the AuthCognito CDK construct run?', 500); - return userPoolId; - } -``` - -**Every method is a thin SDK wrapper through the shared `asApiError`** (reuse the exact helper from `index.aws.ts:531` — it maps `e.name` → HTTP status and preserves the Cognito exception name so `isKitError` works): - -```typescript - async addUserToGroup(username: string, group: GroupOf): Promise { - try { - await this.client.send(new AdminAddUserToGroupCommand({ - UserPoolId: this.userPoolId, Username: username, GroupName: group as string, - })); - } catch (e) { throw asApiError(e); } - } - - async getUser(username: string): Promise | null> { - try { - const r = await this.client.send(new AdminGetUserCommand({ - UserPoolId: this.userPoolId, Username: username })); - const groups = await this.fetchGroups(username); // AdminListGroupsForUser - return toCognitoUser({ username: r.Username!, attributes: attrsToRecord(r.UserAttributes), groups }); - } catch (e) { - if (e instanceof Error && e.name === 'UserNotFoundException') return null; // G3: null for absence - throw asApiError(e); - } - } - - async *scan(options?: AdminScanOptions): AsyncIterable> { - let token: string | undefined; - do { - const r = await this.client.send(new ListUsersCommand({ - UserPoolId: this.userPoolId, - Filter: options?.filter ? `${options.filter.attribute} ${options.filter.op} "${options.filter.value}"` : undefined, - Limit: 60, PaginationToken: token, - })); - for (const u of r.Users ?? []) yield toCognitoUser({ - username: u.Username!, attributes: attrsToRecord(u.Attributes), groups: [] }); // groups omitted on scan (cost) - token = r.PaginationToken; - } while (token); - } -``` - -**Notes that matter for the build:** -- `getUser` returns `null` on `UserNotFoundException` (G3) — the *only* place that swallows an SDK error; everything else rethrows via `asApiError`. -- `scan` and `getUsersInGroup` use the **identical pagination loop** as `fetchDevices` (`index.aws.ts:1555-1580`): `Limit: 60`, `do { … } while (PaginationToken)`. `scan` yields users *without* resolving each one's groups — doing a per-user `AdminListGroupsForUser` inside a full-pool scan would be O(users) extra calls (a G18 cost trap). `getUser`/`getUsersInGroup` resolve groups because they're bounded. -- `createUser` maps `permanent` → a follow-up `AdminSetUserPassword({ Permanent: true })` after `AdminCreateUser` (Cognito has no single "create with permanent password" call), and `suppressInvite` → `MessageAction: 'SUPPRESS'`. Reuse `attrsToList` (`index.aws.ts:150`) + `prefixCustomAttrs` for attribute marshalling. -- `revokeUserSessions` → `AdminUserGlobalSignOutCommand`. Note it invalidates Cognito refresh tokens but the customer's **server-side KVStore session record still exists** until its next access-token expiry; document that the user is fully locked out only after the access token TTL, or pair with deleting the session record if immediate lockout is required. -- `toCognitoUser` is a tiny local shaper to the **same `CognitoUser`** the auth block returns (G2 — one shape). Do not invent a parallel `AdminUser` type. - -## Package Scaffold & Conditional Exports - -New package `packages/bb-auth-cognito-admin/` following the standard four-entry layout (`08-building-block-architecture.md`): - -``` -packages/bb-auth-cognito-admin/ -├── package.json # exports map below; deps: bb-auth-cognito, core, @aws-sdk/client-cognito-identity-provider -├── README.md # G11 source-of-truth docs (mirror class JSDoc) -├── DESIGN.md # this design, condensed for extenders -├── tsconfig.json -└── src/ - ├── types.ts # AuthCognitoAdminOptions, AdminScanOptions, AdminCreateUserOptions, AuthCognitoAdminErrors - │ # — re-exports CognitoUser/GroupOf/AttrOf from bb-auth-cognito (no parallel types) - ├── index.ts # default (mock) — imports the /internal port from bb-auth-cognito - ├── index.aws.ts # aws-runtime — SDK wrappers above - ├── index.cdk.ts # cdk — IAM grant only - ├── index.browser.ts # browser — throwing stub (prevents AWS SDK bundling; server-only block) - ├── version.ts # BB_NAME / BB_VERSION (generated, matches bb-auth-cognito convention) - └── *.test.ts # see Test Plan -``` - -```jsonc -// package.json exports — identical condition order to bb-auth-cognito -{ - "name": "@aws-blocks/bb-auth-cognito-admin", - "exports": { - ".": { - "browser": "./dist/index.browser.js", - "cdk": { "types": "./dist/index.cdk.d.ts", "default": "./dist/index.cdk.js" }, - "aws-runtime": "./dist/index.aws.js", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - } -} -``` - -**The one change required in `bb-auth-cognito` itself** — add an `./internal` subpath that exposes the mock admin port. This is the only edit to the existing package: - -```jsonc -// bb-auth-cognito/package.json — add alongside "." and "./ui" -"./internal": { - "types": "./dist/internal.d.ts", - "aws-runtime": "./dist/internal.aws.js", // throwing stub - "cdk": "./dist/internal.cdk.js", // throwing stub - "default": "./dist/internal.js" // real port factory (mock) -} -``` - -`internal.ts` (mock) exports `getCognitoMockAdminPort(auth: AuthCognito): CognitoMockAdminPort`, returning a port bound to that instance's live in-memory `state` (+ its private `flushToDisk`). The `aws-runtime`/`cdk` stubs throw if imported (the admin block's aws/cdk entries never touch them). This subpath is invisible to `conditional-exports.test.ts` (it only imports the bare `"."`), so parity is unaffected — but the umbrella `@aws-blocks/blocks` must **not** re-export `/internal` (it's not customer API). - -## Test Plan - -Mirrors `bb-auth-cognito`'s test layout (`*.test.ts` per entry + a sandbox suite): - -| File | Covers | -|---|---| -| `index.test.ts` (mock) | Full lifecycle against the mock port: add/remove group → `getUserGroups` reflects it → a **fresh `auth.signIn`** then sees the new claim in `requireRole` (the end-to-end bug-fix assertion); `createUser`+`groups` → member appears in `getUsersInGroup`; `deleteUser` strips group membership; `disableUser` → `auth.signIn` throws `NotAuthorized` (regression-guards the already-shipped `index.ts:452` check); `removeUserFromGroup` non-member = no-op; `addUserToGroup` to unseeded group throws `GroupNotFound`. | -| `index.cdk.test.ts` | Synth a stack with `AuthCognito` + `AuthCognitoAdmin`; assert the handler role has the 13 `Admin*`/`List*` actions scoped to the pool ARN, and that an app **without** the admin block has none of them (least-privilege regression). | -| `types.types-test.ts` | `addUserToGroup(u, 'typo')` is a compile error when `auth` was built with `groups: [...] as const`; widens to `string` without `as const` (matches `requireRole`). | -| `scenarios.sandbox.test.ts` | Real-Cognito: create user → add to group → user signs in → ID token carries `cognito:groups` → `requireRole` passes; `revokeUserSessions` invalidates refresh. Gated behind the same sandbox harness as `bb-auth-cognito`. | - -## Serialization - -All return values are plain `CognitoUser` objects (or `null`, or `void`) — natively `JSON.stringify`-able and client-safe (G2). `getUsersInGroup` / `scan` return server-only `AsyncIterable` (G5): consume them inside an API method and return a collected plain array to the client; an `AsyncIterable` cannot cross the wire directly. - -## Usage Examples - -> **How a blocks customer builds an admin UI in v1.** There is no auto-generated admin panel yet (an `AdminSite` "Users" panel is future, additive work — see Resolved Decision 9). The first-class, supported path *today* is exactly what's shown below: the customer wires **their own guarded `ApiNamespace` routes** over `AuthCognitoAdmin` and renders the plain-data responses in their own frontend. The "full user-management admin screen" example is a copy-pasteable starting point for that. - -### Guarded admin routes (the only correct way to expose these) - -```typescript -const auth = new AuthCognito(scope, 'auth', { groups: ['admins', 'readers'] as const }); -const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); - -export const adminApi = new ApiNamespace(scope, 'admin-api', (context: KitContext) => ({ - async promote(username: string) { - await auth.requireRole(context, 'admins'); // gate first - await admin.addUserToGroup(username, 'admins'); // GroupOf-typed - }, - async listAdmins() { - await auth.requireRole(context, 'admins'); - const out: CognitoUser[] = []; - for await (const u of admin.getUsersInGroup('admins')) out.push(u); - return out; // plain array → client-safe - }, -})); -``` - -### Seeding the first admin (bootstrap script, not a public route) - -```typescript -// scripts/seed-admin.ts — run once via a migration/CLI, not from the API. -// Solves the bootstrapping paradox: no admin exists yet to promote the first one. -await admin.createUser('founder', { - temporaryPassword: process.env.SEED_PASSWORD, - attributes: { email: 'founder@example.com' }, - groups: ['admins'], -}); -``` - -### Error handling - -```typescript -import { isKitError } from '@aws-blocks/core'; -import { AuthCognitoAdminErrors } from '@aws-blocks/bb-auth-cognito-admin'; - -try { - await admin.addUserToGroup(username, 'admins'); -} catch (e) { - if (isKitError(e, AuthCognitoAdminErrors.UserNotFound)) { /* surface 404 */ } - if (isKitError(e, AuthCognitoAdminErrors.GroupNotFound)) { /* misconfigured group */ } - throw e; -} -``` - -### Real-world: a full user-management admin screen - -A complete backend for an internal "Users" admin page — list with search, view detail, change role, suspend. Every route gated; every return value plain data the frontend renders directly. - -```typescript -const auth = new AuthCognito(scope, 'auth', { - groups: ['admins', 'editors', 'readers'] as const, -}); -const admin = new AuthCognitoAdmin(scope, 'admin', { auth }); - -export const usersAdminApi = new ApiNamespace(scope, 'users-admin-api', (context: KitContext) => { - // One gate helper, applied at the top of every method. - const gate = () => auth.requireRole(context, 'admins'); - - return { - // Paginated list. AsyncIterable is server-only (G5) — collect a page and - // return plain data. Optional server-side prefix search on email. - async listUsers(emailPrefix?: string) { - await gate(); - const users: CognitoUser[] = []; - const iter = emailPrefix - ? admin.scan({ filter: { attribute: 'email', op: '^=', value: emailPrefix } }) - : admin.scan(); - for await (const u of iter) { - users.push(u); - if (users.length >= 100) break; // cap the page in the handler - } - return users; // plain array → client-safe - }, - - async getUserDetail(username: string) { - await gate(); - return await admin.getUser(username); // CognitoUser | null - }, - - // Change a user's single role: remove from all configured groups, add the new one. - async setRole(username: string, role: 'admins' | 'editors' | 'readers') { - await gate(); - const current = await admin.getUserGroups(username); - await Promise.all(current.map((g) => admin.removeUserFromGroup(username, g))); - await admin.addUserToGroup(username, role); - // Make the change effective immediately rather than next token refresh: - await admin.revokeUserSessions(username); - return { username, role }; - }, - - async suspendUser(username: string) { - await gate(); - await admin.disableUser(username); - await admin.revokeUserSessions(username); // kick active sessions - return { username, status: 'suspended' }; - }, - - async reinstateUser(username: string) { - await gate(); - await admin.enableUser(username); - return { username, status: 'active' }; - }, - }; -}); -``` - -### Real-world: bulk onboarding from a CSV (background job) - -Admin-create a batch of users with a temp password and an initial role. Run from an `AsyncJob` or a CLI script — not a request handler — so a large batch isn't bound to one HTTP timeout. - -```typescript -async function onboard(rows: { email: string; role: 'editors' | 'readers' }[]) { - for (const { email, role } of rows) { - try { - await admin.createUser(email, { - attributes: { email }, - groups: [role], - suppressInvite: false, // let Cognito email the temp password + invite - }); - } catch (e) { - if (isKitError(e, AuthCognitoAdminErrors.UserAlreadyExists)) continue; // idempotent re-run - throw e; - } - } -} -``` - -### Real-world: "make me an admin" is impossible by design — seed instead - -There's a deliberate bootstrapping paradox: `setRole` is gated by `requireRole('admins')`, so with zero admins nobody can create the first one through the API. That's correct — promotion must not be self-service. Seed the first admin out-of-band: - -```typescript -// scripts/seed-admin.ts — `npx tsx scripts/seed-admin.ts`, run once per environment. -import { admin } from '../aws-blocks/index.js'; - -await admin.createUser(process.env.FOUNDER_EMAIL!, { - temporaryPassword: process.env.FOUNDER_TEMP_PASSWORD!, - attributes: { email: process.env.FOUNDER_EMAIL! }, - groups: ['admins'], -}); -console.log('Seeded founding admin. Sign in and change the temporary password.'); -``` - -### Gotcha: group changes are not retroactive to live sessions - -`requireRole` reads the `cognito:groups` claim baked into the user's ID token at sign-in (`bb-auth-cognito` DESIGN.md:183). After `addUserToGroup`, an already-signed-in user keeps their old permissions until their token refreshes. To force the new role to take effect *now*, call `revokeUserSessions(username)` (as `setRole`/`suspendUser` above do). This is inherent Cognito behavior, not a KIT quirk — surfaced explicitly so it's never a silent surprise. - -## Resolved Decisions (from design research) - -These were open questions, settled by reading the implementation: - -1. **Wiring shape → standalone `new AuthCognitoAdmin(scope, id, { auth })`.** Chosen over `auth.createAdmin()`. The deciding factor is the mock process model (below): the admin block needs a **live reference** to the auth instance, not just a discovery key, and `{ auth }` supplies exactly that. It also keeps the `Admin*` IAM grant out of `AuthCognito` (least privilege) and mirrors the existing `AdminSite({ auth })` precedent (`BB-admin-site.md:74`). The generic `O` is recovered from `auth: AuthCognito`, so `GroupOf` narrowing flows through with no extra annotation. - -2. **Mock state-sharing → in-memory delegation via a `CognitoMockAdminPort`, NOT file-sharing.** The mock loads `state.json` once at construction and never reloads (`index.ts:216`); blocks are module-scope singletons reused across all requests (`dev-server.ts:76`). Two instances on one file would clobber each other (lost updates). The admin mock therefore delegates to the live auth instance's in-memory `state` through a narrow port exported from a **`@aws-blocks/bb-auth-cognito/internal` subpath** (not in the public types). The export-parity test (`conditional-exports.test.ts`) inspects only the bare `"."` specifier, so `/internal` is out of its scope — see Mock Implementation for the per-condition resolution of that subpath. See Mock Implementation above. - -3. **Session-freshness → document + `revokeUserSessions()`.** `requireRole` reads the token's `cognito:groups` claim, not live state (both runtimes). Group changes take effect on next token refresh; `revokeUserSessions()` forces immediate effect. This is inherent Cognito semantics, surfaced explicitly rather than hidden. - -4. **Group *definition* stays deploy-time.** This block manages membership and user lifecycle at runtime only. Creating/deleting `CfnUserPoolGroup` remains an `AuthCognito.options.groups` (synth-time) concern — runtime group creation would violate G7 (constructor is the only infra side effect). `addUserToGroup` to an unseeded group throws `GroupNotFound`. - -5. **`scan` filter → typed subset, not raw string.** Resolved during review: `AdminScanOptions.filter` is `{ attribute; op; value }` over the attributes Cognito's `ListUsers` narrows server-side (G10 — don't leak the filter DSL; G18 — don't imply efficient filtering on unsupported attributes). See API Surface. - -6. **Disabled-user enforcement already ships** (corrected during review): the mock `signIn` already rejects `disabled` users (`index.ts:452`). This block only adds the `disableUser`/`enableUser` mutators + a regression test — it does not wire the `signIn` check. Reuse the existing `forcePasswordChange` field name (`index.ts:506`). - -7. **`addUserToGroup` is single-group, no array overload.** Cognito has no batch `AdminAddUserToGroup`; an array param would loop internally, which G14 forbids (don't fake batch over a loop — it misrepresents cost). Callers compose `Promise.all(roles.map(r => admin.addUserToGroup(u, r)))`. The common "assign roles at creation" case is already covered by `createUser({ groups: [...] })`. - -8. **`createUser` models `FORCE_CHANGE_PASSWORD` faithfully.** When `permanent` is falsy, the mock sets `forcePasswordChange = true` on the `MockUserRecord`; the existing mock `signIn` already routes such users through the `CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED` challenge (`index.ts:506-514` — the code comment even says "seed it to `true` when simulating an `AdminCreateUser({ Permanent: false })` user"). So faithful mock↔AWS parity is ~one line, not a shortcut. `permanent: true` skips the flag and writes the password directly (AWS: a follow-up `AdminSetUserPassword({ Permanent: true })`). - -9. **`AdminSite` composition deferred — but the user-management path is accounted for, not left to chance.** No `AdminSite` work in v1, and the API is already compatible (every return is client-safe plain data / a collectable `AsyncIterable`). Crucially, a blocks customer must still be able to ship a user-management UI **today** — that path is the **customer-built guarded `ApiNamespace` routes** shown in § Usage Examples ("full user-management admin screen"). That is the supported, documented v1 answer; the future `AdminSite` auto-panel is an additive convenience on top of the same methods, not a prerequisite. **Action for the build:** the README must lead with the guarded-routes recipe as the first-class way to build admin UI, and explicitly name `AuthCognitoAdmin` as the intended backend for a later `AdminSite` "Users" panel so return shapes are kept stable. Tracked as a forward-compat note, not a v1 dependency. - -10. **Ships as a separate package `@aws-blocks/bb-auth-cognito-admin`** (not a `bb-auth-cognito/admin` subpath). Three reasons: (a) **opt-in least privilege** — apps that import only `bb-auth-cognito` never pull the `Admin*` IAM grant or admin SDK surface; a subpath risks the grant leaking through shared CDK code; (b) **D-004 naming** blesses `bb-auth-*` family packages and sorts it next to its parent; (c) **browser-bundle safety** — its own throwing `index.browser.ts` keeps the AWS SDK out of client bundles without entangling the parent's exports. Cost: one more package to version (accepted; the release coupling a subpath would create is worse). +> The authoritative design and implementation plan is **[BB-auth-cognito-admin-implementation-plan.md](./BB-auth-cognito-admin-implementation-plan.md)**. For the shipped API and usage, see the [`bb-auth-cognito` README](../../packages/bb-auth-cognito/README.md) (*Admin surface*) and [DESIGN.md](../../packages/bb-auth-cognito/DESIGN.md). -> **Independent review applied.** This doc was reviewed by an independent agent against G1–G18 / T1–T5 and verified against the implementation. Corrections folded in: the false "disabled not yet enforced" claim (it is, `index.ts:452`); `GroupNotFound` confirmed to match the shipped `AuthCognitoErrors` value `ResourceNotFoundException` (the *parent* doc `BB-auth-cognito.md:296` is stale); `/internal` per-condition resolution + parity-test framing made precise; generic-narrowing `as const` caveat added; browser-stub + single-Lambda assumptions documented; G14 `createUser`/`setUserPassword` deviations acknowledged; method `@example`s/`@returns` completed; `scan` filter typed. +## Why the direction changed -## Open Questions +The separate-package design carried a second construct, a `/internal` subpath for sharing mock state across instances, and cross-construct wiring — all to preserve an admin/client separation that, on the shared backend-Lambda model, is enforced by API surface + lint rather than IAM anyway. The in-package handle preserves that same separation (plus a compile-time opt-in gate) at a fraction of the cost. The full trade study, type-safety analysis (including the `AuthCognito` variance constraint that makes `admin.actions` scope the IAM grant rather than the typed method set), task list, and verification plan live in the implementation-plan doc. -*None blocking. All prior open questions (Q1–Q4) are resolved above (decisions 7–10). New questions that surface during implementation should be appended here.* +The original 755-line design doc (API surface, error model, mock-parity research) was retired here to avoid drift: its code snippets predated the rebrand and its package/wiring model no longer matches shipped code. Its still-relevant content was folded into the implementation plan and the package README/DESIGN. diff --git a/packages/bb-auth-cognito/DESIGN.md b/packages/bb-auth-cognito/DESIGN.md index 3de535311..356514ae2 100644 --- a/packages/bb-auth-cognito/DESIGN.md +++ b/packages/bb-auth-cognito/DESIGN.md @@ -18,10 +18,10 @@ Design document. For usage, see [README.md](./README.md). - Device tracking (list, forget). - Password reset via verification code. - Provider-agnostic state machine driving the same `` UI as every other Blocks auth BB. +- **Admin user-lifecycle + group-membership APIs** (`createUser`, `deleteUser`, `addUserToGroup`, `listUsersInGroup`, etc.) — exposed as an **opt-in `auth.admin` handle** on this class, gated by the `admin` options object. Off by default (no handle, no `Admin*` IAM). See *Admin surface* below and [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md) for the rationale. **Out (for this BB):** -- **Admin user-lifecycle APIs** (`createUser`, `deleteUser`, `addUserToGroup`, `listUsers`, etc.) — deliberately excluded. Those operate on *any* user and can't self-gate from a `context`, so they don't belong on the same class that exposes `signIn`/`signUp`. They are provided by a separate admin Building Block. - **HostedUI + federated sign-in** (Google / Facebook / LoginWithAmazon / Apple / OIDC / SAML) — not supported. - **Cognito Lambda triggers** (`preSignUp`, `postConfirmation`, `preAuthentication`, …) — customers who need them can attach against the underlying `userPool` construct directly. - **Auth flows other than `USER_PASSWORD_AUTH`** — widened typing only. @@ -47,7 +47,7 @@ Creates under the construct's scope: - **Nested `AppSetting(this, 'session-secret', { secret: true })`** — SSM SecureString HMAC for signing session-ID cookies. `AppSetting` handles the custom-resource wiring (CF can't create SecureString natively) and grants `ssm:GetParameter` + `kms:Decrypt` to `this.handler` automatically. Both the CDK layer and AWS runtime instantiate an `AppSetting` at the same scope path, so each side derives the same SSM parameter name without a dedicated env var. - **Nested `KVStore(this, 'sessions', { removalPolicy })`** — DynamoDB table holding server-side session records. -The Lambda handler receives env vars `BLOCKS_AUTH_COGNITO__{USER_POOL_ID, CLIENT_ID, REGION}` so the AWS runtime can discover the pool/client IDs without CfnOutput round-trips. IAM grants are a **single** policy statement scoped to the pool ARN — the 17 client-facing Cognito actions (sign-up, sign-in, MFA, devices, self-service password/attribute mutations, `GlobalSignOut`). No `cognito-idp:Admin*` or `cognito-idp:List*` actions are granted; those belong to a separate admin Building Block. +The Lambda handler receives env vars `BLOCKS_AUTH_COGNITO__{USER_POOL_ID, CLIENT_ID, REGION}` so the AWS runtime can discover the pool/client IDs without CfnOutput round-trips. IAM grants are scoped to the pool ARN: a base policy statement covering the 17 client-facing Cognito actions (sign-up, sign-in, MFA, devices, self-service password/attribute mutations, `GlobalSignOut`) is always attached. `cognito-idp:Admin*` / `cognito-idp:List*` actions are granted **only** when the `admin` option is set — a second statement whose action set is scoped by `admin.actions` (`'groups'` and/or `'lifecycle'`). Omit `admin` and the synthesized role is byte-identical to the client-only grant (least privilege by default). See *Admin surface*. `userPoolName` is `this.fullId`; the construct throws at synth time if the ID exceeds Cognito's 128-char limit. @@ -81,6 +81,15 @@ Everything else (`username`, `userSub`, `groups`, `attributes`) is derived on re **Challenge envelopes.** The challenge token returned by `signIn` on MFA / `NEW_PASSWORD_REQUIRED` is a small HMAC-signed envelope the client echoes back in `confirmSignIn`. Cognito's raw session token never reaches the browser; the envelope carries enough state to reconstruct `ChallengeResponses` on the server. +## Admin surface (`auth.admin`) + +Server-side group-membership and user-lifecycle administration is exposed as an **opt-in handle on this class**, not a separate Building Block. Enable it by passing an `admin` options object; the handle grants the matching `Admin*` / `List*` IAM (scoped by `admin.actions`). This keeps the admin/client API distinction structural while avoiding a second package, its `/internal` mock-state-sharing port, and cross-construct wiring — the handle mutates the same live `state`/pool the client methods use. See [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md) for the full rationale and the package-vs-handle trade study. + +- **Compile-time gate.** The getter's type is `AdminGetterOf = O extends { admin: object } ? AdminSurface : AdminDisabled`. Without an `admin` object, `auth.admin` is `AdminDisabled` and any access is a compile error whose message names the fix; the getter also throws at runtime for untyped JS callers. Group names on the methods narrow via `GroupOf`. +- **`actions` scopes the IAM grant, not the typed method set.** `AdminSurface` is always the full `GroupAdmin & LifecycleAdmin`. Narrowing the *type* by `actions` via a conditional over `O` would force `AuthCognito` invariant (it broke assignability across the codebase — verified), so `actions` scopes only the CDK grant. Calling a method whose action wasn't granted fails at runtime with an IAM `AccessDenied`. +- **Not an access boundary.** Like every block on the shared backend Lambda, client and admin run under one role; separation is by API surface + lint, not IAM. Gate every admin route behind `requireRole`. +- **Session freshness.** `revokeUserSessions` revokes Cognito refresh tokens (AWS: `AdminUserGlobalSignOut`; mock: deletes session records). On AWS an already-issued access token stays valid until it expires, so `checkAuth` does not flip immediately — a known mock-vs-AWS parity difference. Group changes likewise apply on the next sign-in / `fetchAuthSession({ forceRefresh: true })`, not to a live session. + ## Options Split: `AuthCognitoOptions` vs `AuthCognitoMockOptions` `AuthCognitoOptions` is the cross-runtime option type — CDK, AWS, and mock all accept it. The mock entry accepts a widened type: @@ -134,3 +143,4 @@ Resolved by the MFA_SETUP + USER_AUTH work: 4. **Mock + AWS runtimes share the session/cookie code path.** `SessionStore`, cookie helpers, JWT decode — all shared. The only divergence is that mock issues placeholder-signature JWTs and never talks to Cognito. 5. **Error constants mirror Cognito's wire-format exception names.** Every value equals the name Cognito puts on the wire, so `isBlocksError(e, AuthCognitoErrors.X)` works identically for errors thrown by the mock (matched name directly) and errors propagated from the AWS SDK (name preserved when re-thrown as `ApiError`). 6. **`GroupNotFound` maps to `ResourceNotFoundException`.** Real Cognito returns `ResourceNotFoundException` (not the intuitive `GroupNotFoundException`) for a missing group. The constant name preserves the semantic label while the value matches what Cognito actually returns. +7. **Admin surface as an opt-in `auth.admin` handle, not a separate Building Block.** The admin/client distinction is preserved by the handle (and the `admin` opt-in gate) rather than by a second package. This removes a whole package, an `/internal` subpath for sharing mock state, and cross-construct wiring, at the cost of independent versioning (not needed). Because narrowing the typed method set by `admin.actions` would make `AuthCognito` invariant, `actions` scopes the IAM grant only; the typed surface is always the full set. Full trade study in [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md). diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index 267f68158..0e037dd79 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -815,7 +815,7 @@ export class AuthCognito = {}; diff --git a/packages/blocks/API.md b/packages/blocks/API.md index 62785807b..0809bdcd6 100644 --- a/packages/blocks/API.md +++ b/packages/blocks/API.md @@ -4,6 +4,12 @@ ```ts +import { AdminCreateInit } from '@aws-blocks/bb-auth-cognito'; +import { AdminDisabled } from '@aws-blocks/bb-auth-cognito'; +import { AdminGetterOf } from '@aws-blocks/bb-auth-cognito'; +import { AdminOptions } from '@aws-blocks/bb-auth-cognito'; +import { AdminSurface } from '@aws-blocks/bb-auth-cognito'; +import { AdminUser } from '@aws-blocks/bb-auth-cognito'; import { Agent } from '@aws-blocks/bb-agent'; import { AgentConfig } from '@aws-blocks/bb-agent'; import { AgentErrors } from '@aws-blocks/bb-agent'; @@ -94,12 +100,14 @@ import { fromExisting } from '@aws-blocks/bb-data'; import { GetUrlOptions } from '@aws-blocks/bb-file-bucket'; import { github } from '@aws-blocks/bb-auth-oidc'; import { google } from '@aws-blocks/bb-auth-oidc'; +import { GroupAdmin } from '@aws-blocks/bb-auth-cognito'; import { KnowledgeBase } from '@aws-blocks/bb-knowledge-base'; import { KnowledgeBaseErrors } from '@aws-blocks/bb-knowledge-base'; import { KnowledgeBaseOptions } from '@aws-blocks/bb-knowledge-base'; import { KVStore } from '@aws-blocks/bb-kv-store'; import { KVStoreErrors } from '@aws-blocks/bb-kv-store'; import { KVStoreOptions } from '@aws-blocks/bb-kv-store'; +import { LifecycleAdmin } from '@aws-blocks/bb-auth-cognito'; import { LifecycleRule } from '@aws-blocks/bb-file-bucket'; import { LogEntry } from '@aws-blocks/bb-logger'; import { Logger } from '@aws-blocks/bb-logger'; @@ -165,6 +173,18 @@ import { UpdateAttributeOutcome } from '@aws-blocks/bb-auth-cognito'; import { UserAttribute } from '@aws-blocks/bb-auth-cognito'; import { WaitUntilSyncedOptions } from '@aws-blocks/bb-knowledge-base'; +export { AdminCreateInit } + +export { AdminDisabled } + +export { AdminGetterOf } + +export { AdminOptions } + +export { AdminSurface } + +export { AdminUser } + export { Agent } export { AgentConfig } @@ -434,6 +454,8 @@ export { github } export { google } +export { GroupAdmin } + export { KnowledgeBase } export { KnowledgeBaseErrors } @@ -446,6 +468,8 @@ export { KVStoreErrors } export { KVStoreOptions } +export { LifecycleAdmin } + export { LifecycleRule } export { LogEntry } diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 48ad12151..30a1653cd 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -69,6 +69,14 @@ export type { UserAttribute, ExternalUserPoolRef, CodeDeliveryFn, + AdminOptions, + AdminUser, + AdminCreateInit, + GroupAdmin, + LifecycleAdmin, + AdminSurface, + AdminGetterOf, + AdminDisabled, } from '@aws-blocks/bb-auth-cognito'; /** From 5398ce87d5441dc816512586c85448d26a0078ae Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Thu, 9 Jul 2026 13:42:30 -0400 Subject: [PATCH 15/21] fix(test-apps): stop declaring the standard 'email' attr in native-bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'email' is a built-in Cognito standard attribute; declaring it in userAttributes is invalid per DESIGN.md (built-ins are implicit). Harmless under the old wide generic, but once AuthCognito narrowed the literal, CognitoUser.attributes emitted BOTH 'email' and 'custom:email' in the generated OpenRPC spec — which the Dart codegen turned into a duplicate field / colon-in- identifier, breaking 'dart analyze' with error-level diagnostics (Native SDK E2E / Dart E2E). Removing the redundant declaration restores the open-map attributes shape. Root cause of the Dart E2E regression since the const-O commit; Swift was unaffected because its generator maps the shape to a Map. Verified locally: regenerated spec has 0 custom:email; dart analyze on the regenerated client reports 0 errors (infos only, which analyze tolerates). --- test-apps/native-bindings/aws-blocks/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test-apps/native-bindings/aws-blocks/index.ts b/test-apps/native-bindings/aws-blocks/index.ts index c7006b8cd..3429f22e1 100644 --- a/test-apps/native-bindings/aws-blocks/index.ts +++ b/test-apps/native-bindings/aws-blocks/index.ts @@ -38,7 +38,11 @@ const authBasic = new AuthBasic(scope, 'auth-basic', {}); let lastCognitoCode: { username: string; code: string; purpose: string } | null = null; const authCognito = new AuthCognito(scope, 'auth-cognito', { passwordPolicy: { minLength: 8, requireDigits: true }, - userAttributes: [{ name: 'email' }], + // NOTE: `email` is a built-in standard Cognito attribute and must NOT be + // declared here (see DESIGN.md — built-ins are implicit). Declaring it made + // the const-O-narrowed `CognitoUser.attributes` schema carry both `email` + // and `custom:email`, which the Dart codegen turned into a duplicate / + // `:`-in-identifier compile error. No custom attributes are needed here. groups: ['admins', 'users'], mfa: 'off', mfaTypes: ['TOTP'], From 8d2027bc366feba3064fe13ddb579d093f7009df Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Thu, 9 Jul 2026 16:57:44 -0400 Subject: [PATCH 16/21] feat(bb-auth-cognito): close auth.admin type-safety & ergonomics gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer-identified gaps in the admin surface: - Gap 1 (typed reads): AdminUser is now AdminUser — getUser/scan/listUsersInGroup return narrowed groups (GroupOf) and attributes (ReadAttrOf), matching the client-side CognitoUser. createUser attributes narrow via AttrOf too, catching typos the way signUp does. - Gap 3 (ungranted-action feedback): a new AdminActionGate rest-param makes calling an ungranted method (e.g. deleteUser under actions:['groups']) a COMPILE error — variance-safe because the gate is in parameter position, not the surface shape (verified against the call sites the earlier shape-narrowing attempt regressed). A runtime guard also fast-fails with a clear message instead of AWS AccessDenied. - Gap 4 (scan filter): scan(filter?) accepts an AdminUserFilter mapped to Cognito's ListUsers Filter (startsWith/equals); mock filters in memory. - Gap 5 (readability): setUserPassword(username, password, { permanent }) uses a named options object instead of a bare boolean. Umbrella @aws-blocks/blocks re-exports the new public types. API reports + changeset updated. Package suite: 228 pass, 0 fail; full type-test incl. gate + variance guard. --- .changeset/auth-cognito-admin-handle.md | 10 +- packages/bb-auth-cognito/API.md | 63 +++++--- packages/bb-auth-cognito/src/admin.test.ts | 45 +++++- .../bb-auth-cognito/src/admin.types-test.ts | 83 ++++++++-- packages/bb-auth-cognito/src/index.aws.ts | 87 +++++++--- packages/bb-auth-cognito/src/index.ts | 64 ++++++-- packages/bb-auth-cognito/src/types.ts | 150 ++++++++++++------ packages/blocks/API.md | 15 ++ packages/blocks/src/index.ts | 5 + test-apps/comprehensive/aws-blocks/index.ts | 2 +- 10 files changed, 415 insertions(+), 109 deletions(-) diff --git a/.changeset/auth-cognito-admin-handle.md b/.changeset/auth-cognito-admin-handle.md index 3a9be310c..f035eadc0 100644 --- a/.changeset/auth-cognito-admin-handle.md +++ b/.changeset/auth-cognito-admin-handle.md @@ -4,13 +4,21 @@ Add an opt-in `auth.admin` handle to `AuthCognito` for server-side group-membership and user-lifecycle administration. -Enable it by passing an `admin` options object; `admin.actions` scopes the granted `Admin*` / `List*` IAM. Without it, `auth.admin` is a compile error and no admin IAM is granted (unchanged default). Group names on the admin methods narrow via `GroupOf`. +Enable it by passing an `admin` options object; `admin.actions` scopes both the granted `Admin*` / `List*` IAM **and** the compile-time method surface. Without it, `auth.admin` is a compile error and no admin IAM is granted (unchanged default). ```ts const auth = new AuthCognito(scope, 'auth', { groups: ['admins'], admin: { actions: ['groups'] } }); await auth.admin.addUserToGroup('alice', 'admins'); ``` +The admin surface is fully typed by the pool config `O`: + +- **Action gating:** calling a method whose action group wasn't granted (e.g. `deleteUser` under `actions: ['groups']`) is a compile error, and fast-fails at runtime with a clear message instead of a cryptic AWS `AccessDenied`. +- **Typed reads:** `getUser` / `scan` / `listUsersInGroup` return `AdminUser` — `groups` narrows to the configured group union and `attributes` keys to the declared attributes, matching the client-side `CognitoUser`. +- **Typed writes:** `createUser`'s `attributes` narrow to the declared keys (catches typos like `signUp` does). +- **`scan(filter?)`** accepts a server-side `AdminUserFilter` mapped to Cognito's `ListUsers` `Filter`. +- **`setUserPassword(username, password, { permanent })`** takes a named options object instead of a bare boolean. + The `AuthCognito` class generic is now a `const` type parameter, so inline options literals narrow without `as const`. BREAKING CHANGE: `const O` narrows the params of `requireRole`, `updateUserAttribute`, and `updateMFAPreference` for inline-literal options. Callers passing widened `string` variables to these now need a cast or literal arguments. diff --git a/packages/bb-auth-cognito/API.md b/packages/bb-auth-cognito/API.md index 12c1c7162..b1ef1f6b9 100644 --- a/packages/bb-auth-cognito/API.md +++ b/packages/bb-auth-cognito/API.md @@ -13,8 +13,14 @@ import { Scope } from '@aws-blocks/core'; import type { ScopeParent } from '@aws-blocks/core'; // @public -export interface AdminCreateInit { - attributes?: Record; +export type AdminAction = 'groups' | 'lifecycle'; + +// @public +export type AdminActionGate = AdminGrants extends true ? [] : [ERROR_admin_action_not_granted: never]; + +// @public +export interface AdminCreateInit { + attributes?: Partial, string>>; suppressInvite?: boolean; temporaryPassword?: string; } @@ -29,28 +35,42 @@ export type AdminGetterOf = O extends { admin: object; } ? AdminSurface : AdminDisabled; +// @public +export type AdminGrants = O extends { + admin: { + actions: infer L extends readonly string[]; + }; +} ? (A extends L[number] ? true : false) : true; + // @public export interface AdminOptions { - actions?: readonly ('groups' | 'lifecycle')[]; + actions?: readonly AdminAction[]; } // @public export type AdminSurface = GroupAdmin & LifecycleAdmin; // @public -export interface AdminUser { +export interface AdminUser { // (undocumented) - attributes: Record; + attributes: Partial, string>>; // (undocumented) enabled: boolean; // (undocumented) - groups?: string[]; + groups?: GroupOf[]; // (undocumented) username: string; // (undocumented) userSub: string; } +// @public +export interface AdminUserFilter { + attribute: string; + match: 'startsWith' | 'equals'; + value: string; +} + // Warning: (ae-incompatible-release-tags) The symbol "AttrOf" is marked as @public, but its signature references "CustomAttrNames" which is marked as @internal // // @public @@ -314,13 +334,13 @@ export interface FetchAuthSessionOptions { // @public export interface GroupAdmin { // (undocumented) - addUserToGroup(username: string, group: GroupOf): Promise; + addUserToGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; // (undocumented) - listGroupsForUser(username: string): Promise[]>; + listGroupsForUser(username: string, ...gate: AdminActionGate): Promise[]>; // (undocumented) - listUsersInGroup(group: GroupOf): Promise; + listUsersInGroup(group: GroupOf, ...gate: AdminActionGate): Promise[]>; // (undocumented) - removeUserFromGroup(username: string, group: GroupOf): Promise; + removeUserFromGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; } // @public @@ -348,23 +368,23 @@ export interface JWT { // @public export interface LifecycleAdmin { // (undocumented) - createUser(username: string, init?: AdminCreateInit): Promise; + createUser(username: string, init?: AdminCreateInit, ...gate: AdminActionGate): Promise>; // (undocumented) - deleteUser(username: string): Promise; + deleteUser(username: string, ...gate: AdminActionGate): Promise; // (undocumented) - disableUser(username: string): Promise; + disableUser(username: string, ...gate: AdminActionGate): Promise; // (undocumented) - enableUser(username: string): Promise; + enableUser(username: string, ...gate: AdminActionGate): Promise; // (undocumented) - getUser(username: string): Promise; + getUser(username: string, ...gate: AdminActionGate): Promise | null>; // (undocumented) - resetUserPassword(username: string): Promise; + resetUserPassword(username: string, ...gate: AdminActionGate): Promise; // (undocumented) - revokeUserSessions(username: string): Promise; + revokeUserSessions(username: string, ...gate: AdminActionGate): Promise; // (undocumented) - scan(): AsyncIterable; + scan(filter?: AdminUserFilter, ...gate: AdminActionGate): AsyncIterable>; // (undocumented) - setUserPassword(username: string, password: string, permanent: boolean): Promise; + setUserPassword(username: string, password: string, options?: SetPasswordOptions, ...gate: AdminActionGate): Promise; } // @public @@ -458,6 +478,11 @@ export class SessionStore { updateSession(sessionId: string, update: Partial): Promise; } +// @public +export interface SetPasswordOptions { + permanent?: boolean; +} + // @public export type SignInNextStep = { name: 'CONFIRM_SIGN_IN_WITH_SMS_CODE'; diff --git a/packages/bb-auth-cognito/src/admin.test.ts b/packages/bb-auth-cognito/src/admin.test.ts index e198cf176..5335b0505 100644 --- a/packages/bb-auth-cognito/src/admin.test.ts +++ b/packages/bb-auth-cognito/src/admin.test.ts @@ -172,7 +172,7 @@ describe('auth.admin user lifecycle', () => { test('setUserPassword(permanent) lets the user sign in with the new password', async () => { const auth = new AuthCognito(ROOT, unique(), { admin: {} }); await signUpAndConfirm(auth, 'hank'); - await auth.admin.setUserPassword('hank', 'NewPass!2', true); + await auth.admin.setUserPassword('hank', 'NewPass!2', { permanent: true }); const r = await auth.signIn('hank', 'NewPass!2', freshContext()); assert.strictEqual(r.status, 'signedIn'); }); @@ -186,6 +186,18 @@ describe('auth.admin user lifecycle', () => { assert.deepStrictEqual(seen.sort(), ['ida', 'jack']); }); + test('scan with a startsWith filter narrows results (Gap 4)', async () => { + const auth = new AuthCognito(ROOT, unique(), { admin: {} }); + await auth.admin.createUser('alice'); + await auth.admin.createUser('albert'); + await auth.admin.createUser('bob'); + const seen: string[] = []; + for await (const u of auth.admin.scan({ attribute: 'username', match: 'startsWith', value: 'al' })) { + seen.push(u.username); + } + assert.deepStrictEqual(seen.sort(), ['albert', 'alice']); + }); + test('revokeUserSessions deletes the user session (forces re-auth)', async () => { const auth = new AuthCognito(ROOT, unique(), { admin: {} }); await signUpAndConfirm(auth, 'kara'); @@ -197,3 +209,34 @@ describe('auth.admin user lifecycle', () => { assert.strictEqual(await auth.checkAuth(ctx), false); }); }); + +describe('auth.admin action-scope runtime gate (Gap 3)', () => { + test('groups-only pool fast-fails a lifecycle call with a clear error', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['groups'] } }); + // Cast past the compile-time gate to reach the runtime guard (an untyped + // JS caller would hit this path). Error must be clear, not AWS AccessDenied. + const admin = auth.admin as unknown as { createUser(u: string): Promise }; + await assert.rejects( + () => admin.createUser('x'), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized) && /lifecycle actions not granted/.test((e as Error).message), + ); + }); + + test('lifecycle-only pool fast-fails a groups call', async () => { + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['lifecycle'] } }); + const admin = auth.admin as unknown as { addUserToGroup(u: string, g: string): Promise }; + await assert.rejects( + () => admin.addUserToGroup('x', 'admins'), + (e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized) && /groups actions not granted/.test((e as Error).message), + ); + }); + + test('granted action + admin:{} (all) do not fast-fail', async () => { + const groupsOnly = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['groups'] } }); + await signUpAndConfirm(groupsOnly, 'gwen'); + await groupsOnly.admin.addUserToGroup('gwen', 'admins'); // granted → no throw + + const all = new AuthCognito(ROOT, unique(), { admin: {} }); + await all.admin.createUser('hugo'); // no actions restriction → no throw + }); +}); diff --git a/packages/bb-auth-cognito/src/admin.types-test.ts b/packages/bb-auth-cognito/src/admin.types-test.ts index 83fede55c..fc88b1254 100644 --- a/packages/bb-auth-cognito/src/admin.types-test.ts +++ b/packages/bb-auth-cognito/src/admin.types-test.ts @@ -40,20 +40,26 @@ async function fullSurface() { } // ───────────────────────────────────────────────────────────────────────────── -// (3) `actions` scopes the IAM grant, NOT the typed method set — the full -// surface is present at the type level regardless of `actions`. (Narrowing -// the type by `actions` would force `AuthCognito` invariant; see -// `AdminSurface` doc.) A method whose action wasn't granted fails at -// runtime with IAM AccessDenied, not at compile time. +// (3) `actions` gates the methods at COMPILE TIME (via AdminActionGate rest +// params) as well as scoping the IAM grant — calling an ungranted method is +// a type error. This is variance-safe (the gate lives in a parameter +// position, not the surface shape); the variance guard is case (8). // ───────────────────────────────────────────────────────────────────────────── -async function actionsScopeGrantNotTypes() { +async function actionsGateMethodsAtCompileTime() { const groupsScoped = new AuthCognito(scope, 'a3', { groups: ['admins'], admin: { actions: ['groups'] } }); - await groupsScoped.admin.addUserToGroup('u', 'admins'); - await groupsScoped.admin.createUser('u'); // present at type level (grant-scoped at runtime) + await groupsScoped.admin.addUserToGroup('u', 'admins'); // granted → ok + // @ts-expect-error — lifecycle not granted by actions: ['groups']. + await groupsScoped.admin.createUser('u'); const lifecycleScoped = new AuthCognito(scope, 'a4', { groups: ['admins'], admin: { actions: ['lifecycle'] } }); - await lifecycleScoped.admin.createUser('u'); - await lifecycleScoped.admin.addUserToGroup('u', 'admins'); // present at type level + await lifecycleScoped.admin.createUser('u'); // granted → ok + // @ts-expect-error — groups not granted by actions: ['lifecycle']. + await lifecycleScoped.admin.addUserToGroup('u', 'admins'); + + // admin: {} (no actions) grants everything — both call groups compile. + const all = new AuthCognito(scope, 'a3b', { groups: ['admins'], admin: {} }); + await all.admin.addUserToGroup('u', 'admins'); + await all.admin.createUser('u'); } // ───────────────────────────────────────────────────────────────────────────── @@ -85,3 +91,60 @@ async function defaultO() { // @ts-expect-error — admin disabled on the default (wide) O. await auth.admin.createUser('u'); } + +// ───────────────────────────────────────────────────────────────────────────── +// (8) Variance guard — an instance narrowed on groups/attributes/mfa is still +// assignable to the wide AuthCognito. This is the exact property the earlier +// shape-narrowing attempt broke (it regressed 14 call sites); the +// parameter-position action gate must NOT reintroduce it. +// +// Note: an admin-*enabled* instance is intentionally NOT assignable to a +// wide instance whose O leaves admin disabled (AdminSurface vs AdminDisabled) +// — that is a property of the opt-in gate itself, unrelated to the action +// gate, and does not affect real call sites (nothing assigns an +// admin-enabled instance to a plain `AuthCognito`). +// ───────────────────────────────────────────────────────────────────────────── +function takesWide(_auth: AuthCognito) { /* no-op */ } +function varianceGuard() { + takesWide(new AuthCognito(scope, 'a8a', { groups: ['admins', 'readers'] })); + takesWide(new AuthCognito(scope, 'a8b', { userAttributes: [{ name: 'department' }] })); + takesWide(new AuthCognito(scope, 'a8c', { mfa: 'optional', mfaTypes: ['TOTP'] })); +} + +// ───────────────────────────────────────────────────────────────────────────── +// (9) Gap 1 — admin reads are typed by O: `attributes` keys and `groups` narrow +// just like the client-side CognitoUser, catching typos with no autocomplete +// loss. (Previously AdminUser was un-parameterized: string[] / untyped bag.) +// ───────────────────────────────────────────────────────────────────────────── +async function typedAdminReads() { + const auth = new AuthCognito(scope, 'a9', { + groups: ['admins', 'readers'], + userAttributes: [{ name: 'department' }], + admin: {}, + }); + const user = await auth.admin.getUser('alice'); + if (user) { + const dept: string | undefined = user.attributes['custom:department']; // declared attr → ok + const email: string | undefined = user.attributes['email']; // standard attr → ok + void dept; void email; + // @ts-expect-error — 'custom:deparment' (typo) is not a known attribute key. + void user.attributes['custom:deparment']; + if (user.groups) { + const g: 'admins' | 'readers' = user.groups[0]; // groups narrowed to the union + void g; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// (10) Gap 4 — scan accepts an optional server-side filter. +// ───────────────────────────────────────────────────────────────────────────── +async function scanFilter() { + const auth = new AuthCognito(scope, 'a10', { admin: {} }); + for await (const u of auth.admin.scan({ attribute: 'email', match: 'startsWith', value: 'a' })) { + void u.username; + } + for await (const u of auth.admin.scan()) void u.username; // filter is optional + // @ts-expect-error — 'contains' is not a supported match mode. + auth.admin.scan({ attribute: 'email', match: 'contains', value: 'a' }); +} diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index 0e037dd79..a53aa34ab 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -106,6 +106,7 @@ import { type AdminCreateInit, type AdminGetterOf, type AdminUser, + type AdminUserFilter, type GroupAdmin, type LifecycleAdmin, type AuthCognitoOptions, @@ -681,6 +682,23 @@ export class AuthCognito & LifecycleAdmin { const toAdminUser = (u: UserType): AdminUser => { const attributes: Record = {}; @@ -694,10 +712,19 @@ export class AuthCognito { + if (!filter) return undefined; + const op = filter.match === 'startsWith' ? '^=' : '='; + const escaped = filter.value.replace(/"/g, '\\"'); + return `${filter.attribute} ${op} "${escaped}"`; + }; return { // ── GroupAdmin ─────────────────────────────────────────────────── addUserToGroup: async (username, group) => { + this.assertAdminAction('groups'); try { await this.client.send(new AdminAddUserToGroupCommand({ UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group), @@ -705,6 +732,7 @@ export class AuthCognito { + this.assertAdminAction('groups'); try { await this.client.send(new AdminRemoveUserFromGroupCommand({ UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group), @@ -712,6 +740,7 @@ export class AuthCognito { + this.assertAdminAction('groups'); try { const out: string[] = []; let nextToken: string | undefined; @@ -726,6 +755,7 @@ export class AuthCognito { + this.assertAdminAction('groups'); try { const out: AdminUser[] = []; let nextToken: string | undefined; @@ -742,6 +772,7 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); try { const attrs: AttributeType[] = Object.entries(init?.attributes ?? {}).map( ([Name, Value]) => ({ Name, Value }), @@ -757,6 +788,7 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminDeleteUserCommand({ UserPoolId: this.adminUserPoolId(), Username: username, @@ -764,6 +796,7 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminDisableUserCommand({ UserPoolId: this.adminUserPoolId(), Username: username, @@ -771,6 +804,7 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminEnableUserCommand({ UserPoolId: this.adminUserPoolId(), Username: username, @@ -778,20 +812,23 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminResetUserPasswordCommand({ UserPoolId: this.adminUserPoolId(), Username: username, })); } catch (e) { throw asApiError(e); } }, - setUserPassword: async (username, password, permanent) => { + setUserPassword: async (username, password, options) => { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminSetUserPasswordCommand({ - UserPoolId: this.adminUserPoolId(), Username: username, Password: password, Permanent: permanent, + UserPoolId: this.adminUserPoolId(), Username: username, Password: password, Permanent: options?.permanent ?? false, })); } catch (e) { throw asApiError(e); } }, getUser: async (username) => { + this.assertAdminAction('lifecycle'); try { const resp = await this.client.send(new AdminGetUserCommand({ UserPoolId: this.adminUserPoolId(), Username: username, @@ -811,28 +848,34 @@ export class AuthCognito) { - let paginationToken: string | undefined; - do { - const resp = await this.client.send(new ListUsersCommand({ - UserPoolId: this.adminUserPoolId(), Limit: 60, PaginationToken: paginationToken, - })); - for (const u of resp.Users ?? []) { - const attributes: Record = {}; - for (const a of (u.Attributes ?? []) as AttributeType[]) { - if (a.Name) attributes[a.Name] = a.Value ?? ''; + scan: (filter?: AdminUserFilter) => { + this.assertAdminAction('lifecycle'); + const self = this; + const cognitoFilter = toCognitoFilter(filter); + return (async function* () { + let paginationToken: string | undefined; + do { + const resp = await self.client.send(new ListUsersCommand({ + UserPoolId: self.adminUserPoolId(), Limit: 60, Filter: cognitoFilter, PaginationToken: paginationToken, + })); + for (const u of resp.Users ?? []) { + const attributes: Record = {}; + for (const a of (u.Attributes ?? []) as AttributeType[]) { + if (a.Name) attributes[a.Name] = a.Value ?? ''; + } + yield { + username: u.Username ?? '', + userSub: attributes['sub'] ?? '', + enabled: u.Enabled ?? true, + attributes, + }; } - yield { - username: u.Username ?? '', - userSub: attributes['sub'] ?? '', - enabled: u.Enabled ?? true, - attributes, - }; - } - paginationToken = resp.PaginationToken; - } while (paginationToken); - }.bind(this), + paginationToken = resp.PaginationToken; + } while (paginationToken); + })(); + }, revokeUserSessions: async (username) => { + this.assertAdminAction('lifecycle'); try { await this.client.send(new AdminUserGlobalSignOutCommand({ UserPoolId: this.adminUserPoolId(), Username: username, diff --git a/packages/bb-auth-cognito/src/index.ts b/packages/bb-auth-cognito/src/index.ts index 4cf6cd0ea..369e88775 100644 --- a/packages/bb-auth-cognito/src/index.ts +++ b/packages/bb-auth-cognito/src/index.ts @@ -52,6 +52,7 @@ import { type AdminCreateInit, type AdminGetterOf, type AdminUser, + type AdminUserFilter, type GroupAdmin, type LifecycleAdmin, type AuthCognitoOptions, @@ -281,6 +282,23 @@ export class AuthCognito; } + /** + * Throw a clear error when an admin method's action group wasn't granted by + * `admin.actions` — the runtime half of the {@link AdminActionGate} compile + * gate, so untyped JS callers (or widened generics) fast-fail here instead + * of getting a cryptic AWS `AccessDenied` from Cognito. + */ + private assertAdminAction(action: 'groups' | 'lifecycle'): void { + const actions = this.options.admin?.actions; + if (actions && !actions.includes(action)) { + throw new ApiError( + `admin.${action} actions not granted: construct AuthCognito with admin: { actions: [..., '${action}'] }`, + 403, + { name: AuthCognitoErrors.NotAuthorized }, + ); + } + } + /** * Build the mock admin surface against the live `this.state`. All mutators * call the existing private `flushToDisk()` so changes persist and are seen @@ -308,32 +326,47 @@ export class AuthCognito this.state.groups[g].includes(username)), }); + // Apply a scan filter in memory, mirroring Cognito's ListUsers Filter. + const matchesFilter = (user: AdminUser, filter?: AdminUserFilter): boolean => { + if (!filter) return true; + const candidate = + filter.attribute === 'username' ? user.username + : filter.attribute === 'status' ? (user.enabled ? 'Enabled' : 'Disabled') + : (user.attributes as Record)[filter.attribute]; + if (candidate == null) return false; + return filter.match === 'startsWith' ? candidate.startsWith(filter.value) : candidate === filter.value; + }; return { // ── GroupAdmin ─────────────────────────────────────────────────── addUserToGroup: async (username, group) => { + this.assertAdminAction('groups'); requireUser(username); const members = requireGroup(group); if (!members.includes(username)) members.push(username); this.flushToDisk(); }, removeUserFromGroup: async (username, group) => { + this.assertAdminAction('groups'); requireUser(username); const members = requireGroup(group); this.state.groups[group] = members.filter((u) => u !== username); this.flushToDisk(); }, listGroupsForUser: async (username) => { + this.assertAdminAction('groups'); requireUser(username); return Object.keys(this.state.groups).filter((g) => this.state.groups[g].includes(username)); }, listUsersInGroup: async (group) => { + this.assertAdminAction('groups'); const members = requireGroup(group); return members.map((u) => toAdminUser(u, requireUser(u))); }, // ── LifecycleAdmin ─────────────────────────────────────────────── createUser: async (username, init) => { + this.assertAdminAction('lifecycle'); if (this.state.users[username]) { throw new ApiError('User already exists', 409, { name: AuthCognitoErrors.UserAlreadyExists }); } @@ -361,6 +394,7 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); requireUser(username); delete this.state.users[username]; for (const group of Object.keys(this.state.groups)) { @@ -369,23 +403,27 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); requireUser(username).disabled = true; this.flushToDisk(); }, enableUser: async (username) => { + this.assertAdminAction('lifecycle'); requireUser(username).disabled = false; this.flushToDisk(); }, resetUserPassword: async (username) => { + this.assertAdminAction('lifecycle'); const user = requireUser(username) as MockUserRecord & { forcePasswordChange?: boolean }; user.forcePasswordChange = true; this.flushToDisk(); }, - setUserPassword: async (username, password, permanent) => { + setUserPassword: async (username, password, options) => { + this.assertAdminAction('lifecycle'); this.enforcePasswordPolicy(password); const user = requireUser(username) as MockUserRecord & { forcePasswordChange?: boolean }; user.password = password; - if (permanent) { + if (options?.permanent) { delete user.forcePasswordChange; } else { user.forcePasswordChange = true; @@ -393,17 +431,25 @@ export class AuthCognito { + this.assertAdminAction('lifecycle'); const user = this.state.users[username]; return user ? toAdminUser(username, user) : null; }, - scan: async function* (this: AuthCognito) { - // Snapshot keys so concurrent mutation during iteration is safe. - for (const username of Object.keys(this.state.users)) { - const user = this.state.users[username]; - if (user) yield toAdminUser(username, user); - } - }.bind(this), + scan: (filter?: AdminUserFilter) => { + this.assertAdminAction('lifecycle'); + const self = this; + return (async function* () { + // Snapshot keys so concurrent mutation during iteration is safe. + for (const username of Object.keys(self.state.users)) { + const user = self.state.users[username]; + if (!user) continue; + const adminUser = toAdminUser(username, user); + if (matchesFilter(adminUser, filter)) yield adminUser; + } + })(); + }, revokeUserSessions: async (username) => { + this.assertAdminAction('lifecycle'); requireUser(username); await this.sessions.deleteByUsername(username); }, diff --git a/packages/bb-auth-cognito/src/types.ts b/packages/bb-auth-cognito/src/types.ts index 51a0ce37d..81fe4e258 100644 --- a/packages/bb-auth-cognito/src/types.ts +++ b/packages/bb-auth-cognito/src/types.ts @@ -286,69 +286,118 @@ export type MfaTypeOf = // Group names on the admin methods narrow via `GroupOf`, mirroring the // literal-tuple projection style of `GroupOf` / `MfaTypeOf` above. +/** The admin action groups that {@link AdminOptions.actions} can scope. */ +export type AdminAction = 'groups' | 'lifecycle'; + /** * Opt-in configuration for the admin surface. Presence of this object on * `AuthCognitoOptions.admin` enables `auth.admin` and the matching IAM grant. */ export interface AdminOptions { /** - * Scopes the IAM grant. Omit to grant everything. `['groups']` grants only - * the group-membership `Admin*` actions; `['lifecycle']` grants only the + * Scopes both the IAM grant and the compile-time method gate. Omit to grant + * everything. `['groups']` grants only the group-membership `Admin*` actions + * (and only the group methods typecheck); `['lifecycle']` grants only the * user-lifecycle actions. * - * Note: `actions` scopes the **IAM grant**, not the typed method set — - * `auth.admin` always exposes the full surface (see {@link AdminSurface} - * for why narrowing the type by `actions` is not possible without breaking - * `AuthCognito` variance). A method whose action wasn't granted fails at - * runtime with an IAM `AccessDenied`. + * When `actions` is a narrowed literal (inline or `as const`), calling a + * method whose action group wasn't granted is a **compile error** (see + * {@link AdminActionGate}). The runtime also fast-fails such calls with a + * clear error rather than a cryptic AWS `AccessDenied`. */ - actions?: readonly ('groups' | 'lifecycle')[]; + actions?: readonly AdminAction[]; } +/** + * Whether pool config `O` grants admin action group `A`. An omitted + * `admin.actions` (or a widened `readonly AdminAction[]`) grants everything. + */ +export type AdminGrants = + O extends { admin: { actions: infer L extends readonly string[] } } + ? (A extends L[number] ? true : false) + : true; + +/** + * Compile-time gate applied as a trailing rest parameter on each admin method. + * When `O` grants action group `A`, this is an empty tuple (the method is + * callable normally). When it doesn't, the method requires an extra argument of + * type `never`, so the call is a type error whose parameter name explains why. + * + * This lives in a **parameter** position on individual methods (not as a + * conditional over the surface *shape*), which is why it does not make + * `AuthCognito` invariant — verified against the same call sites the earlier + * shape-narrowing attempt regressed. + */ +export type AdminActionGate = + AdminGrants extends true ? [] : [ERROR_admin_action_not_granted: never]; + /** * Group-membership admin operations. Group names narrow via `GroupOf`, so - * `addUserToGroup(user, 'typo')` is a compile error on a narrowed pool. + * `addUserToGroup(user, 'typo')` is a compile error on a narrowed pool. Each + * method is gated on the `'groups'` action (see {@link AdminActionGate}). */ export interface GroupAdmin { - addUserToGroup(username: string, group: GroupOf): Promise; - removeUserFromGroup(username: string, group: GroupOf): Promise; - listGroupsForUser(username: string): Promise[]>; - listUsersInGroup(group: GroupOf): Promise; + addUserToGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; + removeUserFromGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; + listGroupsForUser(username: string, ...gate: AdminActionGate): Promise[]>; + listUsersInGroup(group: GroupOf, ...gate: AdminActionGate): Promise[]>; } /** * User-lifecycle admin operations — create/delete/enable/disable, password - * management, enumeration, and session revocation. + * management, enumeration, and session revocation. Each method is gated on the + * `'lifecycle'` action (see {@link AdminActionGate}). */ export interface LifecycleAdmin { - createUser(username: string, init?: AdminCreateInit): Promise; - deleteUser(username: string): Promise; - disableUser(username: string): Promise; - enableUser(username: string): Promise; - resetUserPassword(username: string): Promise; - setUserPassword(username: string, password: string, permanent: boolean): Promise; - getUser(username: string): Promise; - scan(): AsyncIterable; - revokeUserSessions(username: string): Promise; + createUser(username: string, init?: AdminCreateInit, ...gate: AdminActionGate): Promise>; + deleteUser(username: string, ...gate: AdminActionGate): Promise; + disableUser(username: string, ...gate: AdminActionGate): Promise; + enableUser(username: string, ...gate: AdminActionGate): Promise; + resetUserPassword(username: string, ...gate: AdminActionGate): Promise; + setUserPassword(username: string, password: string, options?: SetPasswordOptions, ...gate: AdminActionGate): Promise; + getUser(username: string, ...gate: AdminActionGate): Promise | null>; + scan(filter?: AdminUserFilter, ...gate: AdminActionGate): AsyncIterable>; + revokeUserSessions(username: string, ...gate: AdminActionGate): Promise; +} + +/** Options for {@link LifecycleAdmin.setUserPassword}. */ +export interface SetPasswordOptions { + /** + * When `true`, the password is permanent and the user signs in with it + * directly. When `false`/omitted, the password is temporary and the user is + * forced to change it on next sign-in (Cognito `FORCE_CHANGE_PASSWORD`). + */ + permanent?: boolean; +} + +/** + * Server-side filter for {@link LifecycleAdmin.scan}. Maps to Cognito's + * `ListUsers` `Filter` expression, so the pool does the filtering rather than + * the caller pulling every user and filtering in memory. + * + * Cognito supports a single filter clause on one attribute; `match: 'startsWith'` + * maps to the `^=` prefix operator and `match: 'equals'` to `=`. + */ +export interface AdminUserFilter { + /** Attribute to filter on — e.g. `'email'`, `'username'`, `'status'`. */ + attribute: string; + /** Comparison operator. Cognito supports prefix (`^=`) and exact (`=`). */ + match: 'startsWith' | 'equals'; + /** Value to compare against. */ + value: string; } /** * The typed `auth.admin` surface for a given pool config — the full set of - * group + lifecycle operations. Group names still narrow via `GroupOf`. + * group + lifecycle operations, each gated on its action group at compile time + * via {@link AdminActionGate}. Group names narrow via `GroupOf` and returned + * user shapes narrow via {@link AdminUser}. * - * **Why not narrow the method set by `actions`?** An earlier design hid the - * lifecycle methods when `actions: ['groups']` (and vice-versa) via a - * conditional type over `O`. A conditional type over the class's own generic - * parameter, used as a *property* type, forces TypeScript to treat - * `AuthCognito` as **invariant** in `O` — which breaks the long-standing - * contract that `AuthCognito` is assignable to - * `AuthCognito` (relied on across the codebase, e.g. - * helpers typed `auth: AuthCognito`). Verified empirically: the conditional - * form regressed 14 existing call sites. `actions` therefore scopes the **IAM - * grant** (CDK) only; the typed surface is always the full set. Calling a - * method whose action wasn't granted fails at runtime with an IAM - * `AccessDenied`, the same outcome a separate-package design would give a - * client route that imported the admin block. + * The action gate lives in method **parameter** positions rather than as a + * conditional over this surface's *shape*, so `AuthCognito` stays covariant + * in `O` (an earlier shape-narrowing attempt made it invariant and regressed 14 + * call sites). `actions` therefore gates the methods AND scopes the CDK IAM + * grant from the same source. */ export type AdminSurface = GroupAdmin & LifecycleAdmin; @@ -369,27 +418,36 @@ export type AdminDisabled = { * `admin: true` fails the gate, so the opt-in MUST be an object (`admin: {}`). * - The gate condition tests a **fixed shape** and the positive branch is a * plain generic interface (`AdminSurface`), so `AuthCognito` stays - * covariant in `O`. (Do not reintroduce a conditional over `O` inside - * `AdminSurface` — see its doc comment.) + * covariant in `O`. */ export type AdminGetterOf = O extends { admin: object } ? AdminSurface : AdminDisabled; -/** A user as seen by the admin surface (group + lifecycle reads). */ -export interface AdminUser { +/** + * A user as seen by the admin surface (group + lifecycle reads). Mirrors the + * client-side {@link CognitoUser} narrowing: `groups` narrows to the configured + * group union and `attributes` to the declared attribute keys when `O` is + * narrowed (inline literal / `as const`); both fall back to the wide types + * otherwise. + */ +export interface AdminUser { username: string; userSub: string; enabled: boolean; - attributes: Record; - groups?: string[]; + attributes: Partial, string>>; + groups?: GroupOf[]; } /** Initial state for {@link LifecycleAdmin.createUser}. */ -export interface AdminCreateInit { +export interface AdminCreateInit { /** Temporary password. When omitted, the runtime generates one. */ temporaryPassword?: string; - /** Standard / `custom:`-prefixed attributes to seed on the new user. */ - attributes?: Record; + /** + * Attributes to seed on the new user. Narrows to the pool's declared + * attribute keys (standard + `custom:*`) when `O` is narrowed, catching + * typos the same way `signUp`'s attributes do. + */ + attributes?: Partial, string>>; /** Suppress the Cognito invitation email/SMS (AWS runtime only). */ suppressInvite?: boolean; } diff --git a/packages/blocks/API.md b/packages/blocks/API.md index 0809bdcd6..51b94d241 100644 --- a/packages/blocks/API.md +++ b/packages/blocks/API.md @@ -4,12 +4,16 @@ ```ts +import { AdminAction } from '@aws-blocks/bb-auth-cognito'; +import { AdminActionGate } from '@aws-blocks/bb-auth-cognito'; import { AdminCreateInit } from '@aws-blocks/bb-auth-cognito'; import { AdminDisabled } from '@aws-blocks/bb-auth-cognito'; import { AdminGetterOf } from '@aws-blocks/bb-auth-cognito'; +import { AdminGrants } from '@aws-blocks/bb-auth-cognito'; import { AdminOptions } from '@aws-blocks/bb-auth-cognito'; import { AdminSurface } from '@aws-blocks/bb-auth-cognito'; import { AdminUser } from '@aws-blocks/bb-auth-cognito'; +import { AdminUserFilter } from '@aws-blocks/bb-auth-cognito'; import { Agent } from '@aws-blocks/bb-agent'; import { AgentConfig } from '@aws-blocks/bb-agent'; import { AgentErrors } from '@aws-blocks/bb-agent'; @@ -144,6 +148,7 @@ import { RetrieveResult } from '@aws-blocks/bb-knowledge-base'; import { Segment } from '@aws-blocks/bb-tracer'; import { SendBatchResult } from '@aws-blocks/bb-email-client'; import { SendResult } from '@aws-blocks/bb-email-client'; +import { SetPasswordOptions } from '@aws-blocks/bb-auth-cognito'; import { SignInNextStep } from '@aws-blocks/bb-auth-cognito'; import { SignInOptions } from '@aws-blocks/bb-auth-cognito'; import { SignInResult } from '@aws-blocks/bb-auth-cognito'; @@ -173,18 +178,26 @@ import { UpdateAttributeOutcome } from '@aws-blocks/bb-auth-cognito'; import { UserAttribute } from '@aws-blocks/bb-auth-cognito'; import { WaitUntilSyncedOptions } from '@aws-blocks/bb-knowledge-base'; +export { AdminAction } + +export { AdminActionGate } + export { AdminCreateInit } export { AdminDisabled } export { AdminGetterOf } +export { AdminGrants } + export { AdminOptions } export { AdminSurface } export { AdminUser } +export { AdminUserFilter } + export { Agent } export { AgentConfig } @@ -542,6 +555,8 @@ export { SendBatchResult } export { SendResult } +export { SetPasswordOptions } + export { SignInNextStep } export { SignInOptions } diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 30a1653cd..2082309fc 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -70,13 +70,18 @@ export type { ExternalUserPoolRef, CodeDeliveryFn, AdminOptions, + AdminAction, AdminUser, AdminCreateInit, + AdminUserFilter, + SetPasswordOptions, GroupAdmin, LifecycleAdmin, AdminSurface, AdminGetterOf, AdminDisabled, + AdminGrants, + AdminActionGate, } from '@aws-blocks/bb-auth-cognito'; /** diff --git a/test-apps/comprehensive/aws-blocks/index.ts b/test-apps/comprehensive/aws-blocks/index.ts index 91f3ce39c..92b354829 100644 --- a/test-apps/comprehensive/aws-blocks/index.ts +++ b/test-apps/comprehensive/aws-blocks/index.ts @@ -970,7 +970,7 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ return { username: u.username, enabled: u.enabled }; }, async authCAdminSetPassword(username: string, password: string) { - await authC.admin.setUserPassword(username, password, true); + await authC.admin.setUserPassword(username, password, { permanent: true }); return { success: true }; }, async authCAdminAddToGroup(username: string, group: string) { From 1be77a9112c14bfa66aececc2a890c8616d3a9c3 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Wed, 15 Jul 2026 20:08:23 -0400 Subject: [PATCH 17/21] fix(bb-auth-cognito): admin createUser attr-prefix + getUser groups (AWS parity) Live sandbox e2e for the new admin runtime paths surfaced two mock-vs-AWS parity bugs the unit tests couldn't: - AWS admin.createUser passed custom attributes unprefixed, so Cognito rejected them ('attribute department is not defined in schema'). Now runs them through prefixCustomAttrs like signUp and the mock do. - AWS admin.getUser never populated AdminUser.groups (AdminGetUser doesn't return memberships), so the typed groups field was always empty on AWS. Now fetches them via AdminListGroupsForUser, matching the mock. Adds sandbox e2e coverage for the new surface: getUser attribute+group round-trip (Gap 1) and scan() with a Cognito ListUsers Filter (Gap 4), plus the backend routes they drive. Verified live (us-west-2): all 7 admin e2e tests pass. --- packages/bb-auth-cognito/src/index.aws.ts | 18 ++++++++- test-apps/comprehensive/aws-blocks/index.ts | 25 ++++++++++++ .../test/auth-cognito-admin-sandbox.test.ts | 39 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index a53aa34ab..28e3cc9c8 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -774,7 +774,10 @@ export class AuthCognito { this.assertAdminAction('lifecycle'); try { - const attrs: AttributeType[] = Object.entries(init?.attributes ?? {}).map( + // Prefix declared custom attributes with `custom:` (matching signUp + // and the mock) — Cognito rejects an unprefixed custom attr name as + // "not defined in schema". + const attrs: AttributeType[] = Object.entries(this.prefixCustomAttrs(init?.attributes ?? {})).map( ([Name, Value]) => ({ Name, Value }), ); const resp = await this.client.send(new AdminCreateUserCommand({ @@ -837,11 +840,24 @@ export class AuthCognito[], }; } catch (e) { if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null; diff --git a/test-apps/comprehensive/aws-blocks/index.ts b/test-apps/comprehensive/aws-blocks/index.ts index 92b354829..6fad20c1e 100644 --- a/test-apps/comprehensive/aws-blocks/index.ts +++ b/test-apps/comprehensive/aws-blocks/index.ts @@ -969,6 +969,31 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ const u = await authC.admin.createUser(username, { temporaryPassword }); return { username: u.username, enabled: u.enabled }; }, + async authCAdminCreateUserWithDept(username: string, temporaryPassword: string, department: string) { + // Seeds a declared custom attribute so authCAdminGetUser can round-trip it. + const u = await authC.admin.createUser(username, { + temporaryPassword, + attributes: { department, email: `${username}@example.com` }, + }); + return { username: u.username, enabled: u.enabled }; + }, + async authCAdminGetUser(username: string) { + const u = await authC.admin.getUser(username); + if (!u) return null; + // Return the typed reads so the e2e can assert the attribute/group round-trip. + return { + username: u.username, + userSub: u.userSub, + enabled: u.enabled, + department: u.attributes['custom:department'] ?? null, + groups: u.groups ?? [], + }; + }, + async authCAdminScan(filter?: { attribute: string; match: 'startsWith' | 'equals'; value: string }) { + const usernames: string[] = []; + for await (const u of authC.admin.scan(filter)) usernames.push(u.username); + return usernames; + }, async authCAdminSetPassword(username: string, password: string) { await authC.admin.setUserPassword(username, password, { permanent: true }); return { success: true }; diff --git a/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts b/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts index 4c7ef08ea..a522b713d 100644 --- a/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts +++ b/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts @@ -110,5 +110,44 @@ export function authCognitoAdminTests(getApi: () => typeof apiType) { await assert.rejects(() => api.authCSignIn(u, PW)); }); + + // ── Gap 1: typed reads round-trip real Cognito data ────────────────── + test('admin.getUser round-trips custom attribute + group membership', async () => { + const api = getApi(); + const u = uniqueUser(); + await api.authCAdminCreateUserWithDept(u, PW, 'engineering'); + await api.authCAdminAddToGroup(u, 'admins'); + + const got = await api.authCAdminGetUser(u); + assert.ok(got, 'expected a user'); + assert.strictEqual(got.username, u); + assert.strictEqual(got.department, 'engineering'); + assert.ok(got.groups.includes('admins'), `expected admins in ${JSON.stringify(got.groups)}`); + assert.strictEqual(await api.authCAdminGetUser(`${u}-missing`), null); + + await api.authCAdminDeleteUser(u); + }); + + // ── Gap 4: scan filter is executed by Cognito (ListUsers Filter) ───── + test('admin.scan with a startsWith filter narrows to matching users', async () => { + const api = getApi(); + const prefix = `scanflt-${uniqueUser()}`; + const a = `${prefix}-alpha`; + const b = `${prefix}-beta`; + await api.authCAdminCreateUser(a, PW); + await api.authCAdminCreateUser(b, PW); + + // Cognito validates this Filter expression server-side — the whole point + // of exercising it live rather than in the in-memory mock. + const matched = await api.authCAdminScan({ attribute: 'username', match: 'startsWith', value: prefix }); + assert.ok(matched.includes(a) && matched.includes(b), `expected both seeded users, got ${JSON.stringify(matched)}`); + + // A prefix that matches neither returns an empty (or non-matching) set. + const none = await api.authCAdminScan({ attribute: 'username', match: 'startsWith', value: `${prefix}-zzz` }); + assert.ok(!none.includes(a) && !none.includes(b), 'non-matching filter should exclude seeded users'); + + await api.authCAdminDeleteUser(a); + await api.authCAdminDeleteUser(b); + }); }); } From 0d62d88492d3e09ce0ea10499e4a833b0156edbe Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Wed, 15 Jul 2026 20:30:34 -0400 Subject: [PATCH 18/21] fix(bb-auth-cognito): lifecycle slice self-sufficient for getUser group read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUser is lifecycle-gated but reports group memberships via AdminListGroupsForUser, which was only in the 'groups' IAM slice. A pool with admin: { actions: ['lifecycle'] } would grant AdminGetUser but not AdminListGroupsForUser, so getUser 500'd with IAM AccessDenied only in the deployed AWS runtime (mock reads groups from memory; the admin:{} sandbox e2e grants everything — both hid it). - Add AdminListGroupsForUser to the lifecycle IAM slice (shared with groups). - Make getUser's group fetch best-effort: swallow AccessDenied and return groups: undefined rather than failing the whole read under a hand-narrowed policy. - Regression tests: CDK lifecycle-only self-sufficiency (AdminGetUser + AdminListGroupsForUser both granted) and a mock lifecycle-only getUser path. Caught in PR review; a self-inflicted gap from the earlier getUser groups fix. --- packages/bb-auth-cognito/src/admin.test.ts | 14 +++++++++ packages/bb-auth-cognito/src/index.aws.ts | 31 ++++++++++++------- .../bb-auth-cognito/src/index.cdk.test.ts | 28 ++++++++++++++--- packages/bb-auth-cognito/src/index.cdk.ts | 4 +++ 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/bb-auth-cognito/src/admin.test.ts b/packages/bb-auth-cognito/src/admin.test.ts index 5335b0505..6a4a12363 100644 --- a/packages/bb-auth-cognito/src/admin.test.ts +++ b/packages/bb-auth-cognito/src/admin.test.ts @@ -239,4 +239,18 @@ describe('auth.admin action-scope runtime gate (Gap 3)', () => { const all = new AuthCognito(ROOT, unique(), { admin: {} }); await all.admin.createUser('hugo'); // no actions restriction → no throw }); + + test('lifecycle-only getUser still reports groups (no cross-action gate)', async () => { + // getUser reports group memberships but is lifecycle-gated. A lifecycle-only + // pool must NOT be blocked by the runtime action gate on the group read + // (the CDK grant makes AdminListGroupsForUser available to lifecycle too). + const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['lifecycle'] } }); + await auth.admin.createUser('ivy'); + // addUserToGroup is a groups action → seed membership via the all-access + // instance sharing the same on-disk state would differ; instead assert + // getUser works and returns an (empty) groups array without throwing. + const user = await auth.admin.getUser('ivy'); + assert.ok(user); + assert.deepStrictEqual(user.groups, []); + }); }); diff --git a/packages/bb-auth-cognito/src/index.aws.ts b/packages/bb-auth-cognito/src/index.aws.ts index 28e3cc9c8..f6402bed2 100644 --- a/packages/bb-auth-cognito/src/index.aws.ts +++ b/packages/bb-auth-cognito/src/index.aws.ts @@ -842,22 +842,31 @@ export class AuthCognito[], + groups: groups as GroupOf[] | undefined, }; } catch (e) { if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null; diff --git a/packages/bb-auth-cognito/src/index.cdk.test.ts b/packages/bb-auth-cognito/src/index.cdk.test.ts index 877a5cb33..871b8b416 100644 --- a/packages/bb-auth-cognito/src/index.cdk.test.ts +++ b/packages/bb-auth-cognito/src/index.cdk.test.ts @@ -568,9 +568,17 @@ const LIFECYCLE_ADMIN_ACTIONS = [ 'cognito-idp:AdminResetUserPassword', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminGetUser', + // Shared with the group slice — getUser reports group memberships, so the + // lifecycle slice must be self-sufficient for that read. + 'cognito-idp:AdminListGroupsForUser', 'cognito-idp:ListUsers', 'cognito-idp:AdminUserGlobalSignOut', ]; +// Actions granted by BOTH slices — excluded from the "does not grant the other +// slice's actions" cross-checks below. +const SHARED_ADMIN_ACTIONS = ['cognito-idp:AdminListGroupsForUser']; +const groupOnlyActions = GROUP_ADMIN_ACTIONS.filter((a) => !SHARED_ADMIN_ACTIONS.includes(a)); +const lifecycleOnlyActions = LIFECYCLE_ADMIN_ACTIONS.filter((a) => !SHARED_ADMIN_ACTIONS.includes(a)); describe('AuthCognito (CDK) — admin IAM grant', () => { test('no admin option → NO Admin* actions granted (least privilege)', () => { @@ -593,21 +601,33 @@ describe('AuthCognito (CDK) — admin IAM grant', () => { } }); - test("actions: ['groups'] → grants group actions only", () => { + test("actions: ['groups'] → grants group actions, not lifecycle-only actions", () => { const template = synth((stack) => { new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['groups'] } }); }); const actions = grantedActions(template); for (const a of GROUP_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing group action ${a}`); - for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected lifecycle action ${a}`); + for (const a of lifecycleOnlyActions) assert.ok(!actions.has(a), `unexpected lifecycle action ${a}`); }); - test("actions: ['lifecycle'] → grants lifecycle actions only", () => { + test("actions: ['lifecycle'] → grants lifecycle actions, not group-only actions", () => { const template = synth((stack) => { new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['lifecycle'] } }); }); const actions = grantedActions(template); for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing lifecycle action ${a}`); - for (const a of GROUP_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected group action ${a}`); + for (const a of groupOnlyActions) assert.ok(!actions.has(a), `unexpected group action ${a}`); + }); + + test("actions: ['lifecycle'] is self-sufficient for getUser's group read (regression)", () => { + // getUser is lifecycle-gated but reports group memberships via + // AdminListGroupsForUser. A lifecycle-only pool must therefore grant that + // action, or getUser 500s with IAM AccessDenied in the deployed runtime. + const template = synth((stack) => { + new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['lifecycle'] } }); + }); + const actions = grantedActions(template); + assert.ok(actions.has('cognito-idp:AdminGetUser'), 'lifecycle must grant AdminGetUser'); + assert.ok(actions.has('cognito-idp:AdminListGroupsForUser'), 'lifecycle must grant AdminListGroupsForUser for getUser groups'); }); }); diff --git a/packages/bb-auth-cognito/src/index.cdk.ts b/packages/bb-auth-cognito/src/index.cdk.ts index e9a810c7f..5ace421c7 100644 --- a/packages/bb-auth-cognito/src/index.cdk.ts +++ b/packages/bb-auth-cognito/src/index.cdk.ts @@ -388,6 +388,10 @@ function adminIamActions(actions?: readonly ('groups' | 'lifecycle')[]): string[ 'cognito-idp:AdminResetUserPassword', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminGetUser', + // getUser also reports the user's group memberships (AdminGetUser does + // not return them), so the lifecycle slice must be self-sufficient for + // that read. Shared with the `groups` slice, which also grants it. + 'cognito-idp:AdminListGroupsForUser', 'cognito-idp:ListUsers', 'cognito-idp:AdminUserGlobalSignOut', ]; From 5745210889f8dc74a3116a2383ce4090bfec4057 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Wed, 22 Jul 2026 12:39:40 -0400 Subject: [PATCH 19/21] chore(bb-auth-cognito): release auth.admin as patch, not minor Reserve minor bumps for real API changes during preview; the const O breaking-change condition is unlikely to hit in practice (only literal-variable call sites, 2 found repo-wide). --- .changeset/auth-cognito-admin-handle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/auth-cognito-admin-handle.md b/.changeset/auth-cognito-admin-handle.md index f035eadc0..6fc95302b 100644 --- a/.changeset/auth-cognito-admin-handle.md +++ b/.changeset/auth-cognito-admin-handle.md @@ -1,5 +1,5 @@ --- -"@aws-blocks/bb-auth-cognito": minor +"@aws-blocks/bb-auth-cognito": patch --- Add an opt-in `auth.admin` handle to `AuthCognito` for server-side group-membership and user-lifecycle administration. From e0c6fcb5c6a8e4d9608b6a73d224ef68e194eeba Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Wed, 22 Jul 2026 12:41:41 -0400 Subject: [PATCH 20/21] chore(bb-auth-cognito): soften const O note (not a breaking change) --- .changeset/auth-cognito-admin-handle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/auth-cognito-admin-handle.md b/.changeset/auth-cognito-admin-handle.md index 6fc95302b..2e3ce18a2 100644 --- a/.changeset/auth-cognito-admin-handle.md +++ b/.changeset/auth-cognito-admin-handle.md @@ -21,4 +21,4 @@ The admin surface is fully typed by the pool config `O`: The `AuthCognito` class generic is now a `const` type parameter, so inline options literals narrow without `as const`. -BREAKING CHANGE: `const O` narrows the params of `requireRole`, `updateUserAttribute`, and `updateMFAPreference` for inline-literal options. Callers passing widened `string` variables to these now need a cast or literal arguments. +Note: `const O` narrows the params of `requireRole`, `updateUserAttribute`, and `updateMFAPreference` for inline-literal options. Callers passing widened `string` variables to these may need a cast or literal arguments. From 90f0340def95ffd8ce850cd464506133268a2d16 Mon Sep 17 00:00:00 2001 From: Harshdeep Singh Date: Wed, 22 Jul 2026 14:01:13 -0400 Subject: [PATCH 21/21] docs(bb-auth-cognito): fix README/DESIGN to match compile-time action gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped AdminActionGate (types.ts) gates each admin method at compile time via a trailing rest parameter, so an ungranted call is a compile error (proven by admin.types-test.ts) — not merely a runtime AccessDenied. The parameter-position gate keeps AuthCognito covariant, so the 'not possible without breaking variance' framing is also wrong. Update README:170 + DESIGN admin-surface bullet and Key Design Decision #7 accordingly, and fix the stale setUserPassword signature (options object, not a bare permanent boolean). --- packages/bb-auth-cognito/DESIGN.md | 4 ++-- packages/bb-auth-cognito/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bb-auth-cognito/DESIGN.md b/packages/bb-auth-cognito/DESIGN.md index 356514ae2..3441ac81a 100644 --- a/packages/bb-auth-cognito/DESIGN.md +++ b/packages/bb-auth-cognito/DESIGN.md @@ -86,7 +86,7 @@ Everything else (`username`, `userSub`, `groups`, `attributes`) is derived on re Server-side group-membership and user-lifecycle administration is exposed as an **opt-in handle on this class**, not a separate Building Block. Enable it by passing an `admin` options object; the handle grants the matching `Admin*` / `List*` IAM (scoped by `admin.actions`). This keeps the admin/client API distinction structural while avoiding a second package, its `/internal` mock-state-sharing port, and cross-construct wiring — the handle mutates the same live `state`/pool the client methods use. See [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md) for the full rationale and the package-vs-handle trade study. - **Compile-time gate.** The getter's type is `AdminGetterOf = O extends { admin: object } ? AdminSurface : AdminDisabled`. Without an `admin` object, `auth.admin` is `AdminDisabled` and any access is a compile error whose message names the fix; the getter also throws at runtime for untyped JS callers. Group names on the methods narrow via `GroupOf`. -- **`actions` scopes the IAM grant, not the typed method set.** `AdminSurface` is always the full `GroupAdmin & LifecycleAdmin`. Narrowing the *type* by `actions` via a conditional over `O` would force `AuthCognito` invariant (it broke assignability across the codebase — verified), so `actions` scopes only the CDK grant. Calling a method whose action wasn't granted fails at runtime with an IAM `AccessDenied`. +- **`actions` scopes both the IAM grant and the compile-time method surface.** Each method on `GroupAdmin` / `LifecycleAdmin` carries a trailing `...gate: AdminActionGate` parameter: when `actions` grants action group `A` the gate is an empty tuple (callable normally), otherwise it demands an extra `never` argument, so an ungranted call is a **compile error**. Crucially the gate lives in a *parameter* position, not a conditional over the surface *shape* — so it does **not** make `AuthCognito` invariant (a shape-narrowing conditional over `O` did, breaking assignability across the codebase — verified). `actions` therefore scopes both the CDK grant and the typed surface. Untyped JS callers that reach past the gate additionally fast-fail at runtime with a clear error. - **Not an access boundary.** Like every block on the shared backend Lambda, client and admin run under one role; separation is by API surface + lint, not IAM. Gate every admin route behind `requireRole`. - **Session freshness.** `revokeUserSessions` revokes Cognito refresh tokens (AWS: `AdminUserGlobalSignOut`; mock: deletes session records). On AWS an already-issued access token stays valid until it expires, so `checkAuth` does not flip immediately — a known mock-vs-AWS parity difference. Group changes likewise apply on the next sign-in / `fetchAuthSession({ forceRefresh: true })`, not to a live session. @@ -143,4 +143,4 @@ Resolved by the MFA_SETUP + USER_AUTH work: 4. **Mock + AWS runtimes share the session/cookie code path.** `SessionStore`, cookie helpers, JWT decode — all shared. The only divergence is that mock issues placeholder-signature JWTs and never talks to Cognito. 5. **Error constants mirror Cognito's wire-format exception names.** Every value equals the name Cognito puts on the wire, so `isBlocksError(e, AuthCognitoErrors.X)` works identically for errors thrown by the mock (matched name directly) and errors propagated from the AWS SDK (name preserved when re-thrown as `ApiError`). 6. **`GroupNotFound` maps to `ResourceNotFoundException`.** Real Cognito returns `ResourceNotFoundException` (not the intuitive `GroupNotFoundException`) for a missing group. The constant name preserves the semantic label while the value matches what Cognito actually returns. -7. **Admin surface as an opt-in `auth.admin` handle, not a separate Building Block.** The admin/client distinction is preserved by the handle (and the `admin` opt-in gate) rather than by a second package. This removes a whole package, an `/internal` subpath for sharing mock state, and cross-construct wiring, at the cost of independent versioning (not needed). Because narrowing the typed method set by `admin.actions` would make `AuthCognito` invariant, `actions` scopes the IAM grant only; the typed surface is always the full set. Full trade study in [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md). +7. **Admin surface as an opt-in `auth.admin` handle, not a separate Building Block.** The admin/client distinction is preserved by the handle (and the `admin` opt-in gate) rather than by a second package. This removes a whole package, an `/internal` subpath for sharing mock state, and cross-construct wiring, at the cost of independent versioning (not needed). `admin.actions` scopes both the IAM grant and the compile-time method surface via a parameter-position `AdminActionGate` (ungranted calls are compile errors); the gate is a method *parameter* rather than a conditional over the surface shape, so `AuthCognito` stays covariant in `O`. Full trade study in [BB-auth-cognito-admin-implementation-plan.md](../../docs/tech-design/BB-auth-cognito-admin-implementation-plan.md). diff --git a/packages/bb-auth-cognito/README.md b/packages/bb-auth-cognito/README.md index 109927dc0..78fa45c82 100644 --- a/packages/bb-auth-cognito/README.md +++ b/packages/bb-auth-cognito/README.md @@ -167,7 +167,7 @@ await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via Grou ``` - **Compile-time gate.** Without an `admin` options object, `auth.admin` is typed `AdminDisabled` and any access is a compile error whose message names the fix (`construct AuthCognito with { admin: {} }`). The getter also throws at runtime for untyped JS callers. -- **`actions` scopes the IAM grant, not the method set.** `actions: ['groups']` grants only the group `Admin*` actions; `['lifecycle']` only the user-lifecycle actions; omitted grants both. The **typed surface is always the full set** — narrowing the method types by `actions` is not possible without breaking `AuthCognito` variance, so calling a method whose action wasn't granted fails at runtime with an IAM `AccessDenied`. +- **`actions` scopes both the IAM grant and the typed method set.** `actions: ['groups']` grants only the group `Admin*` actions and makes only the group methods typecheck; `['lifecycle']` only the user-lifecycle actions/methods; omitted grants both. The method gate lives in a trailing parameter position (`AdminActionGate`), so calling a method whose action wasn't granted is a **compile error** while `AuthCognito` stays covariant in `O`. Untyped JS callers that reach past the gate additionally fast-fail at runtime with a clear error rather than a cryptic AWS `AccessDenied`. - **Not an access boundary.** Like every block on the shared backend Lambda, client and admin run under one role. Separation is by API surface + lint, not IAM — gate every admin route with `requireRole`. **Group membership** (`actions: 'groups'`): @@ -187,7 +187,7 @@ await auth.admin.addUserToGroup('alice', 'admins'); // group narrowed via Grou | `admin.deleteUser(username)` | `Promise` | Delete a user (also strips them from every group). | | `admin.disableUser(username)` / `admin.enableUser(username)` | `Promise` | Toggle sign-in. A disabled user's `signIn` is rejected with `NotAuthorizedException`. | | `admin.resetUserPassword(username)` | `Promise` | Force the user to set a new password on next sign-in. | -| `admin.setUserPassword(username, password, permanent)` | `Promise` | Set a password. `permanent: true` clears the force-change flag. | +| `admin.setUserPassword(username, password, { permanent })` | `Promise` | Set a password. `permanent: true` clears the force-change flag. | | `admin.getUser(username)` | `Promise` | Look up a user, or `null` if absent. | | `admin.scan()` | `AsyncIterable` | Enumerate all users (paginates internally). | | `admin.revokeUserSessions(username)` | `Promise` | Revoke the user's refresh tokens (AWS: `AdminUserGlobalSignOut`; mock: delete session records). New tokens can no longer be minted; see the session-freshness note for when this takes effect. |