diff --git a/README.md b/README.md index e33803be..08fb3c72 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | AdminUpdateUserAttributes | ✅ | | AdminUserGlobalSignOut | ❌ | | AssociateSoftwareToken | ❌ | -| ChangePassword | 🕒 (partial support) | +| ChangePassword | ✅ | | ConfirmDevice | ❌ | | ConfirmForgotPassword | 🕒 (partial support) | | ConfirmSignUp | 🕒 (partial support) | diff --git a/integration-tests/aws-sdk/changePassword.test.ts b/integration-tests/aws-sdk/changePassword.test.ts new file mode 100644 index 00000000..d012c09e --- /dev/null +++ b/integration-tests/aws-sdk/changePassword.test.ts @@ -0,0 +1,87 @@ +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.changePassword", + withCognitoSdk((Cognito) => { + it("deletes the current user", async () => { + const client = Cognito(); + + // create the user pool client + const upc = await client + .createUserPoolClient({ + UserPoolId: "test", + ClientName: "test", + }) + .promise(); + + // create a user + await client + .adminCreateUser({ + DesiredDeliveryMediums: ["EMAIL"], + TemporaryPassword: "def", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + Username: "abc", + UserPoolId: "test", + }) + .promise(); + + await client + .adminSetUserPassword({ + Password: "firstPassword", + Permanent: true, + Username: "abc", + UserPoolId: "test", + }) + .promise(); + + // login + const initAuthResponse = await client + .initiateAuth({ + ClientId: upc.UserPoolClient?.ClientId!, + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "firstPassword", + }, + }) + .promise(); + + // delete the user with their token + await client + .changePassword({ + AccessToken: initAuthResponse.AuthenticationResult?.AccessToken!, + PreviousPassword: "firstPassword", + ProposedPassword: "secondPassword", + }) + .promise(); + + // (fail to) login with the old password + await expect( + client + .initiateAuth({ + ClientId: upc.UserPoolClient?.ClientId!, + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "firstPassword", + }, + }) + .promise() + ).rejects.toBeDefined(); + + // login with the new password + const initAuthResponse2nd = await client + .initiateAuth({ + ClientId: upc.UserPoolClient?.ClientId!, + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "secondPassword", + }, + }) + .promise(); + + expect(initAuthResponse2nd).toBeDefined(); + }); + }) +); diff --git a/src/targets/changePassword.test.ts b/src/targets/changePassword.test.ts new file mode 100644 index 00000000..6ba8456d --- /dev/null +++ b/src/targets/changePassword.test.ts @@ -0,0 +1,148 @@ +import jwt from "jsonwebtoken"; +import * as uuid from "uuid"; +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import * as TDB from "../__tests__/testDataBuilder"; +import { + InvalidParameterError, + InvalidPasswordError, + NotAuthorizedError, +} from "../errors"; +import PrivateKey from "../keys/cognitoLocal.private.json"; +import { UserPoolService } from "../services"; +import { ChangePassword, ChangePasswordTarget } from "./changePassword"; + +const currentDate = new Date(); + +describe("ChangePassword target", () => { + let changePassword: ChangePasswordTarget; + let mockUserPoolService: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + changePassword = ChangePassword({ + cognito: newMockCognitoService(mockUserPoolService), + clock: new ClockFake(currentDate), + }); + }); + + it("throws if token isn't valid", async () => { + await expect( + changePassword(TestContext, { + AccessToken: "blah", + PreviousPassword: "abc", + ProposedPassword: "def", + }) + ).rejects.toBeInstanceOf(InvalidParameterError); + + expect(mockUserPoolService.saveUser).not.toHaveBeenCalled(); + }); + + it("throws if user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + changePassword(TestContext, { + AccessToken: jwt.sign( + { + sub: "0000-0000", + event_id: "0", + token_use: "access", + scope: "aws.cognito.signin.user.admin", + auth_time: new Date(), + jti: uuid.v4(), + client_id: "test", + username: "0000-0000", + }, + PrivateKey.pem, + { + algorithm: "RS256", + issuer: `http://localhost:9229/test`, + expiresIn: "24h", + keyid: "CognitoLocal", + } + ), + PreviousPassword: "abc", + ProposedPassword: "def", + }) + ).rejects.toEqual(new NotAuthorizedError()); + + expect(mockUserPoolService.saveUser).not.toHaveBeenCalled(); + }); + + it("throws if previous password doesn't match", async () => { + const user = TDB.user({ + Password: "previous-password", + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await expect( + changePassword(TestContext, { + AccessToken: jwt.sign( + { + sub: "0000-0000", + event_id: "0", + token_use: "access", + scope: "aws.cognito.signin.user.admin", + auth_time: new Date(), + jti: uuid.v4(), + client_id: "test", + username: "0000-0000", + }, + PrivateKey.pem, + { + algorithm: "RS256", + issuer: `http://localhost:9229/test`, + expiresIn: "24h", + keyid: "CognitoLocal", + } + ), + PreviousPassword: "abc", + ProposedPassword: "def", + }) + ).rejects.toEqual(new InvalidPasswordError()); + + expect(mockUserPoolService.saveUser).not.toHaveBeenCalled(); + }); + + it("updates the user's password if the previous password matches", async () => { + const user = TDB.user({ + Password: "previous-password", + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await changePassword(TestContext, { + AccessToken: jwt.sign( + { + sub: "0000-0000", + event_id: "0", + token_use: "access", + scope: "aws.cognito.signin.user.admin", + auth_time: new Date(), + jti: uuid.v4(), + client_id: "test", + username: "0000-0000", + }, + PrivateKey.pem, + { + algorithm: "RS256", + issuer: `http://localhost:9229/test`, + expiresIn: "24h", + keyid: "CognitoLocal", + } + ), + PreviousPassword: "previous-password", + ProposedPassword: "new-password", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Password: "new-password", + UserLastModifiedDate: currentDate, + }); + }); +}); diff --git a/src/targets/changePassword.ts b/src/targets/changePassword.ts index dac2f9fb..124f48da 100644 --- a/src/targets/changePassword.ts +++ b/src/targets/changePassword.ts @@ -4,7 +4,12 @@ import { } from "aws-sdk/clients/cognitoidentityserviceprovider"; import jwt from "jsonwebtoken"; import { Services } from "../services"; -import { NotAuthorizedError } from "../errors"; +import { + InvalidParameterError, + InvalidPasswordError, + NotAuthorizedError, +} from "../errors"; +import { Token } from "../services/tokenGenerator"; import { Target } from "./router"; export type ChangePasswordTarget = Target< @@ -12,19 +17,30 @@ export type ChangePasswordTarget = Target< ChangePasswordResponse >; +type ChangePasswordServices = Pick; + export const ChangePassword = - ({ cognito, clock }: Services): ChangePasswordTarget => + ({ cognito, clock }: ChangePasswordServices): ChangePasswordTarget => async (ctx, req) => { - const claims = jwt.decode(req.AccessToken) as any; + const decodedToken = jwt.decode(req.AccessToken) as Token | null; + if (!decodedToken) { + ctx.logger.info("Unable to decode token"); + throw new InvalidParameterError(); + } + const userPool = await cognito.getUserPoolForClientId( ctx, - claims.client_id + decodedToken.client_id ); - const user = await userPool.getUserByUsername(ctx, claims.username); + const user = await userPool.getUserByUsername(ctx, decodedToken.username); if (!user) { throw new NotAuthorizedError(); } - // TODO: Should check previous password. + + if (req.PreviousPassword !== user.Password) { + throw new InvalidPasswordError(); + } + await userPool.saveUser(ctx, { ...user, Password: req.ProposedPassword,