diff --git a/server/api/@types/auth.ts b/server/api/@types/auth.ts index 33dfb20..326d120 100644 --- a/server/api/@types/auth.ts +++ b/server/api/@types/auth.ts @@ -1,5 +1,9 @@ import type { + AdminCreateUserRequest, + AdminCreateUserResponse, AdminDeleteUserRequest, + AdminInitiateAuthRequest, + AdminInitiateAuthResponse, ListUserPoolsRequest, ListUserPoolsResponse, } from '@aws-sdk/client-cognito-identity-provider'; @@ -116,7 +120,15 @@ export type ResendConfirmationCodeTarget = TargetBody< export type ListUserPoolsTarget = TargetBody; +export type AdminCreateUserTarget = TargetBody; + export type AdminDeleteUserTarget = TargetBody>; + +export type AdminInitiateAuthTarget = TargetBody< + AdminInitiateAuthRequest, + AdminInitiateAuthResponse +>; + export type ChangePasswordTarget = TargetBody< { AccessToken: string; @@ -135,6 +147,8 @@ export type AmzTargets = { 'AWSCognitoIdentityProviderService.RevokeToken': RevokeTokenTarget; 'AWSCognitoIdentityProviderService.ResendConfirmationCode': ResendConfirmationCodeTarget; 'AWSCognitoIdentityProviderService.ListUserPools': ListUserPoolsTarget; + 'AWSCognitoIdentityProviderService.AdminCreateUser': AdminCreateUserTarget; 'AWSCognitoIdentityProviderService.AdminDeleteUser': AdminDeleteUserTarget; + 'AWSCognitoIdentityProviderService.AdminInitiateAuth': AdminInitiateAuthTarget; 'AWSCognitoIdentityProviderService.ChangePassword': ChangePasswordTarget; }; diff --git a/server/api/controller.ts b/server/api/controller.ts index 356ad02..27dc4e0 100644 --- a/server/api/controller.ts +++ b/server/api/controller.ts @@ -78,10 +78,37 @@ const targets: { validator: z.object({ MaxResults: z.number(), NextToken: z.string().optional() }), useCase: authUseCase.listUserPools, }, + 'AWSCognitoIdentityProviderService.AdminCreateUser': { + validator: z.object({ + UserPoolId: brandedId.userPool.maybe, + Username: z.string(), + UserAttributes: z + .array(z.object({ Name: z.string(), Value: z.string().optional() })) + .optional(), + ValidationData: z + .array(z.object({ Name: z.string(), Value: z.string().optional() })) + .optional(), + TemporaryPassword: z.string().optional(), + ForceAliasCreation: z.boolean().optional(), + MessageAction: z.enum(['RESEND', 'SUPPRESS']).optional(), + DesiredDeliveryMediums: z.array(z.enum(['EMAIL', 'SMS'])).optional(), + ClientMetadata: z.record(z.string()).optional(), + }), + useCase: adminUseCase.createUser, + }, 'AWSCognitoIdentityProviderService.AdminDeleteUser': { validator: z.object({ UserPoolId: brandedId.userPool.maybe, Username: z.string() }), useCase: adminUseCase.deleteUser, }, + 'AWSCognitoIdentityProviderService.AdminInitiateAuth': { + validator: z.object({ + AuthFlow: z.literal('ADMIN_NO_SRP_AUTH'), + UserPoolId: brandedId.userPool.maybe, + ClientId: brandedId.userPoolClient.maybe, + AuthParameters: z.object({ USERNAME: z.string(), PASSWORD: z.string() }), + }), + useCase: adminUseCase.initiateAuth, + }, 'AWSCognitoIdentityProviderService.ChangePassword': { validator: z.object({ AccessToken: z.string(), diff --git a/server/api/public/backdoor/controller.ts b/server/api/public/backdoor/controller.ts deleted file mode 100644 index d121195..0000000 --- a/server/api/public/backdoor/controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { UserEntity } from 'api/@types/user'; -import { userMethod } from 'domain/user/model/userMethod'; -import { userCommand } from 'domain/user/repository/userCommand'; -import { userQuery } from 'domain/user/repository/userQuery'; -import { genCredentials } from 'domain/user/service/genCredentials'; -import { genTokens } from 'domain/user/service/genTokens'; -import { userPoolQuery } from 'domain/userPool/repository/userPoolQuery'; -import { DEFAULT_USER_POOL_CLIENT_ID, DEFAULT_USER_POOL_ID } from 'service/envValues'; -import { prismaClient } from 'service/prismaClient'; -import { genJwks } from 'service/privateKey'; -import { defineController } from './$relay'; - -// サインアップフローを省略してテストを書くためのエンドポイント -// 200行まではcontrollerにべた書きで良い -export default defineController(() => ({ - post: async ({ body }) => { - const { salt, verifier } = genCredentials({ - poolId: DEFAULT_USER_POOL_ID, - username: body.username, - password: body.password, - }); - const idCount = await userQuery.countId(prismaClient, body.username); - const user: UserEntity = { - ...userMethod.createUser(idCount, { - name: body.username, - password: body.password, - email: body.email, - salt, - verifier, - userPoolId: DEFAULT_USER_POOL_ID, - }), - verified: true, - }; - - await userCommand.save(prismaClient, user); - - const pool = await userPoolQuery.findById(prismaClient, DEFAULT_USER_POOL_ID); - const jwks = await genJwks(pool.privateKey); - const tokens = genTokens({ - privateKey: pool.privateKey, - userPoolClientId: DEFAULT_USER_POOL_CLIENT_ID, - jwks, - user, - }); - - return { - status: 200, - body: { ...tokens, ExpiresIn: 3600, RefreshToken: user.refreshToken, TokenType: 'Bearer' }, - }; - }, -})); diff --git a/server/api/public/backdoor/index.ts b/server/api/public/backdoor/index.ts deleted file mode 100644 index 94108d9..0000000 --- a/server/api/public/backdoor/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { RespondToAuthChallengeTarget } from 'api/@types/auth'; -import type { DefineMethods } from 'aspida'; - -export type Methods = DefineMethods<{ - post: { - reqBody: { - username: string; - email: string; - password: string; - }; - resBody: RespondToAuthChallengeTarget['resBody']['AuthenticationResult']; - }; -}>; diff --git a/server/domain/user/model/userMethod.ts b/server/domain/user/model/userMethod.ts index 945fd10..fc676c3 100644 --- a/server/domain/user/model/userMethod.ts +++ b/server/domain/user/model/userMethod.ts @@ -18,18 +18,17 @@ import { cognitoAssert } from 'service/cognitoAssert'; import { ulid } from 'ulid'; import { z } from 'zod'; +type CreateUserVal = { + name: string; + password: string; + email: string; + salt: string; + verifier: string; + userPoolId: EntityId['userPool']; +}; + export const userMethod = { - createUser: ( - idCount: number, - val: { - name: string; - password: string; - email: string; - salt: string; - verifier: string; - userPoolId: EntityId['userPool']; - }, - ): UserEntity => { + createUser: (idCount: number, val: CreateUserVal): UserEntity => { cognitoAssert(idCount === 0, 'User already exists'); cognitoAssert( /^[a-z][a-z\d_-]/.test(val.name), @@ -70,6 +69,10 @@ export const userMethod = { createdTime: Date.now(), }; }, + createVerifiedUser: (idCount: number, val: CreateUserVal): UserEntity => ({ + ...userMethod.createUser(idCount, val), + verified: true, + }), verifyUser: (user: UserEntity, confirmationCode: string): UserEntity => { cognitoAssert( user.confirmationCode === confirmationCode, diff --git a/server/domain/user/useCase/adminUseCase.ts b/server/domain/user/useCase/adminUseCase.ts index 23b9721..c8744f0 100644 --- a/server/domain/user/useCase/adminUseCase.ts +++ b/server/domain/user/useCase/adminUseCase.ts @@ -1,11 +1,49 @@ -import type { AdminDeleteUserTarget } from 'api/@types/auth'; +import type { + AdminCreateUserTarget, + AdminDeleteUserTarget, + AdminInitiateAuthTarget, +} from 'api/@types/auth'; import assert from 'assert'; +import { userPoolQuery } from 'domain/userPool/repository/userPoolQuery'; +import { brandedId } from 'service/brandedId'; import { transaction } from 'service/prismaClient'; +import { genJwks } from 'service/privateKey'; import { userMethod } from '../model/userMethod'; import { userCommand } from '../repository/userCommand'; import { userQuery } from '../repository/userQuery'; +import { genCredentials } from '../service/genCredentials'; +import { genTokens } from '../service/genTokens'; export const adminUseCase = { + createUser: (req: AdminCreateUserTarget['reqBody']): Promise => + transaction(async (tx) => { + const email = req.UserAttributes?.find((attr) => attr.Name === 'email')?.Value; + + assert(email); + assert(req.UserPoolId); + assert(req.Username); + assert(req.TemporaryPassword); + + const userPool = await userPoolQuery.findById(tx, req.UserPoolId); + const { salt, verifier } = genCredentials({ + poolId: userPool.id, + username: req.Username, + password: req.TemporaryPassword, + }); + const idCount = await userQuery.countId(tx, req.Username); + const user = userMethod.createVerifiedUser(idCount, { + name: req.Username, + password: req.TemporaryPassword, + email, + salt, + verifier, + userPoolId: userPool.id, + }); + + await userCommand.save(tx, user); + + return { User: { Username: user.name, Attributes: [{ Name: 'email', Value: user.email }] } }; + }), deleteUser: (req: AdminDeleteUserTarget['reqBody']): Promise => transaction(async (tx) => { assert(req.Username); @@ -18,4 +56,34 @@ export const adminUseCase = { return {}; }), + initiateAuth: ( + req: AdminInitiateAuthTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + assert(req.UserPoolId); + assert(req.AuthParameters); + + const user = await userQuery.findByName(tx, req.AuthParameters.USERNAME); + const pool = await userPoolQuery.findById(tx, req.UserPoolId); + const poolClient = await userPoolQuery.findClientById( + tx, + brandedId.userPoolClient.maybe.parse(req.ClientId), + ); + const jwks = await genJwks(pool.privateKey); + const tokens = genTokens({ + privateKey: pool.privateKey, + userPoolClientId: poolClient.id, + jwks, + user, + }); + + return { + AuthenticationResult: { + ...tokens, + ExpiresIn: 3600, + RefreshToken: user.refreshToken, + TokenType: 'Bearer', + }, + }; + }), }; diff --git a/server/tests/api/apiClient.ts b/server/tests/api/apiClient.ts index 41640d3..0a2dc4b 100644 --- a/server/tests/api/apiClient.ts +++ b/server/tests/api/apiClient.ts @@ -1,10 +1,15 @@ import aspida from '@aspida/axios'; -import { AdminDeleteUserCommand } from '@aws-sdk/client-cognito-identity-provider'; +import { + AdminCreateUserCommand, + AdminDeleteUserCommand, + AdminInitiateAuthCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import api from 'api/$api'; +import assert from 'assert'; import axios from 'axios'; import { cognitoClient } from 'service/cognito'; import { COOKIE_NAME } from 'service/constants'; -import { DEFAULT_USER_POOL_ID, PORT } from 'service/envValues'; +import { DEFAULT_USER_POOL_CLIENT_ID, DEFAULT_USER_POOL_ID, PORT } from 'service/envValues'; import { ulid } from 'ulid'; const baseURL = `http://127.0.0.1:${PORT}`; @@ -18,12 +23,31 @@ export const testUserName = 'test-client'; export const testPassword = 'Test-client-password1'; export const createUserClient = async (): Promise => { - const tokens = await noCookieClient.public.backdoor.$post({ - body: { username: testUserName, email: `${ulid()}@example.com`, password: testPassword }, + const command1 = new AdminCreateUserCommand({ + UserPoolId: DEFAULT_USER_POOL_ID, + Username: testUserName, + TemporaryPassword: testPassword, + UserAttributes: [{ Name: 'email', Value: `${ulid()}@example.com` }], + }); + + await cognitoClient.send(command1); + + const command2 = new AdminInitiateAuthCommand({ + AuthFlow: 'ADMIN_NO_SRP_AUTH', + UserPoolId: DEFAULT_USER_POOL_ID, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + AuthParameters: { USERNAME: testUserName, PASSWORD: testPassword }, }); + + const res = await cognitoClient.send(command2); + assert(res.AuthenticationResult); + const agent = axios.create({ baseURL, - headers: { cookie: `${COOKIE_NAME}=${tokens.IdToken}`, 'Content-Type': 'text/plain' }, + headers: { + cookie: `${COOKIE_NAME}=${res.AuthenticationResult.IdToken}`, + 'Content-Type': 'text/plain', + }, }); agent.interceptors.response.use(undefined, (err) => diff --git a/server/tests/api/changePassword.test.ts b/server/tests/api/changePassword.test.ts index 00ac54f..b0e6a29 100644 --- a/server/tests/api/changePassword.test.ts +++ b/server/tests/api/changePassword.test.ts @@ -27,6 +27,7 @@ test('changePassword', async () => { }); assert('ChallengeParameters' in res1); + assert(res1.ChallengeParameters); const secretBlock1 = res1.ChallengeParameters.SECRET_BLOCK; const signature1 = calcClientSignature({ secretBlock: secretBlock1, @@ -54,6 +55,8 @@ test('changePassword', async () => { }); assert('AuthenticationResult' in res2); + assert(res2.AuthenticationResult); + assert(res2.AuthenticationResult.AccessToken); assert('RefreshToken' in res2.AuthenticationResult); const newPassword = 'Test-client-password2'; @@ -77,6 +80,7 @@ test('changePassword', async () => { }); assert('ChallengeParameters' in res3); + assert(res3.ChallengeParameters); const secretBlock2 = res3.ChallengeParameters.SECRET_BLOCK; const signature2 = calcClientSignature({ secretBlock: secretBlock2, @@ -104,6 +108,7 @@ test('changePassword', async () => { }); assert('AuthenticationResult' in res4); + assert(res4.AuthenticationResult); assert('RefreshToken' in res4.AuthenticationResult); await deleteUser(); diff --git a/server/tests/api/signIn.test.ts b/server/tests/api/signIn.test.ts index 9e3843e..63f362c 100644 --- a/server/tests/api/signIn.test.ts +++ b/server/tests/api/signIn.test.ts @@ -28,6 +28,7 @@ test('signIn', async () => { }); assert('ChallengeParameters' in res1); + assert(res1.ChallengeParameters); const secretBlock = res1.ChallengeParameters.SECRET_BLOCK; const signature = calcClientSignature({ secretBlock, @@ -55,7 +56,10 @@ test('signIn', async () => { }); assert('AuthenticationResult' in res2); + assert(res2.AuthenticationResult); + assert(res2.AuthenticationResult.AccessToken); assert('RefreshToken' in res2.AuthenticationResult); + assert(res2.AuthenticationResult.RefreshToken); await noCookieClient.$post({ headers: { 'x-amz-target': 'AWSCognitoIdentityProviderService.GetUser' }, diff --git a/server/tests/sdk/admin.test.ts b/server/tests/sdk/admin.test.ts index 7f7cf3a..a087806 100644 --- a/server/tests/sdk/admin.test.ts +++ b/server/tests/sdk/admin.test.ts @@ -1,9 +1,38 @@ -import { AdminDeleteUserCommand } from '@aws-sdk/client-cognito-identity-provider'; +import { + AdminCreateUserCommand, + AdminDeleteUserCommand, + AdminInitiateAuthCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import { cognitoClient } from 'service/cognito'; -import { DEFAULT_USER_POOL_ID } from 'service/envValues'; -import { createUserClient, testUserName } from 'tests/api/apiClient'; +import { DEFAULT_USER_POOL_CLIENT_ID, DEFAULT_USER_POOL_ID } from 'service/envValues'; +import { createUserClient, deleteUser, testPassword, testUserName } from 'tests/api/apiClient'; +import { ulid } from 'ulid'; import { expect, test } from 'vitest'; +test('AdminCreateUserCommand', async () => { + const command1 = new AdminCreateUserCommand({ + UserPoolId: DEFAULT_USER_POOL_ID, + Username: testUserName, + TemporaryPassword: testPassword, + UserAttributes: [{ Name: 'email', Value: `${ulid()}@example.com` }], + }); + + await cognitoClient.send(command1); + + const command2 = new AdminInitiateAuthCommand({ + AuthFlow: 'ADMIN_NO_SRP_AUTH', + UserPoolId: DEFAULT_USER_POOL_ID, + ClientId: DEFAULT_USER_POOL_CLIENT_ID, + AuthParameters: { USERNAME: testUserName, PASSWORD: testPassword }, + }); + + const tokens = await cognitoClient.send(command2); + + expect(tokens.AuthenticationResult).toBeTruthy(); + + await deleteUser(); +}); + test('AdminDeleteUserCommand', async () => { const userClient = await createUserClient();