473 lines
26 KiB
TypeScript
473 lines
26 KiB
TypeScript
import {createHmac} from 'node:crypto'
|
|
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
AdminAddUserToGroupCommand,
|
|
AdminConfirmSignUpCommand,
|
|
AdminDisableUserCommand,
|
|
AdminListGroupsForUserCommand,
|
|
AdminRemoveUserFromGroupCommand,
|
|
AdminUserGlobalSignOutCommand,
|
|
CreateGroupCommand,
|
|
DeleteGroupCommand,
|
|
DescribeUserPoolClientCommand,
|
|
InitiateAuthCommand,
|
|
ListGroupsCommand,
|
|
AdminCreateUserCommand,
|
|
AdminDeleteUserCommand,
|
|
AdminGetUserCommand,
|
|
AdminSetUserPasswordCommand,
|
|
CreateUserPoolClientCommand,
|
|
CreateUserPoolCommand,
|
|
DeleteUserPoolCommand,
|
|
DescribeUserPoolCommand,
|
|
ListUserPoolClientsCommand,
|
|
ListUserPoolsCommand,
|
|
ListUsersCommand,
|
|
UpdateUserPoolClientCommand,
|
|
type CognitoIdentityProviderClient,
|
|
} from '@aws-sdk/client-cognito-identity-provider'
|
|
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
|
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
|
|
|
type SendResult = Record<string, unknown>
|
|
|
|
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
const sent: object[] = []
|
|
const client = {
|
|
async send(command: object) {
|
|
sent.push(command)
|
|
return handler(command)
|
|
},
|
|
} as unknown as CognitoIdentityProviderClient
|
|
return {client, sent}
|
|
}
|
|
|
|
function awsError(name: string): Error {
|
|
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}})
|
|
}
|
|
|
|
const POOL_ID = 'us-east-1_8e65d229a'
|
|
const CREATED = new Date('2026-10-23T16:01:22.000Z')
|
|
|
|
const pool = {
|
|
Id: POOL_ID,
|
|
Name: 'orders-users',
|
|
Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`,
|
|
Status: 'Enabled',
|
|
CreationDate: CREATED,
|
|
UsernameAttributes: ['email'],
|
|
EstimatedNumberOfUsers: 1,
|
|
MfaConfiguration: 'OFF',
|
|
DeletionProtection: 'INACTIVE',
|
|
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}},
|
|
}
|
|
|
|
const alice = {
|
|
Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436',
|
|
Attributes: [
|
|
{Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'},
|
|
{Name: 'email', Value: 'alice@example.com'},
|
|
{Name: 'email_verified', Value: 'true'},
|
|
],
|
|
UserCreateDate: CREATED,
|
|
UserLastModifiedDate: CREATED,
|
|
Enabled: true,
|
|
UserStatus: 'CONFIRMED',
|
|
}
|
|
|
|
function runtimeStub() {
|
|
return stubCognito((command) => {
|
|
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
|
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]}
|
|
if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}}
|
|
if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes}
|
|
return {}
|
|
})
|
|
}
|
|
|
|
describe('AwsUserPoolsAdapter', () => {
|
|
test('lists every page of user pools and filters by name or id', async () => {
|
|
const {client, sent} = stubCognito((command) => {
|
|
if (!(command instanceof ListUserPoolsCommand)) return {}
|
|
return command.input.NextToken
|
|
? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]}
|
|
: {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'}
|
|
})
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second'])
|
|
expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2)
|
|
expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing'])
|
|
expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()})
|
|
})
|
|
|
|
test('lists pools with the details the inspector shows', async () => {
|
|
const {client} = stubCognito((command) => {
|
|
if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]}
|
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
|
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]}
|
|
return {}
|
|
})
|
|
|
|
const [listed] = await new AwsUserPoolsAdapter(client).list()
|
|
expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]})
|
|
})
|
|
|
|
test('describes a pool with its sign-in method, password policy and app clients', async () => {
|
|
const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID)
|
|
|
|
expect(resource?.metadata).toEqual({
|
|
arn: pool.Arn,
|
|
signInWith: 'email',
|
|
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
|
|
estimatedNumberOfUsers: 1,
|
|
userCount: 0,
|
|
mfaConfiguration: 'OFF',
|
|
deletionProtection: 'INACTIVE',
|
|
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
|
lambdaTriggers: [],
|
|
})
|
|
})
|
|
|
|
test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => {
|
|
const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}`
|
|
const {client} = stubCognito((command) => {
|
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: {
|
|
PreTokenGeneration: arn('claims'),
|
|
PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'},
|
|
PreSignUp: `${arn('signup')}:live`,
|
|
CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'},
|
|
KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1',
|
|
}}}
|
|
return {}
|
|
})
|
|
|
|
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
|
expect(resource?.metadata.lambdaTriggers).toEqual([
|
|
{trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null},
|
|
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'},
|
|
{trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'},
|
|
])
|
|
})
|
|
|
|
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
|
|
const {client} = stubCognito((command) => {
|
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
|
|
if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'}
|
|
return {}
|
|
})
|
|
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
|
|
|
expect(resource?.metadata.userCount).toBe(3)
|
|
expect(resource?.metadata.estimatedNumberOfUsers).toBe(0)
|
|
})
|
|
|
|
test('summarises a relaxed password policy without character rules', async () => {
|
|
const {client} = stubCognito((command) => {
|
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
|
|
return {}
|
|
})
|
|
|
|
expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters')
|
|
})
|
|
|
|
test('returns null for a pool the runtime does not know', async () => {
|
|
const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') })
|
|
|
|
expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull()
|
|
})
|
|
|
|
test('creates an email pool with a public app client by default', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})
|
|
|
|
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
|
expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']})
|
|
const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand
|
|
expect(createClient.input).toEqual({
|
|
UserPoolId: POOL_ID,
|
|
ClientName: 'orders-users-client',
|
|
GenerateSecret: false,
|
|
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
|
})
|
|
})
|
|
|
|
test('creates a username pool with a relaxed password policy and no app client', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}})
|
|
|
|
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
|
expect(createPool.input).toEqual({
|
|
PoolName: 'testers',
|
|
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}},
|
|
})
|
|
expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false)
|
|
})
|
|
|
|
test('removes the new pool when its app client cannot be created', async () => {
|
|
const {client, sent} = stubCognito((command) => {
|
|
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
|
if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException')
|
|
return {}
|
|
})
|
|
|
|
await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException')
|
|
expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
|
})
|
|
|
|
test('rejects invalid create input before calling the runtime', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError)
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test('deletes a pool by id', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
await new AwsUserPoolsAdapter(client).delete(POOL_ID)
|
|
|
|
expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand)
|
|
expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
|
})
|
|
})
|
|
|
|
describe('AwsUserPoolsAdapter users', () => {
|
|
test('lists users as items keyed by username with their email and status', async () => {
|
|
const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'}))
|
|
const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'})
|
|
|
|
expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'})
|
|
expect(page.nextCursor).toBe('next-page')
|
|
expect(page.items).toEqual([{
|
|
id: alice.Username,
|
|
collectionId: null,
|
|
timestamp: CREATED.toISOString(),
|
|
body: {
|
|
username: alice.Username,
|
|
email: 'alice@example.com',
|
|
emailVerified: true,
|
|
status: 'CONFIRMED',
|
|
enabled: true,
|
|
lastModifiedAt: CREATED.toISOString(),
|
|
attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'},
|
|
},
|
|
metadata: {},
|
|
}])
|
|
})
|
|
|
|
test('creates a user whose password is permanent so it can sign in at once', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '})
|
|
|
|
expect((sent[0] as AdminCreateUserCommand).input).toEqual({
|
|
UserPoolId: POOL_ID,
|
|
Username: 'alice@example.com',
|
|
UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}],
|
|
TemporaryPassword: ' Passw0rd! ',
|
|
MessageAction: 'SUPPRESS',
|
|
})
|
|
expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true})
|
|
expect(user.body.status).toBe('CONFIRMED')
|
|
})
|
|
|
|
test('leaves the password temporary when asked', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
|
|
|
expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob')
|
|
expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([])
|
|
expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false)
|
|
})
|
|
|
|
test('rejects a user without a name, password or valid email', async () => {
|
|
const {client, sent} = runtimeStub()
|
|
const users = new AwsUserPoolsAdapter(client).items
|
|
|
|
await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address')
|
|
await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required')
|
|
await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address')
|
|
await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false')
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test('returns null for a missing user and deletes by username', async () => {
|
|
const missing = stubCognito(() => { throw awsError('UserNotFoundException') })
|
|
expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull()
|
|
|
|
const {client, sent} = runtimeStub()
|
|
await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username)
|
|
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
|
|
})
|
|
})
|
|
|
|
function jwt(claims: Record<string, unknown>): string {
|
|
return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.')
|
|
}
|
|
|
|
describe('AwsUserPoolsAdapter user actions and groups', () => {
|
|
test('reads a user with the groups it belongs to', async () => {
|
|
const {client, sent} = stubCognito((command) => {
|
|
if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED}
|
|
if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]}
|
|
return {}
|
|
})
|
|
const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username)
|
|
|
|
expect(user?.body.groups).toEqual(['admins'])
|
|
expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username})
|
|
})
|
|
|
|
test('runs each user action as its Admin call', async () => {
|
|
const {client, sent} = stubCognito(() => ({}))
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'})
|
|
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})
|
|
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'})
|
|
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '})
|
|
|
|
expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand)
|
|
expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand)
|
|
expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand)
|
|
expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true})
|
|
})
|
|
|
|
test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => {
|
|
const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') })
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError)
|
|
})
|
|
|
|
test('sets a user\'s groups by adding and removing only the difference', async () => {
|
|
const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {})
|
|
await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins'])
|
|
|
|
const changes = sent.slice(1)
|
|
expect(changes).toHaveLength(2)
|
|
expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand)
|
|
expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins')
|
|
expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand)
|
|
expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta')
|
|
})
|
|
|
|
test('lists, creates and deletes groups', async () => {
|
|
const {client, sent} = stubCognito((command) => {
|
|
if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]}
|
|
if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}}
|
|
return {}
|
|
})
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}])
|
|
expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5})
|
|
await adapter.deleteUserPoolGroup(POOL_ID, 'editors')
|
|
|
|
expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5})
|
|
expect(sent[2]).toBeInstanceOf(DeleteGroupCommand)
|
|
})
|
|
|
|
test('validates a group before creating it', async () => {
|
|
const {client, sent} = stubCognito(() => ({}))
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
|
|
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError)
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => {
|
|
const {client, sent} = stubCognito((command) => {
|
|
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}}
|
|
if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}}
|
|
return {}
|
|
})
|
|
const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'})
|
|
|
|
expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}})
|
|
const auth = (sent[1] as InitiateAuthCommand).input
|
|
expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH')
|
|
expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64'))
|
|
})
|
|
|
|
test('test sign-in reports a wrong password or a challenge as its outcome', async () => {
|
|
const refusing = stubCognito((command) => {
|
|
if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'})
|
|
return {UserPoolClient: {ClientId: 'client-1'}}
|
|
})
|
|
expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'}))
|
|
.toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
|
expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined()
|
|
|
|
const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}})
|
|
expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'}))
|
|
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
|
|
})
|
|
})
|
|
|
|
describe('app client OAuth settings', () => {
|
|
const CLIENT_ID = '40808d60e188483d876bbbf646'
|
|
const stored = {
|
|
UserPoolId: POOL_ID, ClientId: CLIENT_ID, ClientName: 'notes-demo-web',
|
|
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
|
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
|
SupportedIdentityProviders: ['COGNITO'],
|
|
}
|
|
|
|
function clientStub() {
|
|
return stubCognito((command) => {
|
|
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: stored}
|
|
if (command instanceof UpdateUserPoolClientCommand) return {UserPoolClient: {...stored, ...command.input}}
|
|
return {}
|
|
})
|
|
}
|
|
|
|
test('reads a client with no OAuth set up', async () => {
|
|
const settings = await new AwsUserPoolsAdapter(clientStub().client).describeUserPoolClient(POOL_ID, CLIENT_ID)
|
|
expect(settings).toEqual({
|
|
clientId: CLIENT_ID, clientName: 'notes-demo-web', hasSecret: false,
|
|
explicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
|
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO'],
|
|
})
|
|
})
|
|
|
|
test('turns on the code flow and sends the client\'s other settings back unchanged', async () => {
|
|
const {client, sent} = clientStub()
|
|
const settings = await new AwsUserPoolsAdapter(client).updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {
|
|
callbackUrls: ['http://localhost:4600/api/auth/callback', ' http://localhost:4600/api/auth/callback '],
|
|
logoutUrls: ['http://localhost:4600/'],
|
|
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
|
|
})
|
|
|
|
const update = sent.find((command) => command instanceof UpdateUserPoolClientCommand) as UpdateUserPoolClientCommand
|
|
expect(update.input).toMatchObject({
|
|
ClientName: 'notes-demo-web', ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
|
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
|
CallbackURLs: ['http://localhost:4600/api/auth/callback'], LogoutURLs: ['http://localhost:4600/'],
|
|
AllowedOAuthFlows: ['code'], AllowedOAuthScopes: ['openid', 'email'], AllowedOAuthFlowsUserPoolClient: true,
|
|
SupportedIdentityProviders: ['COGNITO'],
|
|
})
|
|
expect(settings.oauthFlows).toEqual(['code'])
|
|
})
|
|
|
|
test('applies Cognito\'s rules before calling it', async () => {
|
|
const {client, sent} = clientStub()
|
|
const adapter = new AwsUserPoolsAdapter(client)
|
|
const valid = {callbackUrls: ['https://app.example/callback'], logoutUrls: [], oauthFlows: ['code' as const], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
|
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['http://app.example/callback']})).rejects.toThrow(/https/)
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['https://app.example/#done']})).rejects.toThrow(/fragment/)
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: []})).rejects.toThrow(/callback URL/)
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthScopes: []})).rejects.toThrow(/scope/)
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['code', 'client_credentials']})).rejects.toThrow(ValidationError)
|
|
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['client_credentials']})).rejects.toThrow(/secret/)
|
|
expect(sent.some((command) => command instanceof UpdateUserPoolClientCommand)).toBe(false)
|
|
})
|
|
})
|