simonmcallister0210 / cognito-srp-helper

A helper for SRP authentication in AWS Cognito
Apache License 2.0
12 stars 3 forks source link

Feature request: add support to `DEVICE_SRP_AUTH` and `DEVICE_PASSWORD_VERIFIER` challanges #34

Closed renatoargh closed 2 months ago

renatoargh commented 7 months ago

Hey! πŸ‘‹

I am currently working with Cognito's remember devices feature so I would like to know whether it would be possible to add support for the DEVICE_SRP_AUTH and DEVICE_PASSWORD_VERIFIER challenges which are necessary to authenticate remembered devices.

My current (working) code is this;

const email = 'potato@example.com'
const password = 'potato'
const cognitoConfig = { poolId: 'my-pool-id', clientId: 'my-client-id' }

const srpSession = createSrpSession('', '', cognitoConfig.poolId);
const authParameters: Record<string, string> = {
  CHALLENGE_NAME: 'SRP_A',
  SRP_A: srpSession.largeA,
  USERNAME: email,
};

const deviceKey = getStringItem(LocalStorageKey.DEVICE_KEY) || '';
if (deviceKey) {
  authParameters.DEVICE_KEY = deviceKey; // <--- we are making use of remember device feature 
}

const initAuthCommand = new InitiateAuthCommand(
  wrapInitiateAuth(srpSession, {
    ClientId: cognitoConfig.clientId,
    AuthFlow: AuthFlowType.USER_SRP_AUTH,
    AuthParameters: authParameters,
  }),
);

const initAuthResponse = await client.send(initAuthCommand);
const userId = initAuthResponse.ChallengeParameters?.USER_ID_FOR_SRP || '';
srpSession.username = userId;
srpSession.passwordHash = createPasswordHash(userId, password, cognitoConfig.poolId);

const signedSrpSession = signSrpSession(srpSession, initAuthResponse);
const challangeResponses: Record<string, string> = {
  USERNAME: email,
};

if (deviceKey) {
  challangeResponses.DEVICE_KEY = deviceKey; // <--- we are making use of remember device feature 
}

const responseToAuthChallangeCommand = new RespondToAuthChallengeCommand(
  wrapAuthChallenge(signedSrpSession, {
    ClientId: cognitoConfig.clientId,
    ChallengeName: ChallengeNameType.PASSWORD_VERIFIER,
    ChallengeResponses: challangeResponses,
  }),
);

const authChallangeResponse = await client.send(responseToAuthChallangeCommand);

// All good and nice at this point! βœ…

The code above works fine! The problem is that when I have a deviceKey on local storage I then get two more challenges after this point, that are DEVICE_SRP_AUTH and DEVICE_PASSWORD_VERIFIER. I have found instructions here: https://repost.aws/knowledge-center/cognito-user-pool-remembered-devices#:~:text=Call%20RespondToAuthChallenge%20for%20DEVICE_SRP_AUTH but all seems super cryptic to me and I was unable to adapt cognito-srp-helper to work with the device challenges.

Would it be possible to extend this lib to add support for this feature or maybe tell me what steps to follow in order to make it work with the current state of the library?

More references here: https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-device-tracking.html#user-pools-remembered-devices-signing-in-with-a-device

thank you a lot, great work guys!!!!

simonmcallister0210 commented 7 months ago

Hey @renatoargh good to see you again!

I feel like this should be possible by extending the library. I'll see if I can prototype a solution. I'm in the middle of moving house right now, but I should be able to carve out some time to do this soon

renatoargh commented 7 months ago

Thank you a lot, Simon! Take your time and no rush.. please finish moving houses before paying attention to this! I would love trying to contribute a PR but all seems super cryptic to me. And if you enable sponsoring on this repo I will make sure to send you a few 🍻 !

simonmcallister0210 commented 7 months ago

I've pushed a branch that's WIP

