feat(api): Cognito user actions, groups and test sign-in
Adds enable, disable, confirm, sign-out, password reset and set-password for one user; user group membership; group list, create and delete; and a test sign-in that returns decoded token claims. A refused test sign-in is an outcome rather than a 401, which would end the console session. Also adds a generic GET for one flat item and a schema deleteImpact so delete confirmations can say what else goes with a resource.
This commit is contained in:
@@ -1,17 +1,32 @@
|
||||
import {createHmac} from 'node:crypto'
|
||||
import {
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminConfirmSignUpCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminDisableUserCommand,
|
||||
AdminEnableUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminResetUserPasswordCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
AdminUserGlobalSignOutCommand,
|
||||
CreateGroupCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteGroupCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolClientCommand,
|
||||
DescribeUserPoolCommand,
|
||||
InitiateAuthCommand,
|
||||
ListGroupsCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type GroupType,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolDescriptionType,
|
||||
@@ -19,8 +34,9 @@ import {
|
||||
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 {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_GROUP_NAME_PATTERN,
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
@@ -30,8 +46,13 @@ import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
CreateUserPoolGroupInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** Cognito caps every list page at 60. */
|
||||
@@ -51,6 +72,9 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
* 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.
|
||||
* - 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.
|
||||
@@ -140,6 +164,126 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
|
||||
return listGroupNamesForUser(this.cognito, poolId, username)
|
||||
}
|
||||
|
||||
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
|
||||
const clients: UserPoolClientDescription[] = []
|
||||
let nextToken: string | undefined
|
||||
@@ -169,7 +313,9 @@ class UserStore implements ItemStoreAdapter {
|
||||
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
|
||||
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
|
||||
@@ -215,6 +361,53 @@ class UserStore implements ItemStoreAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 [{
|
||||
|
||||
Reference in New Issue
Block a user