DEV Community

Duarte Nunes
Duarte Nunes

Posted on • Updated on

Passwordless Authentication with Cognito

Passwordless authentication is a broad term for any authentication method that doesn't rely on passwords. Implementations typically perform proof of identity based on something that is uniquely associated with a user, such as an e-mail address, a phone, a software one-time password (OTP) generator, or a hardware authentication device like a YubiKey: the user inputs the secret that the system shares with one of those methods, proving its ownership. A passwordless flow essentially skips the "what you know" factor of two-factor authentication. We're epistemological skeptics now.

Cognito is an infamous AWS service for identity management. It can do much for us: authentication, access control, and integration with external identity providers. A big advantage over competing solutions is that it integrates well with other AWS services, like AppSync.

In this post we'll look at how we can implement passwordless authentication using Cognito to satisfy our requirements, which aren't quite as described in this AWS blog post. We assume a passing familiarity with Cognito and AWS Lambda. The code examples are Lambda functions written in Go.

"As a user, I want..."

The app we're going to work on authenticates users according to the following UX:

  1. A user inputs their e-mail or phone number.
  2. We send an OTP over e-mail or SMS.
  3. The user inputs the code.
  4. If this is their first sign-in:
    1. We collect additional data, including a secondary authentication method.
    2. We verify it using a second OTP.

Example UI
Example UI

This is very convenient: users can sign-in using information they know by heart, without needing to use password managers or, worse, their memory (they don't even need to remember whether they've already signed-up).

These are our requirements:

  • [ ] The user must be able to sign-in with their email or phone number
  • [ ] There is no separate sign-up process, as it should follow directly from the sign-in
  • [ ] We mustn't leak user existence errors through the public API, including Cognito's
  • [ ] OTPs are valid only for a brief period of time
  • [ ] Users have more than one attempt to input the correct OTP

What features does Cognito offer that help us meet these requirements? Let's find out.

Cognito Custom Auth

Cognito doesn't have a built-in passwordless feature, but it supports a custom authentication flow implemented as a state machine based on three Lambda function triggers that we need to implement.

  1. The define auth challenge Lambda is the state machine coordinator. It returns instructions to Cognito on how the flow should progress. The options are: a challenge is required; the authentication failed; or the authentication succeeded and tokens can be emitted. It is invoked by Cognito when a client calls the InititateAuth API.
  2. If a challenge is required, the create auth challenge Lambda creates it. It's invoked right after the previous Lambda1, and is responsible for delivering the challenge to the user. This can be done through public challenge parameters returned by InitiateAuth, or through some other means2. Private challenge parameters are shared with the verify auth challenge response Lambda, typically including the challenge answer.
  3. The verify auth challenge response Lambda is invoked by Cognito following the RespondToAuthChallenge API call, containing the challenge answer provided by the user. It returns whether the answer is correct, and Cognito invokes the define auth challenge Lambda again to decide whether the authentication can terminate or should continue with more challenges (in which case the create auth challenge Lambda gets called again).

Example UI

The custom auth state machine, from the documentation.

A custom authentication flow can be composed of many challenges. State is kept in an encrypted session object, which is initially returned by the InititateAuth API call, and then threaded back to Cognito when calling RespondToAuthChallenge. It expires after 3 minutes, which already allows us to tick off a requirement!

  • [ ] The user must be able to sign-in with their email or phone number
  • [ ] There is not separate sign-up process, as it should follow directly from the sign-in
  • [ ] We mustn't leak user existence errors through the public API, including Cognito's
  • [x] OTPs are valid only for a brief period of time
  • [ ] Users have more than one attempt to input the correct OTP

Configuring the Cognito user pool

Our journey first tasks us with configuring a Cognito user pool in such a way that e-mails and phone numbers can be used as aliases. Thanks to the CDK, we don't have to confront the maze of darkness that is CloudFormation YAML:



this.pool = new UserPool(stack, "UserPool", {
  userPoolName: props.userPoolName,
  signInAliases: { username: true, email: true, phone: true },
  lambdaTriggers: {
    defineAuthChallenge: props.triggers.defineAuthChallenge,
    createAuthChallenge: props.triggers.createAuthChallenge,
    verifyAuthChallengeResponse: props.triggers.verifyAuthChallengeResponse,
  },
  selfSignUpEnabled: false,
})


Enter fullscreen mode Exit fullscreen mode

