aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.42k stars 2.12k forks source link

Cognito passwordless authentication via link #1896

Open aldarund opened 5 years ago

aldarund commented 5 years ago

I'm trying to implement auth via link without password. E.g. user enter his email -> cognito send an email with link that user can click and login. It

It is supported by cognito via custom challenge. E.g. https://aws-amplify.github.io/amplify-js/media/authentication_guide#using-a-custom-challenge

I have created DefineAuthChallenge, CreateAuthChallenge, VerifyAuthChallenge lambda function that associated with my cognito pool. CreateAuthChallenge generate code that is send to user email with link to site.

Next i was planning to grab that code from url on site and login user via Auth.sendCustomChallengeAnswer

But here is the problem. Auth.sendCustomChallengeAnswer require user object that is passed from Auth.signIn. But if user just click link from email there wont be any user object at all. And amplify lib dont save that middle session user object from Auth.Signin, so on page reload its lost. It is saving only if auth completed to its storage https://github.com/aws-amplify/amplify-js/blob/master/packages/amazon-cognito-identity-js/src/CognitoUser.js#L175

So the question is how can i save & reconstruct user object from Auth.signIn function on page reload to be able to call sendCustomChallengeAnswer. Or if there a better approach to sign in via link without password?

Which Category is your question related to? Authentification

What AWS Services are you utilizing? Cognito

Provide additional details e.g. code snippets

powerful23 commented 5 years ago

@aldarund for now there is no way to get a user object which is in the middle state of signing in. We will mark this as feature request.

Maybe you can try a work around which is to construct the user object your self like:

const userPoolData = {
                UserPoolId: userPoolId,
                ClientId: userPoolWebClientId,
                Storage: // by default is localStorage
            };

const userPool = new CognitoUserPool(userPoolData);

const userData = {
    UserName: 'username'
    Pool: userPool,
    Storage: the_storage_object_for_caching // by default is local storage
}

const user = new CognitoUser(userData);
jaehyeon-kim commented 5 years ago

Here is a way that works for me.

The mandatory argument of Auth.signIn() is username. Let say the link is constructed as /:username/:challengeAnswer and it's possible to extract the values as props.

Then sign-in can be done by executing the following when a component is created (or page is visited). (This example is based on Vue)

created () {
    Auth.signIn(this.username)
        .then(user => {
            Auth.sendCustomChallengeAnswer(user, this.challengeAnswer)
                .then(user => {
                    this.$router.push('/')
                })
                .catch(err => {
                    this.$router.push('/unauthorized')
                })
        })
        .catch(err => {
            this.$router.push('/error')
        })
}

Hope this helps.

aldarund commented 5 years ago

@jaehyeon-kim hm, isnt it will create a new session so a challengeAnswer will be different too?

jaehyeon-kim commented 5 years ago

@aldarund

You mentioned you've already created all lambda triggers + code, which is a challenge answer. I consider only username is missing in this case.

See the relevant section of the Amplify document - https://aws-amplify.github.io/amplify-js/media/authentication_guide#defining-a-custom-challenge (actually it's in Verify Challenge Response section just below it, which cannot be tagged.)

aldarund commented 5 years ago

@jaehyeon-kim not what i mean. I mean challenge answer created during login and by logic should be tied to session. When u do Auth.signIn(this.username) it will create new challenge code with new session, so i dont see how your solution could work

jaehyeon-kim commented 5 years ago

@aldarund

I see. Yes, a different session will be created. However it's up to you which to return as a challenge answer or whether to return at all. For example, as far as I've checked, it's possible to fake the create trigger because eventually what you need is whether event.response.answerCorrect is true or false in the verify trigger.

For more example, the following will always result in successful authentication. It's up to you how to verify.

export const handler = async (event, context) => {
    if (event.request.privateChallengeParameters.answer === event.request.challengeAnswer) {
        event.response.answerCorrect = true;
    } else {
        event.response.answerCorrect = false;
    }

    return event;
};

// change into

export const handler = async (event, context) => {
   event.response.answerCorrect = true;
    return event;
};
aldarund commented 5 years ago

@jaehyeon-kim yes, but that still will send user new email on second login call, so it endup with two email for login flow which is bad.

