Front-End Web & Mobile

One-time Password Authentication with the Amplify Libraries for Swift

There are many types of authentication flows for different apps, but using a one-time password (OTP) is one of the more mainstream authentication flows users expect from an app. An OTP flow consist of a user providing either a username or email address and receiving a code (usually 6 digits) via an email or text message.

In this article, you will learn how to create an OTP auth flow for an iOS app using Amazon Simple Email Service (SES) to send an email containing a 6-digit code and authenticate the user using the Auth and Function categories with the AWS Amplify Libraries for Swift.

If you would like to follow along, you can clone the Kilo-Loco/one-time-password-sign-in-amplify-swift repository from GitHub.

Setup Amazon SES

Open the Amazon SES homepage on the AWS Management Console. It is important that you select a region that will match your project. I will be using us-west-2, but you can choose a different region that may match your existing project or that is aligned with your Amplify CLI profile.

Start by clicking the Create Identity button on the Amazon SES homepage.

Next, create an identity by providing an email address that you can verify. This will be the email address that appears to users in their inbox. It can be changed at a later time, so feel free to use a personal email for testing.

Click Create identity to continue.

If you intend on using a single email address to follow along, it’s important that you use an email address with a service that allows you to send emails to yourself. If your email service doesn’t allow you to email yourself, you will need to create a second identity that you will use to sign up in the app for testing.

You will now see that the Identity status is set to Verification pending.

Open your email and click the link to verify your email address.

The link will take you to a congratulations page that signifies that your email has been verified.

Refresh the Amazon SES Verified identities page and you will see that email address now has an Identity status of Verified.

At this point, your Amazon SES account will still be in sandbox, meaning you will only be able to send emails to verified identities. If you attempt to send emails to (sign up with) non-verified email addresses, the SES will not send out an email.

Setup Amplify Project

I will be using the Amplify CLI to create the Amplify project in the root directory of my iOS project, but you can use Amplify Studio if you’re more comfortable with that.

In the root directory of your Xcode project, run the following command in the terminal:

amplify init

I will use the default configuration for an iOS project and ensure I use a profile that matches the region of my Amazon SES identity. You can see the values I selected in the following snippet:

? Enter a name for the project OTPSwiftBlog
The following configuration will be applied:

Project information
| Name: OTPSwiftBlog
| Environment: dev
| Default editor: Visual Studio Code
| App type: ios

? Initialize the project with the above configuration? Yes
Using default provider  awscloudformation
? Select the authentication method you want to use: AWS profile
? Please choose the profile you want to use kyle-west-2
✔ Help improve Amplify CLI by sharing non sensitive configurations on failures (y/N) · no

Once your Amplify project in successfully created, you must use the Amplify CLI to configure the Auth category since an OTP flow requires a manual configuration.

Enter the following command in the terminal:

amplify add auth

Answer the prompts with the following values

Do you want to use the default authentication and security configuration? 
   Manual configuration
Select the authentication/authorization services that you want to use: 
   User Sign-Up, Sign-In, connected with AWS IAM controls (Enables per-user Storage features for images or other content, Analytics, and more)
Provide a friendly name for your resource that will be used to label this category in the project: 
   <Hit enter to provide default value or enter a custom value>
Enter a name for your identity pool. 
   <Hit enter to provide default value or enter a custom value>
Allow unauthenticated logins? (Provides scoped down permissions that you can control via AWS IAM) 
   No
Do you want to enable 3rd party authentication providers in your identity pool? 
   No
Provide a name for your user pool: 
   <Hit enter to provide default value or enter a custom value>
How do you want users to be able to sign in? 
   Username
Do you want to add User Pool Groups? 
   No
Do you want to add an admin queries API? 
   No
Multifactor authentication (MFA) user login options: 
   OFF
Email based user registration/forgot password: 
   Enabled (Requires per-user email entry at registration)
Specify an email verification subject: 
   Your verification code
