690 lines
32 KiB
TypeScript
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
|
|
}
|