diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f986c0..9dc1d71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,7 @@ name: test on: - pull_request: - branches: - - main + push: workflow_dispatch: jobs: diff --git a/README.md b/README.md index d6156f0..df3dd53 100644 --- a/README.md +++ b/README.md @@ -28,91 +28,141 @@ Here is an example of how you would use the helper to implement SRP authenticati const secretHash = createSecretHash(username, clientId, secretId); const srpSession = createSrpSession(username, password, poolId, false); -const initiateAuthRes = await cognitoIdentityProviderClient - .send( - new InitiateAuthCommand( - wrapInitiateAuth(srpSession, { - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - // . . . - }); +const initiateAuthRes = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( + wrapInitiateAuth(srpSession, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), +); const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); -const respondToAuthChallengeRes = await cognitoIdentityProviderClient - .send( - new RespondToAuthChallengeCommand( - wrapAuthChallenge(signedSrpSession, { - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - // . . . - }); - -// . . . return login tokens from respondToAuthChallengeResponse +const respondToAuthChallengeRes = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), +); + +// . . . return login tokens from respondToAuthChallengeRes ``` -Here is an example of how you would use the helper to implement SRP authentication with Cognito using the AWS JavaScript SDK v2 (deprecated) using a pre-hashed password: +Here is an example of how you would use createDeviceVerifier to confirm a device: + +```ts +// Calculate device verifier and a random password using the device and group key + +const { DeviceGroupKey, DeviceKey } = respondToAuthChallengeResponse.AuthenticationResult.NewDeviceMetadata; +const { DeviceSecretVerifierConfig, DeviceRandomPassword } = createDeviceVerifier(DeviceKey, DeviceGroupKey); + +await cognitoIdentityProviderClient.send( + new ConfirmDeviceCommand({ + AccessToken, + DeviceKey, + DeviceName: "example-friendly-name", // usually this is set a User-Agent + DeviceSecretVerifierConfig, + }), +); +``` + +Here is an exampe of how you would use signSrpSessionWithDevice to complete signin with a device. Remember you need DeviceKey to complete authentication with a device, so store in on your initial signin attempt before it's required for subsequent authentication attempts with a device. DeviceGroupKey can be obtained from RespondToAuthChallenge responses: ```ts // . . . obtain user credentials, IDs, and setup Cognito client -const secretHash = createSecretHash(username, clientId, secretId); -const passwordHash = createPasswordHash(username, password, poolId); -const srpSession = createSrpSession(username, passwordHash, poolId); +// Initiate signin with username and password + +const srpSession = createSrpSession(username, password, poolId, false); -const initiateAuthRes = await cognitoIdentityServiceProvider - .initiateAuth( +const initiateAuthRes = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( wrapInitiateAuth(srpSession, { - ClientId: CLIENT_ID, + ClientId: clientId, AuthFlow: "USER_SRP_AUTH", AuthParameters: { CHALLENGE_NAME: "SRP_A", SECRET_HASH: secretHash, USERNAME: username, + DEVICE_KEY: DeviceKey, // Fetch this from client storage }, }), - ) - .promise() - .catch((err) => { - throw err; - }); + ), +); + +// Respond to PASSWORD_VERIFIER challenge const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); -const respondToAuthChallengeRes = await cognitoIdentityServiceProvider - .respondToAuthChallenge( +const respondToAuthChallengeRes1 = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( wrapAuthChallenge(signedSrpSession, { - ClientId: CLIENT_ID, + ClientId: clientId, ChallengeName: "PASSWORD_VERIFIER", ChallengeResponses: { SECRET_HASH: secretHash, USERNAME: username, + DEVICE_KEY: DeviceKey, }, + Session: initiateAuthRes.Session, }), - ) - .promise() - .catch((err) => { - throw err; - }); + ), +); -// . . . return login tokens from respondToAuthChallengeResponse +// Respond to DEVICE_SRP_AUTH challenge + +const respondToAuthChallengeRes2 = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession, { + ClientId: clientId, + ChallengeName: "DEVICE_SRP_AUTH", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes1.Session, + }), + ), +); + +// Respond to DEVICE_PASSWORD_VERIFIER challenge + +const signedSrpSessionWithDevice = signSrpSessionWithDevice( + srpSession, + respondToAuthChallengeRes2, + DeviceGroupKey, + DeviceRandomPassword, +); + +const respondToAuthChallengeRes3 = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSessionWithDevice, { + ClientId: clientId, + ChallengeName: "DEVICE_PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes2.Session, + }), + ), +); + +// . . . return login tokens from respondToAuthChallengeRes3 ``` ## API @@ -177,6 +227,20 @@ _SrpSession_ - Client SRP session object containing user credentials and session --- +### `createDeviceVerifier` + +When you confirm a device with ConfirmDeviceCommand you need to pass in DeviceSecretVerifierConfig. You can get this value from this function. The function will also generate a unique password DeviceRandomPassword which you will need to authenticate the device in future DEVICE_SRP_AUTH flows + +`deviceKey` - _string_ - The device unique key returned from a RespondToAuthChallengeResponse + +`deviceGroupKey` - _string_ - The device group key returned from a RespondToAuthChallengeResponse + +**Returns**: + +_DeviceVerifier_ - An object containing DeviceRandomPassword, PasswordVerifier, and Salt. Used for device verification and authentication + +--- + ### `signSrpSession` With a successful initiateAuth call using the USER_SRP_AUTH flow (or CUSTOM_AUTH if SRP is configured) we receive values from Cognito that we can use to verify the user's password. With this response we can 'sign' our session by generating a password signature and attaching it to our session @@ -193,6 +257,26 @@ _SrpSessionSigned_ - A signed version of the SRP session object --- +### `signSrpSessionWithDevice` + +When responding to a DEVICE_SRP_AUTH challenge, you need to sign the SRP session with a device using this function. With a RespondToAuthChallenge response we can 'sign' our session by generating a password signature and attaching it to our session + +**Parameters**: + +`session` - _SrpSession_ - Client SRP session object containing user credentials and session keys + +`response` - _RespondToAuthChallengeResponse_ - The Cognito response from initiateAuth. This response contains SRP values (SRP_B, SALT, SECRET_BLOCK, and DEVICE_KEY when authenticating a device) which are used to verify the user's password + +`deviceGroupKey` - _string_ - The device group key + +`deviceRandomPassword` - _string_ - The random password generated by createDeviceVerifier + +**Returns**: + +_SrpSessionSigned_ - A signed version of the SRP session object + +--- + ### `wrapInitiateAuth` Wraps a _InitiateAuthRequest_ and attaches the SRP_A field required to initiate SRP diff --git a/package-lock.json b/package-lock.json index 14a08b1..18d2db7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "lodash.omit": "^4.5.0", "prettier": "^3.0.2", "randexp": "^0.5.3", + "totp-generator": "^1.0.0", "ts-jest": "^29.0.3", "typescript": "^4.8.4" } @@ -6757,6 +6758,15 @@ "node": ">=6" } }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -8312,6 +8322,15 @@ "node": ">=8.0" } }, + "node_modules/totp-generator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/totp-generator/-/totp-generator-1.0.0.tgz", + "integrity": "sha512-Iu/1Lk60/MH8FE+5cDWPiGbwKK1hxzSq+KT9oSqhZ1BEczGIKGcN50bP0WMLiIZKRg7t29iWLxw6f81TICQdoA==", + "dev": true, + "dependencies": { + "jssha": "^3.3.1" + } + }, "node_modules/ts-jest": { "version": "29.0.3", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.3.tgz", @@ -13899,6 +13918,12 @@ "integrity": "sha512-46Tk9JiOL2z7ytNQWFLpj99RZkVgeHf87yGQKsIkaPz1qSH9UczKH1rO7K3wgRselo0tYMUNfecYpm/p1vC7tQ==", "dev": true }, + "jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "dev": true + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -15003,6 +15028,15 @@ "is-number": "^7.0.0" } }, + "totp-generator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/totp-generator/-/totp-generator-1.0.0.tgz", + "integrity": "sha512-Iu/1Lk60/MH8FE+5cDWPiGbwKK1hxzSq+KT9oSqhZ1BEczGIKGcN50bP0WMLiIZKRg7t29iWLxw6f81TICQdoA==", + "dev": true, + "requires": { + "jssha": "^3.3.1" + } + }, "ts-jest": { "version": "29.0.3", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.3.tgz", diff --git a/package.json b/package.json index b4b3365..157b94e 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "prettier": "^3.0.2", "randexp": "^0.5.3", "ts-jest": "^29.0.3", - "typescript": "^4.8.4" + "typescript": "^4.8.4", + "totp-generator": "^1.0.0" }, "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.433.0", diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts new file mode 100644 index 0000000..6cbde36 --- /dev/null +++ b/src/__tests__/integration/helpers.ts @@ -0,0 +1,53 @@ +import { CognitoIdentityProviderClient, SignUpCommand } from "@aws-sdk/client-cognito-identity-provider"; +import { CognitoIdentityServiceProvider } from "aws-sdk"; + +import { createSecretHash } from "../../cognito-srp-helper"; + +type SignupOptionsV2 = { + username: string; + password: string; + cognitoIdentityServiceProvider: CognitoIdentityServiceProvider; + clientId: string; + secretId: string; +}; + +export const signupV2 = async (options: SignupOptionsV2) => { + const { username, password, cognitoIdentityServiceProvider, clientId, secretId } = options; + const secretHash = createSecretHash(username, clientId, secretId); + + return cognitoIdentityServiceProvider + .signUp({ + ClientId: clientId, + Username: username, + Password: password, + SecretHash: secretHash, + }) + .promise(); +}; + +type SignupOptionsV3 = { + username: string; + password: string; + cognitoIdentityProviderClient: CognitoIdentityProviderClient; + clientId: string; + secretId: string; +}; + +export const signupV3 = async (options: SignupOptionsV3) => { + const { username, password, cognitoIdentityProviderClient, clientId, secretId } = options; + const secretHash = createSecretHash(username, clientId, secretId); + + await cognitoIdentityProviderClient + .send( + // There's a pre-signup trigger to auto-confirm new users, so no need to Confirm post signup + new SignUpCommand({ + ClientId: clientId, + Username: username, + Password: password, + SecretHash: secretHash, + }), + ) + .catch((err) => { + throw err; + }); +}; diff --git a/src/__tests__/integration/sdk-v2-device-flow.test.ts b/src/__tests__/integration/sdk-v2-device-flow.test.ts new file mode 100644 index 0000000..6e677c1 --- /dev/null +++ b/src/__tests__/integration/sdk-v2-device-flow.test.ts @@ -0,0 +1,668 @@ +import { faker } from "@faker-js/faker"; +import { CognitoIdentityServiceProvider } from "aws-sdk"; +import dotenv from "dotenv"; +import path from "path"; +import RandExp from "randexp"; +import { TOTP } from "totp-generator"; + +import { + createDeviceVerifier, + createPasswordHash, + createSecretHash, + createSrpSession, + signSrpSession, + signSrpSessionWithDevice, + wrapAuthChallenge, + wrapInitiateAuth, +} from "../../cognito-srp-helper"; + +import { signupV2 } from "./helpers"; + +// Load in env variables from .env if it / they exist.. + +dotenv.config({ + path: path.join(process.cwd(), ".env"), +}); + +// Assert environment variables exist before we begin + +const { + AWS_REGION = "", + AWS_ACCESS_KEY_ID = "", + AWS_SECRET_ACCESS_KEY = "", + INT_TEST__USERNAME__POOL_ID = "", + INT_TEST__USERNAME__CLIENT_ID = "", + INT_TEST__USERNAME__SECRET_ID = "", + INT_TEST__EMAIL__POOL_ID = "", + INT_TEST__EMAIL__CLIENT_ID = "", + INT_TEST__EMAIL__SECRET_ID = "", + INT_TEST__PHONE__POOL_ID = "", + INT_TEST__PHONE__CLIENT_ID = "", + INT_TEST__PHONE__SECRET_ID = "", +} = process.env; + +Object.entries({ + AWS_REGION, + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + INT_TEST__USERNAME__POOL_ID, + INT_TEST__USERNAME__CLIENT_ID, + INT_TEST__USERNAME__SECRET_ID, + INT_TEST__EMAIL__POOL_ID, + INT_TEST__EMAIL__CLIENT_ID, + INT_TEST__EMAIL__SECRET_ID, + INT_TEST__PHONE__POOL_ID, + INT_TEST__PHONE__CLIENT_ID, + INT_TEST__PHONE__SECRET_ID, +}).forEach(([key, value]) => { + if (value === "") { + throw new ReferenceError(` + Integration test could not run because ${key} is undefined or empty. + + If you are running this project locally and you need to setup integration + tests, make sure you follow the guide in CONTRIBUTING. Alternatively, you + can run just the unit tests locally as the integration tests will be + triggered when you push to the remote repo + `); + } +}); + +const wait = async (time: number) => new Promise((resolve) => setTimeout(resolve, time - new Date().getTime())); + +const createCredentials = () => [ + { + testCaseName: "random username and a pre-hashed password", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: true, + }, + { + testCaseName: "random username", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random email", + username: faker.internet.email(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__EMAIL__POOL_ID, + clientId: INT_TEST__EMAIL__CLIENT_ID, + secretId: INT_TEST__EMAIL__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random phone", + username: new RandExp(/^\+1\d{10}$/).gen(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__PHONE__POOL_ID, + clientId: INT_TEST__PHONE__CLIENT_ID, + secretId: INT_TEST__PHONE__SECRET_ID, + isPreHashedPassword: false, + }, +]; + +describe("SDK v2 integration - DEVICE_SRP_AUTH flow", () => { + const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider({ + region: AWS_REGION, + credentials: { + accessKeyId: AWS_ACCESS_KEY_ID, + secretAccessKey: AWS_SECRET_ACCESS_KEY, + }, + }); + + describe("normal flow", () => { + const credentials = createCredentials(); + + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV2({ + cognitoIdentityServiceProvider, + ...creds, + }), + ), + ), + ); + + it.each(credentials)( + "$testCaseName", + async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession1 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + // ---------- Signin 1. initiate signin attempt ---------- + + const initiateAuthRes1 = await cognitoIdentityServiceProvider + .initiateAuth( + wrapInitiateAuth(srpSession1, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Signin 1. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession1 = signSrpSession(srpSession1, initiateAuthRes1); + + const respondToAuthChallengeRes1a = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSession1, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Associate a TOTP token with the user ---------- + + const AccessToken = respondToAuthChallengeRes1a.AuthenticationResult?.AccessToken; + if (!AccessToken) throw Error("AccessToken is undefined"); + + const associateSoftwareTokenRes = await cognitoIdentityServiceProvider + .associateSoftwareToken({ + AccessToken, + }) + .promise(); + + // ---------- Verify the TOTP token with the user ---------- + + const { SecretCode } = associateSoftwareTokenRes; + if (!SecretCode) throw Error("SecretCode is undefined"); + const { otp: otp1, expires } = TOTP.generate(SecretCode); + + await cognitoIdentityServiceProvider + .verifySoftwareToken({ + AccessToken, + UserCode: otp1, + }) + .promise(); + + // ---------- Set MFA preference to TOTP ---------- + + await cognitoIdentityServiceProvider + .setUserMFAPreference({ + AccessToken, + SoftwareTokenMfaSettings: { + // won't work unless we associate and verify TOTP token with user + Enabled: true, + PreferredMfa: true, + }, + }) + .promise(); + + // ---------- Wait for a new OTP to generate ---------- + + await wait(expires); + + // ---------- Signin 2. initiate signin attempt ---------- + + const srpSession2 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + const initiateAuthRes2 = await cognitoIdentityServiceProvider + .initiateAuth( + wrapInitiateAuth(srpSession2, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Signin 2. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession2 = signSrpSession(srpSession2, initiateAuthRes2); + const USER_ID_FOR_SRP = initiateAuthRes2.ChallengeParameters?.USER_ID_FOR_SRP; + if (!USER_ID_FOR_SRP) throw Error("USER_ID_FOR_SRP is undefined"); + const secretHash2 = createSecretHash(USER_ID_FOR_SRP, clientId, secretId); + + const respondToAuthChallengeRes2a = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSession2, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash2, + USERNAME: USER_ID_FOR_SRP, + }, + }), + ) + .promise(); + + // ---------- Signin 2. respond to SOFTWARE_TOKEN_MFA challenge ---------- + + const { otp: otp2 } = TOTP.generate(SecretCode); + const { Session: Session2a } = respondToAuthChallengeRes2a; + + const respondToAuthChallengeRes2b = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSession2, { + ClientId: clientId, + ChallengeName: "SOFTWARE_TOKEN_MFA", + ChallengeResponses: { + SECRET_HASH: secretHash2, + SOFTWARE_TOKEN_MFA_CODE: otp2, + USERNAME: USER_ID_FOR_SRP, + }, + Session: Session2a, + }), + ) + .promise(); + + // ---------- Confirm the device (for tracking) ---------- + + const DeviceGroupKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + const DeviceKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + if (!DeviceKey) throw Error("DeviceKey is undefined"); + const { DeviceSecretVerifierConfig, DeviceRandomPassword } = createDeviceVerifier(DeviceKey, DeviceGroupKey); + + await cognitoIdentityServiceProvider + .confirmDevice({ + AccessToken, + DeviceKey, + DeviceName: "example-friendly-name", // usually this is set a User-Agent + DeviceSecretVerifierConfig, + }) + .promise(); + + // ---------- Remember the device (for easier logins) ---------- + + await cognitoIdentityServiceProvider + .updateDeviceStatus({ + AccessToken, + DeviceKey, + DeviceRememberedStatus: "remembered", + }) + .promise(); + + // ---------- Signin 3. initiate signin attempt ---------- + + const srpSession3 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes3 = await cognitoIdentityServiceProvider + .initiateAuth( + wrapInitiateAuth(srpSession3, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + }), + ) + .promise(); + + // ---------- Signin 3. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession3 = signSrpSession(srpSession3, initiateAuthRes3); + + const respondToAuthChallengeRes3a = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSession3, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: initiateAuthRes3.Session, + }), + ) + .promise(); + + // ---------- Signin 3. respond to DEVICE_SRP_AUTH challenge ---------- + + const respondToAuthChallengeRes3b = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSession3, { + ClientId: clientId, + ChallengeName: "DEVICE_SRP_AUTH", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3a.Session, + }), + ) + .promise(); + + // ---------- Signin 3. respond to DEVICE_PASSWORD_VERIFIER challenge ---------- + + const signedSrpSessionWithDevice3 = signSrpSessionWithDevice( + srpSession3, + respondToAuthChallengeRes3b, + DeviceGroupKey, + DeviceRandomPassword, + ); + + const respondToAuthChallengeRes3c = await cognitoIdentityServiceProvider + .respondToAuthChallenge( + wrapAuthChallenge(signedSrpSessionWithDevice3, { + ClientId: clientId, + ChallengeName: "DEVICE_PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3b.Session, + }), + ) + .promise(); + + expect(respondToAuthChallengeRes3c).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("RefreshToken"); + }, + 1000 * 35 /* 35 seconds = 30 seconds to account for OTP expiration + 5 seconds for standard timeout */, + ); + }); + + describe("admin flow", () => { + const credentials = createCredentials(); + + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV2({ + cognitoIdentityServiceProvider, + ...creds, + }), + ), + ), + ); + + it.each(credentials)( + "$testCaseName", + async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession1 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + // ---------- Signin 1. initiate signin attempt ---------- + + const initiateAuthRes1 = await cognitoIdentityServiceProvider + .adminInitiateAuth( + wrapInitiateAuth(srpSession1, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Signin 1. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession1 = signSrpSession(srpSession1, initiateAuthRes1); + + const respondToAuthChallengeRes1a = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSession1, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Associate a TOTP token with the user ---------- + + const AccessToken = respondToAuthChallengeRes1a.AuthenticationResult?.AccessToken; + if (!AccessToken) throw Error("AccessToken is undefined"); + + const associateSoftwareTokenRes = await cognitoIdentityServiceProvider + .associateSoftwareToken({ + AccessToken, + }) + .promise(); + + // ---------- Verify the TOTP token with the user ---------- + + const { SecretCode } = associateSoftwareTokenRes; + if (!SecretCode) throw Error("SecretCode is undefined"); + const { otp: otp1, expires } = TOTP.generate(SecretCode); + + await cognitoIdentityServiceProvider + .verifySoftwareToken({ + AccessToken, + UserCode: otp1, + }) + .promise(); + + // ---------- Set MFA preference to TOTP ---------- + + await cognitoIdentityServiceProvider + .adminSetUserMFAPreference({ + UserPoolId: poolId, + Username: username, + SoftwareTokenMfaSettings: { + // won't work unless we associate and verify TOTP token with user + Enabled: true, + PreferredMfa: true, + }, + }) + .promise(); + + // ---------- Wait for a new OTP to generate ---------- + + await wait(expires); + + // ---------- Signin 2. initiate signin attempt ---------- + + const srpSession2 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + const initiateAuthRes2 = await cognitoIdentityServiceProvider + .adminInitiateAuth( + wrapInitiateAuth(srpSession2, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ) + .promise(); + + // ---------- Signin 2. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession2 = signSrpSession(srpSession2, initiateAuthRes2); + const USER_ID_FOR_SRP = initiateAuthRes2.ChallengeParameters?.USER_ID_FOR_SRP; + if (!USER_ID_FOR_SRP) throw Error("USER_ID_FOR_SRP is undefined"); + const secretHash2 = createSecretHash(USER_ID_FOR_SRP, clientId, secretId); + + const respondToAuthChallengeRes2a = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSession2, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash2, + USERNAME: USER_ID_FOR_SRP, + }, + }), + ) + .promise(); + + // ---------- Signin 2. respond to SOFTWARE_TOKEN_MFA challenge ---------- + + const { otp: otp2 } = TOTP.generate(SecretCode); + const { Session: Session2a } = respondToAuthChallengeRes2a; + + const respondToAuthChallengeRes2b = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSession2, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "SOFTWARE_TOKEN_MFA", + ChallengeResponses: { + SECRET_HASH: secretHash2, + SOFTWARE_TOKEN_MFA_CODE: otp2, + USERNAME: USER_ID_FOR_SRP, + }, + Session: Session2a, + }), + ) + .promise(); + + // ---------- Confirm the device (for tracking) ---------- + + const DeviceGroupKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + const DeviceKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + if (!DeviceKey) throw Error("DeviceKey is undefined"); + const { DeviceSecretVerifierConfig, DeviceRandomPassword } = createDeviceVerifier(DeviceKey, DeviceGroupKey); + + await cognitoIdentityServiceProvider + .confirmDevice({ + AccessToken, + DeviceKey, + DeviceName: "example-friendly-name", // usually this is set a User-Agent + DeviceSecretVerifierConfig, + }) + .promise(); + + // ---------- Remember the device (for easier logins) ---------- + + await cognitoIdentityServiceProvider + .adminUpdateDeviceStatus({ + UserPoolId: poolId, + Username: USER_ID_FOR_SRP, + DeviceKey, + DeviceRememberedStatus: "remembered", + }) + .promise(); + + // ---------- Signin 3. initiate signin attempt ---------- + + const srpSession3 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes3 = await cognitoIdentityServiceProvider + .adminInitiateAuth( + wrapInitiateAuth(srpSession3, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + }), + ) + .promise(); + + // ---------- Signin 3. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession3 = signSrpSession(srpSession3, initiateAuthRes3); + + const respondToAuthChallengeRes3a = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSession3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: initiateAuthRes3.Session, + }), + ) + .promise(); + + // ---------- Signin 3. respond to DEVICE_SRP_AUTH challenge ---------- + + const respondToAuthChallengeRes3b = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSession3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "DEVICE_SRP_AUTH", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3a.Session, + }), + ) + .promise(); + + // ---------- Signin 3. respond to DEVICE_PASSWORD_VERIFIER challenge ---------- + + const signedSrpSessionWithDevice3 = signSrpSessionWithDevice( + srpSession3, + respondToAuthChallengeRes3b, + DeviceGroupKey, + DeviceRandomPassword, + ); + + const respondToAuthChallengeRes3c = await cognitoIdentityServiceProvider + .adminRespondToAuthChallenge( + wrapAuthChallenge(signedSrpSessionWithDevice3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "DEVICE_PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3b.Session, + }), + ) + .promise(); + + expect(respondToAuthChallengeRes3c).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("RefreshToken"); + }, + 1000 * 35 /* 35 seconds = 30 seconds to account for OTP expiration + 5 seconds for standard timeout */, + ); + }); +}); diff --git a/src/__tests__/integration/sdk-v2.test.ts b/src/__tests__/integration/sdk-v2-standard-flow.test.ts similarity index 51% rename from src/__tests__/integration/sdk-v2.test.ts rename to src/__tests__/integration/sdk-v2-standard-flow.test.ts index 58d1b53..36010ee 100644 --- a/src/__tests__/integration/sdk-v2.test.ts +++ b/src/__tests__/integration/sdk-v2-standard-flow.test.ts @@ -1,6 +1,8 @@ +import { faker } from "@faker-js/faker"; import { CognitoIdentityServiceProvider } from "aws-sdk"; import dotenv from "dotenv"; import path from "path"; +import RandExp from "randexp"; import { createPasswordHash, @@ -11,6 +13,8 @@ import { wrapInitiateAuth, } from "../../cognito-srp-helper"; +import { signupV2 } from "./helpers"; + // Load in env variables from .env if it / they exist.. dotenv.config({ @@ -23,18 +27,12 @@ const { AWS_REGION = "", AWS_ACCESS_KEY_ID = "", AWS_SECRET_ACCESS_KEY = "", - INT_TEST__USERNAME__USERNAME = "", - INT_TEST__USERNAME__PASSWORD = "", INT_TEST__USERNAME__POOL_ID = "", INT_TEST__USERNAME__CLIENT_ID = "", INT_TEST__USERNAME__SECRET_ID = "", - INT_TEST__EMAIL__USERNAME = "", - INT_TEST__EMAIL__PASSWORD = "", INT_TEST__EMAIL__POOL_ID = "", INT_TEST__EMAIL__CLIENT_ID = "", INT_TEST__EMAIL__SECRET_ID = "", - INT_TEST__PHONE__USERNAME = "", - INT_TEST__PHONE__PASSWORD = "", INT_TEST__PHONE__POOL_ID = "", INT_TEST__PHONE__CLIENT_ID = "", INT_TEST__PHONE__SECRET_ID = "", @@ -44,18 +42,12 @@ Object.entries({ AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, - INT_TEST__USERNAME__USERNAME, - INT_TEST__USERNAME__PASSWORD, INT_TEST__USERNAME__POOL_ID, INT_TEST__USERNAME__CLIENT_ID, INT_TEST__USERNAME__SECRET_ID, - INT_TEST__EMAIL__USERNAME, - INT_TEST__EMAIL__PASSWORD, INT_TEST__EMAIL__POOL_ID, INT_TEST__EMAIL__CLIENT_ID, INT_TEST__EMAIL__SECRET_ID, - INT_TEST__PHONE__USERNAME, - INT_TEST__PHONE__PASSWORD, INT_TEST__PHONE__POOL_ID, INT_TEST__PHONE__CLIENT_ID, INT_TEST__PHONE__SECRET_ID, @@ -72,31 +64,46 @@ Object.entries({ } }); -const positiveCredentials = { - username: { - username: INT_TEST__USERNAME__USERNAME, - password: INT_TEST__USERNAME__PASSWORD, +const credentials = [ + { + testCaseName: "random username and a pre-hashed password", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: true, + }, + { + testCaseName: "random username", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), poolId: INT_TEST__USERNAME__POOL_ID, clientId: INT_TEST__USERNAME__CLIENT_ID, secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: false, }, - email: { - username: INT_TEST__EMAIL__USERNAME, - password: INT_TEST__EMAIL__PASSWORD, + { + testCaseName: "random email", + username: faker.internet.email(), + password: faker.internet.password(20, true, undefined, "A1!"), poolId: INT_TEST__EMAIL__POOL_ID, clientId: INT_TEST__EMAIL__CLIENT_ID, secretId: INT_TEST__EMAIL__SECRET_ID, + isPreHashedPassword: false, }, - phone: { - username: INT_TEST__PHONE__USERNAME, - password: INT_TEST__PHONE__PASSWORD, + { + testCaseName: "random phone", + username: new RandExp(/^\+1\d{10}$/).gen(), + password: faker.internet.password(20, true, undefined, "A1!"), poolId: INT_TEST__PHONE__POOL_ID, clientId: INT_TEST__PHONE__CLIENT_ID, secretId: INT_TEST__PHONE__SECRET_ID, + isPreHashedPassword: false, }, -}; +]; -describe("SDK v2 integration", () => { +describe("SDK v2 integration - USER_SRP_AUTH flow", () => { const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider({ region: AWS_REGION, credentials: { @@ -105,11 +112,24 @@ describe("SDK v2 integration", () => { }, }); - it("should work with initiateAuth and respondToAuthChallenge (hashed password)", async () => { - const { username, password, poolId, clientId, secretId } = positiveCredentials.username; + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV2({ + cognitoIdentityServiceProvider, + ...creds, + }), + ), + ), + ); + + it.each(credentials)("normal flow with $testCaseName", async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + const secretHash = createSecretHash(username, clientId, secretId); - const passwordHash = createPasswordHash(username, password, poolId); - const srpSession = createSrpSession(username, passwordHash, poolId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); const initiateAuthRes = await cognitoIdentityServiceProvider .initiateAuth( @@ -151,11 +171,11 @@ describe("SDK v2 integration", () => { expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); }); - it("should work with adminInitiateAuth and adminRespondToAuthChallenge (hashed password)", async () => { - const { username, password, poolId, clientId, secretId } = positiveCredentials.username; + it.each(credentials)("admin flow with $testCaseName", async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; const secretHash = createSecretHash(username, clientId, secretId); - const passwordHash = createPasswordHash(username, password, poolId); - const srpSession = createSrpSession(username, passwordHash, poolId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); const adminInitiateAuthRes = await cognitoIdentityServiceProvider .adminInitiateAuth( @@ -198,100 +218,4 @@ describe("SDK v2 integration", () => { expect(adminRespondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); expect(adminRespondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); }); - - it.each(Object.values(positiveCredentials))( - "should work with initiateAuth and respondToAuthChallenge (unhashed password): credentials %#", - async ({ username, password, poolId, clientId, secretId }) => { - const secretHash = createSecretHash(username, clientId, secretId); - const srpSession = createSrpSession(username, password, poolId, false); - - const initiateAuthRes = await cognitoIdentityServiceProvider - .initiateAuth( - wrapInitiateAuth(srpSession, { - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ) - .promise() - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); - - const respondToAuthChallengeRes = await cognitoIdentityServiceProvider - .respondToAuthChallenge( - wrapAuthChallenge(signedSrpSession, { - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ) - .promise() - .catch((err) => { - throw err; - }); - - expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }, - ); - - it.each(Object.values(positiveCredentials))( - "should work with adminInitiateAuth and adminRespondToAuthChallenge (unhashed password): credentials %#", - async ({ username, password, poolId, clientId, secretId }) => { - const secretHash = createSecretHash(username, clientId, secretId); - const srpSession = createSrpSession(username, password, poolId, false); - - const adminInitiateAuthRes = await cognitoIdentityServiceProvider - .adminInitiateAuth( - wrapInitiateAuth(srpSession, { - UserPoolId: poolId, - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ) - .promise() - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, adminInitiateAuthRes); - - const adminRespondToAuthChallengeRes = await cognitoIdentityServiceProvider - .adminRespondToAuthChallenge( - wrapAuthChallenge(signedSrpSession, { - UserPoolId: poolId, - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ) - .promise() - .catch((err) => { - throw err; - }); - - expect(adminRespondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(adminRespondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(adminRespondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }, - ); }); diff --git a/src/__tests__/integration/sdk-v3-device-flow.test.ts b/src/__tests__/integration/sdk-v3-device-flow.test.ts new file mode 100644 index 0000000..648d112 --- /dev/null +++ b/src/__tests__/integration/sdk-v3-device-flow.test.ts @@ -0,0 +1,673 @@ +import { + AdminInitiateAuthCommand, + AdminRespondToAuthChallengeCommand, + AdminSetUserMFAPreferenceCommand, + AdminUpdateDeviceStatusCommand, + AssociateSoftwareTokenCommand, + CognitoIdentityProviderClient, + ConfirmDeviceCommand, + InitiateAuthCommand, + RespondToAuthChallengeCommand, + SetUserMFAPreferenceCommand, + UpdateDeviceStatusCommand, + VerifySoftwareTokenCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import { faker } from "@faker-js/faker"; +import dotenv from "dotenv"; +import path from "path"; +import RandExp from "randexp"; +import { TOTP } from "totp-generator"; + +import { + createDeviceVerifier, + createPasswordHash, + createSecretHash, + createSrpSession, + signSrpSession, + signSrpSessionWithDevice, + wrapAuthChallenge, + wrapInitiateAuth, +} from "../../cognito-srp-helper"; + +import { signupV3 } from "./helpers"; + +// Load in env variables from .env if it / they exist.. + +dotenv.config({ + path: path.join(process.cwd(), ".env"), +}); + +// Assert environment variables exist before we begin + +const { + AWS_REGION = "", + INT_TEST__USERNAME__POOL_ID = "", + INT_TEST__USERNAME__CLIENT_ID = "", + INT_TEST__USERNAME__SECRET_ID = "", + INT_TEST__EMAIL__POOL_ID = "", + INT_TEST__EMAIL__CLIENT_ID = "", + INT_TEST__EMAIL__SECRET_ID = "", + INT_TEST__PHONE__POOL_ID = "", + INT_TEST__PHONE__CLIENT_ID = "", + INT_TEST__PHONE__SECRET_ID = "", +} = process.env; + +Object.entries({ + AWS_REGION, + INT_TEST__USERNAME__POOL_ID, + INT_TEST__USERNAME__CLIENT_ID, + INT_TEST__USERNAME__SECRET_ID, + INT_TEST__EMAIL__POOL_ID, + INT_TEST__EMAIL__CLIENT_ID, + INT_TEST__EMAIL__SECRET_ID, + INT_TEST__PHONE__POOL_ID, + INT_TEST__PHONE__CLIENT_ID, + INT_TEST__PHONE__SECRET_ID, +}).forEach(([key, value]) => { + if (value === "") { + throw new ReferenceError(` + Integration test could not run because ${key} is undefined or empty. + + If you are running this project locally and you need to setup integration + tests, make sure you follow the guide in CONTRIBUTING. Alternatively, you + can run just the unit tests locally as the integration tests will be + triggered when you push to the remote repo + `); + } +}); + +const wait = async (time: number) => new Promise((resolve) => setTimeout(resolve, time - new Date().getTime())); + +const createCredentials = () => [ + { + testCaseName: "random username and a pre-hashed password", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: true, + }, + { + testCaseName: "random username", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random email", + username: faker.internet.email(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__EMAIL__POOL_ID, + clientId: INT_TEST__EMAIL__CLIENT_ID, + secretId: INT_TEST__EMAIL__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random phone", + username: new RandExp(/^\+1\d{10}$/).gen(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__PHONE__POOL_ID, + clientId: INT_TEST__PHONE__CLIENT_ID, + secretId: INT_TEST__PHONE__SECRET_ID, + isPreHashedPassword: false, + }, +]; + +describe("SDK v3 integration - DEVICE_SRP_AUTH flow", () => { + const cognitoIdentityProviderClient = new CognitoIdentityProviderClient({ + region: "eu-west-2", + }); + + describe("normal flow", () => { + const credentials = createCredentials(); + + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV3({ + cognitoIdentityProviderClient, + ...creds, + }), + ), + ), + ); + + it.each(credentials)( + "$testCaseName", + async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession1 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + // ---------- Signin 1. initiate signin attempt ---------- + + const initiateAuthRes1 = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( + wrapInitiateAuth(srpSession1, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Signin 1. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession1 = signSrpSession(srpSession1, initiateAuthRes1); + + const respondToAuthChallengeRes1a = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession1, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Associate a TOTP token with the user ---------- + + const AccessToken = respondToAuthChallengeRes1a.AuthenticationResult?.AccessToken; + if (!AccessToken) throw Error("AccessToken is undefined"); + + const associateSoftwareTokenRes = await cognitoIdentityProviderClient.send( + new AssociateSoftwareTokenCommand({ + AccessToken, + }), + ); + + // ---------- Verify the TOTP token with the user ---------- + + const { SecretCode } = associateSoftwareTokenRes; + if (!SecretCode) throw Error("SecretCode is undefined"); + const { otp: otp1, expires } = TOTP.generate(SecretCode); + + await cognitoIdentityProviderClient.send( + new VerifySoftwareTokenCommand({ + AccessToken, + UserCode: otp1, + }), + ); + + // ---------- Set MFA preference to TOTP ---------- + + await cognitoIdentityProviderClient.send( + new AdminSetUserMFAPreferenceCommand({ + UserPoolId: poolId, + Username: username, + SoftwareTokenMfaSettings: { + // won't work unless we associate and verify TOTP token with user + Enabled: true, + PreferredMfa: true, + }, + }), + ); + + // ---------- Wait for a new OTP to generate ---------- + + await wait(expires); + + // ---------- Signin 2. initiate signin attempt ---------- + + const srpSession2 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + const initiateAuthRes2 = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( + wrapInitiateAuth(srpSession2, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Signin 2. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession2 = signSrpSession(srpSession2, initiateAuthRes2); + const USER_ID_FOR_SRP = initiateAuthRes2.ChallengeParameters?.USER_ID_FOR_SRP; + if (!USER_ID_FOR_SRP) throw Error("USER_ID_FOR_SRP is undefined"); + const secretHash2 = createSecretHash(USER_ID_FOR_SRP, clientId, secretId); + + const respondToAuthChallengeRes2a = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession2, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash2, + USERNAME: USER_ID_FOR_SRP, + }, + }), + ), + ); + + // ---------- Signin 2. respond to SOFTWARE_TOKEN_MFA challenge ---------- + + const { otp: otp2 } = TOTP.generate(SecretCode); + const { Session: Session2a } = respondToAuthChallengeRes2a; + + const respondToAuthChallengeRes2b = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession2, { + ClientId: clientId, + ChallengeName: "SOFTWARE_TOKEN_MFA", + ChallengeResponses: { + SECRET_HASH: secretHash2, + SOFTWARE_TOKEN_MFA_CODE: otp2, + USERNAME: USER_ID_FOR_SRP, + }, + Session: Session2a, + }), + ), + ); + + // ---------- Confirm the device (for tracking) ---------- + + const DeviceGroupKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + const DeviceKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + if (!DeviceKey) throw Error("DeviceKey is undefined"); + const { DeviceSecretVerifierConfig, DeviceRandomPassword } = createDeviceVerifier(DeviceKey, DeviceGroupKey); + + await cognitoIdentityProviderClient.send( + new ConfirmDeviceCommand({ + AccessToken, + DeviceKey, + DeviceName: "example-friendly-name", // usually this is set a User-Agent + DeviceSecretVerifierConfig, + }), + ); + + // ---------- Remember the device (for easier logins) ---------- + + await cognitoIdentityProviderClient.send( + new UpdateDeviceStatusCommand({ + AccessToken, + DeviceKey, + DeviceRememberedStatus: "remembered", + }), + ); + + // ---------- Signin 3. initiate signin attempt ---------- + + const srpSession3 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes3 = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( + wrapInitiateAuth(srpSession3, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + }), + ), + ); + + // ---------- Signin 3. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession3 = signSrpSession(srpSession3, initiateAuthRes3); + + const respondToAuthChallengeRes3a = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession3, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: initiateAuthRes3.Session, + }), + ), + ); + + // ---------- Signin 3. respond to DEVICE_SRP_AUTH challenge ---------- + + const respondToAuthChallengeRes3b = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession3, { + ClientId: clientId, + ChallengeName: "DEVICE_SRP_AUTH", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3a.Session, + }), + ), + ); + + // ---------- Signin 3. respond to DEVICE_PASSWORD_VERIFIER challenge ---------- + + const signedSrpSessionWithDevice3 = signSrpSessionWithDevice( + srpSession3, + respondToAuthChallengeRes3b, + DeviceGroupKey, + DeviceRandomPassword, + ); + + const respondToAuthChallengeRes3c = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSessionWithDevice3, { + ClientId: clientId, + ChallengeName: "DEVICE_PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3b.Session, + }), + ), + ); + + expect(respondToAuthChallengeRes3c).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("RefreshToken"); + }, + 1000 * 35 /* 35 seconds = 30 seconds to account for OTP expiration + 5 seconds for standard timeout */, + ); + }); + + describe("admin flow", () => { + const credentials = createCredentials(); + + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV3({ + cognitoIdentityProviderClient, + ...creds, + }), + ), + ), + ); + + it.each(credentials)( + "$testCaseName", + async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession1 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + // ---------- Signin 1. initiate signin attempt ---------- + + const initiateAuthRes1 = await cognitoIdentityProviderClient.send( + new AdminInitiateAuthCommand( + wrapInitiateAuth(srpSession1, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Signin 1. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession1 = signSrpSession(srpSession1, initiateAuthRes1); + + const respondToAuthChallengeRes1a = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession1, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Associate a TOTP token with the user ---------- + + const AccessToken = respondToAuthChallengeRes1a.AuthenticationResult?.AccessToken; + if (!AccessToken) throw Error("AccessToken is undefined"); + + const associateSoftwareTokenRes = await cognitoIdentityProviderClient.send( + new AssociateSoftwareTokenCommand({ + AccessToken, + }), + ); + + // ---------- Verify the TOTP token with the user ---------- + + const { SecretCode } = associateSoftwareTokenRes; + if (!SecretCode) throw Error("SecretCode is undefined"); + const { otp: otp1, expires } = TOTP.generate(SecretCode); + + await cognitoIdentityProviderClient.send( + new VerifySoftwareTokenCommand({ + AccessToken, + UserCode: otp1, + }), + ); + + // ---------- Set MFA preference to TOTP ---------- + + await cognitoIdentityProviderClient.send( + new SetUserMFAPreferenceCommand({ + AccessToken, + SoftwareTokenMfaSettings: { + // won't work unless we associate and verify TOTP token with user + Enabled: true, + PreferredMfa: true, + }, + }), + ); + + // ---------- Wait for a new OTP to generate ---------- + + await wait(expires); + + // ---------- Signin 2. initiate signin attempt ---------- + + const srpSession2 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + const initiateAuthRes2 = await cognitoIdentityProviderClient.send( + new AdminInitiateAuthCommand( + wrapInitiateAuth(srpSession2, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + // ---------- Signin 2. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession2 = signSrpSession(srpSession2, initiateAuthRes2); + const USER_ID_FOR_SRP = initiateAuthRes2.ChallengeParameters?.USER_ID_FOR_SRP; + if (!USER_ID_FOR_SRP) throw Error("USER_ID_FOR_SRP is undefined"); + const secretHash2 = createSecretHash(USER_ID_FOR_SRP, clientId, secretId); + + const respondToAuthChallengeRes2a = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession2, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash2, + USERNAME: USER_ID_FOR_SRP, + }, + }), + ), + ); + + // ---------- Signin 2. respond to SOFTWARE_TOKEN_MFA challenge ---------- + + const { otp: otp2 } = TOTP.generate(SecretCode); + const { Session: Session2a } = respondToAuthChallengeRes2a; + + const respondToAuthChallengeRes2b = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession2, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "SOFTWARE_TOKEN_MFA", + ChallengeResponses: { + SECRET_HASH: secretHash2, + SOFTWARE_TOKEN_MFA_CODE: otp2, + USERNAME: USER_ID_FOR_SRP, + }, + Session: Session2a, + }), + ), + ); + + // ---------- Confirm the device (for tracking) ---------- + + const DeviceGroupKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + const DeviceKey = respondToAuthChallengeRes2b?.AuthenticationResult?.NewDeviceMetadata?.DeviceKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + if (!DeviceKey) throw Error("DeviceKey is undefined"); + const { DeviceSecretVerifierConfig, DeviceRandomPassword } = createDeviceVerifier(DeviceKey, DeviceGroupKey); + + await cognitoIdentityProviderClient.send( + new ConfirmDeviceCommand({ + AccessToken, + DeviceKey, + DeviceName: "example-friendly-name", // usually this is set a User-Agent + DeviceSecretVerifierConfig, + }), + ); + + // ---------- Remember the device (for easier logins) ---------- + + await cognitoIdentityProviderClient.send( + new AdminUpdateDeviceStatusCommand({ + UserPoolId: poolId, + Username: USER_ID_FOR_SRP, + DeviceKey, + DeviceRememberedStatus: "remembered", + }), + ); + + // ---------- Signin 3. initiate signin attempt ---------- + + const srpSession3 = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes3 = await cognitoIdentityProviderClient.send( + new AdminInitiateAuthCommand( + wrapInitiateAuth(srpSession3, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + }), + ), + ); + + // ---------- Signin 3. respond to PASSWORD_VERIFIER challenge ---------- + + const signedSrpSession3 = signSrpSession(srpSession3, initiateAuthRes3); + + const respondToAuthChallengeRes3a = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: initiateAuthRes3.Session, + }), + ), + ); + + // ---------- Signin 3. respond to DEVICE_SRP_AUTH challenge ---------- + + const respondToAuthChallengeRes3b = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "DEVICE_SRP_AUTH", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3a.Session, + }), + ), + ); + + // ---------- Signin 3. respond to DEVICE_PASSWORD_VERIFIER challenge ---------- + + const signedSrpSessionWithDevice3 = signSrpSessionWithDevice( + srpSession3, + respondToAuthChallengeRes3b, + DeviceGroupKey, + DeviceRandomPassword, + ); + + const respondToAuthChallengeRes3c = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSessionWithDevice3, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "DEVICE_PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + DEVICE_KEY: DeviceKey, + }, + Session: respondToAuthChallengeRes3b.Session, + }), + ), + ); + + expect(respondToAuthChallengeRes3c).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes3c.AuthenticationResult).toHaveProperty("RefreshToken"); + }, + 1000 * 35 /* 35 seconds = 30 seconds to account for OTP expiration + 5 seconds for standard timeout */, + ); + }); +}); diff --git a/src/__tests__/integration/sdk-v3-standard-flow.test.ts b/src/__tests__/integration/sdk-v3-standard-flow.test.ts new file mode 100644 index 0000000..9aacf37 --- /dev/null +++ b/src/__tests__/integration/sdk-v3-standard-flow.test.ts @@ -0,0 +1,208 @@ +import { + AdminInitiateAuthCommand, + AdminRespondToAuthChallengeCommand, + CognitoIdentityProviderClient, + InitiateAuthCommand, + RespondToAuthChallengeCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import { faker } from "@faker-js/faker"; +import dotenv from "dotenv"; +import path from "path"; +import RandExp from "randexp"; + +import { + createPasswordHash, + createSecretHash, + createSrpSession, + signSrpSession, + wrapAuthChallenge, + wrapInitiateAuth, +} from "../../cognito-srp-helper"; + +import { signupV3 } from "./helpers"; + +// Load in env variables from .env if it / they exist.. + +dotenv.config({ + path: path.join(process.cwd(), ".env"), +}); + +// Assert environment variables exist before we begin + +const { + AWS_REGION = "", + INT_TEST__USERNAME__POOL_ID = "", + INT_TEST__USERNAME__CLIENT_ID = "", + INT_TEST__USERNAME__SECRET_ID = "", + INT_TEST__EMAIL__POOL_ID = "", + INT_TEST__EMAIL__CLIENT_ID = "", + INT_TEST__EMAIL__SECRET_ID = "", + INT_TEST__PHONE__POOL_ID = "", + INT_TEST__PHONE__CLIENT_ID = "", + INT_TEST__PHONE__SECRET_ID = "", +} = process.env; + +Object.entries({ + AWS_REGION, + INT_TEST__USERNAME__POOL_ID, + INT_TEST__USERNAME__CLIENT_ID, + INT_TEST__USERNAME__SECRET_ID, + INT_TEST__EMAIL__POOL_ID, + INT_TEST__EMAIL__CLIENT_ID, + INT_TEST__EMAIL__SECRET_ID, + INT_TEST__PHONE__POOL_ID, + INT_TEST__PHONE__CLIENT_ID, + INT_TEST__PHONE__SECRET_ID, +}).forEach(([key, value]) => { + if (value === "") { + throw new ReferenceError(` + Integration test could not run because ${key} is undefined or empty. + + If you are running this project locally and you need to setup integration + tests, make sure you follow the guide in CONTRIBUTING. Alternatively, you + can run just the unit tests locally as the integration tests will be + triggered when you push to the remote repo + `); + } +}); + +const credentials = [ + { + testCaseName: "random username and a pre-hashed password", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: true, + }, + { + testCaseName: "random username", + username: faker.internet.userName().replace(/\s+/g, ""), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__USERNAME__POOL_ID, + clientId: INT_TEST__USERNAME__CLIENT_ID, + secretId: INT_TEST__USERNAME__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random email", + username: faker.internet.email(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__EMAIL__POOL_ID, + clientId: INT_TEST__EMAIL__CLIENT_ID, + secretId: INT_TEST__EMAIL__SECRET_ID, + isPreHashedPassword: false, + }, + { + testCaseName: "random phone", + username: new RandExp(/^\+1\d{10}$/).gen(), + password: faker.internet.password(20, true, undefined, "A1!"), + poolId: INT_TEST__PHONE__POOL_ID, + clientId: INT_TEST__PHONE__CLIENT_ID, + secretId: INT_TEST__PHONE__SECRET_ID, + isPreHashedPassword: false, + }, +]; + +describe("SDK v3 integration - USER_SRP_AUTH flow", () => { + const cognitoIdentityProviderClient = new CognitoIdentityProviderClient({ + region: AWS_REGION, + }); + + // signup with all the test credentials before we begin testing + beforeAll(async () => + Promise.all( + credentials.map((creds) => + signupV3({ + cognitoIdentityProviderClient, + ...creds, + }), + ), + ), + ); + + it.each(credentials)("normal flow with $testCaseName", async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes = await cognitoIdentityProviderClient.send( + new InitiateAuthCommand( + wrapInitiateAuth(srpSession, { + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); + + const respondToAuthChallengeRes = await cognitoIdentityProviderClient.send( + new RespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession, { + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); + }); + + it.each(credentials)("admin flow with $testCaseName", async (credentials) => { + const { username, password, poolId, clientId, secretId, isPreHashedPassword } = credentials; + + const secretHash = createSecretHash(username, clientId, secretId); + const passwordHash = isPreHashedPassword ? createPasswordHash(username, password, poolId) : password; + const srpSession = createSrpSession(username, passwordHash, poolId, isPreHashedPassword); + + const initiateAuthRes = await cognitoIdentityProviderClient.send( + new AdminInitiateAuthCommand( + wrapInitiateAuth(srpSession, { + UserPoolId: poolId, + ClientId: clientId, + AuthFlow: "USER_SRP_AUTH", + AuthParameters: { + CHALLENGE_NAME: "SRP_A", + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); + + const respondToAuthChallengeRes = await cognitoIdentityProviderClient.send( + new AdminRespondToAuthChallengeCommand( + wrapAuthChallenge(signedSrpSession, { + UserPoolId: poolId, + ClientId: clientId, + ChallengeName: "PASSWORD_VERIFIER", + ChallengeResponses: { + SECRET_HASH: secretHash, + USERNAME: username, + }, + }), + ), + ); + + expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); + expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); + expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); + }); +}); diff --git a/src/__tests__/integration/sdk-v3.test.ts b/src/__tests__/integration/sdk-v3.test.ts deleted file mode 100644 index 494aa3d..0000000 --- a/src/__tests__/integration/sdk-v3.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - AdminInitiateAuthCommand, - AdminRespondToAuthChallengeCommand, - CognitoIdentityProviderClient, - InitiateAuthCommand, - RespondToAuthChallengeCommand, -} from "@aws-sdk/client-cognito-identity-provider"; -import dotenv from "dotenv"; -import path from "path"; - -import { - createPasswordHash, - createSecretHash, - createSrpSession, - signSrpSession, - wrapAuthChallenge, - wrapInitiateAuth, -} from "../../cognito-srp-helper"; - -// Load in env variables from .env if it / they exist.. - -dotenv.config({ - path: path.join(process.cwd(), ".env"), -}); - -// Assert environment variables exist before we begin - -const { - AWS_REGION = "", - INT_TEST__USERNAME__USERNAME = "", - INT_TEST__USERNAME__PASSWORD = "", - INT_TEST__USERNAME__POOL_ID = "", - INT_TEST__USERNAME__CLIENT_ID = "", - INT_TEST__USERNAME__SECRET_ID = "", - INT_TEST__EMAIL__USERNAME = "", - INT_TEST__EMAIL__PASSWORD = "", - INT_TEST__EMAIL__POOL_ID = "", - INT_TEST__EMAIL__CLIENT_ID = "", - INT_TEST__EMAIL__SECRET_ID = "", - INT_TEST__PHONE__USERNAME = "", - INT_TEST__PHONE__PASSWORD = "", - INT_TEST__PHONE__POOL_ID = "", - INT_TEST__PHONE__CLIENT_ID = "", - INT_TEST__PHONE__SECRET_ID = "", -} = process.env; - -Object.entries({ - AWS_REGION, - INT_TEST__USERNAME__USERNAME, - INT_TEST__USERNAME__PASSWORD, - INT_TEST__USERNAME__POOL_ID, - INT_TEST__USERNAME__CLIENT_ID, - INT_TEST__USERNAME__SECRET_ID, - INT_TEST__EMAIL__USERNAME, - INT_TEST__EMAIL__PASSWORD, - INT_TEST__EMAIL__POOL_ID, - INT_TEST__EMAIL__CLIENT_ID, - INT_TEST__EMAIL__SECRET_ID, - INT_TEST__PHONE__USERNAME, - INT_TEST__PHONE__PASSWORD, - INT_TEST__PHONE__POOL_ID, - INT_TEST__PHONE__CLIENT_ID, - INT_TEST__PHONE__SECRET_ID, -}).forEach(([key, value]) => { - if (value === "") { - throw new ReferenceError(` - Integration test could not run because ${key} is undefined or empty. - - If you are running this project locally and you need to setup integration - tests, make sure you follow the guide in CONTRIBUTING. Alternatively, you - can run just the unit tests locally as the integration tests will be - triggered when you push to the remote repo - `); - } -}); - -const positiveCredentials = { - username: { - username: INT_TEST__USERNAME__USERNAME, - password: INT_TEST__USERNAME__PASSWORD, - poolId: INT_TEST__USERNAME__POOL_ID, - clientId: INT_TEST__USERNAME__CLIENT_ID, - secretId: INT_TEST__USERNAME__SECRET_ID, - }, - email: { - username: INT_TEST__EMAIL__USERNAME, - password: INT_TEST__EMAIL__PASSWORD, - poolId: INT_TEST__EMAIL__POOL_ID, - clientId: INT_TEST__EMAIL__CLIENT_ID, - secretId: INT_TEST__EMAIL__SECRET_ID, - }, - phone: { - username: INT_TEST__PHONE__USERNAME, - password: INT_TEST__PHONE__PASSWORD, - poolId: INT_TEST__PHONE__POOL_ID, - clientId: INT_TEST__PHONE__CLIENT_ID, - secretId: INT_TEST__PHONE__SECRET_ID, - }, -}; - -describe("SDK v3 integration", () => { - const cognitoIdentityProviderClient = new CognitoIdentityProviderClient({ - region: AWS_REGION, - }); - - it("should work with InitiateAuthCommand and RespondToAuthChallengeCommand (hashed password)", async () => { - const { username, password, poolId, clientId, secretId } = positiveCredentials.username; - const secretHash = createSecretHash(username, clientId, secretId); - const passwordHash = createPasswordHash(username, password, poolId); - const srpSession = createSrpSession(username, passwordHash, poolId); - - const initiateAuthRes = await cognitoIdentityProviderClient - .send( - new InitiateAuthCommand( - wrapInitiateAuth(srpSession, { - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); - - const respondToAuthChallengeRes = await cognitoIdentityProviderClient - .send( - new RespondToAuthChallengeCommand( - wrapAuthChallenge(signedSrpSession, { - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }); - - it("should work with AdminInitiateAuthCommand and AdminRespondToAuthChallengeCommand (hashed password)", async () => { - const { username, password, poolId, clientId, secretId } = positiveCredentials.username; - const secretHash = createSecretHash(username, clientId, secretId); - const passwordHash = createPasswordHash(username, password, poolId); - const srpSession = createSrpSession(username, passwordHash, poolId); - - const initiateAuthRes = await cognitoIdentityProviderClient - .send( - new AdminInitiateAuthCommand( - wrapInitiateAuth(srpSession, { - UserPoolId: poolId, - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); - - const respondToAuthChallengeRes = await cognitoIdentityProviderClient - .send( - new AdminRespondToAuthChallengeCommand( - wrapAuthChallenge(signedSrpSession, { - UserPoolId: poolId, - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }); - - it.each(Object.values(positiveCredentials))( - "should work with InitiateAuthCommand and RespondToAuthChallengeCommand (unhashed password): credentials %#", - async ({ username, password, poolId, clientId, secretId }) => { - const secretHash = createSecretHash(username, clientId, secretId); - const srpSession = createSrpSession(username, password, poolId, false); - - const initiateAuthRes = await cognitoIdentityProviderClient - .send( - new InitiateAuthCommand( - wrapInitiateAuth(srpSession, { - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); - - const respondToAuthChallengeRes = await cognitoIdentityProviderClient - .send( - new RespondToAuthChallengeCommand( - wrapAuthChallenge(signedSrpSession, { - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }, - ); - - it.each(Object.values(positiveCredentials))( - "should work with AdminInitiateAuthCommand and AdminRespondToAuthChallengeCommand (unhashed password): credentials %#", - async ({ username, password, poolId, clientId, secretId }) => { - const secretHash = createSecretHash(username, clientId, secretId); - const srpSession = createSrpSession(username, password, poolId, false); - - const initiateAuthRes = await cognitoIdentityProviderClient - .send( - new AdminInitiateAuthCommand( - wrapInitiateAuth(srpSession, { - UserPoolId: poolId, - ClientId: clientId, - AuthFlow: "USER_SRP_AUTH", - AuthParameters: { - CHALLENGE_NAME: "SRP_A", - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - const signedSrpSession = signSrpSession(srpSession, initiateAuthRes); - - const respondToAuthChallengeRes = await cognitoIdentityProviderClient - .send( - new AdminRespondToAuthChallengeCommand( - wrapAuthChallenge(signedSrpSession, { - UserPoolId: poolId, - ClientId: clientId, - ChallengeName: "PASSWORD_VERIFIER", - ChallengeResponses: { - SECRET_HASH: secretHash, - USERNAME: username, - }, - }), - ), - ) - .catch((err) => { - throw err; - }); - - expect(respondToAuthChallengeRes).toHaveProperty("AuthenticationResult"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("AccessToken"); - expect(respondToAuthChallengeRes.AuthenticationResult).toHaveProperty("RefreshToken"); - }, - ); -}); diff --git a/src/__tests__/mocks/data.ts b/src/__tests__/mocks/data.ts index c3da960..6630436 100644 --- a/src/__tests__/mocks/data.ts +++ b/src/__tests__/mocks/data.ts @@ -22,6 +22,18 @@ export const secret = "gn7F/ENIPPk/KZ7P2xYr4S0pZhCn29Y0nxXBtEPEc8Tt2cqbQpWorIuwSAC0g3fpDW9djD5+b9v30TctMTiVt6xjGaZeuWzzgAnVrc0l0xROzJItEQM1V96ySkrfFbL4tKuZokrqiszsRkgwnu4SGBz3akVpUD8pZnAg1Ycfc3LLSyQETFTndRHjS8UFGIovw7wqQfxKNKDC1nY2XYWR1nFuEIvHXb+Tvc76IxNZcaEvl5sAuTjwpVlmfwPSOELXnYxvDxUcFYO3sNguNqahn6aH4j16ccEZ/Wqoubn7QkomXV3Qdo5khQbw4F9CWFaxCHoRZwFAc8RQ4oGiIHLvuMb/Y/JhGTL3HefSkBXQ+uvoPK7YdgY8yn3SejYv4xD8lw+KLTPy1EbCXX9Aq1uwgjzeSygLl7/tkf1C/YzhefXEOfaLk2eNRlAschYunQ7WeIZWBYzW+38+r4bQpaecj8GcH/ycAOpacfoLB+/q3E/dHsFOFlzetYO6Qll5AXEv4hzJZgNh1/2zK3N8j2JBUjZs2h60Rh8OS+ZcQGa7ICPfS7XDvSV8U6Xan3j2m0Azz2ara+1I4MYDVj5P4/BneVDh4L+6Dl3EWEAF9UO8Ur1/5seiOVcAQe3TlwN9UWgmOZHIq/UGU14ofpmZI46WWS3qB+yBPHbWWJ0MMFFtRwCTzFl+2kRGthRR5A+6MSuLCfdcQwqb6FYRZEdgXZzMa6Td0Pog/5TFqxygkRB0P3Yq4lfOnRKI6cC3+2g8uMsEmOJfzsVrsfi6GTAPrP+lIPpNe12ZquFzPGi9Z68Odj+aNDYChalBpuvJgxWR2EFuyj2MebKz6Zo/5V/w964aTTYjUt1pPHURLx3MKl7Ns1xF1lklQ1DAOQYNrVWxWJRs4TxipduEq4N02uh9XKWzXxLJuHsc1B9e1Dc+1sHs122POq7i/VYoln6LhO30o32lDHOGa56krde0N/+FY7034zB1pqL8VuDd2/8o8XZ+D/ez/1gBwQ2y1q6CRvvZmNyYBIC8an/Vgb++e5zgx6Cb43o9+PEFc8UEA8QljTgZ2m+kwjia1TSYeyYnKQOIqCKMkZa6IBIwMZ6AHIDSWk//IR/UMiq4QGfb5CJFPYOTKSGXEU9dFPYbx+pZmq/Fq7QgFCM/I7M6jKZPwn+T4/RGLEg1YNRwQS/oQI/uu5iF9sIbtWO5XTRQ8q2ld4s3tEFflYdmELHXrt1UuFaCi82iTJZAgFPeIx9mHPb0U+I7iutfEPogIE5b/2T+qJhIBgeIQUVsJuo+uLdriFm1c7YwBMBTJWC9R2mL+2x6quavpx7u0zgHrmbDxFx6+nBl0DEZxsY5h8AEDUVhyi2w+7aK9T/uh7Pz3/fH+AwXfWcA1cBueYYHX0Due6+3hRAHfOokXTbi27+YoS2AKuXDl2EibHedm5lb1Tg18V66dXtGsvpIn/OUWO2FphUGdtCMgZc51ahYTiN1m6Qqx+z1DFRM2BLw6HLxb1uT1OmDyucvCXIsMl4vNyyeIdJwstIbQrECxg1Fdow6jHrzKyJw/FKsYGSnyDRk++1jTt3J46wz16S45XaMSDG8RMH7AKKeAX/lfJ0d247ajB4U8Hei1h1kclX/DAYp7A0KyoCG7Qt9hZGsxhsloT2eYohHEKshPDs2WU48WtuHwgXydkTABvx3b1ixLp2CEuQiMyy3jZpvDD8WGaqWqbQgnH/yQw=="; export const passwordSignature = "RrSiIBQazZkaxHQf34oRro1qjzfEvwdP6/Avltpd34E="; +// device credentials +export const deviceKey = "eu-west-2_627477ae-3631-4248-bed9-d2e934100372"; +export const deviceGroupKey = "-FDRunwQv"; +export const deviceRandomPassword = "BAUl"; +export const deviceRandomPasswordBytes = + "4i5q25v4vl5yvmo9oug9dqmsag3sma523agmxddk6x6p90ztms680pyrnuyqc8hmr8m8dd9ethtgtuaa"; +export const devicePasswordVerifier = + "AId3eNiAEuiPgZh0E2vpuui6V04+mfZvj6CuBDdiRwDDj40OhVvIPHC11mKB+ME0ErD0EXxfwAIwZ+rq6rEbrO5/ooShXSJlSfMpZFzy80cPEtCOz1DSm+GHmeBZQZeQrCMa2ryFV7Z88yBrTYGawF52eCoOfKC1eU7WKxdD1LPAxC5tkc/KFrVXNy4OnGrywYv12uyK6ZGlmtNTEBoOHrra9pRjIoiqF34dz1gEw33JEvE3Sb5DJYEUB9/OVeKhjuag1wZ+DTpdkWE+WHBMrQb9t0szhj+UTQ7bPX8jaqATtsbeNXG/f6707glGoWXCPcw8jhZkZrGfkM6PCtBnai5NCEaAZXe5Xg+gSYfbiT6hWszWqJHUURXQlSHL+qu9Wj5ZrCM5GPO+5uYUN7+AdzQXihRRu66nrW7MSIC6k4iRTyTYve9GqV2OybDSPY5CYCiNa8r5+o9tXxugAfFdlqp07aNCGARCbusksBOXqKDKc1oPjjhqoaFj/x7KBFsGbw=="; +export const deviceSalt = "AOuW"; +export const deviceSaltBytes = "eb96rmj4qcv658ulmsag3523agmxddk9"; +export const devicePasswordSignature = "or56+yUDCYxPbtknfpk2fO88rycinSHoINPLk7wpSPI="; + // This object isn't typed because it is a collection of external inputs export const mockCredentials = { sub, @@ -56,6 +68,14 @@ export const mockSessionSigned = { passwordSignature, }; +export const mockSessionSignedWithDevice = { + ...mockSession, + largeB, + salt: deviceSalt, + secret, + passwordSignature: devicePasswordSignature, +}; + // InitiateAuthRequest export const mockInitiateAuthRequest = { @@ -84,6 +104,21 @@ export const mockInitiateAuthResponse = { }, }; +export const mockInitiateAuthResponseWithNewDevice = { + AuthenticationResult: { + NewDeviceMetadata: { + DeviceGroupKey: deviceGroupKey, + DeviceKey: deviceKey, + }, + }, + ChallengeParameters: { + SRP_B: largeB, + SALT: salt, + SECRET_BLOCK: secret, + USER_ID_FOR_SRP: sub, + }, +}; + // RespondToAuthChallengeRequest export const mockRespondToAuthChallengeRequest = { @@ -99,3 +134,31 @@ export const mockAdminRespondToAuthChallengeRequest = { ...mockRespondToAuthChallengeRequest, UserPoolId: poolId, }; + +// RespondToAuthChallengeResponse + +export const mockRespondToAuthChallengeResponse = { + ChallengeName: ChallengeNameType.DEVICE_PASSWORD_VERIFIER, + ChallengeParameters: { + SALT: deviceSalt, + SECRET_BLOCK: secret, + SRP_B: largeB, + DEVICE_KEY: deviceKey, + USERNAME: username, + }, +}; + +export const mockAdminRespondToAuthChallengeResponse = { + ...mockRespondToAuthChallengeResponse, + UserPoolId: poolId, +}; + +// deviceVerifier + +export const mockDeviceVerifier = { + DeviceRandomPassword: deviceRandomPassword, + DeviceSecretVerifierConfig: { + PasswordVerifier: devicePasswordVerifier, + Salt: deviceSalt, + }, +}; diff --git a/src/__tests__/mocks/factories.ts b/src/__tests__/mocks/factories.ts index 94163ac..9bfcb61 100644 --- a/src/__tests__/mocks/factories.ts +++ b/src/__tests__/mocks/factories.ts @@ -1,8 +1,10 @@ import { Credentials, + DeviceVerifier, InitiateAuthRequest, InitiateAuthResponse, RespondToAuthChallengeRequest, + RespondToAuthChallengeResponse, SrpSession, SrpSessionSigned, } from "../../types"; @@ -10,12 +12,17 @@ import { import { mockAdminInitiateAuthRequest, mockAdminRespondToAuthChallengeRequest, + mockAdminRespondToAuthChallengeResponse, mockCredentials, + mockDeviceVerifier, mockInitiateAuthRequest, mockInitiateAuthResponse, + mockInitiateAuthResponseWithNewDevice, mockRespondToAuthChallengeRequest, + mockRespondToAuthChallengeResponse, mockSession, mockSessionSigned, + mockSessionSignedWithDevice, } from "./data"; export const mockCredentialsFactory = (credentials?: Partial): Credentials => @@ -36,6 +43,12 @@ export const mockSrpSessionSignedFactory = (session?: Partial) ...session, }); +export const mockSrpSessionSignedWithDeviceFactory = (session?: Partial): SrpSessionSigned => + structuredClone({ + ...mockSessionSignedWithDevice, + ...session, + }); + // InitiateAuthRequest export const mockInitiateAuthRequestFactory = (request?: Partial): InitiateAuthRequest => @@ -58,6 +71,14 @@ export const mockInitiateAuthResponseFactory = (response?: Partial, +): InitiateAuthResponse => + structuredClone({ + ...mockInitiateAuthResponseWithNewDevice, + ...response, + }); + // RespondToAuthChallengeRequest export const mockRespondToAuthChallengeRequestFactory = ( @@ -75,3 +96,29 @@ export const mockAdminRespondToAuthChallengeRequestFactory = ( ...mockAdminRespondToAuthChallengeRequest, ...request, }); + +// RespondToAuthChallengeResponse + +export const mockRespondToAuthChallengeResponseFactory = ( + response?: Partial, +): RespondToAuthChallengeResponse => + structuredClone({ + ...mockRespondToAuthChallengeResponse, + ...response, + }); + +export const mockAdminRespondToAuthChallengeResponseFactory = ( + response?: Partial, +): RespondToAuthChallengeResponse => + structuredClone({ + ...mockAdminRespondToAuthChallengeResponse, + ...response, + }); + +// DeviceVerifier + +export const mockDeviceVerifierFactory = (verifier?: Partial): DeviceVerifier => + structuredClone({ + ...mockDeviceVerifier, + ...verifier, + }); diff --git a/src/__tests__/test-cases/index.ts b/src/__tests__/test-cases/index.ts index fa7603c..7d27dec 100644 --- a/src/__tests__/test-cases/index.ts +++ b/src/__tests__/test-cases/index.ts @@ -3,7 +3,9 @@ export * from "./admin-respond-to-auth-challenge-request"; export * from "./credentials"; export * from "./initiate-auth-request"; export * from "./initiate-auth-response"; +export * from "./initiate-auth-response-with-new-device"; export * from "./respond-to-auth-challenge-request"; +export * from "./respond-to-auth-challenge-response"; export * from "./srp-sessions"; export * from "./srp-sessions-signed"; export * from "./timestamps"; diff --git a/src/__tests__/test-cases/initiate-auth-response-with-new-device.ts b/src/__tests__/test-cases/initiate-auth-response-with-new-device.ts new file mode 100644 index 0000000..6eda983 --- /dev/null +++ b/src/__tests__/test-cases/initiate-auth-response-with-new-device.ts @@ -0,0 +1,181 @@ +import { faker } from "@faker-js/faker"; +import omit from "lodash.omit"; +import RandExp from "randexp"; + +import { InitiateAuthResponse } from "../../types"; +import { mockInitiateAuthResponseWithNewDeviceFactory } from "../mocks/factories"; + +const { ChallengeParameters, AuthenticationResult } = mockInitiateAuthResponseWithNewDeviceFactory(); +const { NewDeviceMetadata } = AuthenticationResult ?? {}; +if (!NewDeviceMetadata) throw Error("NewDeviceMetadata is undefined"); + +export const positiveInitiateAuthResponseWithNewDevice: Record = { + default: mockInitiateAuthResponseWithNewDeviceFactory(), + // largeB + largeBRandom: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: faker.random.alphaNumeric(1024, { casing: "lower" }), + }, + }), + largeBShort: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + // 1 / 62 chance to return "0" which will trigger a AbortOnZeroBSrpError, so ban the char + SRP_B: faker.random.alphaNumeric(1, { casing: "lower", bannedChars: "0" }), + }, + }), + largeBLong: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: faker.random.alphaNumeric(10000, { casing: "lower" }), + }, + }), + // salt + saltRandom: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(32, { casing: "lower" }), + }, + }), + saltShort: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(1, { casing: "lower" }), + }, + }), + saltLong: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(10000, { casing: "lower" }), + }, + }), + // secret + secretRandom: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{1724}$/).gen(), + }, + }), + secretShort: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{1}$/).gen(), + }, + }), + secretLong: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{10000}$/).gen(), + }, + }), + // DeviceGroupKey + deviceGroupKeyRandom: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceGroupKey: new RandExp(/^[A-Za-z0-9+=/]{9}$/).gen(), + }, + }, + }), + deviceGroupKeyShort: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceGroupKey: new RandExp(/^[A-Za-z0-9+=/]{1}$/).gen(), + }, + }, + }), + deviceGroupKeyLong: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceGroupKey: new RandExp(/^[A-Za-z0-9+=/]{10000}$/).gen(), + }, + }, + }), + // DeviceKey + deviceKeyRandom: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceKey: + new RandExp(/^(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)_$/).gen() + + faker.datatype.uuid(), + }, + }, + }), +}; + +export const negativeInitiateAuthResponseWithNewDevice: Record = { + default: mockInitiateAuthResponseWithNewDeviceFactory(), + // ChallengeParameters + challengeParametersUndefined: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: undefined, + }), + challengeParametersOmitted: omit(mockInitiateAuthResponseWithNewDeviceFactory(), "ChallengeParameters"), + // salt + saltOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SALT"), + }, + }), + // secret + secretOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SECRET_BLOCK"), + }, + }), + // largeB + largeBOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SRP_B"), + }, + }), + // AuthenticationResult + authenticationResultUndefined: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: undefined, + }), + authenticationResultOmitted: omit(mockInitiateAuthResponseWithNewDeviceFactory(), "AuthenticationResult"), + // NewDeviceMetadata + newDeviceMetadataUndefined: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: undefined, + }, + }), + newDeviceMetadataOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: omit(AuthenticationResult, "NewDeviceMetadata"), + }), + // DeviceGroupKey + deviceGroupKeyUndefined: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceGroupKey: undefined, + }, + }, + }), + deviceGroupKeyOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...omit(NewDeviceMetadata, "DeviceGroupKey"), + }, + }, + }), + // DeviceKey + deviceKeyUndefined: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...NewDeviceMetadata, + DeviceKey: undefined, + }, + }, + }), + deviceKeyOmitted: mockInitiateAuthResponseWithNewDeviceFactory({ + AuthenticationResult: { + NewDeviceMetadata: { + ...omit(NewDeviceMetadata, "DeviceKey"), + }, + }, + }), +}; diff --git a/src/__tests__/test-cases/respond-to-auth-challenge-response.ts b/src/__tests__/test-cases/respond-to-auth-challenge-response.ts new file mode 100644 index 0000000..0a2d311 --- /dev/null +++ b/src/__tests__/test-cases/respond-to-auth-challenge-response.ts @@ -0,0 +1,162 @@ +import { faker } from "@faker-js/faker"; +import omit from "lodash.omit"; +import RandExp from "randexp"; + +import { RespondToAuthChallengeResponse } from "../../types"; +import { mockRespondToAuthChallengeResponseFactory } from "../mocks/factories"; + +const { ChallengeParameters } = mockRespondToAuthChallengeResponseFactory(); + +export const positiveRespondToAuthChallengeResponses: Record = { + default: mockRespondToAuthChallengeResponseFactory(), + // ChallengeName + challengeNamePasswordVerifier: mockRespondToAuthChallengeResponseFactory({ + ChallengeName: "PASSWORD_VERIFIER", + }), + challengeNameUnknown: mockRespondToAuthChallengeResponseFactory({ + ChallengeName: "UNKNOWN", + }), + // SALT + saltRandom: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(32, { casing: "lower" }), + }, + }), + saltShort: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(1, { casing: "lower" }), + }, + }), + saltLong: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SALT: faker.random.alphaNumeric(10000, { casing: "lower" }), + }, + }), + // SECRET_BLOCK + secretRandom: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{1724}$/).gen(), + }, + }), + secretShort: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{1}$/).gen(), + }, + }), + secretLong: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SECRET_BLOCK: new RandExp(/^[A-Za-z0-9+=/]{10000}$/).gen(), + }, + }), + // SRP_B + largeBRandom: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: faker.random.alphaNumeric(1024, { casing: "lower" }), + }, + }), + largeBShort: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + // 1 / 62 chance to return "0" which will trigger a AbortOnZeroBSrpError, so ban the char + SRP_B: faker.random.alphaNumeric(1, { casing: "lower", bannedChars: "0" }), + }, + }), + largeBLong: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: faker.random.alphaNumeric(10000, { casing: "lower" }), + }, + }), + // DEVICE_KEY + deviceKeyRandom: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + DEVICE_KEY: + new RandExp(/^(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)_$/).gen() + faker.datatype.uuid(), + }, + }), + // USERNAME + usernameTypical: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.internet.userName(), + }, + }), + usernameEmail: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.internet.email(), + }, + }), + usernameEmailSpecialChars: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.internet.email("john", "doe", "example.fakerjs.dev", { + allowSpecialCharacters: true, + }), + }, + }), + usernamePhone: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.phone.number(), + }, + }), + usernameUuid: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.datatype.uuid(), + }, + }), + usernameSymbols: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: faker.datatype.string(), + }, + }), + usernameEmpty: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + USERNAME: "", + }, + }), +}; + +export const negativeRespondToAuthChallengeResponses: Record = { + // ChallengeParameters + challengeParametersUndefined: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: undefined, + }), + challengeParametersOmitted: omit(mockRespondToAuthChallengeResponseFactory(), "ChallengeParameters"), + // SALT + saltOmitted: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SALT"), + }, + }), + // SECRET_BLOCK + secretOmitted: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SECRET_BLOCK"), + }, + }), + // SRP_B + largeBOmitted: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "SRP_B"), + }, + }), + // DEVICE_KEY + deviceKeyOmitted: mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...omit(ChallengeParameters, "DEVICE_KEY"), + }, + }), +}; diff --git a/src/__tests__/unit/create-device-verifier.test.ts b/src/__tests__/unit/create-device-verifier.test.ts new file mode 100644 index 0000000..376bf06 --- /dev/null +++ b/src/__tests__/unit/create-device-verifier.test.ts @@ -0,0 +1,42 @@ +import { Buffer } from "buffer/"; + +import { createDeviceVerifier } from "../../cognito-srp-helper"; +import * as utils from "../../utils"; +import { deviceRandomPasswordBytes, deviceSaltBytes } from "../mocks/data"; +import { mockDeviceVerifierFactory, mockInitiateAuthResponseWithNewDeviceFactory } from "../mocks/factories"; +import { positiveInitiateAuthResponseWithNewDevice as positiveResponses } from "../test-cases"; + +describe("createDeviceVerifier", () => { + describe("positive", () => { + it("should create the correct device hash", () => { + const response = mockInitiateAuthResponseWithNewDeviceFactory(); + + // ensure randomBytes returns what we expect + jest.spyOn(utils, "randomBytes").mockReturnValueOnce(Buffer.from(deviceRandomPasswordBytes, "hex")); + jest.spyOn(utils, "randomBytes").mockReturnValueOnce(Buffer.from(deviceSaltBytes, "hex")); + + const { DeviceKey, DeviceGroupKey } = response.AuthenticationResult?.NewDeviceMetadata ?? {}; + if (!DeviceKey) throw Error("DeviceKey is undefined"); + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + const verifier = createDeviceVerifier(DeviceKey, DeviceGroupKey); + const expected = mockDeviceVerifierFactory(); + expect(verifier).toEqual(expected); + }); + + it.each(Object.values(positiveResponses))( + "should create a device verifier with the correct format: response %#", + (response) => { + const { DeviceKey, DeviceGroupKey } = response.AuthenticationResult?.NewDeviceMetadata ?? {}; + if (!DeviceKey) throw Error("DeviceKey is undefined"); + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + const verifier = createDeviceVerifier(DeviceKey, DeviceGroupKey); + + expect(verifier.DeviceRandomPassword).toMatch(/^[A-Za-z0-9+=/]+$/); + expect(verifier.DeviceSecretVerifierConfig.PasswordVerifier).toMatch(/^[A-Za-z0-9+=/]+$/); + expect(verifier.DeviceSecretVerifierConfig.Salt).toMatch(/^[A-Za-z0-9+=/]+$/); + }, + ); + }); +}); diff --git a/src/__tests__/unit/sign-srp-session-with-device.test.ts b/src/__tests__/unit/sign-srp-session-with-device.test.ts new file mode 100644 index 0000000..f67eda5 --- /dev/null +++ b/src/__tests__/unit/sign-srp-session-with-device.test.ts @@ -0,0 +1,182 @@ +import { signSrpSessionWithDevice } from "../../cognito-srp-helper"; +import { + AbortOnZeroBSrpError, + AbortOnZeroSrpError, + AbortOnZeroUSrpError, + MissingChallengeResponsesError, + MissingDeviceKeyError, + MissingLargeBError, + MissingSaltError, + MissingSecretError, + SignSrpSessionError, +} from "../../errors"; +import * as utils from "../../utils"; +import { + mockDeviceVerifierFactory, + mockInitiateAuthResponseWithNewDeviceFactory, + mockRespondToAuthChallengeResponseFactory, + mockSrpSessionFactory, + mockSrpSessionSignedWithDeviceFactory, +} from "../mocks/factories"; +import { + negativeRespondToAuthChallengeResponses as negativeResponses, + positiveRespondToAuthChallengeResponses as positiveResponses, + positiveSrpSessionsSigned as positiveSessions, +} from "../test-cases"; + +const { ChallengeParameters } = mockRespondToAuthChallengeResponseFactory(); + +describe("signSrpSessionWithDevice", () => { + describe("positive", () => { + it("should create the correct signed SRP session", () => { + const session = mockSrpSessionFactory(); + const response = mockRespondToAuthChallengeResponseFactory(); + const { DeviceRandomPassword } = mockDeviceVerifierFactory(); + const { AuthenticationResult } = mockInitiateAuthResponseWithNewDeviceFactory(); + const DeviceGroupKey = AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + const sessionSigned = signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + + const expected = mockSrpSessionSignedWithDeviceFactory(); + expect(sessionSigned).toEqual(expected); + }); + + it.each(Object.values(positiveSessions))( + "should create a signed SRP session with the correct format: session %#", + (session) => { + const response = mockRespondToAuthChallengeResponseFactory(); + const { SRP_B, SALT, SECRET_BLOCK } = response.ChallengeParameters ?? {}; + const { DeviceRandomPassword } = mockDeviceVerifierFactory(); + const { AuthenticationResult } = mockInitiateAuthResponseWithNewDeviceFactory(); + const DeviceGroupKey = AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + const sessionSigned = signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + + // previous session values should remain the same + expect(sessionSigned.username).toMatch(session.username); + expect(sessionSigned.password).toMatch(session.password); + expect(sessionSigned.poolIdAbbr).toMatch(session.poolIdAbbr); + expect(sessionSigned.timestamp).toMatch(session.timestamp); + expect(sessionSigned.smallA).toMatch(session.smallA); + expect(sessionSigned.largeA).toMatch(session.largeA); + // response ChallengeParameters should remain the same + expect(sessionSigned.largeB).toMatch(SRP_B); + expect(sessionSigned.salt).toMatch(SALT); + expect(sessionSigned.secret).toMatch(SECRET_BLOCK); + // password signature should be new value with following format + expect(sessionSigned.passwordSignature).toMatch(/^[A-Za-z0-9+=/]+$/); + }, + ); + + it.each(Object.values(positiveResponses))( + "should create a signed SRP session with the correct format: response %#", + (response) => { + const session = mockSrpSessionFactory(); + const { SRP_B, SALT, SECRET_BLOCK } = response.ChallengeParameters ?? {}; + const { DeviceRandomPassword } = mockDeviceVerifierFactory(); + const { AuthenticationResult } = mockInitiateAuthResponseWithNewDeviceFactory(); + const DeviceGroupKey = AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + const sessionSigned = signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + + // previous session values should remain the same + expect(sessionSigned.username).toMatch(session.username); + expect(sessionSigned.password).toMatch(session.password); + expect(sessionSigned.poolIdAbbr).toMatch(session.poolIdAbbr); + expect(sessionSigned.timestamp).toMatch(session.timestamp); + expect(sessionSigned.smallA).toMatch(session.smallA); + expect(sessionSigned.largeA).toMatch(session.largeA); + // response ChallengeParameters should remain the same + expect(sessionSigned.largeB).toMatch(SRP_B); + expect(sessionSigned.salt).toMatch(SALT); + expect(sessionSigned.secret).toMatch(SECRET_BLOCK); + // password signature should be new value with following format + expect(sessionSigned.passwordSignature).toMatch(/^[A-Za-z0-9+=/]+$/); + }, + ); + }); + + describe("negative", () => { + const session = mockSrpSessionFactory(); + const { DeviceRandomPassword } = mockDeviceVerifierFactory(); + const { AuthenticationResult } = mockInitiateAuthResponseWithNewDeviceFactory(); + const DeviceGroupKey = AuthenticationResult?.NewDeviceMetadata?.DeviceGroupKey; + if (!DeviceGroupKey) throw Error("DeviceGroupKey is undefined"); + + it("should throw a AbortOnZeroBSrpError if SRP B is 0", () => { + const responseShortZero = mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: "0", + }, + }); + const responseLongZero = mockRespondToAuthChallengeResponseFactory({ + ChallengeParameters: { + ...ChallengeParameters, + SRP_B: "0000000000", + }, + }); + + // First check if the parent AbortOnZeroSrpError is thrown + expect(() => { + signSrpSessionWithDevice(session, responseShortZero, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroSrpError); + + // Throw on single zero + expect(() => { + signSrpSessionWithDevice(session, responseShortZero, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroBSrpError); + + // Throw on multiple zeros (because 0 = 000... in hexadecimal) + expect(() => { + signSrpSessionWithDevice(session, responseLongZero, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroBSrpError); + }); + + it("should throw a AbortOnZeroUSrpError if SRP U is 0", () => { + const response = mockRespondToAuthChallengeResponseFactory(); + + // make sure our u = H(A, B) calculation returns 0 + + // First check if the parent AbortOnZeroSrpError is thrown + jest.spyOn(utils, "hexHash").mockImplementationOnce(() => "0"); + expect(() => { + signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroSrpError); + + // Throw on single zero + jest.spyOn(utils, "hexHash").mockImplementationOnce(() => "0"); + expect(() => { + signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroUSrpError); + + // Throw on multiple zeros (because 0 = 000... in hexadecimal) + jest.spyOn(utils, "hexHash").mockImplementationOnce(() => "0000000000"); + expect(() => { + signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(AbortOnZeroUSrpError); + }); + + it.each([ + [negativeResponses.challengeParametersUndefined, MissingChallengeResponsesError], + [negativeResponses.challengeParametersOmitted, MissingChallengeResponsesError], + [negativeResponses.saltOmitted, MissingSaltError], + [negativeResponses.secretOmitted, MissingSecretError], + [negativeResponses.largeBOmitted, MissingLargeBError], + [negativeResponses.deviceKeyOmitted, MissingDeviceKeyError], + ])("should throw a SignSrpSessionError: response %#", (response, error) => { + // First check if the parent SignSrpSessionError is thrown + expect(() => { + signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(SignSrpSessionError); + + // Throw specific SignSrpSessionError error + expect(() => { + signSrpSessionWithDevice(session, response, DeviceGroupKey, DeviceRandomPassword); + }).toThrow(error); + }); + }); +}); diff --git a/src/cognito-srp-helper.ts b/src/cognito-srp-helper.ts index dd1e03a..0707f83 100644 --- a/src/cognito-srp-helper.ts +++ b/src/cognito-srp-helper.ts @@ -8,18 +8,22 @@ import { AbortOnZeroBSrpError, AbortOnZeroUSrpError, MissingChallengeResponsesError, + MissingDeviceKeyError, MissingLargeBError, MissingSaltError, MissingSecretError, + MissingUserIdForSrpBError, } from "./errors"; import { + DeviceVerifier, InitiateAuthRequest, InitiateAuthResponse, RespondToAuthChallengeRequest, + RespondToAuthChallengeResponse, SrpSession, SrpSessionSigned, } from "./types"; -import { hash, hexHash, padHex, randomBytes } from "./utils"; +import { getBytesFromHex, hash, hexHash, padHex, randomBytes } from "./utils"; const generateSmallA = (): BigInteger => { // This will be interpreted as a postive 128-bit integer @@ -116,6 +120,42 @@ export const createPasswordHash = (userId: string, password: string, poolId: str return passwordHash; }; +const createDeviceHash = (deviceKey: string, password: string, deviceGroupKey: string): string => { + const devicePassword = `${deviceGroupKey}${deviceKey}:${password}`; + const deviceHash = hash(devicePassword); + + return deviceHash; +}; + +export const createDeviceVerifier = (deviceKey: string, deviceGroupKey: string): DeviceVerifier => { + // 40 random bytes encoded as base64 (aka. RANDOM_PASSWORD) + const passwordRandom = randomBytes(40).toString("base64"); + + // Device string (aka. FULL_PASSWORD) + const deviceHash = createDeviceHash(deviceKey, passwordRandom, deviceGroupKey); + + // Salt + const salt = randomBytes(16).toString("hex"); + const saltHash = padHex(new BigInteger(salt, 16)); + const saltBytes = getBytesFromHex(saltHash); + const saltBase64 = Buffer.from(saltBytes).toString("base64"); + + // Password verifier + const passwordSalted = hexHash(saltHash + deviceHash); + const passwordVerifier = G.modPow(new BigInteger(passwordSalted, 16), N); + const passwordVerifierPadded = padHex(passwordVerifier); + const passwordVerifierBytes = getBytesFromHex(passwordVerifierPadded); + const passwordVerifierBase64 = Buffer.from(passwordVerifierBytes).toString("base64"); + + return { + DeviceRandomPassword: passwordRandom, + DeviceSecretVerifierConfig: { + PasswordVerifier: passwordVerifierBase64, + Salt: saltBase64, + }, + }; +}; + export const createSrpSession = (username: string, password: string, poolId: string, isHashed = true): SrpSession => { const poolIdAbbr = poolId.split("_")[1]; const timestamp = createTimestamp(); @@ -140,6 +180,7 @@ export const signSrpSession = (session: SrpSession, response: InitiateAuthRespon if (!response.ChallengeParameters.SALT) throw new MissingSaltError(); if (!response.ChallengeParameters.SECRET_BLOCK) throw new MissingSecretError(); if (!response.ChallengeParameters.SRP_B) throw new MissingLargeBError(); + if (!response.ChallengeParameters.USER_ID_FOR_SRP) throw new MissingUserIdForSrpBError(); const { SALT: salt, @@ -180,6 +221,52 @@ export const signSrpSession = (session: SrpSession, response: InitiateAuthRespon }; }; +export const signSrpSessionWithDevice = ( + session: SrpSession, + response: RespondToAuthChallengeResponse, + deviceGroupKey: string, + deviceRandomPassword: string, +): SrpSessionSigned => { + // Assert SRP ChallengeParameters + if (!response.ChallengeParameters) throw new MissingChallengeResponsesError(); + if (!response.ChallengeParameters.SALT) throw new MissingSaltError(); + if (!response.ChallengeParameters.SECRET_BLOCK) throw new MissingSecretError(); + if (!response.ChallengeParameters.SRP_B) throw new MissingLargeBError(); + if (!response.ChallengeParameters.DEVICE_KEY) throw new MissingDeviceKeyError(); + + const { DEVICE_KEY: deviceKey, SALT: salt, SECRET_BLOCK: secret, SRP_B: largeB } = response.ChallengeParameters; + const { timestamp, smallA, largeA } = session; + + // Check server public key isn't 0 + if (largeB.replace(/^0+/, "") === "") throw new AbortOnZeroBSrpError(); + + const deviceHash = createDeviceHash(deviceKey, deviceRandomPassword, deviceGroupKey); + + const u = calculateU(new BigInteger(largeA, 16), new BigInteger(largeB, 16)); + const x = calculateX(new BigInteger(salt, 16), deviceHash); + const s = calculateS(x, new BigInteger(largeB, 16), new BigInteger(smallA, 16), u); + const hkdf = computeHkdf(Buffer.from(padHex(s), "hex"), Buffer.from(padHex(u), "hex")); + + const key = CryptoJS.lib.WordArray.create(hkdf); + const message = CryptoJS.lib.WordArray.create( + Buffer.concat([ + Buffer.from(deviceGroupKey, "utf8"), + Buffer.from(deviceKey, "utf8"), + Buffer.from(secret, "base64"), + Buffer.from(timestamp, "utf8"), + ]), + ); + const passwordSignature = CryptoJS.enc.Base64.stringify(CryptoJS.HmacSHA256(message, key)); + + return { + ...session, + salt, + secret, + largeB, + passwordSignature, + }; +}; + export const wrapInitiateAuth = (session: SrpSession, request: T): T => ({ ...request, AuthParameters: { @@ -197,6 +284,7 @@ export const wrapAuthChallenge = ( ...request.ChallengeResponses, // ignored if request.ChallengeResponses doesn't exist PASSWORD_CLAIM_SECRET_BLOCK: session.secret, PASSWORD_CLAIM_SIGNATURE: session.passwordSignature, + SRP_A: session.largeA, TIMESTAMP: session.timestamp, }, }); diff --git a/src/constants.ts b/src/constants.ts index a0cd5f1..7dbf483 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -25,3 +25,261 @@ export const N = new BigInteger( 16, ); export const K = new BigInteger(hexHash(`${padHex(N)}${padHex(G)}`), 16); +export const HEX_TO_SHORT: Record = { + "00": 0, + "01": 1, + "02": 2, + "03": 3, + "04": 4, + "05": 5, + "06": 6, + "07": 7, + "08": 8, + "09": 9, + "0a": 10, + "0b": 11, + "0c": 12, + "0d": 13, + "0e": 14, + "0f": 15, + "10": 16, + "11": 17, + "12": 18, + "13": 19, + "14": 20, + "15": 21, + "16": 22, + "17": 23, + "18": 24, + "19": 25, + "1a": 26, + "1b": 27, + "1c": 28, + "1d": 29, + "1e": 30, + "1f": 31, + "20": 32, + "21": 33, + "22": 34, + "23": 35, + "24": 36, + "25": 37, + "26": 38, + "27": 39, + "28": 40, + "29": 41, + "2a": 42, + "2b": 43, + "2c": 44, + "2d": 45, + "2e": 46, + "2f": 47, + "30": 48, + "31": 49, + "32": 50, + "33": 51, + "34": 52, + "35": 53, + "36": 54, + "37": 55, + "38": 56, + "39": 57, + "3a": 58, + "3b": 59, + "3c": 60, + "3d": 61, + "3e": 62, + "3f": 63, + "40": 64, + "41": 65, + "42": 66, + "43": 67, + "44": 68, + "45": 69, + "46": 70, + "47": 71, + "48": 72, + "49": 73, + "4a": 74, + "4b": 75, + "4c": 76, + "4d": 77, + "4e": 78, + "4f": 79, + "50": 80, + "51": 81, + "52": 82, + "53": 83, + "54": 84, + "55": 85, + "56": 86, + "57": 87, + "58": 88, + "59": 89, + "5a": 90, + "5b": 91, + "5c": 92, + "5d": 93, + "5e": 94, + "5f": 95, + "60": 96, + "61": 97, + "62": 98, + "63": 99, + "64": 100, + "65": 101, + "66": 102, + "67": 103, + "68": 104, + "69": 105, + "6a": 106, + "6b": 107, + "6c": 108, + "6d": 109, + "6e": 110, + "6f": 111, + "70": 112, + "71": 113, + "72": 114, + "73": 115, + "74": 116, + "75": 117, + "76": 118, + "77": 119, + "78": 120, + "79": 121, + "7a": 122, + "7b": 123, + "7c": 124, + "7d": 125, + "7e": 126, + "7f": 127, + "80": 128, + "81": 129, + "82": 130, + "83": 131, + "84": 132, + "85": 133, + "86": 134, + "87": 135, + "88": 136, + "89": 137, + "8a": 138, + "8b": 139, + "8c": 140, + "8d": 141, + "8e": 142, + "8f": 143, + "90": 144, + "91": 145, + "92": 146, + "93": 147, + "94": 148, + "95": 149, + "96": 150, + "97": 151, + "98": 152, + "99": 153, + "9a": 154, + "9b": 155, + "9c": 156, + "9d": 157, + "9e": 158, + "9f": 159, + a0: 160, + a1: 161, + a2: 162, + a3: 163, + a4: 164, + a5: 165, + a6: 166, + a7: 167, + a8: 168, + a9: 169, + aa: 170, + ab: 171, + ac: 172, + ad: 173, + ae: 174, + af: 175, + b0: 176, + b1: 177, + b2: 178, + b3: 179, + b4: 180, + b5: 181, + b6: 182, + b7: 183, + b8: 184, + b9: 185, + ba: 186, + bb: 187, + bc: 188, + bd: 189, + be: 190, + bf: 191, + c0: 192, + c1: 193, + c2: 194, + c3: 195, + c4: 196, + c5: 197, + c6: 198, + c7: 199, + c8: 200, + c9: 201, + ca: 202, + cb: 203, + cc: 204, + cd: 205, + ce: 206, + cf: 207, + d0: 208, + d1: 209, + d2: 210, + d3: 211, + d4: 212, + d5: 213, + d6: 214, + d7: 215, + d8: 216, + d9: 217, + da: 218, + db: 219, + dc: 220, + dd: 221, + de: 222, + df: 223, + e0: 224, + e1: 225, + e2: 226, + e3: 227, + e4: 228, + e5: 229, + e6: 230, + e7: 231, + e8: 232, + e9: 233, + ea: 234, + eb: 235, + ec: 236, + ed: 237, + ee: 238, + ef: 239, + f0: 240, + f1: 241, + f2: 242, + f3: 243, + f4: 244, + f5: 245, + f6: 246, + f7: 247, + f8: 248, + f9: 249, + fa: 250, + fb: 251, + fc: 252, + fd: 253, + fe: 254, + ff: 255, +}; diff --git a/src/errors.ts b/src/errors.ts index 978ad00..393dc3d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -36,6 +36,22 @@ export class MissingLargeBError extends SignSrpSessionError { } } +export class MissingUserIdForSrpBError extends SignSrpSessionError { + constructor( + message = "Could not sign SRP session because of missing or undefined USER_ID_FOR_SRP in response.ChallengeResponses", + ) { + super(message); + } +} + +export class MissingDeviceKeyError extends SignSrpSessionError { + constructor( + message = "Could not sign SRP session because of missing or undefined DEVICE_KEY in response.ChallengeResponses", + ) { + super(message); + } +} + // SRP calculation errors export class AbortOnZeroSrpError extends Error { diff --git a/src/types.ts b/src/types.ts index 7cd740e..b405e39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,11 +2,14 @@ import type { AdminInitiateAuthRequest, AdminInitiateAuthResponse, AdminRespondToAuthChallengeRequest, + AdminRespondToAuthChallengeResponse, AuthFlowType, ChallengeNameType, + DeviceSecretVerifierConfigType, InitiateAuthRequest as ClientInitiateAuthRequest, InitiateAuthResponse as ClientInitiateAuthResponse, RespondToAuthChallengeRequest as ClientRespondToAuthChallengeRequest, + RespondToAuthChallengeResponse as ClientRespondToAuthChallengeResponse, } from "@aws-sdk/client-cognito-identity-provider"; /* Things you should be aware of: @@ -44,6 +47,17 @@ export type RespondToAuthChallengeRequest = ChallengeName?: ChallengeNameType | string; }); +/** + * Either RespondToAuthChallengeResponse or AdminRespondToAuthChallengeResponse from `@aws-sdk/client-cognito-identity-provider`. Should be compatible with SDK v2 and Command forms of the response + */ +export type RespondToAuthChallengeResponse = + | (Omit & { + ChallengeName?: ChallengeNameType | string; + }) + | (Omit & { + ChallengeName?: ChallengeNameType | string; + }); + /** * Credentials needed for SRP authentication */ @@ -99,3 +113,14 @@ export type SrpSessionSigned = SrpSession & { /** The signatire used to verify the user's password */ passwordSignature: string; }; + +/** + * An object containing the DeviceSecretVerifierConfig required for the ConfirmDeviceCommand step, + * and DeviceRandomPassword used for to generate the signature for the DEVICE_PASSWORD_VERIFIER step + */ +export type DeviceVerifier = { + /** The random password associated with a device. Used to generate the password signature for DEVICE_PASSWORD_VERIFIER */ + DeviceRandomPassword: string; + /** The device verifier used to confirm the device in ConfirmDeviceCommand */ + DeviceSecretVerifierConfig: DeviceSecretVerifierConfigType; +}; diff --git a/src/utils.ts b/src/utils.ts index 1ce1607..89bfebe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,8 @@ import { Buffer } from "buffer/"; // use the browser compatible buffer library import { lib, SHA256 } from "crypto-js"; import { BigInteger } from "jsbn"; +import { HEX_TO_SHORT } from "./constants"; + /** * Calculate a hash from a bitArray * @@ -120,3 +122,26 @@ export const randomBytes = (nBytes: number): Buffer => { return bytes; }; + +/** + * Converts a hexadecimal encoded string to a Uint8Array of bytes. + * + * @param encoded The hexadecimal encoded string + */ +export const getBytesFromHex = (encoded: string): Uint8Array => { + if (encoded.length % 2 !== 0) { + throw new Error("Hex encoded strings must have an even number length"); + } + + const out = new Uint8Array(encoded.length / 2); + for (let i = 0; i < encoded.length; i += 2) { + const encodedByte = encoded.slice(i, i + 2).toLowerCase(); + if (encodedByte in HEX_TO_SHORT) { + out[i / 2] = HEX_TO_SHORT[encodedByte]; + } else { + throw new Error(`Cannot decode unrecognized sequence ${encodedByte} as hexadecimal`); + } + } + + return out; +};