This translates to a user pool that's part of a CloudFormation stack, which:

  • Allows users to sign-in using their e-mail address and phone-number;
  • Is configured with the ARN of the Lambdas that will carry out our passwordless authentication flow;
  • Doesn't allow users to sign themselves up, because the SignUp API can be used as an oracle for whether a user exists in a pool. This means we must use the AdminCreateUser API.

We also configure the user pool client to only authenticate users through the custom authentication flow, thus disallowing password-based authentication. Clients are allowed to refresh user tokens (tokens have a validity and need to be refreshed for the session to be extended without users having to go through the sign-in process again).



this.pool.addClient(`UserPoolClient${clientName}`, {
  userPoolClientName: clientName,
  authFlows: {
    custom: true,
    refreshToken: true,
  },
})


Enter fullscreen mode Exit fullscreen mode

Verifying an email or phone number

Implementing the flow using the extension Lambda functions seems straightforward:

  1. The define auth challenge Lambda checks for a successfully completed challenge; if there isn't any, it either fails the authentication if there are over N attempts, or keeps the state machine running.
  2. The create auth challenge Lambda sends an e-mail or SMS, depending on what was used to sign-in.
  3. The verify auth challenge response receives the answer from the client and marks the challenge as correct or not.

Alas, it's not so simple. We need to pass a bit of information from the client to the create auth challenge Lambda, so it can decide between sending an e-mail or an SMS. Cognito, however, doesn't tell the Lambda what alias was used to start the flow, nor does it thread the ClientMetadata parameter of InititateAuth to the define or create auth challenge Lambdas.

How can we work around this? We could pass that information through a data store like DynamoDB, but a better solution is to leverage that ClientMetadata parameter. InititateAuth doesn't pass it to the Lambda, but RespondToAuthChallenge certainly does.

An RPC by any other name

These authentication challenges actually comprise a complete request-response protocol over which the client can communicate with the state machine. Clients can answer the challenges while passing along arbitrary data to the Lambdas, which can keep the protocol going as long as needed. With this in mind, we can solve the problem by using an extra challenge to specify what the actual method we're verifying is:

Cognito RPC

The first challenge delivers no code, and expects email or phone to be passed in the ClientMetadata. The second challenge is the actual OTP.

The metadata returned by the create auth challenge Lambda is kept in the encrypted session object, and is used to communicate with the define auth challenge Lambda as well as with future invocations of the create auth challenge Lambda. The private parameters are shared only with the verify auth challenge response Lambda.

This custom authentication flow now behaves as a primitive that lets us validate a user attribute, namely an e-mail or phone number.

Implementation

The verify auth challenge response Lambda has the easiest task:



func handler(ctx context.Context, event *events.CognitoEventUserPoolsVerifyAuthChallenge,
) (*events.CognitoEventUserPoolsVerifyAuthChallenge, error) {
    code := event.Request.PrivateChallengeParameters["code"]
    event.Response.AnswerCorrect = code == "" || code == event.Request.ChallengeAnswer
    return event, nil
}


Enter fullscreen mode Exit fullscreen mode

If there is no code in the private challenge parameters it's because this is the first step of the protocol, in which case the answer is always accepted. Otherwise, we match the code against the user-provided answer.

The create auth challenge Lambda calculates and delivers the challenge. We encode a challenge as:



type AttributeType string

const (
    Email          AttributeType = "email"
    Phone          AttributeType = "phone_number"
    NoVerification AttributeType = "none"
)

type Challenge struct {
    Attr AttributeType
    Code string
}


Enter fullscreen mode Exit fullscreen mode

This challenge is persisted in the metadata shared between all Lambdas in the state machine and stored in the session object. The private challenge parameters are shared with the verify auth challenge response Lambda. If our challenge had public parameters, we would specify them here and they would be sent to the client (in plaintext).



func handler(ctx context.Context, event *events.CognitoEventUserPoolsCreateAuthChallenge,
) (*events.CognitoEventUserPoolsCreateAuthChallenge, error) {
    challenge := calculateAndDeliverChallenge(&event.Request, userTest, &metadata)
    event.Response = triggers.CognitoEventUserPoolsCreateAuthChallengeResponse{
        PrivateChallengeParameters: map[string]string{
            "code": challenge.Code,
        },
        ChallengeMetadata: auth.Serialize(challenge),
    }
    return event, nil
}


