import {createHmac} from 'node:crypto' import { AdminAddUserToGroupCommand, AdminConfirmSignUpCommand, AdminCreateUserCommand, AdminDeleteUserCommand, AdminDisableUserCommand, AdminEnableUserCommand, AdminGetUserCommand, AdminListGroupsForUserCommand, AdminRemoveUserFromGroupCommand, AdminResetUserPasswordCommand, AdminSetUserPasswordCommand, AdminUserGlobalSignOutCommand, CreateGroupCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, DeleteGroupCommand, DeleteUserPoolCommand, DescribeUserPoolClientCommand, DescribeUserPoolCommand, InitiateAuthCommand, ListGroupsCommand, ListUserPoolClientsCommand, ListUserPoolsCommand, ListUsersCommand, type AttributeType, type CognitoIdentityProviderClient, type GroupType, 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 {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors' import { USER_POOL_GROUP_NAME_PATTERN, USER_POOL_NAME_PATTERN, USER_POOL_PASSWORD_POLICIES, USER_POOL_SIGN_IN, awsUserPoolsSchema, } from '../cloud-spi/userPoolsSchema' import type { CloudResource, CloudServiceAdapter, CreateResourceInput, CreateUserPoolGroupInput, ResourceQuery, ServiceSchema, UserPoolGroup, UserPoolSignInTestInput, UserPoolSignInTestResult, UserPoolUserAction, } from '../cloud-spi/types' /** Cognito caps every list page at 60. */ const COGNITO_PAGE_LIMIT = 60 /** Counting stops here so a very large pool cannot stall the pool list. */ const USER_COUNT_CAP = 1000 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. * - `EstimatedNumberOfUsers` stays 0 however many users exist (AWS also only * updates it periodically), so `userCount` is counted with `ListUsers`. * - An admin action the user's state does not allow (confirming a confirmed * user) is `NotAuthorizedException`, surfaced here as a conflict because the * console caller is authorised and a 401 would end the console session. * * 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 { 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 { try { const [pool, clients, userCount] = await Promise.all([ this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})), this.listClients(id), this.countUsers(id), ]) return pool.UserPool ? toResource(pool.UserPool, clients, userCount) : null } catch (error) { if (isMissing(error)) return null throw error } } async create(input: CreateResourceInput): Promise { 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 { await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})) } async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise { const target = {UserPoolId: poolId, Username: username} const action = (input as {action?: unknown} | null)?.action const command = (() => { switch (action) { case 'enable': return new AdminEnableUserCommand(target) case 'disable': return new AdminDisableUserCommand(target) case 'confirm': return new AdminConfirmSignUpCommand(target) case 'resetPassword': return new AdminResetUserPasswordCommand(target) case 'signOut': return new AdminUserGlobalSignOutCommand(target) case 'setPassword': { const {password, permanent} = input as {password?: unknown; permanent?: unknown} if (typeof password !== 'string' || !password) throw new ValidationError('password is required') if (permanent !== undefined && typeof permanent !== 'boolean') throw new ValidationError('permanent must be true or false') return new AdminSetUserPasswordCommand({...target, Password: password, Permanent: permanent ?? true}) } default: throw new ValidationError('action must be one of enable, disable, confirm, resetPassword, signOut, setPassword') } })() try { await this.cognito.send(command as AdminEnableUserCommand) } catch (error) { throw asConflict(error) } } /** Makes the user's groups exactly `groups`, adding and removing only the difference. */ async setUserPoolUserGroups(poolId: string, username: string, groups: string[]): Promise { if (!Array.isArray(groups) || groups.some((group) => typeof group !== 'string' || !group)) { throw new ValidationError('groups must be a list of group names') } const wanted = new Set(groups) const current = new Set(await this.listGroupsForUser(poolId, username)) for (const group of wanted) { if (!current.has(group)) await this.cognito.send(new AdminAddUserToGroupCommand({UserPoolId: poolId, Username: username, GroupName: group})) } for (const group of current) { if (!wanted.has(group)) await this.cognito.send(new AdminRemoveUserFromGroupCommand({UserPoolId: poolId, Username: username, GroupName: group})) } } async listUserPoolGroups(poolId: string): Promise { const groups: GroupType[] = [] let nextToken: string | undefined do { const res = await this.cognito.send(new ListGroupsCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) groups.push(...(res.Groups ?? [])) nextToken = res.NextToken || undefined } while (nextToken) return groups.flatMap(toGroup) } async createUserPoolGroup(poolId: string, input: CreateUserPoolGroupInput): Promise { const name = requiredString(input?.name, 'name') if (!new RegExp(USER_POOL_GROUP_NAME_PATTERN, 'u').test(name)) throw new ValidationError('name must be up to 128 characters with no spaces') const description = optionalString(input.description, 'description') if (description && description.length > 2048) throw new ValidationError('description must be 2048 characters or fewer') const precedence = input.precedence if (precedence !== undefined && precedence !== null && (!Number.isInteger(precedence) || precedence < 0)) { throw new ValidationError('precedence must be a whole number of 0 or more') } const res = await this.cognito.send(new CreateGroupCommand({ UserPoolId: poolId, GroupName: name, ...(description ? {Description: description} : {}), ...(typeof precedence === 'number' ? {Precedence: precedence} : {}), })) const group = res.Group ? toGroup(res.Group)[0] : undefined if (!group) throw new RuntimeError(`Cognito did not return the group ${name} created in ${poolId}`) return group } async deleteUserPoolGroup(poolId: string, groupName: string): Promise { await this.cognito.send(new DeleteGroupCommand({UserPoolId: poolId, GroupName: groupName})) } /** * Signs in the way an app would (`InitiateAuth`, `USER_PASSWORD_AUTH`) and * returns the decoded claims. A refused sign-in is a result, not an error: * the wrong password is exactly what this is for trying. */ async testUserPoolSignIn(poolId: string, input: UserPoolSignInTestInput): Promise { const clientId = requiredString(input?.clientId, 'clientId') const username = requiredString(input.username, 'username') if (typeof input.password !== 'string' || !input.password) throw new ValidationError('password is required') const client = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: clientId})) const secret = client.UserPoolClient?.ClientSecret try { const res = await this.cognito.send(new InitiateAuthCommand({ ClientId: clientId, AuthFlow: 'USER_PASSWORD_AUTH', AuthParameters: { USERNAME: username, PASSWORD: input.password, ...(secret ? {SECRET_HASH: secretHash(username, clientId, secret)} : {}), }, })) if (res.ChallengeName) return {outcome: 'challenge', challengeName: res.ChallengeName} const result = res.AuthenticationResult if (!result?.IdToken || !result.AccessToken) throw new RuntimeError('Cognito returned no tokens') return { outcome: 'signed-in', expiresIn: result.ExpiresIn ?? null, idToken: decodeJwtPayload(result.IdToken), accessToken: decodeJwtPayload(result.AccessToken), } } catch (error) { const name = (error as {name?: string} | null)?.name ?? '' if (SIGN_IN_REFUSALS.has(name)) return {outcome: 'rejected', code: name, message: (error as Error).message} throw error } } private async listGroupsForUser(poolId: string, username: string): Promise { return listGroupNamesForUser(this.cognito, poolId, username) } private async countUsers(poolId: string): Promise { let count = 0 let token: string | undefined do { const res = await this.cognito.send(new ListUsersCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(token ? {PaginationToken: token} : {})})) count += res.Users?.length ?? 0 token = res.PaginationToken || undefined } while (token && count < USER_COUNT_CAP) return Math.min(count, USER_COUNT_CAP) } private async listClients(poolId: string): Promise { 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> { 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 { try { const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username})) const item = toUserItem(user.Username, user.UserAttributes, user)[0] if (!item) return null return {...item, body: {...item.body, groups: await listGroupNamesForUser(this.cognito, poolId, item.id)}} } 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): Promise { 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 { await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username})) } } /** Refusals a test sign-in reports as its outcome rather than failing. */ const SIGN_IN_REFUSALS = new Set([ 'NotAuthorizedException', 'UserNotFoundException', 'UserNotConfirmedException', 'PasswordResetRequiredException', 'InvalidParameterException', ]) async function listGroupNamesForUser(cognito: CognitoIdentityProviderClient, poolId: string, username: string): Promise { const names: string[] = [] let nextToken: string | undefined do { const res = await cognito.send(new AdminListGroupsForUserCommand({UserPoolId: poolId, Username: username, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})})) names.push(...(res.Groups ?? []).flatMap((group) => group.GroupName ? [group.GroupName] : [])) nextToken = res.NextToken || undefined } while (nextToken) return names } function toGroup(group: GroupType): UserPoolGroup[] { if (!group.GroupName) return [] return [{ name: group.GroupName, description: group.Description ?? null, precedence: group.Precedence ?? null, createdAt: group.CreationDate ? group.CreationDate.toISOString() : null, }] } /** Required by app clients that have a secret: Base64(HMAC-SHA256(secret, username + clientId)). */ function secretHash(username: string, clientId: string, secret: string): string { return createHmac('sha256', secret).update(username + clientId).digest('base64') } /** The token came straight from Cognito, so only its payload is read; nothing is trusted from it. */ function decodeJwtPayload(token: string): Record { const payload = token.split('.')[1] if (!payload) throw new RuntimeError('Cognito returned a malformed token') return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record } function asConflict(error: unknown): unknown { if ((error as {name?: string} | null)?.name !== 'NotAuthorizedException') return error return new ConflictError((error as Error).message || 'The user is not in a state that allows this') } 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[], userCount: number): 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, userCount, 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, ): 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 `_`. */ 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(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 }