Specify an email verification message: 
   Your verification code is {####}
Do you want to override the default password policy for this User Pool? 
   No
What attributes are required for signing up? 
   Email
Specify the app's refresh token expiration period (in days): 
   30
Do you want to specify the user attributes this app can read and write? 
   No
Do you want to enable any of the following capabilities? 
   <Hit Enter without any selected>
Do you want to use an OAuth flow? 
   No
? Do you want to configure Lambda Triggers for Cognito? 
   Yes
? Which triggers do you want to enable for 
   Cognito Create Auth Challenge, Define Auth Challenge, Verify Auth Challenge Response
? What functionality do you want to use for Create Auth Challenge 
   Custom Auth Challenge Scaffolding (Creation)
? What functionality do you want to use for Define Auth Challenge 
   Custom Auth Challenge Scaffolding (Definition)
? What functionality do you want to use for Verify Auth Challenge Response 
   Custom Auth Challenge Scaffolding (Verification)
? Do you want to edit your boilerplate-create-challenge function now? 
   Yes (See snippet below)
? Do you want to edit your boilerplate-define-challenge function now? 
   Yes (See snippet below)
? Do you want to edit your boilerplate-verify function now? 
   Yes (See snippet below)

Update boilerplate-create-challenge.js with the following code:

var aws = require("aws-sdk");
var ses = new aws.SES({ region: "us-west-2" }); // This is your SES region
const digitGenerator = require("crypto-secure-random-digit");

// 1
function sendChallengeCode(emailAddress, secretCode) {
  var params = {
    Destination: {
      ToAddresses: [emailAddress],
    },
    Message: {
      Body: {
        Text: { Data: secretCode },
      },
      Subject: { Data: "Email Verification Code" },
    },
    Source: "<YOUR_SES_VERIFIED_EMAIL>", // This is you SES Identity Email
  };

  return ses.sendEmail(params).promise();
}

exports.handler = async function (event) {
  if (event.request.challengeName === "CUSTOM_CHALLENGE") {
    // Generate a random code for the custom challenge
    const challengeCode = digitGenerator.randomDigits(6).join("");

    event.response.privateChallengeParameters = {};
		// 2
    event.response.privateChallengeParameters.answer = challengeCode;

		// 3
    event.response.publicChallengeParameters = {};
    event.response.publicChallengeParameters["fieldTitle"] = "Enter the secret";
    event.response.publicChallengeParameters["fieldHint"] = "Check your email";

    return sendChallengeCode(event.request.userAttributes.email, challengeCode);
  }
};
  1. The sendChallengeCode will take the email address that’s provided by the user through the app and uses the AWS SDK to send the secret code from the email address that you verified with SES.
  2. The challenge code will be stored as the answer so when the user receives the challenge code in the email, it can be compared.
  3. The publicChallengeParameters are returned in as a response once the user has provided an email address in the app and triggered the create challenge flow. You can present these values in the app if desired.

Update boilerplate-define-challenge.js with the following code:

exports.handler = async function (event) {
  if (event.request.session.length == 0) {
    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
  ) {
    event.response.issueTokens = true;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  } else {
    event.response.issueTokens = false;
    event.response.failAuthentication = true;
  }
};

The snippet above will check if the session dictionary is empty or has a single value. Once a session exists, authentication tokens can be issued to the user.

Update boilerplate-verify.js with the following code:

function verifyAuthChallengeResponse(event) {
  if (
    event.request.privateChallengeParameters.answer ===
    event.request.challengeAnswer
  ) {
    event.response.answerCorrect = true;
  } else {
    event.response.answerCorrect = false;
  }
}

exports.handler = async (event) => {
  verifyAuthChallengeResponse(event);
};

verifyAuthChallengeResponse simply checks if the challenge code that was stored during the create challenge function matches the code submitted by the user in the app. It then returns whether the answer is correct or not.

Although you may have finished answering all the prompts of amplify add auth at this point, you still must make a few more updates to the functions so they work as expected.

The custom policies must be updated to allow emails to be sent out by Amazon SES. Update the custom-policies.json file (<YOUR_PROJECT>/amplify/backend/function/<project_code>CreateAuthChallenge/custom-policies.json) for the create challenge with the following code:

[
  {
    "Effect": "Allow",
    "Action": [
      "ses:SendEmail",
      "ses:SendRawEmail"
    ],
    "Resource": ["*"]
  }
]

Additionally, since the a random code is being generated via a third party package during the creation of the auth challenge in boilerplate-create-challenge.js, you must update the package.json (<YOUR_PROJECT>/amplify/backend/function/<project_code>CreateAuthChallenge/src/package.json) file for the create challenge to include the dependency.

{
  "name": "otpswiftblog00d2416400d24164CreateAuthChallenge",
  "version": "2.0.0",
  "description": "Lambda function generated by Amplify",
  "main": "index.js",
  "license": "Apache-2.0",
  "dependencies": { // <- Add
    "crypto-secure-random-digit": "^1.0.9" // <- Add
  }, // <- Add
  "devDependencies": {
    "@types/aws-lambda": "^8.10.92"
  }
}

With the Auth category configured and your functions setup, it’s time to push your configuration to the backend. Run the following command:

amplify push

The Amplify CLI will show which resources have been created. It should look similar to the snippet below:

│ Category │ Resource name                             │ Operation │ Provider plugin   │
├──────────┼───────────────────────────────────────────┼───────────┼───────────────────┤
│ Function │ <YOUR_PROJECT>CreateAuthChallenge         │ Create    │ awscloudformation │
├──────────┼───────────────────────────────────────────┼───────────┼───────────────────┤
│ Function │ <YOUR_PROJECT>DefineAuthChallenge         │ Create    │ awscloudformation │
├──────────┼───────────────────────────────────────────┼───────────┼───────────────────┤
│ Function │ <YOUR_PROJECT>VerifyAuthChallengeResponse │ Create    │ awscloudformation │
├──────────┼───────────────────────────────────────────┼───────────┼───────────────────┤
│ Auth     │ <YOUR_PROJECT>                            │ Create    │ awscloudformation │

If everything looks correct, hit Enter to continue. Once the configuration has been successfully deployed, you will see the following output in the terminal:

Deployment completed.

Add Amplify as a Dependency

Now it’s time to open Xcode and add Amplify as a dependency for the iOS project. Open the Search Packages screen (File > Add Packages…):

Enter the Amplify Libraries for Swift repo URL (https://github.com/aws-amplify/amplify-swift/) in the search bar at the top right. Select Up to Next Major Version for Dependency Rule and set the value to 2.0.0, then click Add Package.

Select Amplify and AWSCognitoAuthPlugin as the package products and click Add Package.

You will now see Amplify and its dependencies added to the navigation bar in Xcode.

Configure Amplify

If the two configuration files have not been added to your Xcode project, open Finder, navigate to the root directory of your project, then drag and drop both amplifyconfiguration.json and awsconfiguration.json into the Xcode Navigation pane.

Open the <YOUR_PROJECT>App.swift file (one_time_password_sign_in_amplifyApp.swift if you’re following along from the repo) and add the following import statements to the top:

import Amplify
import AWSCognitoAuthPlugin

Inside the <YOUR_PROJECT>App object, add the following function to configure Amplify:

func configureAmplify() {
    do {
        try Amplify.add(plugin: AWSCognitoAuthPlugin())
        try Amplify.configure()
        print("Successfully configured Amplify")
    } catch {
        print("Failed to initialize Amplify:", error)
    }
}

Add the following init method to ensure that Amplify is configured as soon as the app is initialized:

init() {
    configureAmplify()
}

Build and run. You will see the following output in the Xcode logs:

Successfully configured Amplify

Implement Authentication

Finally, you will be implementing the functionality that will allow the user to sign up, confirm sign up, and login using a passwordless flow by using OTP sent via email.

In SignUpView.swift add the following import statement:

import Amplify

In the SignUpView object, add the following method to handle sign up:

func signUp() {
    Task {
        do {
            let options = AuthSignUpRequest.Options(
                userAttributes: [.init(.email, value: email)]
            )
            let result = try await Amplify.Auth.signUp(
                username: username,
                password: UUID().uuidString, // <- Random password
                options: options
            )
            switch result.nextStep {
            case .confirmUser:
                print("Next step is to confirm user")
                confirmSignUpIsVisible = true
            case .done:
                print("Sign up is finished")
            }
        } catch {
            print("Sign up failed", error)
        }
    }
}

The sign up process is the same as a typical username authentication flow with one exception, the password is a randomly generated string since a value is required but won’t be used for the user to sign in.

In the body of SignUpView, update the sign up button to call signUp for the action:

Button("Sign Up", action: signUp)

In ConfirmSignUpView.swift add the following import statement at the top:

import Amplify

Add the standard confirm sign up functionality to ConfirmSignUpView:

func confirmSignUp() {
    Task {
        do {
            let result = try await Amplify.Auth.confirmSignUp(
                for: username,
                confirmationCode: verificationCode
            )
            switch result.nextStep {
            case .confirmUser:
                print("Unexpected next step")
            case .done:
                print("Sign up finished")
                didConfirmSignUp()
            }
        } catch {
            print("Failed to confirm sign up", error)
        }
    }
}

Update the confirm sign up button to call confirmSignUp:

Button("Confirm Sign Up", action: confirmSignUp)

In LoginView.swift add the following import statements:

import Amplify
import AWSCognitoAuthPlugin

Next, add a login function that will trigger the one-time password authentication flow:

func login() {
    Task {
        do {
						// 1
            let options = AWSAuthSignInOptions(authFlowType: .customWithoutSRP)
            let result = try await Amplify.Auth.signIn(
                username: username,
                options: .init(pluginOptions: options)
            )
            switch result.nextStep {
						// 2
            case .confirmSignInWithCustomChallenge(let info):
                print("User must enter custom challenge. Additonal info: ", info ?? "N/A")
                emailCodeIsVisible = true
            default:
                print("Unexpected auth flow. Next step:", result.nextStep)
            }
        } catch {
            print("Failed to login", error)
        }
    }
}
  1. The auth flow type is set to .customWithoutSRP which will skip the default SRP (Secure Remote Password) and jump straight into the custom auth flow that was built in our functions.
  2. The .confirmSignInWithCustomChallenge case provides additional info which includes information that is provided through publicChallengeParameters of the create challenge function. It also indicates that the custom challenge was a success and the user must move forward and confirm the sign in using the custom challenge.

Update the login button to call login:

Button("Login", action: login)

To finish the authentication process, navigate to EmailCodeView.swift and add the following import statement at the top:

import Amplify

Add a function that will handle the challenge response by entering the code that was emailed to the user:

func confirmSignIn() {
    Task {
        do {
            let result = try await Amplify.Auth.confirmSignIn(challengeResponse: emailCode)
            switch result.nextStep {
            case .done:
                print("User has successfully signed in")
                didConfirmLogin()
            default:
                print("Unexpected step:", result.nextStep)
            }
        } catch {
            print("Confirm sign in failed:", error)
        }
    }
}

confirmSignIn will take the code entered by the user and send it to the verifyAuthChallengeResponse function where it will then be compared to the code that was sent out via email. If answerCorrect comes back as true, the nextStep will be .done, indicating that the user has successfully signed in to the app.

Lastly, update the confirm login button to call confirmSignIn:

Button("Confirm Login", action: confirmSignIn)

Build and run. You will now be able to authenticate a new user using any email addresses that you verified with Amazon SES.

Conclusion

You have successfully setup a custom authentication flow using OTP by using Amazon SES to send a code via email and Amplify Auth to sign in the user without the need for a password. If you’re ready to use this flow in a production app, you will need to request production access on the Amazon SES dashboard so your app can send emails to unverified email addresses. If you don’t plan on maintaining this code, it is recomended that you run amplify delete to remove all the resources that were generated for you during this tutorial.

As you use Amplify to build your next project, be sure to reach out on the GitHub repository, or through the Amplify Discord server under the #swift-help channel to help us prioritize features and enhancements.