Enter fullscreen mode Exit fullscreen mode

We generate an empty code for the first challenge and an OTP for the second one. In the second challenge we expect the client metadata to contain the attribute type to verify (the type, not the value), which we use to select the the attribute value from the current user attributes, delivering the code by e-mail or SMS.3 The client essentially sends us a pointer to the actual attribute to verify.



func calculateAndDeliverChallenge(req *events.CognitoEventUserPoolsCreateAuthChallengeRequest,
) auth.Challenge {
    sessionSize := len(request.Session)
    if sessionSize == 0 {
        return auth.Challenge{auth.NoVerification, ""}
    }
    lastSession := request.Session[sessionSize-1]
    lastChallenge := auth.Deserialize(lastSession.ChallengeMetadata)
    if !lastSession.ChallengeResult {
        return lastChallenge
    }
    attr := auth.AttributeToVerify(request.ClientMetadata, request.UserAttributes)
    code := auth.GenerateOTP()
    deliverCode(attr, code)
    return auth.Challenge{attr.Type, code}
}


Enter fullscreen mode Exit fullscreen mode

If the previous challenge failed, that is, if the user introduced the wrong code, we reuse it.

The define auth challenge Lambda coordinates these two:



type ChallengeStatus struct {
  Challenge      auth.Challenge
    AttemptCount int
    Passed       bool
}

func handler(ctx context.Context, event *events.CognitoEventUserPoolsDefineAuthChallenge,
) (*events.CognitoEventUserPoolsDefineAuthChallenge, error) {
    status := calculateChallengeStatus(event.Request.Session)
    challengeName := ""
    done := status.Passed && status.Challenge.Attr != auth.NoVerification
    if done {
      // ...
    } else {
        challengeName = "CUSTOM_CHALLENGE"
    }
    event.Response = events.CognitoEventUserPoolsDefineAuthChallengeResponse{
        IssueTokens:        done,
        FailAuthentication: status.AttemptCount >= 4,
        ChallengeName:      challengeName,
    }
    return event, nil
}


Enter fullscreen mode Exit fullscreen mode

The state machine keeps turning until we reach the maximum allowed failed attempts or the user provides us with the correct OTP. We tick off two requirements πŸŽ‰

  • [x] The user must be able to sign-in with their email or phone number
  • [ ] There is not separate sign-up process, as it should follow directly from the sign-in
  • [ ] We mustn't leak user existence errors through the public API, including Cognito's
  • [x] OTPs are valid only for a brief period of time
  • [x] Users have more than one attempt to input the correct OTP

Unfortunately, this isn't sufficient to support our authentication flow. Recall that we have no separate sign-up process, so we might not have a registered user for a run of this protocol. However, we need the attribute to verify to be present in the user attributes, which are only populated for existing users (also, Cognito doesn't issue tokens for users absent from the pool). We must ensure a user exists, even if a partial one.

Sign-In

Users aren't allowed to sign themselves up (to prevent using the SignUp API as a user-existence oracle), so we need something that does that on their behalf, say, a Lambda resolver for a GraphQL API on AppSync.

We can call the AdminCreateUser API to ensure the user exists, return to the client and let it handle the rest of the flow, but we'd be optimizing for the less common scenario of a user not existing in the pool.

Instead, we try to InititateAuth and if that fails, we issue the AdminCreateUser request. Does this mean we need to mediate all requests between the client and Cognito? No! We can take the session object returned by Cognito and return it to the client to be used in the RespondToAuthChallenge call.

Since we're already here though, we can even go ahead and execute the first step of the protocol, telling Cognito which attribute we want to verify:



func handler(ctx context.Context, input SignInInput) (*SignInPayload, error) {
  session, op := auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
    if op.Error == nil {
        return &SignInPayload{string(session)}, op
    }
    op = auth.CreateUser(cognito, input.EmailOrPhone, input.Method)
    if op.Error != nil {
        return nil, op.Error
    }
  session, op = auth.InitiateAuth(cognito, input.EmailOrPhone, input.Method)
    return &SignInPayload{string(session)}, op.Error
}


Enter fullscreen mode Exit fullscreen mode

The CreateUser function calls the AdminCreateUser API, generating a random username and password and marking the email or phone number attribute as verified so it can be used as an alias. 4 The InitiateAuth function calls Cognito's own InitiateAuth and then the first RespondToAuthChallenge. The rest is up to the client.