jaehyeon-kim commented 5 years ago

@aldarund

I'm building a portal that supports single sign on. In the portal, a user signs in and then a list of apps that the user has access are shown. If the user clicks one of them, it opens the app while passing the username (email) and a challenge answer that can be independently verified (guess which may be a good challenge answer in this case?) - here I faked the create trigger.

Possibly the key difference is whether a user is already signed in or not. In my app, it's serving signed-in users while yours doesn't seem to be. Event the latter case, however, I don't think it's a big deal as long as it's possible to verify the challenge answer reliably.

As far as I understand, it endup with two email for login flow seems to be due to misunderstanding.

aldarund commented 5 years ago

@jaehyeon-kim i just need sign in via mail. E.g. user enter his email on site -> cognito send mail to him -> user click email and get logged in. So with your solution one email would be send when he tried to login, and second email will be send when he click link from first mail to actually login.

jaehyeon-kim commented 5 years ago

@aldarund

user enter his email on site -> cognito send mail to him - Why Cognito? Isn't it possible to send a username and a verifiable challenge answer without relying on any of the auth challenge triggers? As far as I've checked, the create trigger can be faked and verification can be done independently but reliably.

aldarund commented 5 years ago

yes, it can be, just tried to implement it as a cognito since it does support all this triggers :)

buggy commented 5 years ago

When the user enters their email address invoke a Lambda via the API GW to send the sign-in link. After the user clicks the link and reloads the app then use Auth.signIn() with a custom auth flow to complete the process. You might need to include the email address in the sign-in link.

joebernard commented 5 years ago

Has there been any improvement to the process mentioned by @powerful23 to access a CognitoUser object? I have a similar use case in my React Native app for a passwordless flow:

await Auth.signUp({username, tmpPassword})
const cognitoUser = await Auth.signIn(username);

The user then receives an email linking them back to the app, but at that point I no longer have access to cognitoUser and must re-create it. I've tried using the Amplify Cache to store it, but the functions on the object are not persisted (namely sendCustomChallengeAnswer). For now I'm doing this but looking for a better way.

const CognitoUserPool = require('amazon-cognito-identity-js').CognitoUserPool;

getCognitoUser = function(email) {
  const poolData = { 
    UserPoolId : COGNITO_USER_POOL_ID,
    ClientId : COGNITO_CLIENT_ID
  };
  const userPool = new CognitoUserPool(poolData);
  const userData = {
    Username : email, 
    Pool : userPool
  };
  return new CognitoUser(userData);
};

edit: Also trying this

import { Auth } from "aws-amplify";
const cognitoUser = Auth.createCognitoUser(username);
await Auth.sendCustomChallengeAnswer(cognitoUser, authChallengeAnswer);

but cognitoUser.Session is null causing an error:

{"code":"InvalidParameterException","name":"InvalidParameterException","message":"Missing required parameter Session"}

apuyou commented 5 years ago

Hi,

The login flow is a bit different but I thought it might be useful to link this AWS blog article here (as I found it after this issue): https://aws.amazon.com/blogs/mobile/implementing-passwordless-email-authentication-with-amazon-cognito/

The context is not lost because the user enters the code he has received on the same page that did the request. I think it's even better in a browser as it avoids the user ending with an extra open tab (I agree that clicking a link is better for native apps).

KristineTrona commented 5 years ago

@apuyou That is a good example of how to create a passwordless authentication by sending user a code via email.

Is there any better solution/examples for sending user a magic link via email that would open the app and sign them in?

cliffordh commented 5 years ago

I am also very interested in this. I've gotten 95% of the way there to a "magic link" login, but now I'm stuck at sendCustomChallengeAnswer... going to try and recreate the CognitoUser somehow which might work.

cliffordh commented 5 years ago

The custom challenge flow was a dead end for my purposes. However, I just posted a workaround that might help: https://github.com/aws-amplify/amplify-js/issues/3500

waydelyle commented 4 years ago

It took me a while but I figured out how to do it.

import { Auth } from 'aws-amplify'
import awsConfig from '@configs/aws-config'
import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js'

