diff --git a/server/api/controller.ts b/server/api/controller.ts index 99804a6..e6a8433 100644 --- a/server/api/controller.ts +++ b/server/api/controller.ts @@ -35,6 +35,7 @@ const useCases: { 'AWSCognitoIdentityProviderService.ForgotPassword': authUseCase.forgotPassword, 'AWSCognitoIdentityProviderService.ConfirmForgotPassword': authUseCase.confirmForgotPassword, 'AWSCognitoIdentityProviderService.UpdateUserAttributes': authUseCase.updateUserAttributes, + 'AWSCognitoIdentityProviderService.VerifyUserAttribute': authUseCase.verifyUserAttribute, 'AWSCognitoIdentityProviderService.DeleteUserAttributes': authUseCase.deleteUserAttributes, }; diff --git a/server/common/types/auth.ts b/server/common/types/auth.ts index 7c90fb8..a2a96e5 100644 --- a/server/common/types/auth.ts +++ b/server/common/types/auth.ts @@ -22,10 +22,13 @@ import type { SignUpResponse, UpdateUserAttributesRequest, UpdateUserAttributesResponse, + VerifyUserAttributeRequest, + VerifyUserAttributeResponse, } from '@aws-sdk/client-cognito-identity-provider'; import type { MaybeId } from './brandedId'; +import type { InitiateAuthTarget, RespondToAuthChallengeTarget } from './signIn'; -type TargetBody = { reqBody: Req; resBody: Res }; +export type TargetBody = { reqBody: Req; resBody: Res }; export type SignUpTarget = TargetBody; @@ -34,64 +37,6 @@ export type ConfirmSignUpTarget = TargetBody< Record >; -export type UserSrpAuthTarget = TargetBody< - { - AuthFlow: 'USER_SRP_AUTH'; - AuthParameters: { USERNAME: string; SRP_A: string }; - ClientId: MaybeId['userPoolClient']; - }, - { - ChallengeName: 'PASSWORD_VERIFIER'; - ChallengeParameters: { - SALT: string; - SECRET_BLOCK: string; - SRP_B: string; - USERNAME: string; - USER_ID_FOR_SRP: string; - }; - } ->; - -export type RefreshTokenAuthTarget = TargetBody< - { - AuthFlow: 'REFRESH_TOKEN_AUTH'; - AuthParameters: { REFRESH_TOKEN: string }; - ClientId: MaybeId['userPoolClient']; - }, - { - AuthenticationResult: { - AccessToken: string; - ExpiresIn: number; - IdToken: string; - TokenType: 'Bearer'; - }; - ChallengeParameters: Record; - } ->; - -export type RespondToAuthChallengeTarget = TargetBody< - { - ChallengeName: 'PASSWORD_VERIFIER'; - ChallengeResponses: { - PASSWORD_CLAIM_SECRET_BLOCK: string; - PASSWORD_CLAIM_SIGNATURE: string; - TIMESTAMP: string; - USERNAME: string; - }; - ClientId: MaybeId['userPoolClient']; - }, - { - AuthenticationResult: { - AccessToken: string; - ExpiresIn: number; - IdToken: string; - RefreshToken: string; - TokenType: 'Bearer'; - }; - ChallengeParameters: Record; - } ->; - export type GetUserTarget = TargetBody<{ AccessToken: string }, GetUserResponse>; export type RevokeTokenTarget = TargetBody< @@ -157,6 +102,11 @@ export type UpdateUserAttributesTarget = TargetBody< UpdateUserAttributesResponse >; +export type VerifyUserAttributeTarget = TargetBody< + VerifyUserAttributeRequest, + VerifyUserAttributeResponse +>; + export type DeleteUserAttributesTarget = TargetBody< DeleteUserAttributesRequest, DeleteUserAttributesResponse @@ -165,7 +115,7 @@ export type DeleteUserAttributesTarget = TargetBody< export type AmzTargets = { 'AWSCognitoIdentityProviderService.SignUp': SignUpTarget; 'AWSCognitoIdentityProviderService.ConfirmSignUp': ConfirmSignUpTarget; - 'AWSCognitoIdentityProviderService.InitiateAuth': UserSrpAuthTarget | RefreshTokenAuthTarget; + 'AWSCognitoIdentityProviderService.InitiateAuth': InitiateAuthTarget; 'AWSCognitoIdentityProviderService.RespondToAuthChallenge': RespondToAuthChallengeTarget; 'AWSCognitoIdentityProviderService.GetUser': GetUserTarget; 'AWSCognitoIdentityProviderService.RevokeToken': RevokeTokenTarget; @@ -182,5 +132,6 @@ export type AmzTargets = { 'AWSCognitoIdentityProviderService.ForgotPassword': ForgotPasswordTarget; 'AWSCognitoIdentityProviderService.ConfirmForgotPassword': ConfirmForgotPasswordTarget; 'AWSCognitoIdentityProviderService.UpdateUserAttributes': UpdateUserAttributesTarget; + 'AWSCognitoIdentityProviderService.VerifyUserAttribute': VerifyUserAttributeTarget; 'AWSCognitoIdentityProviderService.DeleteUserAttributes': DeleteUserAttributesTarget; }; diff --git a/server/common/types/signIn.ts b/server/common/types/signIn.ts new file mode 100644 index 0000000..d3c0f09 --- /dev/null +++ b/server/common/types/signIn.ts @@ -0,0 +1,62 @@ +import type { TargetBody } from './auth'; +import type { MaybeId } from './brandedId'; + +export type UserSrpAuthTarget = TargetBody< + { + AuthFlow: 'USER_SRP_AUTH'; + AuthParameters: { USERNAME: string; SRP_A: string }; + ClientId: MaybeId['userPoolClient']; + }, + { + ChallengeName: 'PASSWORD_VERIFIER'; + ChallengeParameters: { + SALT: string; + SECRET_BLOCK: string; + SRP_B: string; + USERNAME: string; + USER_ID_FOR_SRP: string; + }; + } +>; + +export type RefreshTokenAuthTarget = TargetBody< + { + AuthFlow: 'REFRESH_TOKEN_AUTH'; + AuthParameters: { REFRESH_TOKEN: string }; + ClientId: MaybeId['userPoolClient']; + }, + { + AuthenticationResult: { + AccessToken: string; + ExpiresIn: number; + IdToken: string; + TokenType: 'Bearer'; + }; + ChallengeParameters: Record; + } +>; + +export type InitiateAuthTarget = UserSrpAuthTarget | RefreshTokenAuthTarget; + +export type RespondToAuthChallengeTarget = TargetBody< + { + ChallengeName: 'PASSWORD_VERIFIER'; + ChallengeResponses: { + PASSWORD_CLAIM_SECRET_BLOCK: string; + PASSWORD_CLAIM_SIGNATURE: string; + TIMESTAMP: string; + USERNAME: string; + }; + ClientId: MaybeId['userPoolClient']; + }, + { + AuthenticationResult: { + AccessToken: string; + ExpiresIn: number; + IdToken: string; + RefreshToken: string; + TokenType: 'Bearer'; + }; + ChallengeParameters: Record; + } +>; diff --git a/server/domain/user/model/signInMethod.ts b/server/domain/user/model/signInMethod.ts index bf2e626..607ba7b 100644 --- a/server/domain/user/model/signInMethod.ts +++ b/server/domain/user/model/signInMethod.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import type { UserSrpAuthTarget } from 'common/types/auth'; +import type { UserSrpAuthTarget } from 'common/types/signIn'; import type { ChallengeVal, UserEntity } from 'common/types/user'; import type { Jwks, UserPoolClientEntity, UserPoolEntity } from 'common/types/userPool'; import crypto from 'crypto'; diff --git a/server/domain/user/model/userMethod.ts b/server/domain/user/model/userMethod.ts index 4162782..bbbc783 100644 --- a/server/domain/user/model/userMethod.ts +++ b/server/domain/user/model/userMethod.ts @@ -1,6 +1,6 @@ import type { AttributeType } from '@aws-sdk/client-cognito-identity-provider'; import assert from 'assert'; -import type { ChangePasswordTarget } from 'common/types/auth'; +import type { ChangePasswordTarget, VerifyUserAttributeTarget } from 'common/types/auth'; import type { EntityId } from 'common/types/brandedId'; import type { UserEntity } from 'common/types/user'; import { genConfirmationCode } from 'domain/user/service/genConfirmationCode'; @@ -118,16 +118,27 @@ export const userMethod = { updateAttributes: (user: UserEntity, attributes: AttributeType[] | undefined): UserEntity => { assert(attributes); const email = attributes.find((attr) => attr.Name === 'email')?.Value ?? user.email; + const verified = user.email === email; return { ...user, attributes: createAttributes(attributes, user.attributes), - status: user.email === email ? user.status : 'UNCONFIRMED', - confirmationCode: user.email === email ? user.confirmationCode : genConfirmationCode(), + status: verified ? user.status : 'UNCONFIRMED', + confirmationCode: verified ? user.confirmationCode : genConfirmationCode(), + verified, email, updatedTime: Date.now(), }; }, + verifyAttribute: (user: UserEntity, req: VerifyUserAttributeTarget['reqBody']): UserEntity => { + assert(req.AttributeName === 'email'); + cognitoAssert( + user.confirmationCode === req.Code, + 'Invalid verification code provided, please try again.', + ); + + return { ...user, status: 'CONFIRMED', verified: true, updatedTime: Date.now() }; + }, deleteAttributes: (user: UserEntity, attributeNames: string[] | undefined): UserEntity => { assert(attributeNames); diff --git a/server/domain/user/useCase/authUseCase.ts b/server/domain/user/useCase/authUseCase.ts index e1682e0..5e5b71c 100644 --- a/server/domain/user/useCase/authUseCase.ts +++ b/server/domain/user/useCase/authUseCase.ts @@ -7,6 +7,7 @@ import type { GetUserTarget, RevokeTokenTarget, UpdateUserAttributesTarget, + VerifyUserAttributeTarget, } from 'common/types/auth'; import { userMethod } from 'domain/user/model/userMethod'; import { userCommand } from 'domain/user/repository/userCommand'; @@ -75,7 +76,7 @@ export const authUseCase = { return {}; }), - updateUserAttributes: async ( + updateUserAttributes: ( req: UpdateUserAttributesTarget['reqBody'], ): Promise => transaction(async (tx) => { @@ -87,10 +88,23 @@ export const authUseCase = { await userCommand.save(tx, updated); - if (user.confirmationCode !== updated.confirmationCode) await sendConfirmationCode(user); + if (user.confirmationCode !== updated.confirmationCode) await sendConfirmationCode(updated); return { CodeDeliveryDetailsList: [genCodeDeliveryDetails(updated)] }; }), + verifyUserAttribute: ( + req: VerifyUserAttributeTarget['reqBody'], + ): Promise => + transaction(async (tx) => { + assert(req.AccessToken); + + const decoded = jwtDecode(req.AccessToken); + const user = await userQuery.findById(tx, decoded.sub); + + await userCommand.save(tx, userMethod.verifyAttribute(user, req)); + + return {}; + }), deleteUserAttributes: ( req: DeleteUserAttributesTarget['reqBody'], ): Promise => diff --git a/server/domain/user/useCase/signInUseCase.ts b/server/domain/user/useCase/signInUseCase.ts index 60d440d..eda624a 100644 --- a/server/domain/user/useCase/signInUseCase.ts +++ b/server/domain/user/useCase/signInUseCase.ts @@ -3,7 +3,7 @@ import type { RefreshTokenAuthTarget, RespondToAuthChallengeTarget, UserSrpAuthTarget, -} from 'common/types/auth'; +} from 'common/types/signIn'; import { userCommand } from 'domain/user/repository/userCommand'; import { userQuery } from 'domain/user/repository/userQuery'; import { genTokens } from 'domain/user/service/genTokens'; diff --git a/server/tests/sdk/user.test.ts b/server/tests/sdk/user.test.ts index 918cc6a..161922c 100644 --- a/server/tests/sdk/user.test.ts +++ b/server/tests/sdk/user.test.ts @@ -2,15 +2,15 @@ import { DeleteUserAttributesCommand, GetUserCommand, UpdateUserAttributesCommand, + VerifyUserAttributeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { cognitoClient } from 'service/cognito'; -import { createUserAndToken } from 'tests/api/utils'; +import { createUserAndToken, fetchMailBodyAndTrash } from 'tests/api/utils'; import { ulid } from 'ulid'; import { expect, test } from 'vitest'; test(UpdateUserAttributesCommand.name, async () => { const token = await createUserAndToken(); - const newEmail = `${ulid()}@example.com`; const attrName1 = 'custom:test1'; const attrVal1 = 'sample1'; const attrName2 = 'custom:test2'; @@ -30,23 +30,45 @@ test(UpdateUserAttributesCommand.name, async () => { await cognitoClient.send( new UpdateUserAttributesCommand({ ...token, - UserAttributes: [ - { Name: 'email', Value: newEmail }, - { Name: attrName1, Value: attrVal3 }, - ], + UserAttributes: [{ Name: attrName1, Value: attrVal3 }], }), ); const user = await cognitoClient.send(new GetUserCommand(token)); - const emailAttr = user.UserAttributes?.find((attr) => attr.Name === 'email'); const targetAttr1 = user.UserAttributes?.find((attr) => attr.Name === attrName1); const targetAttr2 = user.UserAttributes?.find((attr) => attr.Name === attrName2); - expect(emailAttr?.Value).toBe(newEmail); expect(targetAttr1?.Value).toBe(attrVal3); expect(targetAttr2?.Value).toBe(attrVal2); }); +test(VerifyUserAttributeCommand.name, async () => { + const token = await createUserAndToken(); + const newEmail = `${ulid()}@example.com`; + + await cognitoClient.send( + new UpdateUserAttributesCommand({ + ...token, + UserAttributes: [{ Name: 'email', Value: newEmail }], + }), + ); + + const message = await fetchMailBodyAndTrash(newEmail); + + await cognitoClient.send( + new VerifyUserAttributeCommand({ + ...token, + AttributeName: 'email', + Code: message.split(' ').at(-1), + }), + ); + + const user = await cognitoClient.send(new GetUserCommand(token)); + const emailAttr = user.UserAttributes?.find((attr) => attr.Name === 'email'); + + expect(emailAttr?.Value).toBe(newEmail); +}); + test(DeleteUserAttributesCommand.name, async () => { const token = await createUserAndToken(); const attrName1 = 'custom:test1';