Passwordless authentication in a Cognito user pool
Cognito's custom auth flow is three Lambdas and a state machine you have to write yourself. What each trigger receives, what it must return, and where the tokens come from.
- Published
- Reading
- 7 min
- Tags
- auth
- Originally
- Codemancers
Using a custom authentication flow with Cognito, several steps happen behind the scenes. Understanding the whole flow is worth the time.
It starts with a user request to initiate authentication. Cognito responds by
creating a new session token, which expires after three minutes, and sends it to
the defineChallenge Lambda. That function acts as a state machine for the
entire flow. Since this is the first invocation in the session, we return one of
the predefined challenge types AWS provides. The ChallengeNameType enum in
the AWS SDK lists them all.
Next, Cognito triggers createChallenge with the challenge type. This is where
the handler logic defining how the custom authentication works lives. Once the
challenge parameters are set, the public ones go back to the client along with
the session token.
The user enters a challenge response, which, with the username and session
token, goes to the respondToAuthChallenge command. Cognito takes that response
and triggers verifyChallenge, where we check it against the answer set in
createChallenge.
If the response is correct, Cognito invokes defineAuth again, which responds
with authentication tokens. Cognito sends those to the client and the flow is
done.
Define challenge Lambda
exports.handler = async (event) => {
if (event.request.session.length === 0) {
// First challenge
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';
} else if (
event.request.session.length === 1 &&
event.request.session[0].challengeName === 'CUSTOM_CHALLENGE' &&
event.request.session[0].challengeResult === true
) {
// User has successfully completed the challenge
event.response.issueTokens = true;
event.response.failAuthentication = false;
} else {
// Challenge failed
event.response.issueTokens = false;
event.response.failAuthentication = true;
}
};defineChallenge is the most involved of the three because it is the state
machine for the whole flow. Cognito provides the current session in the request:
an array of every challenge answer so far.
Three properties on the response object build the reply the user pool wants:
issueTokens, failAuthentication and challengeName.
If the session array is empty we issue the challenge, since this is the first
trigger. Otherwise we validate. If the challenge name matches our configuration,
we issue tokens when challengeResult is true and fail authentication when it
is false.
Create challenge Lambda
const emailClient = require('@sendgrid/mail');
const sendgridApiKey = process.env.SENDGRID_API_KEY;
emailClient.setApiKey(sendgridApiKey);
exports.handler = async (event) => {
const code = Math.floor(100000 + Math.random() * 900000).toString(); // Generate a random 6-digit number
const user = event.request.userAttributes;
const email = {
to: `${event.userName} <${user.email}>`,
from: 'example.com <no-reply@example.com>',
subject: `[${event.triggerSource}] Your login token`,
text: `Use the link below to log in to example.com\n http://localhost:4000/verify-login?code=${code}&username=${event.userName} \n this link will expire in three minutes`,
};
await emailClient.send(email);
event.response.publicChallengeParameters = { challenge: 'CUSTOM_CHALLENGE' };
event.response.privateChallengeParameters = { code };
};createChallenge generates the temporary password and notifies the user of it:
email, SMS, push, whatever fits. Here it's an email carrying a login link, with
the code and username as query parameters that the frontend uses to call the
backend API and respond to the challenge.
Note the split between public and private challenge parameters. Public ones go back to the client and must never include the answer. Private ones stay inside the authentication flow.
Verify challenge Lambda
exports.handler = async (event) => {
const expectedOtp = event.request.privateChallengeParameters.code;
const userOtp = event.request.challengeAnswer;
if (userOtp === expectedOtp) {
event.response.answerCorrect = true;
} else {
event.response.answerCorrect = false;
}
return event;
};verifyChallenge is the simple one. It compares the user input against the
answer stored in privateChallengeParameters back in createChallenge, and
assigns the result to answerCorrect.
Login service
async function login({ username }) {
const input = {
ClientId: this.awsConfigService.userPoolClientId,
AuthFlow: 'CUSTOM_AUTH',
AuthParameters: {
USERNAME: username,
},
};
const command = new InitiateAuthCommand(input);
return this.client.send(command);
}AuthFlow is set to CUSTOM_AUTH, which tells Cognito to trigger our
defineChallenge handler and not to expect a PASSWORD field in
AuthParameters.
Verify login
async function verifyLogin({ code, username, session }) {
const input = {
ClientId: '<User-Pool-Client-Id>',
ChallengeName: 'CUSTOM_CHALLENGE',
Session: session,
ChallengeResponses: {
ANSWER: code,
USERNAME: username,
},
};
const command = new RespondToAuthChallengeCommand(input);
const response = await this.client.send(command);
return response.AuthenticationResult;
}verifyLogin splits the authentication process, taking username and challenge
answer separately. The session token from the initial auth command in login is
required. The challenge name has to match the one in defineChallenge. On
success the command responds with authentication tokens.
Skipping the challenge entirely
There's an alternative route to passwordless: issue tokens on the first
invocation of defineChallenge and never create a challenge at all.
exports.handler = async (event) => {
event.response.issueTokens = true;
event.response.failAuthentication = false;
return event;
};The login service is unchanged:
async function login({ username }) {
const input = {
ClientId: this.awsConfigService.userPoolClientId,
AuthFlow: 'CUSTOM_AUTH',
AuthParameters: {
USERNAME: username,
},
};
const command = new InitiateAuthCommand(input);
return this.client.send(command);
}Because Cognito has been told to issue tokens immediately, login now returns
them directly and there is no separate verifyLogin step. Smoother, at the cost
of the verification the challenge was doing.
Conclusion
That's a passwordless flow on Cognito with custom Lambda triggers. Whether you take the challenge-based route or issue directly, the shape is the same: Cognito runs the state machine, and you write what each state does.