Skip to content

Commit feb5be4

Browse files
authored
feat(bb-auth-cognito): opt-in auth.admin handle (group + user-lifecycle admin) (#38)
Co-authored-by: Harshdeep Singh <harsh62@users.noreply.github.com>
1 parent cd1da22 commit feb5be4

23 files changed

Lines changed: 2119 additions & 13 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@aws-blocks/bb-auth-cognito": patch
3+
---
4+
5+
Add an opt-in `auth.admin` handle to `AuthCognito` for server-side group-membership and user-lifecycle administration.
6+
7+
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).
8+
9+
```ts
10+
const auth = new AuthCognito(scope, 'auth', { groups: ['admins'], admin: { actions: ['groups'] } });
11+
await auth.admin.addUserToGroup('alice', 'admins');
12+
```
13+
14+
The admin surface is fully typed by the pool config `O`:
15+
16+
- **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`.
17+
- **Typed reads:** `getUser` / `scan` / `listUsersInGroup` return `AdminUser<O>``groups` narrows to the configured group union and `attributes` keys to the declared attributes, matching the client-side `CognitoUser`.
18+
- **Typed writes:** `createUser`'s `attributes` narrow to the declared keys (catches typos like `signUp` does).
19+
- **`scan(filter?)`** accepts a server-side `AdminUserFilter` mapped to Cognito's `ListUsers` `Filter`.
20+
- **`setUserPassword(username, password, { permanent })`** takes a named options object instead of a bare boolean.
21+
22+
The `AuthCognito` class generic is now a `const` type parameter, so inline options literals narrow without `as const`.
23+
24+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/bb-agent": patch
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/blocks": patch
3+
---
4+
5+
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.

docs/tech-design/BB-auth-cognito-admin-implementation-plan.md

Lines changed: 346 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# BB: AuthCognitoAdmin — SUPERSEDED
2+
3+
> **⚠️ 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.
4+
>
5+
> 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).
6+
7+
## Why the direction changed
8+
9+
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<O>` 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.
10+
11+
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.

packages/bb-auth-cognito/API.md

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,65 @@ import type { ChildLogger } from '@aws-blocks/bb-logger';
1212
import { Scope } from '@aws-blocks/core';
1313
import type { ScopeParent } from '@aws-blocks/core';
1414

15+
// @public
16+
export type AdminAction = 'groups' | 'lifecycle';
17+
18+
// @public
19+
export type AdminActionGate<O extends AuthCognitoOptions, A extends AdminAction> = AdminGrants<O, A> extends true ? [] : [ERROR_admin_action_not_granted: never];
20+
21+
// @public
22+
export interface AdminCreateInit<O extends AuthCognitoOptions = AuthCognitoOptions> {
23+
attributes?: Partial<Record<AttrOf<O>, string>>;
24+
suppressInvite?: boolean;
25+
temporaryPassword?: string;
26+
}
27+
28+
// @public
29+
export type AdminDisabled = {
30+
readonly __adminNotEnabled: 'construct AuthCognito with { admin: {} }';
31+
};
32+
33+
// @public
34+
export type AdminGetterOf<O extends AuthCognitoOptions> = O extends {
35+
admin: object;
36+
} ? AdminSurface<O> : AdminDisabled;
37+
38+
// @public
39+
export type AdminGrants<O extends AuthCognitoOptions, A extends AdminAction> = O extends {
40+
admin: {
41+
actions: infer L extends readonly string[];
42+
};
43+
} ? (A extends L[number] ? true : false) : true;
44+
45+
// @public
46+
export interface AdminOptions {
47+
actions?: readonly AdminAction[];
48+
}
49+
50+
// @public
51+
export type AdminSurface<O extends AuthCognitoOptions = AuthCognitoOptions> = GroupAdmin<O> & LifecycleAdmin<O>;
52+
53+
// @public
54+
export interface AdminUser<O extends AuthCognitoOptions = AuthCognitoOptions> {
55+
// (undocumented)
56+
attributes: Partial<Record<ReadAttrOf<O>, string>>;
57+
// (undocumented)
58+
enabled: boolean;
59+
// (undocumented)
60+
groups?: GroupOf<O>[];
61+
// (undocumented)
62+
username: string;
63+
// (undocumented)
64+
userSub: string;
65+
}
66+
67+
// @public
68+
export interface AdminUserFilter {
69+
attribute: string;
70+
match: 'startsWith' | 'equals';
71+
value: string;
72+
}
73+
1574
// Warning: (ae-incompatible-release-tags) The symbol "AttrOf" is marked as @public, but its signature references "CustomAttrNames" which is marked as @internal
1675
//
1776
// @public
@@ -20,8 +79,9 @@ export type AttrOf<O extends AuthCognitoOptions> = O extends {
2079
} ? StandardUserAttributeKey | CustomAttrNames<O> | `custom:${CustomAttrNames<O>}` : string;
2180

