diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 9056a11..99aedc1 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3,7 +3,7 @@ ## cognito-idp
- 24% implemented + 26% implemented - [ ] AddCustomAttributes - [ ] AdminAddUserToGroup @@ -41,8 +41,8 @@ - [ ] CreateIdentityProvider - [ ] CreateResourceServer - [ ] CreateUserImportJob -- [ ] CreateUserPool -- [ ] CreateUserPoolClient +- [x] CreateUserPool +- [x] CreateUserPoolClient - [ ] CreateUserPoolDomain - [ ] DeleteGroup - [ ] DeleteIdentityProvider diff --git a/server/api/controller.ts b/server/api/controller.ts index 9b6a9f8..ca01f8a 100644 --- a/server/api/controller.ts +++ b/server/api/controller.ts @@ -25,6 +25,8 @@ const useCases: { 'AWSCognitoIdentityProviderService.RevokeToken': authUseCase.revokeToken, 'AWSCognitoIdentityProviderService.ResendConfirmationCode': signUpUseCase.resendConfirmationCode, 'AWSCognitoIdentityProviderService.ListUserPools': userPoolUseCase.listUserPools, + 'AWSCognitoIdentityProviderService.CreateUserPool': userPoolUseCase.createUserPool, + 'AWSCognitoIdentityProviderService.CreateUserPoolClient': userPoolUseCase.createUserPoolClient, 'AWSCognitoIdentityProviderService.ListUsers': authUseCase.listUsers, 'AWSCognitoIdentityProviderService.AdminGetUser': adminUseCase.getUser, 'AWSCognitoIdentityProviderService.AdminCreateUser': adminUseCase.createUser, diff --git a/server/common/types/auth.ts b/server/common/types/auth.ts index 0d9fdec..3cfb5b0 100644 --- a/server/common/types/auth.ts +++ b/server/common/types/auth.ts @@ -15,6 +15,10 @@ import type { AssociateSoftwareTokenRequest, AssociateSoftwareTokenResponse, CodeDeliveryDetailsType, + CreateUserPoolClientRequest, + CreateUserPoolClientResponse, + CreateUserPoolRequest, + CreateUserPoolResponse, DeleteUserAttributesRequest, DeleteUserAttributesResponse, GetUserResponse, @@ -61,6 +65,13 @@ export type ListUsersTarget = TargetBody; export type ListUserPoolsTarget = TargetBody; +export type CreateUserPoolTarget = TargetBody; + +export type CreateUserPoolClientTarget = TargetBody< + CreateUserPoolClientRequest, + CreateUserPoolClientResponse +>; + export type AdminGetUserTarget = TargetBody; export type AdminCreateUserTarget = TargetBody; @@ -147,6 +158,8 @@ export type AmzTargets = { 'AWSCognitoIdentityProviderService.ResendConfirmationCode': ResendConfirmationCodeTarget; 'AWSCognitoIdentityProviderService.ListUsers': ListUsersTarget; 'AWSCognitoIdentityProviderService.ListUserPools': ListUserPoolsTarget; + 'AWSCognitoIdentityProviderService.CreateUserPool': CreateUserPoolTarget; + 'AWSCognitoIdentityProviderService.CreateUserPoolClient': CreateUserPoolClientTarget; 'AWSCognitoIdentityProviderService.AdminGetUser': AdminGetUserTarget; 'AWSCognitoIdentityProviderService.AdminCreateUser': AdminCreateUserTarget; 'AWSCognitoIdentityProviderService.AdminDeleteUser': AdminDeleteUserTarget; diff --git a/server/common/types/userPool.ts b/server/common/types/userPool.ts index 1d42abc..191de2c 100644 --- a/server/common/types/userPool.ts +++ b/server/common/types/userPool.ts @@ -4,6 +4,7 @@ export type Jwks = { keys: [{ kid: string; alg: string }] }; export type UserPoolEntity = { id: EntityId['userPool']; + name: string; privateKey: string; createdTime: number; }; @@ -11,5 +12,6 @@ export type UserPoolEntity = { export type UserPoolClientEntity = { id: EntityId['userPoolClient']; userPoolId: EntityId['userPool']; + name: string; createdTime: number; }; diff --git a/server/domain/userPool/model/userPoolMethod.ts b/server/domain/userPool/model/userPoolMethod.ts index 50dd8f0..dc64edf 100644 --- a/server/domain/userPool/model/userPoolMethod.ts +++ b/server/domain/userPool/model/userPoolMethod.ts @@ -1,15 +1,25 @@ import type { EntityId } from 'common/types/brandedId'; import type { UserPoolClientEntity, UserPoolEntity } from 'common/types/userPool'; +import { randomUUID } from 'crypto'; +import { brandedId } from 'service/brandedId'; +import { createShortHash } from 'service/createShortHash'; +import { REGION } from 'service/envValues'; import { genPrivatekey } from 'service/privateKey'; export const userPoolMethod = { - create: (val: { id: EntityId['userPool'] }): UserPoolEntity => ({ - id: val.id, + create: (val: { id?: EntityId['userPool']; name: string }): UserPoolEntity => ({ + id: brandedId.userPool.entity.parse(`${REGION}_${createShortHash(randomUUID())}`), + ...val, privateKey: genPrivatekey(), createdTime: Date.now(), }), createClient: (val: { - id: EntityId['userPoolClient']; + id?: EntityId['userPoolClient']; + name: string; userPoolId: EntityId['userPool']; - }): UserPoolClientEntity => ({ id: val.id, userPoolId: val.userPoolId, createdTime: Date.now() }), + }): UserPoolClientEntity => ({ + id: brandedId.userPoolClient.entity.parse(randomUUID().replace(/-/g, '')), + ...val, + createdTime: Date.now(), + }), }; diff --git a/server/domain/userPool/repository/toUserPoolEntity.ts b/server/domain/userPool/repository/toUserPoolEntity.ts index 2b6bc5b..eb152ac 100644 --- a/server/domain/userPool/repository/toUserPoolEntity.ts +++ b/server/domain/userPool/repository/toUserPoolEntity.ts @@ -1,19 +1,26 @@ import type { UserPool, UserPoolClient } from '@prisma/client'; +import assert from 'assert'; import type { UserPoolClientEntity, UserPoolEntity } from 'common/types/userPool'; import { brandedId } from 'service/brandedId'; export const toUserPoolEntity = (prismaPool: UserPool): UserPoolEntity => { + assert(prismaPool.name); + return { id: brandedId.userPool.entity.parse(prismaPool.id), + name: prismaPool.name, privateKey: prismaPool.privateKey, createdTime: prismaPool.createdAt.getTime(), }; }; export const toUserPoolClientEntity = (prismaPoolClient: UserPoolClient): UserPoolClientEntity => { + assert(prismaPoolClient.name); + return { id: brandedId.userPoolClient.entity.parse(prismaPoolClient.id), userPoolId: brandedId.userPool.entity.parse(prismaPoolClient.userPoolId), + name: prismaPoolClient.name, createdTime: prismaPoolClient.createdAt.getTime(), }; }; diff --git a/server/domain/userPool/repository/userPoolCommand.ts b/server/domain/userPool/repository/userPoolCommand.ts index 9aac5fd..b55c9a3 100644 --- a/server/domain/userPool/repository/userPoolCommand.ts +++ b/server/domain/userPool/repository/userPoolCommand.ts @@ -1,21 +1,30 @@ +import type { Prisma } from '@prisma/client'; import type { UserPoolClientEntity, UserPoolEntity } from 'common/types/userPool'; -import { prismaClient } from 'service/prismaClient'; export const userPoolCommand = { - save: async (pool: UserPoolEntity): Promise => { - await prismaClient.userPool.upsert({ + save: async (tx: Prisma.TransactionClient, pool: UserPoolEntity): Promise => { + await tx.userPool.upsert({ where: { id: pool.id }, - update: { privateKey: pool.privateKey }, - create: { id: pool.id, privateKey: pool.privateKey, createdAt: new Date(pool.createdTime) }, + update: { name: pool.name, privateKey: pool.privateKey }, + create: { + id: pool.id, + name: pool.name, + privateKey: pool.privateKey, + createdAt: new Date(pool.createdTime), + }, }); }, - saveClient: async (poolClient: UserPoolClientEntity): Promise => { - await prismaClient.userPoolClient.upsert({ + saveClient: async ( + tx: Prisma.TransactionClient, + poolClient: UserPoolClientEntity, + ): Promise => { + await tx.userPoolClient.upsert({ where: { id: poolClient.id }, - update: {}, + update: { name: poolClient.name }, create: { id: poolClient.id, userPoolId: poolClient.userPoolId, + name: poolClient.name, createdAt: new Date(poolClient.createdTime), }, }); diff --git a/server/domain/userPool/useCase/userPoolUseCase.ts b/server/domain/userPool/useCase/userPoolUseCase.ts index 199f5b6..f472876 100644 --- a/server/domain/userPool/useCase/userPoolUseCase.ts +++ b/server/domain/userPool/useCase/userPoolUseCase.ts @@ -1,30 +1,69 @@ -import type { ListUserPoolsTarget } from 'common/types/auth'; +import assert from 'assert'; +import type { + CreateUserPoolClientTarget, + CreateUserPoolTarget, + ListUserPoolsTarget, +} from 'common/types/auth'; import { DEFAULT_USER_POOL_CLIENT_ID, DEFAULT_USER_POOL_ID } from 'service/envValues'; -import { prismaClient } from 'service/prismaClient'; +import { prismaClient, transaction } from 'service/prismaClient'; import { userPoolMethod } from '../model/userPoolMethod'; import { userPoolCommand } from '../repository/userPoolCommand'; import { userPoolQuery } from '../repository/userPoolQuery'; export const userPoolUseCase = { - initDefaults: async (): Promise => { - await userPoolQuery - .findById(prismaClient, DEFAULT_USER_POOL_ID) - .catch(() => userPoolCommand.save(userPoolMethod.create({ id: DEFAULT_USER_POOL_ID }))); + initDefaults: (): Promise => + transaction(async (tx) => { + await userPoolQuery + .findById(prismaClient, DEFAULT_USER_POOL_ID) + .catch(() => + userPoolCommand.save( + tx, + userPoolMethod.create({ id: DEFAULT_USER_POOL_ID, name: 'defaultPool' }), + ), + ); - await userPoolQuery.findClientById(prismaClient, DEFAULT_USER_POOL_CLIENT_ID).catch(() => - userPoolCommand.saveClient( - userPoolMethod.createClient({ - id: DEFAULT_USER_POOL_CLIENT_ID, - userPoolId: DEFAULT_USER_POOL_ID, - }), - ), - ); - }, + await userPoolQuery.findClientById(prismaClient, DEFAULT_USER_POOL_CLIENT_ID).catch(() => + userPoolCommand.saveClient( + tx, + userPoolMethod.createClient({ + id: DEFAULT_USER_POOL_CLIENT_ID, + userPoolId: DEFAULT_USER_POOL_ID, + name: 'defaultPoolClient', + }), + ), + ); + }), listUserPools: async ( req: ListUserPoolsTarget['reqBody'], ): Promise => { const pools = await userPoolQuery.listAll(prismaClient, req.MaxResults); - return { UserPools: pools.map((p) => ({ Id: p.id })) }; + return { UserPools: pools.map((p) => ({ Id: p.id, Name: p.name })) }; }, + createUserPool: ( + req: CreateUserPoolTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + assert(req.PoolName); + + const pool = userPoolMethod.create({ name: req.PoolName }); + await userPoolCommand.save(tx, pool); + + return { UserPool: { Id: pool.id, Name: pool.name } }; + }), + createUserPoolClient: ( + req: CreateUserPoolClientTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + assert(req.ClientName); + assert(req.UserPoolId); + + const pool = await userPoolQuery.findById(tx, req.UserPoolId); + const client = userPoolMethod.createClient({ name: req.ClientName, userPoolId: pool.id }); + await userPoolCommand.saveClient(tx, client); + + return { + UserPoolClient: { ClientId: client.id, UserPoolId: pool.id, ClientName: client.name }, + }; + }), }; diff --git a/server/prisma/migrations/20241229145939_/migration.sql b/server/prisma/migrations/20241229145939_/migration.sql new file mode 100644 index 0000000..9e607e0 --- /dev/null +++ b/server/prisma/migrations/20241229145939_/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "UserPool" ADD COLUMN "name" TEXT; + +-- AlterTable +ALTER TABLE "UserPoolClient" ADD COLUMN "name" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 2f89eb2..0cc8fe3 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -48,6 +48,7 @@ model UserAttribute { model UserPool { id String @id + name String? privateKey String createdAt DateTime users User[] @@ -56,6 +57,7 @@ model UserPool { model UserPoolClient { id String @id + name String? createdAt DateTime UserPool UserPool @relation(fields: [userPoolId], references: [id]) userPoolId String diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 581f9d4..80ba5cf 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -50,7 +50,35 @@ const migrateUser = async (tx: Prisma.TransactionClient): Promise => { await test(); }; -transaction((tx) => Promise.all([migrateUser(tx)])) +const migrateUserPool = async (tx: Prisma.TransactionClient): Promise => { + const pools = await tx.userPool.findMany({ where: { name: null } }); + + await tx.userPool.updateMany({ + where: { id: { in: pools.map((p) => p.id) } }, + data: { name: 'defaultPool' }, + }); + + const poolClients = await tx.userPoolClient.findMany({ where: { name: null } }); + + await tx.userPoolClient.updateMany({ + where: { id: { in: poolClients.map((p) => p.id) } }, + data: { name: 'defaultPoolClient' }, + }); + + const test = async (): Promise => { + const pools = await tx.userPool.findMany(); + + pools.forEach((pool) => assert(pool.name !== null)); + + const poolClients = await tx.userPoolClient.findMany(); + + poolClients.forEach((client) => assert(client.name !== null)); + }; + + await test(); +}; + +transaction((tx) => Promise.all([migrateUser(tx), migrateUserPool(tx)])) .catch((e) => { console.error(e); process.exit(1); diff --git a/server/service/createShortHash.ts b/server/service/createShortHash.ts new file mode 100644 index 0000000..7016793 --- /dev/null +++ b/server/service/createShortHash.ts @@ -0,0 +1,38 @@ +// MIT License +// +// Copyright (c) Sindre Sorhus (sindresorhus.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +const OFFSET_BASIS_32 = 2166136261; + +const fnv1a = (input: string): number => { + let hash = OFFSET_BASIS_32; + + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + + // 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619 + // Using bitshift for accuracy and performance. Numbers in JS suck. + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + + return hash >>> 0; +}; + +export const createShortHash = (input: string): string => fnv1a(input).toString(36); diff --git a/server/tests/sdk/pool.test.ts b/server/tests/sdk/pool.test.ts new file mode 100644 index 0000000..b1bc956 --- /dev/null +++ b/server/tests/sdk/pool.test.ts @@ -0,0 +1,16 @@ +import { + CreateUserPoolClientCommand, + CreateUserPoolCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { cognitoClient } from 'service/cognito'; +import { expect, test } from 'vitest'; + +test(CreateUserPoolCommand.name, async () => { + const pool = await cognitoClient.send(new CreateUserPoolCommand({ PoolName: 'testPool' })); + const client = await cognitoClient.send( + new CreateUserPoolClientCommand({ ClientName: 'testClient', UserPoolId: pool.UserPool?.Id }), + ); + + expect(pool.UserPool?.Name === 'testPool').toBeTruthy(); + expect(client.UserPoolClient?.ClientName === 'testClient').toBeTruthy(); +}); diff --git a/server/vite.config.ts b/server/vite.config.ts index 3af32b8..cacae76 100644 --- a/server/vite.config.ts +++ b/server/vite.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ coverage: { thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }, include: ['api/**/{controller,hooks,validators}.ts', 'domain/**'], - exclude: ['domain/**/model/*Entity.ts'], }, }, });