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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user