2281
// @public
23-
export class AuthCognito<O extends AuthCognitoMockOptions = AuthCognitoMockOptions> extends Scope implements BlocksAuth {
82+
export class AuthCognito<const O extends AuthCognitoMockOptions = AuthCognitoMockOptions> extends Scope implements BlocksAuth {
2483
constructor(scope: ScopeParent, id: string, options?: O);
84+
get admin(): AdminGetterOf<O>;
2585
autoSignIn(context: BlocksContext): Promise<SignInResult<O>>;
2686
checkAuth(context: BlocksContext): Promise<boolean>;
2787
completePasskeyRegistration(context: BlocksContext, credential: string): Promise<CompletePasskeyRegistrationResult>;
@@ -114,6 +174,7 @@ export interface AuthCognitoMockOptions extends AuthCognitoOptions {
114174

115175
// @public
116176
export interface AuthCognitoOptions {
177+
admin?: AdminOptions;
117178
authFlowType?: AuthFlowType;
118179
crossDomain?: boolean;
119180
deviceTracking?: {
@@ -270,6 +331,18 @@ export interface FetchAuthSessionOptions {
270331
forceRefresh?: boolean;
271332
}
272333

334+
// @public
335+
export interface GroupAdmin<O extends AuthCognitoOptions = AuthCognitoOptions> {
336+
// (undocumented)
337+
addUserToGroup(username: string, group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<void>;
338+
// (undocumented)
339+
listGroupsForUser(username: string, ...gate: AdminActionGate<O, 'groups'>): Promise<GroupOf<O>[]>;
340+
// (undocumented)
341+
listUsersInGroup(group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<AdminUser<O>[]>;
342+
// (undocumented)
343+
removeUserFromGroup(username: string, group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<void>;
344+
}
345+
273346
// @public
274347
export type GroupOf<O extends AuthCognitoOptions> = O extends {
275348
groups: readonly [unknown, ...unknown[]];
@@ -292,6 +365,28 @@ export interface JWT {
292365
toString(): string;
293366
}
294367

368+
// @public
369+
export interface LifecycleAdmin<O extends AuthCognitoOptions = AuthCognitoOptions> {
370+
// (undocumented)
371+
createUser(username: string, init?: AdminCreateInit<O>, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<AdminUser<O>>;
372+
// (undocumented)
373+
deleteUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
374+
// (undocumented)
375+
disableUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
376+
// (undocumented)
377+
enableUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
378+
// (undocumented)
379+
getUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<AdminUser<O> | null>;
380+
// (undocumented)
381+
resetUserPassword(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
382+
// (undocumented)
383+
revokeUserSessions(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
384+
// (undocumented)
385+
scan(filter?: AdminUserFilter, ...gate: AdminActionGate<O, 'lifecycle'>): AsyncIterable<AdminUser<O>>;
386+
// (undocumented)
387+
setUserPassword(username: string, password: string, options?: SetPasswordOptions, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
388+
}
389+
295390
// @public
296391
export function makeExternalUserPoolRef(userPoolId: string, clientId?: string): ExternalUserPoolRef;
297392

@@ -376,12 +471,18 @@ export interface SessionRecord {
376471
export class SessionStore {
377472
constructor(scope: ScopeParent, id?: string);
378473
createSession(record: SessionRecord): Promise<string>;
474+
deleteByUsername(username: string): Promise<number>;
379475
deleteSession(sessionId: string): Promise<void>;
380476
lookupSession(sessionId: string): Promise<SessionRecord | null>;
381477
// (undocumented)
382478
updateSession(sessionId: string, update: Partial<SessionRecord>): Promise<void>;
383479
}
384480

481+
// @public
482+
export interface SetPasswordOptions {
483+
permanent?: boolean;
484+
}
485+
385486
// @public
386487
export type SignInNextStep = {
387488
name: 'CONFIRM_SIGN_IN_WITH_SMS_CODE';

packages/bb-auth-cognito/DESIGN.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ Design document. For usage, see [README.md](./README.md).
1818
- Device tracking (list, forget).
1919
- Password reset via verification code.
2020
- Provider-agnostic state machine driving the same `<Authenticator>` UI as every other Blocks auth BB.
21+
- **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.
2122

2223
**Out (for this BB):**
2324

24-
- **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.
2525
- **HostedUI + federated sign-in** (Google / Facebook / LoginWithAmazon / Apple / OIDC / SAML) — not supported.
2626
- **Cognito Lambda triggers** (`preSignUp`, `postConfirmation`, `preAuthentication`, …) — customers who need them can attach against the underlying `userPool` construct directly.
2727
- **Auth flows other than `USER_PASSWORD_AUTH`** — widened typing only.
@@ -47,7 +47,7 @@ Creates under the construct's scope:
4747
- **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.
4848
- **Nested `KVStore(this, 'sessions', { removalPolicy })`** — DynamoDB table holding server-side session records.
4949

50-
The Lambda handler receives env vars `BLOCKS_AUTH_COGNITO_<UPPER_FULLID>_{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 ARNthe 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.
50+
The Lambda handler receives env vars `BLOCKS_AUTH_COGNITO_<UPPER_FULLID>_{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*.
5151

5252
`userPoolName` is `this.fullId`; the construct throws at synth time if the ID exceeds Cognito's 128-char limit.
5353

@@ -81,6 +81,15 @@ Everything else (`username`, `userSub`, `groups`, `attributes`) is derived on re
8181

8282
**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.
8383

84+
## Admin surface (`auth.admin`)
85+
86+
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.
87+
88+
- **Compile-time gate.** The getter's type is `AdminGetterOf<O> = O extends { admin: object } ? AdminSurface<O> : 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<O>`.
89+
- **`actions` scopes both the IAM grant and the compile-time method surface.** Each method on `GroupAdmin<O>` / `LifecycleAdmin<O>` carries a trailing `...gate: AdminActionGate<O, A>` 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<O>` 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.
90+
- **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`.
91+
- **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.
92+
8493
## Options Split: `AuthCognitoOptions` vs `AuthCognitoMockOptions`
8594

8695
`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:
134143
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.
135144
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`).
136145
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.
146+
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<O>` 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).

0 commit comments

Comments
 (0)