feat(api): add Cognito user pools to the cloud explorer

Adds a userpools catalog entry, schema and AwsUserPoolsAdapter, verified
against Floci core:

- list/get/create/delete user pools; create can set email or username
  sign-in, a relaxed password policy for local test users, and a public
  app client (on by default) whose id is surfaced in the pool metadata
- users as the pool's child items: list (paged), get, create and delete;
  passwords are set permanent by default so users can sign in at once
- map Cognito's HTTP-400 error names (UserNotFoundException,
  UsernameExistsException, AliasExistsException, InvalidPasswordException)
- link Floci's cognito-idp service to the new page on the diagnostics page
This commit is contained in:
Zakaria
2026-10-23 12:12:40 -04:00
parent ece36e4a34
commit 4156afccfe
12 changed files with 930 additions and 5 deletions
@@ -0,0 +1,332 @@
import {
AdminCreateUserCommand,
AdminDeleteUserCommand,
AdminGetUserCommand,
AdminSetUserPasswordCommand,
CreateUserPoolClientCommand,
CreateUserPoolCommand,
DeleteUserPoolCommand,
DescribeUserPoolCommand,
ListUserPoolClientsCommand,
ListUserPoolsCommand,
ListUsersCommand,
type AttributeType,
type CognitoIdentityProviderClient,
type PasswordPolicyType,
type UserPoolClientDescription,
type UserPoolDescriptionType,
type UserPoolType,
type UserType,
} from '@aws-sdk/client-cognito-identity-provider'
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
USER_POOL_NAME_PATTERN,
USER_POOL_PASSWORD_POLICIES,
USER_POOL_SIGN_IN,
awsUserPoolsSchema,
} from '../cloud-spi/userPoolsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** Cognito caps every list page at 60. */
const COGNITO_PAGE_LIMIT = 60
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Talks to Cognito user pools — verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - A missing pool is `ResourceNotFoundException` and a missing user is
* `UserNotFoundException`, both with HTTP **400**; `get` matches on the name.
* - With email sign-in the stored `Username` is the generated `sub`, not the
* address, so users are keyed by `Username` and the email is read from the
* attributes. Admin calls accept either form.
* - An omitted password policy defaults to requiring upper, lower, number and
* symbol, matching AWS.
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
* password is permanent, so users created here can sign in immediately.
*
* Users are the pool's child items. The app client is created with the pool, as
* the console's create flow does, and its id is surfaced in the pool metadata.
*/
export class AwsUserPoolsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'userpools' as const
readonly items: ItemStoreAdapter
constructor(private readonly cognito: CognitoIdentityProviderClient) {
this.items = new UserStore(cognito)
}
schema(): ServiceSchema {
return awsUserPoolsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const pools: UserPoolDescriptionType[] = []
let nextToken: string | undefined
do {
const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
pools.push(...(res.UserPools ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
// ListUserPools omits the sign-in method, password policy and app clients the
// inspector shows, so each match is described. Pools are few on a local runtime.
const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search)
return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary))
}
async get(id: string): Promise<CloudResource | null> {
try {
const [pool, clients] = await Promise.all([
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
this.listClients(id),
])
return pool.UserPool ? toResource(pool.UserPool, clients) : null
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredString(input.values.name, 'name')
if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) {
throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -')
}
const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email'
const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default'
const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes'
const created = await this.cognito.send(new CreateUserPoolCommand({
PoolName: name,
...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}),
...(passwordPolicy === 'relaxed'
? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}
: {}),
}))
const id = created.UserPool?.Id
if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`)
if (createAppClient === 'yes') {
try {
await this.cognito.send(new CreateUserPoolClientCommand({
UserPoolId: id,
ClientName: `${name}-client`,
GenerateSecret: false,
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
}))
} catch (error) {
// Without its client the pool is not what was asked for; do not leave it half-made.
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined)
throw error
}
}
const pool = await this.get(id)
if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`)
return pool
}
async delete(id: string): Promise<void> {
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
}
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
const clients: UserPoolClientDescription[] = []
let nextToken: string | undefined
do {
const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
clients.push(...(res.UserPoolClients ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
return clients
}
}
class UserStore implements ItemStoreAdapter {
constructor(private readonly cognito: CognitoIdentityProviderClient) {}
async listItems(poolId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const res = await this.cognito.send(new ListUsersCommand({
UserPoolId: poolId,
Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT),
...(page.cursor ? {PaginationToken: page.cursor} : {}),
}))
return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null}
}
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
try {
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
/**
* Creates a user that can sign in straight away: the password is set as
* permanent unless `permanent` is false, in which case Cognito will demand a
* new one at first sign-in (`FORCE_CHANGE_PASSWORD`).
*/
async putItem(poolId: string, body: Record<string, unknown>): Promise<ChildItem> {
const email = optionalString(body.email, 'email')
if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address')
const username = optionalString(body.username, 'username') ?? email
if (!username) throw new ValidationError('Provide a username or an email address')
// Not trimmed: leading or trailing spaces are part of a password.
const password = body.password
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
const created = await this.cognito.send(new AdminCreateUserCommand({
UserPoolId: poolId,
Username: username,
UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [],
TemporaryPassword: password,
MessageAction: 'SUPPRESS',
}))
const storedUsername = created.User?.Username
if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`)
if (body.permanent !== false) {
await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true}))
}
const user = await this.getItem(poolId, storedUsername)
if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`)
return user
}
async deleteItem(poolId: string, username: string): Promise<void> {
await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username}))
}
}
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
if (!pool.Id) return []
return [{
id: pool.Id,
name: pool.Name ?? pool.Id,
cloud: 'aws',
service: 'userpools',
type: 'user-pool',
region: regionFromPoolId(pool.Id),
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
status: pool.Status ?? null,
metadata: {},
}]
}
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource {
const id = pool.Id ?? ''
const policy = pool.Policies?.PasswordPolicy
return {
id,
name: pool.Name ?? id,
cloud: 'aws',
service: 'userpools',
type: 'user-pool',
region: regionFromPoolId(id),
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
status: pool.Status ?? null,
metadata: {
arn: pool.Arn,
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
mfaConfiguration: pool.MfaConfiguration ?? null,
deletionProtection: pool.DeletionProtection ?? null,
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
},
}
}
function toUserItem(
username: string | undefined,
attributes: AttributeType[] | undefined,
user: Pick<UserType, 'Enabled' | 'UserStatus' | 'UserCreateDate' | 'UserLastModifiedDate'>,
): ChildItem[] {
if (!username) return []
const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : []))
return [{
id: username,
collectionId: null,
timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null,
body: {
username,
email: attributeMap.email ?? null,
emailVerified: attributeMap.email_verified === 'true',
status: user.UserStatus ?? null,
enabled: user.Enabled ?? true,
lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null,
attributes: attributeMap,
},
metadata: {},
}]
}
/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */
function describePasswordPolicy(policy: PasswordPolicyType): string {
const required = [
policy.RequireUppercase && 'uppercase',
policy.RequireLowercase && 'lowercase',
policy.RequireNumbers && 'number',
policy.RequireSymbols && 'symbol',
].filter((rule): rule is string => Boolean(rule))
const length = `${policy.MinimumLength ?? 8}+ characters`
return required.length ? `${length} with ${required.join(', ')}` : length
}
/** Pool ids are `<region>_<suffix>`. */
function regionFromPoolId(id: string): string | null {
const separator = id.indexOf('_')
return separator > 0 ? id.slice(0, separator) : null
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter(
(resource) =>
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
)
}
function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const name = (error as {name?: string}).name
return name === 'ResourceNotFoundException' || name === 'UserNotFoundException'
}
function requiredString(value: unknown, field: string): string {
const trimmed = optionalString(value, field)
if (!trimmed) throw new ValidationError(`${field} is required`)
return trimmed
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
return value.trim() || undefined
}
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T | undefined {
const text = optionalString(value, field)
if (text === undefined) return undefined
if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return text as T
}