We encapsulate the AdminCreateUser API and behave the same regardless of the user existing before the request or not. Yep, another one down:

  • [x] The user must be able to sign-in with their email or phone number
  • [ ] There is not separate sign-up process, as it should follow directly from the sign-in
  • [x] We mustn't leak user existence errors through the public API, including Cognito's
  • [x] OTPs are valid only for a brief period of time
  • [x] Users have more than one attempt to input the correct OTP

Sign-Up

Uff. The user is signed-in. However, there's a chance the JWT token our client got back from Cognito contains only a single claim for either the user's email or phone number, and we need more! In that case, our UI proceeds to collect some user data: their name and the second authentication method.

We have another attribute to verify, so we need to run another instance of our protocol. Lets add a GraphQL resolver that writes the user data into Cognito by updating the user attributes.5



func handler(ctx context.Context, input SignUpInput) (SignUpPayload, error) {
    auth.SetAttributes(cognito, input.Username, map[string]string{
        input.Data.Method.String(): input.Data.EmailOrPhone,
        fmt.Sprintf("%s_verified", input.Data.Method.String()): "false",
        "given_name":  input.Data.GivenName,
        "family_name": input.Data.FamilyName,
    }, auth.UserPoolID()))
  session, op := auth.InitiateAuth(cognito, input.Username, input.Data.Method)
    return SignUpPayload{string(session)}, op.Error
}


Enter fullscreen mode Exit fullscreen mode

Note that we mark that second method as not verified, because Cognito only allows one user to have a given email or phone number marked as verified and thus use it as an alias 6. We want to avoid the following scenario:

  1. Alice uses the verified email E;
  2. Bob signs-in using their phone number, and then signs-up with E;
  3. Bob doesn't verify E, but we already marked the attribute as verified;
  4. Alice signs-in using E, sees Bob's account.

Our system should consider a user as valid only when it has both attributes verified, but who marks the second method as such? That's a job for our define auth challenge Lambda:



if done {
  attr := fmt.Sprintf("%s_verified", string(status.Attr))
    if event.Request.UserAttributes[attr] == "false" {
            auth.SetAttributes(cognito, event.UserName, map[string]string{
            attr: "true",
        })
    }
} // ...


Enter fullscreen mode Exit fullscreen mode

Finally, note that this very same logic can be used to implement an API for changing a user's e-mail or phone number.

Refreshing the token

We're almost done, but there's one missing detail: Cognito isn't prepared for us modifying a user's attributes during the authentication process, so the token the client ends up with after the sign-up doesn't have the second attribute marked as verified. There's no clever solution here: we must call the InitiateAuth API using the REFRESH_TOKEN_AUTH flow instead of CUSTOM_AUTH.

We got them all.

  • [x] The user must be able to sign-in with their email or phone number
  • [x] There is not separate sign-up process, as it should follow directly from the sign-in
  • [x] We mustn't leak user existence errors through the public API, including Cognito's
  • [x] OTPs are valid only for a brief period of time
  • [x] Users have more than one attempt to input the correct OTP

Conclusion

Cognito isn't easy to work with and I find the client libraries and documentation to be somewhat lacking, but at the end of the say it's solving a huge problem on our behalf. It has enough flexibility to be subject to some unorthodox usage, and I hope it keeps on improving.

For this particular use case I missed some additional toggles, which could be added without loss of generality: it could flow the client metadata from InitiateAuth to the authentication Lambdas, and it could allow the define auth challenge Lambda to override some token claims, much like the pre-token generation Lambda can. Another easy improvement is allowing the custom auth Lambdas to return errors, which should be returned from the API calls (even if as some generic error). By contacting AWS support I was able to turn the first of these in a feature request, but of course, without any estimate when it will be available.

Thanks to @_jwnx for reviewing this post.


  1. Yes, an InitiateAuth call can hit 2 Lambda cold-boots. ↩

  2. For example, by sending an e-mail, SMS, or push notification using some AWS service. ↩

  3. Cognito doesn't support returning custom errors from the extension Lambdas. Errors must be propagated in the state machine in such a way that authentication will end up failing. ↩

  4. It also calls the AdminSetUserPassword API to move the user from the FORCE_CHANGE_PASSWORD state to CONFIRMED. ↩

  5. This resolver is using Cognito authorization, so only signed-in users can use it. The Username points to the user making the request. ↩

  6. Cognito understands the email_verified and phone_number_verified attributes. ↩

