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, UpdateUserPoolClientCommand, type AttributeType, type CognitoIdentityProviderClient, type GroupType, type PasswordPolicyType, type UserPoolClientDescription, type UserPoolClientType, 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_OAUTH_FLOWS, USER_POOL_PASSWORD_POLICIES, USER_POOL_SIGN_IN, awsUserPoolsSchema, } from '../cloud-spi/userPoolsSchema' import type { CloudResource, CloudServiceAdapter, CreateResourceInput, CreateUserPoolGroupInput, ResourceQuery, ServiceSchema, UpdateUserPoolClientOAuthInput, UserPoolClientSettings, UserPoolGroup, UserPoolOAuthFlow, 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 } } async describeUserPoolClient(poolId: string, clientId: string): Promise { return toClientSettings(await this.fetchClient(poolId, clientId)) } /** * Changes the settings the hosted login and OAuth endpoints use. UpdateUserPoolClient * resets every setting it is not given, so the client's other settings are sent back as they are. */ async updateUserPoolClientOAuth(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise { const settings = validateOAuthSettings(input) const current = await this.fetchClient(poolId, clientId) if (settings.oauthFlows.includes('client_credentials') && !current.ClientSecret) { throw new ValidationError('The client_credentials flow needs an app client with a secret') } const res = await this.cognito.send(new UpdateUserPoolClientCommand({ UserPoolId: poolId, ClientId: clientId, ClientName: current.ClientName, RefreshTokenValidity: current.RefreshTokenValidity, AccessTokenValidity: current.AccessTokenValidity, IdTokenValidity: current.IdTokenValidity, TokenValidityUnits: current.TokenValidityUnits, ReadAttributes: current.ReadAttributes, WriteAttributes: current.WriteAttributes, ExplicitAuthFlows: current.ExplicitAuthFlows, DefaultRedirectURI: current.DefaultRedirectURI && settings.callbackUrls.includes(current.DefaultRedirectURI) ? current.DefaultRedirectURI : undefined, AnalyticsConfiguration: current.AnalyticsConfiguration, PreventUserExistenceErrors: current.PreventUserExistenceErrors, EnableTokenRevocation: current.EnableTokenRevocation, EnablePropagateAdditionalUserContextData: current.EnablePropagateAdditionalUserContextData, AuthSessionValidity: current.AuthSessionValidity, RefreshTokenRotation: current.RefreshTokenRotation, CallbackURLs: settings.callbackUrls, LogoutURLs: settings.logoutUrls, AllowedOAuthFlows: settings.oauthFlows, AllowedOAuthScopes: settings.oauthScopes, AllowedOAuthFlowsUserPoolClient: settings.oauthFlows.length > 0, SupportedIdentityProviders: settings.identityProviders, })) return toClientSettings(res.UserPoolClient ?? current) } private async fetchClient(poolId: string, clientId: string): Promise { const res = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: requiredString(clientId, 'clientId')})) if (!res.UserPoolClient) throw new RuntimeError(`Cognito returned no app client ${clientId}`) return res.UserPoolClient } 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 ?? ''})), lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig), }, } } // Labels as the AWS console names them, in the order a sign-up and sign-in reach them. const LAMBDA_TRIGGERS = [ ['PreSignUp', 'Pre sign-up'], ['UserMigration', 'User migration'], ['CustomMessage', 'Custom message'], ['PostConfirmation', 'Post confirmation'], ['PreAuthentication', 'Pre authentication'], ['DefineAuthChallenge', 'Define auth challenge'], ['CreateAuthChallenge', 'Create auth challenge'], ['VerifyAuthChallengeResponse', 'Verify auth challenge response'], ['PostAuthentication', 'Post authentication'], ['PreTokenGeneration', 'Pre token generation'], ['CustomEmailSender', 'Custom email sender'], ['CustomSMSSender', 'Custom SMS sender'], ] as const type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null} function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] { if (!config) return [] return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => { // PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field. const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig : trigger === 'CustomEmailSender' ? config.CustomEmailSender : trigger === 'CustomSMSSender' ? config.CustomSMSSender : undefined const functionArn = versioned?.LambdaArn ?? config[trigger] if (typeof functionArn !== 'string' || !functionArn) return [] return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}] }) } function toClientSettings(client: UserPoolClientType): UserPoolClientSettings { return { clientId: client.ClientId ?? '', clientName: client.ClientName ?? '', hasSecret: Boolean(client.ClientSecret), explicitAuthFlows: client.ExplicitAuthFlows ?? [], callbackUrls: client.CallbackURLs ?? [], logoutUrls: client.LogoutURLs ?? [], oauthFlows: client.AllowedOAuthFlowsUserPoolClient ? (client.AllowedOAuthFlows ?? []).filter(isOAuthFlow) : [], oauthScopes: client.AllowedOAuthScopes ?? [], identityProviders: client.SupportedIdentityProviders ?? [], } } function isOAuthFlow(value: string): value is UserPoolOAuthFlow { return (USER_POOL_OAUTH_FLOWS as readonly string[]).includes(value) } /** Cognito's own rules for these settings, checked here so the form can say what is wrong. */ function validateOAuthSettings(input: UpdateUserPoolClientOAuthInput): UpdateUserPoolClientOAuthInput { const callbackUrls = urlList(input?.callbackUrls, 'Callback URL') const logoutUrls = urlList(input.logoutUrls, 'Sign-out URL') const oauthFlows = stringList(input.oauthFlows, 'oauthFlows') const unknown = oauthFlows.find((flow) => !isOAuthFlow(flow)) if (unknown) throw new ValidationError(`Unknown OAuth flow ${unknown}`) if (oauthFlows.includes('client_credentials') && oauthFlows.length > 1) { throw new ValidationError('client_credentials cannot be combined with the code or implicit flows') } if ((oauthFlows.includes('code') || oauthFlows.includes('implicit')) && callbackUrls.length === 0) { throw new ValidationError('Add at least one callback URL for the code or implicit flow') } const oauthScopes = stringList(input.oauthScopes, 'oauthScopes') if (oauthFlows.length > 0 && oauthScopes.length === 0) throw new ValidationError('Choose at least one OAuth scope') const identityProviders = stringList(input.identityProviders, 'identityProviders') return {callbackUrls, logoutUrls, oauthFlows: oauthFlows as UserPoolOAuthFlow[], oauthScopes, identityProviders} } function stringList(value: unknown, field: string): string[] { if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new ValidationError(`${field} must be a list of strings`) return [...new Set(value.map((entry: string) => entry.trim()).filter(Boolean))] } // Cognito accepts https URLs, and http only for localhost; fragments are never allowed. function urlList(value: unknown, label: string): string[] { return stringList(value, label).map((entry) => { let url: URL try { url = new URL(entry) } catch { throw new ValidationError(`${label} ${entry} is not a valid URL`) } const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) { throw new ValidationError(`${label} ${entry} must use https (http is allowed only for localhost)`) } if (url.hash || entry.includes('#')) throw new ValidationError(`${label} ${entry} must not contain a fragment`) return entry }) } 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 }