async function signIn(emailAddress: string) {
  const user = await Auth.signIn(emailAddress)

  // the main issue is that the user session needs to be stored and hydrated later.
  localStorage.setItem(
    'cognitoUser',
    JSON.stringify({
      username: emailAddress,
      session: user.Session,
    })
  )
},

async function answerCustomChallenge(answer: string) {
  const user = JSON.parse(localStorage.getItem('cognitoUser'))
  // rehydrate the CognitoUser
  const authenticationData = {
    Username: user.username,
  }
  const authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails(
    authenticationData
  )
  const poolData = {
    UserPoolId: awsConfig.Auth.userPoolId as string,
    ClientId: awsConfig.Auth.userPoolWebClientId as string,
  }
  const userPool = new AmazonCognitoIdentity.CognitoUserPool(poolData)
  const userData = {
    Username: user.username,
    Pool: userPool,
  }
  const cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData)
  // After this set the session to the previously stored user session
  cognitoUser.Session = user.session
  // rehydrating the user and sending the auth challenge answer directly will not
  // trigger a new email
  cognitoUser.setAuthenticationFlowType('CUSTOM_AUTH')

  cognitoUser.sendCustomChallengeAnswer(answer, {
    async onSuccess(success: any) {
      // If we get here, the answer was sent successfully,
      // but it might have been wrong (1st or 2nd time)
      // So we should test if the user is authenticated now
      try {
        // This will throw an error if the user is not yet authenticated:
        const user = await Auth.currentSession()
      } catch {
        console.error('Apparently the user did not enter the right code')
      }
    },
    onFailure(failure: any) {
      console.error(failure)
    },
  })
},

This solution works with https://aws.amazon.com/blogs/mobile/implementing-passwordless-email-authentication-with-amazon-cognito/

pointcast-dev commented 4 years ago

Thanks @waydelyle for your proposal. It would actually make sense for me to store/retrieve the session information using the local storage.

When I run your code, then the call of sendCustomChallengeAnswer() with the correct code (and session) does not trigger onSuccess or onFailure, but the customChallenge callback. Therefore it seems, it does not work for me. When I try to retrieve the session anyway, I get the following response: 'Session: Error: Local storage is missing an ID Token, Please authenticate.

Any ideas?

waydelyle commented 4 years ago

It seems to do that if signIn and answerCustomChallenge are triggered within the same session. In my case, I'm sending a magic link to the user for login which triggers answerCustomChallenge separately. If signIn is triggered within the same session you can just reference user.sendCustomChallengeAnswer instead.

pointcast-dev commented 4 years ago

We are actually using also magic link mechanism and having a new browser window (i.e. new session), but I messed up with providing the code. Now the solution works fine for me too. Thanks @waydelyle

waydelyle commented 4 years ago

No problem!

cliffordh commented 4 years ago

The only problem I see with this approach over my suggestion with #3500 is that access to localStorage is required, as are Lambdas, which could become a maintenance headache.

robertomoreno commented 4 years ago

I was able to implement this following @waydelyle approach but rather than use local storage I'm using DynamoDB, this way it works for every browser, no matter where you ask the code. It looks like:

export function sendAuthCode(email) {
  const loginDetails = new AuthenticationDetails({ Username: email });
  const preAuthCognitoUser = createCognitoUser(email);
  preAuthCognitoUser.setAuthenticationFlowType("CUSTOM_AUTH");

  return new Promise((resolve, reject) =>
    preAuthCognitoUser.initiateAuth(loginDetails, {
      onSuccess: resolve,
      onFailure: reject,
      customChallenge: async function() {
        await savePreAuthSession({
          email: preAuthCognitoUser.username,
          session: preAuthCognitoUser.Session
        }).catch(e =>
          console.warn(
            "Could not store session remotely. Access from an other browser tab won't be allowed",
            e
          )
        );
        resolve();
      }
    })
  );
}

export async function checkAuthCode(code, email) {
  const sessionData = await getPreAuthSession(email);
  const preAuthCognitoUser = createCognitoUser(email, sessionData.session);
  return new Promise((resolve, reject) =>
    preAuthCognitoUser.sendCustomChallengeAnswer(code, {
      onSuccess: resolve,
      onFailure: reject,
      customChallenge: async () => {
        await savePreAuthSession({
          email: preAuthCognitoUser.username,
          session: preAuthCognitoUser.session
        });
        const error = new Error("Code not valid, try again");
        error.code = "CodeValidationFail";
        reject(error);
      }
    })
  );
}

