Files
floci-ui/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts
T
2026-10-24 11:17:42 -04:00

690 lines
32 KiB
TypeScript

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<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, 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<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}))
}
async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
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<void> {
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<UserPoolGroup[]> {
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<UserPoolGroup> {
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<void> {
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<UserPoolSignInTestResult> {
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<UserPoolClientSettings> {
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<UserPoolClientSettings> {
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<UserPoolClientType> {
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<string[]> {
return listGroupNamesForUser(this.cognito, poolId, username)
}
private async countUsers(poolId: string): Promise<number> {
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<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}))
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<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}))
}
}
/** 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<string[]> {
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<string, unknown> {
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<string, unknown>
}
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<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
}