Skip to content

Commit

Permalink
feat: implement VerifyUserAttribute service
Browse files Browse the repository at this point in the history
  • Loading branch information
solufa committed Jul 15, 2024
1 parent 38020c4 commit d67ab4d
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 75 deletions.
1 change: 1 addition & 0 deletions server/api/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
71 changes: 11 additions & 60 deletions server/common/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Req, Res> = { reqBody: Req; resBody: Res };
export type TargetBody<Req, Res> = { reqBody: Req; resBody: Res };

export type SignUpTarget = TargetBody<SignUpRequest, SignUpResponse>;

Expand All @@ -34,64 +37,6 @@ export type ConfirmSignUpTarget = TargetBody<
Record<string, never>
>;

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<string, never>;
}
>;

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<string, never>;
}
>;

export type GetUserTarget = TargetBody<{ AccessToken: string }, GetUserResponse>;

export type RevokeTokenTarget = TargetBody<
Expand Down Expand Up @@ -157,6 +102,11 @@ export type UpdateUserAttributesTarget = TargetBody<
UpdateUserAttributesResponse
>;

export type VerifyUserAttributeTarget = TargetBody<
VerifyUserAttributeRequest,
VerifyUserAttributeResponse
>;

export type DeleteUserAttributesTarget = TargetBody<
DeleteUserAttributesRequest,
DeleteUserAttributesResponse
Expand All @@ -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;
Expand All @@ -182,5 +132,6 @@ export type AmzTargets = {
'AWSCognitoIdentityProviderService.ForgotPassword': ForgotPasswordTarget;
'AWSCognitoIdentityProviderService.ConfirmForgotPassword': ConfirmForgotPasswordTarget;
'AWSCognitoIdentityProviderService.UpdateUserAttributes': UpdateUserAttributesTarget;
'AWSCognitoIdentityProviderService.VerifyUserAttribute': VerifyUserAttributeTarget;
'AWSCognitoIdentityProviderService.DeleteUserAttributes': DeleteUserAttributesTarget;
};
62 changes: 62 additions & 0 deletions server/common/types/signIn.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>;
}
>;

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<string, never>;
}
>;
2 changes: 1 addition & 1 deletion server/domain/user/model/signInMethod.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
17 changes: 14 additions & 3 deletions server/domain/user/model/userMethod.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down
18 changes: 16 additions & 2 deletions server/domain/user/useCase/authUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,7 +76,7 @@ export const authUseCase = {

return {};
}),
updateUserAttributes: async (
updateUserAttributes: (
req: UpdateUserAttributesTarget['reqBody'],
): Promise<UpdateUserAttributesTarget['resBody']> =>
transaction(async (tx) => {
Expand All @@ -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<VerifyUserAttributeTarget['resBody']> =>
transaction(async (tx) => {
assert(req.AccessToken);

const decoded = jwtDecode<AccessTokenJwt>(req.AccessToken);
const user = await userQuery.findById(tx, decoded.sub);

await userCommand.save(tx, userMethod.verifyAttribute(user, req));

return {};
}),
deleteUserAttributes: (
req: DeleteUserAttributesTarget['reqBody'],
): Promise<DeleteUserAttributesTarget['resBody']> =>
Expand Down
2 changes: 1 addition & 1 deletion server/domain/user/useCase/signInUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
38 changes: 30 additions & 8 deletions server/tests/sdk/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down

0 comments on commit d67ab4d

Please sign in to comment.