Still trying to figure out the response to DEVICE_PASSWORD_VERIFIER. Getting NotAuthorizedException: Incorrect username or password. so I must've done something wrong in one of the steps? I'll keep looking into it this week

simonmcallister0210 commented 7 months ago

Here's the code I'm using to prototype the fix. Should be able to run it on the new branch. It'll create a new user each time, auto-confirm them, setup MFA, remember their device, etc.

const {
  createSrpSession,
  signSrpSession,
  wrapAuthChallenge,
  wrapInitiateAuth,
  createSecretHash,
  createDeviceVerifier,
  signSrpSessionWithDevice,
} = require("../cognito-srp-helper");
const {
  CognitoIdentityProviderClient,
  InitiateAuthCommand,
  RespondToAuthChallengeCommand,
  AssociateSoftwareTokenCommand,
  SetUserMFAPreferenceCommand,
  VerifySoftwareTokenCommand,
  SignUpCommand,
  ConfirmDeviceCommand,
  UpdateDeviceStatusCommand,
} = require("@aws-sdk/client-cognito-identity-provider");
const { TOTP } = require("totp-generator");
const { faker } = require("@faker-js/faker");

const wait = async (time) => new Promise((resolve) => setTimeout(resolve, time - new Date().getTime()));

(async () => {
  // ---------- Setup credentials for new user ----------

  const username = faker.internet.userName();
  const password = "Qwerty1!";
  const poolId = "eu-west-2_ebRTcgfiK";
  const clientId = "1eci0qkm70jpfov0uo2j1ejep";
  const secretId = "1op7af116gm42riug0brsfku3fr1tl1jn5f54lernp5q1d5mksbv";
  const cognitoIdentityProviderClient = new CognitoIdentityProviderClient({
    region: "eu-west-2",
  });

  console.log("credentials:");
  console.log({ username, password });

  // ---------- Signup with new user ----------

  const secretHash = createSecretHash(username, clientId, secretId);

  const signupRes = 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,
    }),
  );

  console.log("signupRes:");
  console.log(signupRes);

  // ---------- Signin 1. initiate signin attempt ----------

  const srpSession1 = createSrpSession(username, password, poolId, false);

  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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("initiateAuthRes1:");
  console.log(initiateAuthRes1);

  // ---------- 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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes1a:");
  console.log(respondToAuthChallengeRes1a);

  // ---------- Associate a TOTP token with the user ----------

  const { AccessToken } = respondToAuthChallengeRes1a.AuthenticationResult;

  const associateSoftwareTokenRes = await cognitoIdentityProviderClient
    .send(
      new AssociateSoftwareTokenCommand({
        AccessToken,
      }),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("associateSoftwareTokenRes:");
  console.log(associateSoftwareTokenRes);

  // ---------- Verify the TOTP token with the user ----------

  const { SecretCode } = associateSoftwareTokenRes;
  const { otp: otp1, expires: expires1 } = TOTP.generate(SecretCode);

  const verifySoftwareTokenRes = await cognitoIdentityProviderClient
    .send(
      new VerifySoftwareTokenCommand({
        AccessToken,
        UserCode: otp1,
      }),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("verifySoftwareTokenRes:");
  console.log(verifySoftwareTokenRes);

  // ---------- Set MFA preference to TOTP ----------

  const setUserMFAPreferenceRes = await cognitoIdentityProviderClient
    .send(
      new SetUserMFAPreferenceCommand({
        AccessToken,
        SoftwareTokenMfaSettings: {
          // won't work unless we associate and verify TOTP token with user
          Enabled: true,
          PreferredMfa: true,
        },
      }),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("setUserMFAPreferenceRes:");
  console.log(setUserMFAPreferenceRes);

  // ---------- Wait for a new OTP to generate ----------

  console.log("waiting for new OTP . . .");
  await wait(expires1);

  // ---------- Signin 2. initiate signin attempt ----------

  const srpSession2 = createSrpSession(username, password, poolId, false);

  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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("initiateAuthRes2:");
  console.log(initiateAuthRes2);

  // ---------- Signin 2. respond to PASSWORD_VERIFIER challenge ----------

  const signedSrpSession2 = signSrpSession(srpSession2, initiateAuthRes2);

  const respondToAuthChallengeRes2a = await cognitoIdentityProviderClient
    .send(
      new RespondToAuthChallengeCommand(
        wrapAuthChallenge(signedSrpSession2, {
          ClientId: clientId,
          ChallengeName: "PASSWORD_VERIFIER",
          ChallengeResponses: {
            SECRET_HASH: secretHash,
            USERNAME: username,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes2a:");
  console.log(respondToAuthChallengeRes2a);

  // ---------- 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: secretHash,
            SOFTWARE_TOKEN_MFA_CODE: otp2,
            USERNAME: username,
          },
          Session: Session2a,
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes2b:");
  console.log(respondToAuthChallengeRes2b);

  // ---------- Confirm the device (for tracking) ----------

  const { DeviceGroupKey, DeviceKey } = respondToAuthChallengeRes2b.AuthenticationResult.NewDeviceMetadata;
  const DeviceSecretVerifierConfig = createDeviceVerifier(username, DeviceGroupKey);

  const confirmDeviceRes = await cognitoIdentityProviderClient
    .send(
      new ConfirmDeviceCommand({
        AccessToken,
        DeviceKey,
        DeviceName: "example-friendly-name", // usually this is set a User-Agent
        DeviceSecretVerifierConfig,
      }),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("confirmDeviceRes:");
  console.log(confirmDeviceRes);

  // ---------- Remember the device (for easier logins) ----------

  const updateDeviceStatusRes = await cognitoIdentityProviderClient
    .send(
      new UpdateDeviceStatusCommand({
        AccessToken,
        DeviceKey,
        DeviceRememberedStatus: "remembered",
      }),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("updateDeviceStatusRes:");
  console.log(updateDeviceStatusRes);

  // ---------- Signin 3. initiate signin attempt ----------

  const srpSession3 = createSrpSession(username, password, poolId, false);

  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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("initiateAuthRes3:");
  console.log(initiateAuthRes3);

  // ---------- 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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes3a:");
  console.log(respondToAuthChallengeRes3a);

  // ---------- 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,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes3b:");
  console.log(respondToAuthChallengeRes3b);

  // ---------- Signin 3. respond to DEVICE_PASSWORD_VERIFIER challenge ----------

  // `NotAuthorizedException: Incorrect username or password.`
  // Must be doing something wrong around here . . .

  const signedSrpSessionWithDevice3 = signSrpSessionWithDevice(
    signedSrpSession3,
    respondToAuthChallengeRes3b,
    DeviceGroupKey,
    DeviceSecretVerifierConfig.RandomPassword,
  );

  const respondToAuthChallengeRes3c = await cognitoIdentityProviderClient
    .send(
      new RespondToAuthChallengeCommand(
        wrapAuthChallenge(signedSrpSessionWithDevice3, {
          ClientId: clientId,
          ChallengeName: "DEVICE_PASSWORD_VERIFIER",
          ChallengeResponses: {
            SECRET_HASH: secretHash,
            USERNAME: username,
            DEVICE_KEY: respondToAuthChallengeRes3b.ChallengeParameters.DEVICE_KEY,
          },
        }),
      ),
    )
    .catch((err) => {
      console.error(err);
      throw err;
    });

  console.log("respondToAuthChallengeRes3c:");
  console.log(respondToAuthChallengeRes3c);
})();
renatoargh commented 7 months ago

Hey @simonmcallister0210 thanks for your update! I will start checking it today!

spatel2693 commented 5 months ago

Hey @renatoargh Were you able to figure out why you were getting // NotAuthorizedException: Incorrect username or password. ?

I am facing the same issue.

renatoargh commented 5 months ago

Hey @spatel2693 we had a big detour on the project and I will be back to it next Monday. So far no progress but if I figure something out I will post it here

spatel2693 commented 4 months ago

Hey @renatoargh, @simonmcallister0210 do you have any updates on this? I'm stuck on this error.

simonmcallister0210 commented 4 months ago

Hey. I had a look at this a month ago and couldn't make any progress.. but I think it's just a case of getting the algorithm correct. I must be making a mistake in the calculation somewhere. I'll have another go over the next few days

spatel2693 commented 4 months ago

Hey. I had a look at this a month ago and couldn't make any progress.. but I think it's just a case of getting the algorithm correct. I must be making a mistake in the calculation somewhere. I'll have another go over the next few days

Thank you, @simonmcallister0210. I really appreciate this. If you enable sponsoring on this repo, I will make sure to send you a few 🍻 !

simonmcallister0210 commented 4 months ago

Have a working demo on the linked branch. Just need to polish it off, write tests, update docs, etc.

spatel2693 commented 4 months ago

Have a working demo on the linked branch. Just need to polish it off, write tests, update docs, etc.

Thank you so much, @simonmcallister0210 , for a quick turnaround. Really appreciate it. I'll give it a try and let you know.

simonmcallister0210 commented 3 months ago

Made a rod for my own back writing so many unit tests, not sure how useful most of those tests are tbh... just need to finish the integration test cases and documentation then we should be good to go

renatoargh commented 2 months ago

Hello again!

I've returned to the project where this feature would be extremely helpful. I attempted to test the code in this branch, but unfortunately, I ran into some issues. Would it be possible for you to publish the contents of this branch as a beta tagged release on npm?

Thanks so much for your help! It looks like the device support is in place, and I suspect the issues might be on my end.

simonmcallister0210 commented 2 months ago

Sure. I've published 2.3.0-beta-v1. If all goes well on the beta release I'll merge this branch in next week / week after. Let me know if there's any issues πŸ‘

One thing to be aware of: the request wrapper functions (wrapInitiateAuth and wrapAuthChallenge) don't use USER_ID_FOR_SRP. You would need to add it yourself like so:

const USER_ID_FOR_SRP = initiateAuthRes2.ChallengeParameters?.USER_ID_FOR_SRP;

// . . .

new RespondToAuthChallengeCommand(
  wrapAuthChallenge(signedSrpSession, {
    ClientId: clientId,
    ChallengeName: "SOFTWARE_TOKEN_MFA",
    ChallengeResponses: {
      SECRET_HASH: secretHash,
      SOFTWARE_TOKEN_MFA_CODE: otp,
      USERNAME: USER_ID_FOR_SRP, // <-- here
    },
    Session: session,
  }),
,

The integration tests are a good reference if you need it. In a future release I'll just attach USER_ID_FOR_SRP to the SRP session

renatoargh commented 2 months ago

Hey, thanks for your quick response! I am testing it now (successfully installed the beta version from npm) and I am now adapting my code because currently, I am still using the workaround from https://github.com/simonmcallister0210/cognito-srp-helper/issues/26

A quick question; what exactly is the secretId, as mentioned here (in my case I am not generating client secrets): image

simonmcallister0210 commented 2 months ago

When you enable secrets you will get that secret ID for the app. Then you'd need to call that function to calculate the value for SECRET_HASH, which you need to pass to all your Cognito API requests. Since you dont have a secret you won't need to provide a SECRET_HASH

renatoargh commented 2 months ago

Hey @simonmcallister0210, thank you so much. I can confirm that I am finally able to make the entire flow work! πŸ₯³ I would like to send you a few 🍻s, make sure to enable sponsoring

simonmcallister0210 commented 2 months ago

It's ok, I'm just happy people are using the library πŸ™‚ thanks for your patience

I'll wait for another week in case there are any issues, then I'll release the beta fix in 2.3.0