Skip to content

Commit 1e6f997

Browse files
committed
feat(bb-auth-cognito): implement opt-in auth.admin handle (types + all runtimes)
Adds the server-side admin surface as an opt-in handle on AuthCognito (T1-T4, T6-T7): admin types + compile-time gate, mock runtime (group/lifecycle mutators on live state), AWS runtime (Admin* SDK commands), browser stub getter, and the CDK IAM grant scoped by admin.actions. Includes admin.types-test.ts. Type-safety finding: narrowing the method set by actions via a conditional over O forces AuthCognito<O> invariant and regressed 14 existing call sites. Resolved by always exposing the full surface (group names still narrow via GroupOf<O>); actions scopes the IAM grant only. Full package build + existing 117 tests green. Remaining: T5 const O, T8/T9 unit tests, T10 integration, T11 README.
1 parent 02d4f11 commit 1e6f997

8 files changed

Lines changed: 691 additions & 2 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
**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).
66
**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).
77

8+
## ⚠️ Implementation finding — `actions` scopes the IAM grant, not the typed surface
9+
10+
During implementation the compiler surfaced a **variance regression** that revised the type design:
11+
12+
- The original plan had `actions: ['groups']` *hide* the lifecycle methods at the type level (via a conditional type over `O` inside `AdminSurface`).
13+
- A conditional type over the class's own generic `O`, used as a **property/getter type**, forces TypeScript to treat `AuthCognito<O>` as **invariant in `O`**. That breaks the long-standing contract that `AuthCognito<NarrowOpts>` is assignable to `AuthCognito<AuthCognitoOptions>` — 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<O> : AdminDisabled`, where `AdminSurface = GroupAdmin<O> & LifecycleAdmin<O>` is a plain generic interface) keeps the class covariant and builds clean.
14+
15+
**Resolved design:** `auth.admin` always exposes the **full** surface (group + lifecycle), with group names still narrowed via `GroupOf<O>`. `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.
16+
817
## Decision
918

1019
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.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Negative + positive type tests for the opt-in `auth.admin` handle.
6+
*
7+
* The compile is the test — no runtime assertions. Each `@ts-expect-error`
8+
* line asserts the following expression is a type error today; if the error
9+
* ever disappears (e.g. the gate or `actions` scoping regresses), `tsc --build`
10+
* fails and points at the now-unsatisfied `@ts-expect-error`.
11+
*
12+
* These cases are the same ones compiled standalone while designing the gate
13+
* (see `docs/tech-design/BB-auth-cognito-admin-implementation-plan.md`,
14+
* Appendix A) — here they run against the real `AuthCognito` class.
15+
*
16+
* @internal
17+
*/
18+
19+
import type { ScopeParent } from '@aws-blocks/core';
20+
import { AuthCognito } from './index.js';
21+
22+
declare const scope: ScopeParent;
23+
24+
// ─────────────────────────────────────────────────────────────────────────────
25+
// (1) No `admin` opt-in → `auth.admin` is `AdminDisabled`; member access errors.
26+
// ─────────────────────────────────────────────────────────────────────────────
27+
async function gateOff() {
28+
const auth = new AuthCognito(scope, 'a1', { groups: ['admins'] });
29+
// @ts-expect-error — admin not enabled; `auth.admin` is AdminDisabled.
30+
await auth.admin.addUserToGroup('u', 'admins');
31+
}
32+
33+
// ─────────────────────────────────────────────────────────────────────────────
34+
// (2) `admin: {}` (no actions) → full surface: both groups + lifecycle present.
35+
// ─────────────────────────────────────────────────────────────────────────────
36+
async function fullSurface() {
37+
const auth = new AuthCognito(scope, 'a2', { groups: ['admins'], admin: {} });
38+
await auth.admin.addUserToGroup('u', 'admins'); // group method present
39+
await auth.admin.createUser('u'); // lifecycle method present
40+
}
41+
42+
// ─────────────────────────────────────────────────────────────────────────────
43+
// (3) `actions` scopes the IAM grant, NOT the typed method set — the full
44+
// surface is present at the type level regardless of `actions`. (Narrowing
45+
// the type by `actions` would force `AuthCognito<O>` invariant; see
46+
// `AdminSurface` doc.) A method whose action wasn't granted fails at
47+
// runtime with IAM AccessDenied, not at compile time.
48+
// ─────────────────────────────────────────────────────────────────────────────
49+
async function actionsScopeGrantNotTypes() {
50+
const groupsScoped = new AuthCognito(scope, 'a3', { groups: ['admins'], admin: { actions: ['groups'] } });
51+
await groupsScoped.admin.addUserToGroup('u', 'admins');
52+
await groupsScoped.admin.createUser('u'); // present at type level (grant-scoped at runtime)
53+
54+
const lifecycleScoped = new AuthCognito(scope, 'a4', { groups: ['admins'], admin: { actions: ['lifecycle'] } });
55+
await lifecycleScoped.admin.createUser('u');
56+
await lifecycleScoped.admin.addUserToGroup('u', 'admins'); // present at type level
57+
}
58+
59+
// ─────────────────────────────────────────────────────────────────────────────
60+
// (5) Group narrowing on admin methods. With `as const` (today) the group union
61+
// narrows and a typo is rejected. (Task T5 — `const O` — will make this hold
62+
// WITHOUT `as const`; the @ts-expect-error below is tightened to the
63+
// non-const form then.)
64+
// ─────────────────────────────────────────────────────────────────────────────
65+
async function groupNarrowing() {
66+
const auth = new AuthCognito(scope, 'a5', { groups: ['admins', 'readers'] as const, admin: { actions: ['groups'] } });
67+
await auth.admin.addUserToGroup('u', 'admins');
68+
await auth.admin.addUserToGroup('u', 'readers');
69+
// @ts-expect-error — 'editor' is not in 'admins' | 'readers'.
70+
await auth.admin.addUserToGroup('u', 'editor');
71+
}
72+
73+
// ─────────────────────────────────────────────────────────────────────────────
74+
// (6) `admin: true` must NOT enable (primitive fails the `{ admin: object }` gate).
75+
// ─────────────────────────────────────────────────────────────────────────────
76+
async function adminTrueRejected() {
77+
// @ts-expect-error — `true` is not assignable to AdminOptions.
78+
const auth = new AuthCognito(scope, 'a6', { groups: ['admins'], admin: true });
79+
void auth;
80+
}
81+
82+
// ─────────────────────────────────────────────────────────────────────────────
83+
// (7) Default `O` (no narrowing) → admin disabled.
84+
// ─────────────────────────────────────────────────────────────────────────────
85+
async function defaultO() {
86+
const auth: AuthCognito = new AuthCognito(scope, 'a7');
87+
// @ts-expect-error — admin disabled on the default (wide) O.
88+
await auth.admin.createUser('u');
89+
}

packages/bb-auth-cognito/src/index.aws.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616

1717
import crypto from 'node:crypto';
1818
import {
19+
AdminAddUserToGroupCommand,
20+
AdminCreateUserCommand,
21+
AdminDeleteUserCommand,
22+
AdminDisableUserCommand,
23+
AdminEnableUserCommand,
24+
AdminGetUserCommand,
25+
AdminListGroupsForUserCommand,
26+
AdminRemoveUserFromGroupCommand,
27+
AdminResetUserPasswordCommand,
28+
AdminSetUserPasswordCommand,
29+
AdminUserGlobalSignOutCommand,
30+
ListUsersCommand,
31+
ListUsersInGroupCommand,
32+
type AttributeType,
33+
type UserType,
1934
AssociateSoftwareTokenCommand,
2035
ChangePasswordCommand,
2136
ChallengeNameType,
@@ -88,6 +103,11 @@ import {
88103
envVarNames,
89104
isRetriableAuthError,
90105
makeExternalUserPoolRef,
106+
type AdminCreateInit,
107+
type AdminGetterOf,
108+
type AdminUser,
109+
type GroupAdmin,
110+
type LifecycleAdmin,
91111
type AuthCognitoOptions,
92112
type CodeDeliveryDetails,
93113
type AttrOf,
@@ -593,6 +613,8 @@ export class AuthCognito<O extends AuthCognitoOptions = AuthCognitoOptions>
593613
private readonly sessions: SessionStore;
594614
private readonly sessionSecretSetting: AppSetting;
595615
private sessionSecret?: string;
616+
/** Full admin surface, built once; the single generic projection lives in `get admin()`. */
617+
private readonly adminSurface: GroupAdmin<AuthCognitoOptions> & LifecycleAdmin<AuthCognitoOptions>;
596618

597619
constructor(scope: ScopeParent, id: string, options?: O) {
598620
super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
@@ -632,6 +654,192 @@ export class AuthCognito<O extends AuthCognitoOptions = AuthCognitoOptions>
632654
// Nested scope `session-secret` — matches the CDK layer's AppSetting
633655
// so both sides derive the same SSM parameter path.
634656
this.sessionSecretSetting = new AppSetting(this, 'session-secret', { secret: true });
657+
this.adminSurface = this.buildAdminSurface();
658+
}
659+
660+
/**
661+
* Opt-in server-side admin surface (group membership + user lifecycle).
662+
* `AdminDisabled` (compile error on access) unless `admin` was configured;
663+
* throws at runtime for untyped JS callers. Methods are narrowed by
664+
* `admin.actions`; the runtime object always carries every method.
665+
*
666+
* @category admin
667+
*/
668+
get admin(): AdminGetterOf<O> {
669+
if (!this.options.admin) {
670+
throw new Error("admin not enabled: construct AuthCognito with { admin: {} }");
671+
}
672+
return this.adminSurface as AdminGetterOf<O>;
673+
}
674+
675+
/** Pool ID for admin SDK calls. Throws if discovery env isn't populated. */
676+
private adminUserPoolId(): string {
677+
const { userPoolId } = getSdkIdentifiers(this);
678+
if (!userPoolId) {
679+
throw new ApiError('Cognito user pool not configured', 500, { name: DEFAULT_API_ERROR_NAME });
680+
}
681+
return userPoolId;
682+
}
683+
684+
private buildAdminSurface(): GroupAdmin<AuthCognitoOptions> & LifecycleAdmin<AuthCognitoOptions> {
685+
const toAdminUser = (u: UserType): AdminUser => {
686+
const attributes: Record<string, string> = {};
687+
for (const a of (u.Attributes ?? []) as AttributeType[]) {
688+
if (a.Name) attributes[a.Name] = a.Value ?? '';
689+
}
690+
return {
691+
username: u.Username ?? '',
692+
userSub: attributes['sub'] ?? '',
693+
enabled: u.Enabled ?? true,
694+
attributes,
695+
};
696+
};
697+
698+
return {
699+
// ── GroupAdmin ───────────────────────────────────────────────────
700+
addUserToGroup: async (username, group) => {
701+
try {
702+
await this.client.send(new AdminAddUserToGroupCommand({
703+
UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group),
704+
}));
705+
} catch (e) { throw asApiError(e); }
706+
},
707+
removeUserFromGroup: async (username, group) => {
708+
try {
709+
await this.client.send(new AdminRemoveUserFromGroupCommand({
710+
UserPoolId: this.adminUserPoolId(), Username: username, GroupName: String(group),
711+
}));
712+
} catch (e) { throw asApiError(e); }
713+
},
714+
listGroupsForUser: async (username) => {
715+
try {
716+
const out: string[] = [];
717+
let nextToken: string | undefined;
718+
do {
719+
const resp = await this.client.send(new AdminListGroupsForUserCommand({
720+
UserPoolId: this.adminUserPoolId(), Username: username, NextToken: nextToken,
721+
}));
722+
for (const g of resp.Groups ?? []) if (g.GroupName) out.push(g.GroupName);
723+
nextToken = resp.NextToken;
724+
} while (nextToken);
725+
return out as GroupOf<AuthCognitoOptions>[];
726+
} catch (e) { throw asApiError(e); }
727+
},
728+
listUsersInGroup: async (group) => {
729+
try {
730+
const out: AdminUser[] = [];
731+
let nextToken: string | undefined;
732+
do {
733+
const resp = await this.client.send(new ListUsersInGroupCommand({
734+
UserPoolId: this.adminUserPoolId(), GroupName: String(group), NextToken: nextToken,
735+
}));
736+
for (const u of resp.Users ?? []) out.push(toAdminUser(u));
737+
nextToken = resp.NextToken;
738+
} while (nextToken);
739+
return out;
740+
} catch (e) { throw asApiError(e); }
741+
},
742+
743+
// ── LifecycleAdmin ───────────────────────────────────────────────
744+
createUser: async (username, init) => {
745+
try {
746+
const attrs: AttributeType[] = Object.entries(init?.attributes ?? {}).map(
747+
([Name, Value]) => ({ Name, Value }),
748+
);
749+
const resp = await this.client.send(new AdminCreateUserCommand({
750+
UserPoolId: this.adminUserPoolId(),
751+
Username: username,
752+
TemporaryPassword: init?.temporaryPassword,
753+
UserAttributes: attrs.length ? attrs : undefined,
754+
MessageAction: init?.suppressInvite ? 'SUPPRESS' : undefined,
755+
}));
756+
return resp.User ? toAdminUser(resp.User) : { username, userSub: '', enabled: true, attributes: {} };
757+
} catch (e) { throw asApiError(e); }
758+
},
759+
deleteUser: async (username) => {
760+
try {
761+
await this.client.send(new AdminDeleteUserCommand({
762+
UserPoolId: this.adminUserPoolId(), Username: username,
763+
}));
764+
} catch (e) { throw asApiError(e); }
765+
},
766+
disableUser: async (username) => {
767+
try {
768+
await this.client.send(new AdminDisableUserCommand({
769+
UserPoolId: this.adminUserPoolId(), Username: username,
770+
}));
771+
} catch (e) { throw asApiError(e); }
772+
},
773+
enableUser: async (username) => {
774+
try {
775+
await this.client.send(new AdminEnableUserCommand({
776+
UserPoolId: this.adminUserPoolId(), Username: username,
777+
}));
778+
} catch (e) { throw asApiError(e); }
779+
},
780+
resetUserPassword: async (username) => {
781+
try {
782+
await this.client.send(new AdminResetUserPasswordCommand({
783+
UserPoolId: this.adminUserPoolId(), Username: username,
784+
}));
785+
} catch (e) { throw asApiError(e); }
786+
},
787+
setUserPassword: async (username, password, permanent) => {
788+
try {
789+
await this.client.send(new AdminSetUserPasswordCommand({
790+
UserPoolId: this.adminUserPoolId(), Username: username, Password: password, Permanent: permanent,
791+
}));
792+
} catch (e) { throw asApiError(e); }
793+
},
794+
getUser: async (username) => {
795+
try {
796+
const resp = await this.client.send(new AdminGetUserCommand({
797+
UserPoolId: this.adminUserPoolId(), Username: username,
798+
}));
799+
const attributes: Record<string, string> = {};
800+
for (const a of (resp.UserAttributes ?? []) as AttributeType[]) {
801+
if (a.Name) attributes[a.Name] = a.Value ?? '';
802+
}
803+
return {
804+
username: resp.Username ?? username,
805+
userSub: attributes['sub'] ?? '',
806+
enabled: resp.Enabled ?? true,
807+
attributes,
808+
};
809+
} catch (e) {
810+
if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null;
811+
throw asApiError(e);
812+
}
813+
},
814+
scan: async function* (this: AuthCognito<O>) {
815+
let paginationToken: string | undefined;
816+
do {
817+
const resp = await this.client.send(new ListUsersCommand({
818+
UserPoolId: this.adminUserPoolId(), PaginationToken: paginationToken,
819+
}));
820+
for (const u of resp.Users ?? []) {
821+
const attributes: Record<string, string> = {};
822+
for (const a of (u.Attributes ?? []) as AttributeType[]) {
823+
if (a.Name) attributes[a.Name] = a.Value ?? '';
824+
}
825+
yield {
826+
username: u.Username ?? '',
827+
userSub: attributes['sub'] ?? '',
828+
enabled: u.Enabled ?? true,
829+
attributes,
830+
};
831+
}
832+
paginationToken = resp.PaginationToken;
833+
} while (paginationToken);
834+
}.bind(this),
835+
revokeUserSessions: async (username) => {
836+
try {
837+
await this.client.send(new AdminUserGlobalSignOutCommand({
838+
UserPoolId: this.adminUserPoolId(), Username: username,
839+
}));
840+
} catch (e) { throw asApiError(e); }
841+
},
842+
};
635843
}
636844

637845
private get verifier(): ReturnType<typeof CognitoJwtVerifier.create> {

packages/bb-auth-cognito/src/index.browser.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import { ApiNamespace } from '@aws-blocks/core';
2121
import type { AuthStateApi } from '@aws-blocks/auth-common';
22-
import { makeExternalUserPoolRef, type AuthCognitoOptions } from './types.js';
22+
import { makeExternalUserPoolRef, type AuthCognitoOptions, type AdminGetterOf } from './types.js';
2323

2424
export type { BlocksAuth, AuthUser, AuthState, AuthAction, AuthField, AuthActionInput, AuthStateApi } from '@aws-blocks/auth-common';
2525
export * from './types.js';
@@ -28,8 +28,16 @@ export * from './types.js';
2828
// that references `AuthCognito<typeof options>` typechecks identically under
2929
// `--conditions=browser`. The browser stub never executes any method —
3030
// bundlers scan imports, they don't call anything.
31-
export class AuthCognito<_O extends AuthCognitoOptions = AuthCognitoOptions> {
31+
export class AuthCognito<O extends AuthCognitoOptions = AuthCognitoOptions> {
3232
constructor(..._args: unknown[]) { /* no-op */ }
33+
/**
34+
* Server-only admin surface. Present so `auth.admin` typechecks under
35+
* `--conditions=browser`; never reachable at runtime (the browser never
36+
* instantiates `AuthCognito`). Throws if somehow called.
37+
*/
38+
get admin(): AdminGetterOf<O> {
39+
throw new Error('AuthCognito.admin is server-only; the browser should call the generated client');
40+
}
3341
createApi(): AuthStateApi {
3442
// Real state-machine lives on the server. Browsers invoke the typed
3543
// generated client, not this stub — these methods only exist so the

0 commit comments

Comments
 (0)