Top comments (12)

Collapse
 
oldtinroof profile image
Les Cochrane

Thank you Duarte, this has been a fantastic post for me to get my head around OTP with Cognito - I'm porting it over to JS as I've got more experience with that but I'm stuck on the last couple of steps.

Is the auth you call for auth.SetAttributes, and auth.CreateUser from an SDK, or a private library?

Collapse
 
duarten profile image
Duarte Nunes

Glad you enjoyed it :D

The auth module is a private library. Essentially, SetAttributes() wraps AdminUpdateUserAttributes, and CreateUser wraps AdminCreateUser and AdminSetUserPassword with a random password.

Collapse
 
oldtinroof profile image
Les Cochrane • Edited

Awesome, that's exactly the nudge in the right direction I needed, thank you.

One of the things that sets your post apart (in a positive way) is that you didn't give code for every single step, so it's encouraged me to go research and really understand what's going on and write my own equivalent of the auth library. Cheers!

Collapse
 
shirikodama profile image
Michael Thomas

so is this a truly passwordless system? that is, a password is never sent over the net at any point ever to an auth/enroll server? i've been working on an example of exactly that kind of system where it uses asymmetric keys to enroll and authenticate users. i'm aware of webauthn but it is heavily focused on crypto dongles which is vast overkill for most situations (say this site, for example). using WebCrypto to generate key pairs and signing login requests allows the server to just need to remember the public keys associated with a given user and an out of band (email, sms...) way to verify their possession of that method.

it's all pretty simple honestly, and it's something of a mystery why it's not gaining traction since webcrypto has been a round for a while now.

you can check out my example and code here: out.mtcc.com/hoba-bis

Collapse
 
duarten profile image
Duarte Nunes

Interesting approach :) However, that does require some effort from users as they have to store their private key on their devices. In our system, users are authenticated through social login or through an OTP as described in the post.

Collapse
 
shirikodama profile image
Michael Thomas • Edited

js code makes it completely transparent to the user. in my example, you join by typing in a username and an email address then click join. you login by entering your username and clicking login. all of the complexity is under the hood, with the keys (wrapped by a local password if you want), stored in localStorage or indexedDB. it's not even particularly complex and pretty much resembles existing login code. the backend just verifies the key bound to the user and verifies the sig. i patterned the exchange after digest auth (rfc 7616). i came up with this years ago and documented it in rfc 7486 well before webcrypto and webauthn.

Collapse
 
jrothman profile image
Joel Rothman

Hi Duarte - thanks for the post. I'm a bit of a Go and Cognito noob, so apologies if this is a dumb question. When I try setup my lambda functions there are a number of dependencies that they need that I cannot figure out, for example: auth.Challenge and request.Session. Could you provide some assistance in terms of the packages (and anything else you think I should know) in order to configure and run your example?

Collapse
 
duarten profile image
Duarte Nunes

Hi Joel, apologies for not preparing a runnable example. auth.Challenge is my own type, defined as

type AttributeType string

const (
    Email       AttributeType = "email"
    Phone       AttributeType = "phone_number"
    NoAttribute string        = ""
)

type Challenge struct {
    Attr AttributeType
    Code string
}

request.Session is a member of CognitoEventUserPoolsCreateAuthChallengeRequest, the type of the Request field in CognitoEventUserPoolsCreateAuthChallenge. The latter is the input type of the Lambda function for the create auth challenge trigger.

Hope that helps.

Collapse
 
jrothman profile image
Joel Rothman

Thanks Duarte - much appreciated!

Collapse
 
geekmidas profile image
Lebogang Mabala

Hey Duarte, I've been trying to follow what you are doing here and I understand for the most part, but you seem to be using a lot of code that is not show here. Do you mind sharing the public repo for this solution? On the other hand it also seems like you are handling both login and sign up in your lambda above. How would you keep the user logged in on the frontend when the tokens expire,. maybe I am not following some of your logic. Thanks in advance.

Collapse
 
hadilsabbagh profile image
Hadil Sabbagh

Hi Duarte! Thanks for the excellent post. I am porting it to Clojure. I have a question:

In the deliverCode function, how do obtain the email address or phone number associated with the user?

Collapse
 
dhananjayrakshe profile image
Dhananjay Rakshe

can I get full source code for this