diff --git a/.changeset/auth-cognito-admin-handle.md b/.changeset/auth-cognito-admin-handle.md new file mode 100644 index 000000000..2e3ce18a2 --- /dev/null +++ b/.changeset/auth-cognito-admin-handle.md @@ -0,0 +1,24 @@ +--- +"@aws-blocks/bb-auth-cognito": patch +--- + +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 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`. + +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. 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/.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-implementation-plan.md b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md new file mode 100644 index 000000000..b332f4558 --- /dev/null +++ b/docs/tech-design/BB-auth-cognito-admin-implementation-plan.md @@ -0,0 +1,346 @@ +# 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 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). + +## ⚠️ 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. + +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. No back-compat shims. + +## Type design (the core of this plan) + +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. + +### 1. Options + +```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 +export interface AdminOptions { + /** + * 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. + */ + actions?: readonly ('groups' | 'lifecycle')[]; +} +``` + +### 2. The gate and surface projections + +```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'; + +/** 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); + +/** 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 +export class AuthCognito { ... get admin(): AdminGetterOf; } +``` + +`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 + +- **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.) + +## Scope of edits (all within `packages/bb-auth-cognito/`) + +| 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. | + +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 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. 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 (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 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`) +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(): 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 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). + +## 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. + +## 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. + +--- + +## 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'); +``` 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..815d623c1 --- /dev/null +++ b/docs/tech-design/BB-auth-cognito-admin.md @@ -0,0 +1,11 @@ +# BB: AuthCognitoAdmin — SUPERSEDED + +> **⚠️ 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. +> +> 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). + +## Why the direction changed + +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. + +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/API.md b/packages/bb-auth-cognito/API.md index 830c4bdee..b1ef1f6b9 100644 --- a/packages/bb-auth-cognito/API.md +++ b/packages/bb-auth-cognito/API.md @@ -12,6 +12,65 @@ import type { ChildLogger } from '@aws-blocks/bb-logger'; import { Scope } from '@aws-blocks/core'; import type { ScopeParent } from '@aws-blocks/core'; +// @public +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; +} + +// @public +export type AdminDisabled = { + readonly __adminNotEnabled: 'construct AuthCognito with { admin: {} }'; +}; + +// @public +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 AdminAction[]; +} + +// @public +export type AdminSurface = GroupAdmin & LifecycleAdmin; + +// @public +export interface AdminUser { + // (undocumented) + attributes: Partial, string>>; + // (undocumented) + enabled: boolean; + // (undocumented) + 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 @@ -20,8 +79,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 +174,7 @@ export interface AuthCognitoMockOptions extends AuthCognitoOptions { // @public export interface AuthCognitoOptions { + admin?: AdminOptions; authFlowType?: AuthFlowType; crossDomain?: boolean; deviceTracking?: { @@ -270,6 +331,18 @@ export interface FetchAuthSessionOptions { forceRefresh?: boolean; } +// @public +export interface GroupAdmin { + // (undocumented) + addUserToGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; + // (undocumented) + listGroupsForUser(username: string, ...gate: AdminActionGate): Promise[]>; + // (undocumented) + listUsersInGroup(group: GroupOf, ...gate: AdminActionGate): Promise[]>; + // (undocumented) + removeUserFromGroup(username: string, group: GroupOf, ...gate: AdminActionGate): Promise; +} + // @public export type GroupOf = O extends { groups: readonly [unknown, ...unknown[]]; @@ -292,6 +365,28 @@ export interface JWT { toString(): string; } +// @public +export interface LifecycleAdmin { + // (undocumented) + createUser(username: string, init?: AdminCreateInit, ...gate: AdminActionGate): Promise>; + // (undocumented) + deleteUser(username: string, ...gate: AdminActionGate): Promise; + // (undocumented) + disableUser(username: string, ...gate: AdminActionGate): Promise; + // (undocumented) + enableUser(username: string, ...gate: AdminActionGate): Promise; + // (undocumented) + getUser(username: string, ...gate: AdminActionGate): Promise | null>; + // (undocumented) + resetUserPassword(username: string, ...gate: AdminActionGate): Promise; + // (undocumented) + revokeUserSessions(username: string, ...gate: AdminActionGate): Promise; + // (undocumented) + scan(filter?: AdminUserFilter, ...gate: AdminActionGate): AsyncIterable>; + // (undocumented) + setUserPassword(username: string, password: string, options?: SetPasswordOptions, ...gate: AdminActionGate): Promise; +} + // @public export function makeExternalUserPoolRef(userPoolId: string, clientId?: string): ExternalUserPoolRef; @@ -376,12 +471,18 @@ 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) 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/DESIGN.md b/packages/bb-auth-cognito/DESIGN.md index 3de535311..3441ac81a 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 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. + ## 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). `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 6684233fc..78fa45c82 100644 --- a/packages/bb-auth-cognito/README.md +++ b/packages/bb-auth-cognito/README.md @@ -151,6 +151,51 @@ 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 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'`): + +| 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 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 })`. +> +> `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 ```typescript @@ -206,7 +251,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 = { 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..6a4a12363 --- /dev/null +++ b/packages/bb-auth-cognito/src/admin.test.ts @@ -0,0 +1,256 @@ +// 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', { permanent: 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('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'); + 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); + }); +}); + +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 + }); + + 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/admin.types-test.ts b/packages/bb-auth-cognito/src/admin.types-test.ts new file mode 100644 index 000000000..fc88b1254 --- /dev/null +++ b/packages/bb-auth-cognito/src/admin.types-test.ts @@ -0,0 +1,150 @@ +// 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` 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 actionsGateMethodsAtCompileTime() { + const groupsScoped = new AuthCognito(scope, 'a3', { groups: ['admins'], admin: { actions: ['groups'] } }); + 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'); // 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'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// (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'], 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'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// (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 ab35e15f3..f6402bed2 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,12 @@ import { envVarNames, isRetriableAuthError, makeExternalUserPoolRef, + type AdminCreateInit, + type AdminGetterOf, + type AdminUser, + type AdminUserFilter, + type GroupAdmin, + type LifecycleAdmin, type AuthCognitoOptions, type CodeDeliveryDetails, type AttrOf, @@ -581,7 +602,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 { @@ -593,6 +614,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 +655,259 @@ 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; + } + + /** + * Fast-fail when an admin method's action group wasn't granted by + * `admin.actions` — the runtime half of the {@link AdminActionGate} compile + * gate, so untyped callers get a clear error instead of Cognito's cryptic + * `AccessDenied` (or, worse, an IAM failure only in production). + */ + 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 }, + ); + } + } + + 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, + }; + }; + // Map an AdminUserFilter to a Cognito ListUsers Filter expression. + // Cognito uses `^=` for prefix and `=` for exact match, value quoted. + const toCognitoFilter = (filter?: AdminUserFilter): string | undefined => { + 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), + })); + } catch (e) { throw asApiError(e); } + }, + removeUserFromGroup: async (username, group) => { + this.assertAdminAction('groups'); + try { + await this.client.send(new AdminRemoveUserFromGroupCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group), + })); + } catch (e) { throw asApiError(e); } + }, + listGroupsForUser: async (username) => { + this.assertAdminAction('groups'); + 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) => { + this.assertAdminAction('groups'); + 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) => { + this.assertAdminAction('lifecycle'); + try { + // 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({ + 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) => { + this.assertAdminAction('lifecycle'); + try { + await this.client.send(new AdminDeleteUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + disableUser: async (username) => { + this.assertAdminAction('lifecycle'); + try { + await this.client.send(new AdminDisableUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + enableUser: async (username) => { + this.assertAdminAction('lifecycle'); + try { + await this.client.send(new AdminEnableUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + resetUserPassword: async (username) => { + this.assertAdminAction('lifecycle'); + try { + await this.client.send(new AdminResetUserPasswordCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, + })); + } catch (e) { throw asApiError(e); } + }, + setUserPassword: async (username, password, options) => { + this.assertAdminAction('lifecycle'); + try { + await this.client.send(new AdminSetUserPasswordCommand({ + 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, + })); + const attributes: Record = {}; + for (const a of (resp.UserAttributes ?? []) as AttributeType[]) { + if (a.Name) attributes[a.Name] = a.Value ?? ''; + } + // AdminGetUser does not return group memberships, so fetch them + // separately to populate AdminUser.groups (matches the mock, + // which reads groups from its in-memory state). The lifecycle + // IAM slice grants AdminListGroupsForUser for exactly this; if a + // hand-narrowed policy omits it, degrade to `groups: undefined` + // rather than failing the whole read. + let groups: string[] | undefined = []; + try { + let nextToken: string | undefined; + do { + const g = await this.client.send(new AdminListGroupsForUserCommand({ + UserPoolId: this.adminUserPoolId(), Username: username, NextToken: nextToken, + })); + for (const grp of g.Groups ?? []) if (grp.GroupName) groups!.push(grp.GroupName); + nextToken = g.NextToken; + } while (nextToken); + } catch (e) { + // Missing AdminListGroupsForUser grant → report groups as unknown. + if (e instanceof Error && /AccessDenied|NotAuthorized/.test(e.name)) groups = undefined; + else throw e; + } + return { + username: resp.Username ?? username, + userSub: attributes['sub'] ?? '', + enabled: resp.Enabled ?? true, + attributes, + groups: groups as GroupOf[] | undefined, + }; + } catch (e) { + if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null; + throw asApiError(e); + } + }, + 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, + }; + } + paginationToken = resp.PaginationToken; + } while (paginationToken); + })(); + }, + revokeUserSessions: async (username) => { + this.assertAdminAction('lifecycle'); + 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..7cfb05b35 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.test.ts b/packages/bb-auth-cognito/src/index.cdk.test.ts index 603aa3a9a..871b8b416 100644 --- a/packages/bb-auth-cognito/src/index.cdk.test.ts +++ b/packages/bb-auth-cognito/src/index.cdk.test.ts @@ -536,3 +536,98 @@ 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', + // 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)', () => { + 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, 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 lifecycleOnlyActions) assert.ok(!actions.has(a), `unexpected lifecycle action ${a}`); + }); + + 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 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 33c083836..5ace421c7 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, @@ -63,15 +64,18 @@ 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; + /** 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,60 @@ 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', + // 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', + ]; + 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..369e88775 100644 --- a/packages/bb-auth-cognito/src/index.ts +++ b/packages/bb-auth-cognito/src/index.ts @@ -49,6 +49,13 @@ import { AuthCognitoErrors, isRetriableAuthError, makeExternalUserPoolRef, + type AdminCreateInit, + type AdminGetterOf, + type AdminUser, + type AdminUserFilter, + type GroupAdmin, + type LifecycleAdmin, + type AuthCognitoOptions, type AuthCognitoMockOptions, type AttrOf, type AuthSession, @@ -197,7 +204,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 { @@ -208,6 +215,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 +253,215 @@ 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; + } + + /** + * 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 + * 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)), + }); + // 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 }); + } + 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) => { + this.assertAdminAction('lifecycle'); + 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) => { + 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, options) => { + this.assertAdminAction('lifecycle'); + this.enforcePasswordPolicy(password); + const user = requireUser(username) as MockUserRecord & { forcePasswordChange?: boolean }; + user.password = password; + if (options?.permanent) { + delete user.forcePasswordChange; + } else { + user.forcePasswordChange = true; + } + this.flushToDisk(); + }, + getUser: async (username) => { + this.assertAdminAction('lifecycle'); + const user = this.state.users[username]; + return user ? toAdminUser(username, user) : null; + }, + 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); + }, + }; + } + + /** 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..81fe4e258 100644 --- a/packages/bb-auth-cognito/src/types.ts +++ b/packages/bb-auth-cognito/src/types.ts @@ -270,10 +270,200 @@ 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. + +/** 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 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. + * + * 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 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. Each + * method is gated on the `'groups'` action (see {@link AdminActionGate}). + */ +export interface GroupAdmin { + 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. Each method is gated on the + * `'lifecycle'` action (see {@link AdminActionGate}). + */ +export interface LifecycleAdmin { + 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, 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}. + * + * 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; + +/** + * 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`. + */ +export type AdminGetterOf = + O extends { admin: object } ? AdminSurface : AdminDisabled; + +/** + * 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: Partial, string>>; + groups?: GroupOf[]; +} + +/** Initial state for {@link LifecycleAdmin.createUser}. */ +export interface AdminCreateInit { + /** Temporary password. When omitted, the runtime generates one. */ + temporaryPassword?: string; + /** + * 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; +} + /** * 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'; /** diff --git a/packages/blocks/API.md b/packages/blocks/API.md index 62785807b..51b94d241 100644 --- a/packages/blocks/API.md +++ b/packages/blocks/API.md @@ -4,6 +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'; @@ -94,12 +104,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'; @@ -136,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'; @@ -165,6 +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 } @@ -434,6 +467,8 @@ export { github } export { google } +export { GroupAdmin } + export { KnowledgeBase } export { KnowledgeBaseErrors } @@ -446,6 +481,8 @@ export { KVStoreErrors } export { KVStoreOptions } +export { LifecycleAdmin } + export { LifecycleRule } export { LogEntry } @@ -518,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 48ad12151..2082309fc 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -69,6 +69,19 @@ export type { UserAttribute, 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 3daf380af..53466e76f 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, @@ -952,10 +955,73 @@ 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; }, + // ── 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 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 }; + }, + 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); }, @@ -1082,7 +1148,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/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..a522b713d --- /dev/null +++ b/test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts @@ -0,0 +1,153 @@ +// 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)); + }); + + // ── 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); + }); + }); +} 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/native-bindings/aws-blocks/index.ts b/test-apps/native-bindings/aws-blocks/index.ts index b2b84c3cf..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'], @@ -238,7 +242,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() {