function createCognitoUser(email, session) {
  const cognitoUser = new CognitoUser({ Username: email, Pool: userPool });
  if (session) {
    cognitoUser.Session = session;
  }
  return cognitoUser;
}

Notice that savePreAuthSession and getPreAuthSession are just rest call to save and fetch the session.

kazinayem2011 commented 4 years ago

@aldarund for now there is no way to get a user object which is in the middle state of signing in. We will mark this as feature request.

Maybe you can try a work around which is to construct the user object your self like:

const userPoolData = {
                UserPoolId: userPoolId,
                ClientId: userPoolWebClientId,
                Storage: // by default is localStorage
            };

const userPool = new CognitoUserPool(userPoolData);

const userData = {
    UserName: 'username'
    Pool: userPool,
    Storage: the_storage_object_for_caching // by default is local storage
}

const user = new CognitoUser(userData);

@powerful23 , Can you please tell me what you mean by "Storage: // by default is localStorage" in both userPoolData and userData? Is it the object which Auth.signIn returns? I'm getting error : " AuthClass - Failed to get the signed in user No current user "

yairau commented 4 years ago

Another approach is to persist the challenge code as a custom property on the user object in Cognito. This means you can separate the link creation from the sing in session. For detailed implementation check this post. The only point I would add to that post is that you properly should hash the challenge code so that you don't have them available in plain text to people with access to the user pool.

mdunbavan commented 3 years ago

Hey @robertomoreno I know this is quite a while ago now but can you share more of your implementation above? I am hitting cognitoUser Session issues with it being null for some reason.

anklos commented 3 years ago

@kazinayem2011 i am getting the same AuthClass - Failed to get the signed in user No current user error when sending the corect challenge anwser.

anyone has overcome this issue?

mdivani commented 3 years ago

have same issue, I will be using suggested solutions here but also feel like this should have been addressed a while ago

afenton90 commented 3 years ago

Building on the solution from @waydelyle I found this worked well. The main difference is UniversalStorage is used to support withSSRContext in your app, meaning you can get the authed user on your server.

Use the signIn method to capture the users email and initiate a login. Use the answerCustomChallenge method to take the value from your magic link and send back to Cognito for authentication.

import { UniversalStorage } from '@aws-amplify/core';
import { Auth } from '@aws-amplify/auth';
import { CognitoUser, CognitoUserPool } from 'amazon-cognito-identity-js';

async function signIn(emailAddress: string) {
  const user = await Auth.signIn(emailAddress)

  // the main issue is that the user session needs to be stored and hydrated later.
  sessionStorage.setItem(
    'cognitoUser',
    JSON.stringify({
      username: emailAddress,
      session: user.Session,
    })
  )
}

// Do all this when your magic link mounts in the client
async function answerCustomChallenge(answer: string) {
  const cognitoUserFromSession = JSON.parse(sessionStorage.getItem('cognitoUser'));

  if (!cognitoUserFromSession) {
    // Handle error behaviour in your app
  } else {
    const cognitoUser = new CognitoUser({
      Username: cognitoUserFromSession.username,
      Pool: new CognitoUserPool({
        UserPoolId: // <Your user pool id>,
        ClientId: // <Your client id>,
      }),
      Storage: new UniversalStorage(), // This is important for SSR contexts when signed in
    });

    // TypeScript will probably error here. Ignore it.
    cognitoUser.Session = cognitoUserFromSession.session;
    cognitoUser.setAuthenticationFlowType('CUSTOM_AUTH');

    try {
      await Auth.sendCustomChallengeAnswer(cognitoUser, answer);
      // If we get here, the answer was sent successfully,
      // but we should test if the user is authenticated now.
      // This will throw an error if the user is not yet authenticated.
      const authedUser = await Auth.currentAuthenticatedUser();

      // Handle success behaviour here
    } catch {
      // Handle error behaviour in your app
    }
  }
}