= {
15 | home: {
16 | path: '/',
17 | routePath: () => '/',
18 | routeComponent: PrivateRoute,
19 | component: React.lazy(() => import('pages/Home/Home')),
20 | exact: true,
21 | },
22 | signIn: {
23 | path: '/signIn',
24 | routePath: () => '/signIn',
25 | routeComponent: PublicRoute,
26 | component: React.lazy(() => import('pages/SignIn/SignIn')),
27 | },
28 | verify: {
29 | path: '/verify',
30 | routePath: () => '/verify',
31 | routeComponent: PublicRoute,
32 | component: React.lazy(
33 | () => import('pages/VerifyMagicLink/VerifyMagicLink')
34 | ),
35 | },
36 | }
37 |
38 | export const renderRoutes = Object.entries(routes)
39 |
--------------------------------------------------------------------------------
/packages/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.0.0",
4 | "bin": {
5 | "passwordless-login": "bin/passwordless-login.js"
6 | },
7 | "scripts": {
8 | "build": "tsc",
9 | "watch": "tsc -w",
10 | "test": "jest",
11 | "test:hook": "jest --onlyChanged",
12 | "cdk": "dotenv -- cdk",
13 | "deploy": "yarn cdk deploy -O ../frontend/src/cdk-exports.json"
14 | },
15 | "devDependencies": {
16 | "@aws-cdk/assert": "1.116.0",
17 | "@aws-cdk/assertions": "1.116.0",
18 | "@types/aws-lambda": "8.10.81",
19 | "@types/jest": "^26.0.10",
20 | "@types/node": "16.4.8",
21 | "aws-cdk": "1.116.0",
22 | "aws-sdk": "2.958.0",
23 | "dotenv-cli": "4.0.0",
24 | "esbuild": "0.12.17",
25 | "husky": "7.0.1",
26 | "jest": "^26.4.2",
27 | "lint-staged": "11.1.1",
28 | "prettier": "2.3.2",
29 | "ts-jest": "^26.2.0",
30 | "ts-node": "^9.0.0",
31 | "typescript": "~4.3.5"
32 | },
33 | "dependencies": {
34 | "@aws-cdk/aws-apigateway": "1.116.0",
35 | "@aws-cdk/aws-cognito": "1.116.0",
36 | "@aws-cdk/aws-iam": "1.116.0",
37 | "@aws-cdk/aws-lambda-nodejs": "1.116.0",
38 | "@aws-cdk/aws-logs": "1.116.0",
39 | "@aws-cdk/core": "1.116.0",
40 | "crypto-secure-random-digit": "1.0.9",
41 | "source-map-support": "^0.5.16"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "@craco/craco": "6.2.0",
6 | "@testing-library/jest-dom": "^5.11.4",
7 | "@testing-library/react": "^11.1.0",
8 | "@testing-library/user-event": "^12.1.10",
9 | "@types/jest": "^26.0.15",
10 | "@types/node": "^12.0.0",
11 | "@types/react": "^17.0.0",
12 | "@types/react-dom": "^17.0.0",
13 | "@types/react-router-dom": "^5.1.8",
14 | "antd": "4.16.9",
15 | "aws-amplify": "^4.2.2",
16 | "axios": "^0.21.1",
17 | "craco-antd": "1.19.0",
18 | "react": "^17.0.2",
19 | "react-dom": "^17.0.2",
20 | "react-router-dom": "^5.2.0",
21 | "react-scripts": "4.0.3",
22 | "typescript": "^4.1.2",
23 | "web-vitals": "^1.0.1"
24 | },
25 | "scripts": {
26 | "dev": "craco start",
27 | "build": "craco build",
28 | "test": "craco test",
29 | "test:hook": "react-scripts test --watchAll=false --onlyChanged",
30 | "eject": "react-scripts eject"
31 | },
32 | "eslintConfig": {
33 | "extends": [
34 | "react-app",
35 | "react-app/jest"
36 | ]
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.1%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Passwordless Auth
2 |
3 | This stack allows a user to login directly via email without any need for a pasword. This uses Cognito for authentication along with Lambda triggers. Here's the [blog post](https://dev.to/ryands17/magic-links-with-cognito-using-the-cdk-24a9) for the same.
4 |
5 | The `cdk.json` file tells the CDK Toolkit how to execute your app.
6 |
7 | ## Prerequisites
8 |
9 | - Install dependencies using `yarn`
10 | - Rename `.example.env` to `.env` in `packages/backend` and replace the value in `SES_FROM_ADDRESS` to your verified email address in SES
11 | - Rename `.example.env` to `.env` in `packages/frontend` and replace the value in `AWS_REGION` to the region your stack is deployed to. Default is `us-east-2`
12 |
13 | ## Useful commands
14 |
15 | ### CDK
16 |
17 | - `yarn workspace backend build` compile typescript to js
18 | - `yarn workspace backend watch` watch for changes and compile
19 | - `yarn workspace backend test` perform the jest unit tests
20 | - `yarn workspace backend cdk deploy` deploy this stack to your default AWS account/region
21 | - `yarn workspace backend cdk diff` compare deployed stack with current state
22 | - `yarn workspace backend cdk synth` emits the synthesized CloudFormation template
23 |
24 | ### Webapp
25 |
26 | - `yarn workspace frontend dev` starts the dev server on [http://localhost:3000](http://localhost:3000)
27 | - `yarn workspace frontend build` builds the app for production to the `build` folder
28 | - `yarn workspace frontend test` launches the test runner in the interactive watch mode
29 |
--------------------------------------------------------------------------------
/packages/backend/functions/defineAuthChallenge.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DefineAuthChallengeTriggerEvent,
3 | DefineAuthChallengeTriggerHandler,
4 | } from 'aws-lambda'
5 |
6 | export const handler: DefineAuthChallengeTriggerHandler = async (event) => {
7 | if (notCustomChallenge(event)) {
8 | // We only accept custom challenges; fail auth
9 | event.response.issueTokens = false
10 | event.response.failAuthentication = true
11 | } else if (tooManyFailedAttempts(event)) {
12 | // The user provided a wrong answer 3 times; fail auth
13 | event.response.issueTokens = false
14 | event.response.failAuthentication = true
15 | } else if (successfulAnswer(event)) {
16 | // The user provided the right answer; succeed auth
17 | event.response.issueTokens = true
18 | event.response.failAuthentication = false
19 | } else {
20 | // The user did not provide a correct answer yet; present challenge
21 | event.response.issueTokens = false
22 | event.response.failAuthentication = false
23 | event.response.challengeName = 'CUSTOM_CHALLENGE'
24 | }
25 |
26 | return event
27 | }
28 |
29 | export const notCustomChallenge = (event: DefineAuthChallengeTriggerEvent) =>
30 | event.request.session &&
31 | !!event.request.session.find(
32 | (attempt) => attempt.challengeName !== 'CUSTOM_CHALLENGE'
33 | )
34 |
35 | export const tooManyFailedAttempts = (event: DefineAuthChallengeTriggerEvent) =>
36 | event.request.session &&
37 | event.request.session.length >= 3 &&
38 | event.request.session.slice(-1)[0].challengeResult === false
39 |
40 | export const successfulAnswer = (event: DefineAuthChallengeTriggerEvent) =>
41 | event.request.session &&
42 | !!event.request.session.length &&
43 | event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE' && // Doubly stitched, holds better
44 | event.request.session.slice(-1)[0].challengeResult === true
45 |
--------------------------------------------------------------------------------
/packages/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/packages/frontend/src/pages/SignIn/SignIn.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Card, Form, Input, Button, message } from 'antd'
3 | import { useAuth } from 'config/auth'
4 | import styles from './SignIn.module.css'
5 |
6 | const formItemLayout = {
7 | labelCol: {
8 | xs: { span: 24 },
9 | sm: { span: 6 },
10 | },
11 | wrapperCol: {
12 | xs: { span: 24 },
13 | sm: { span: 16 },
14 | },
15 | }
16 |
17 | const tailFormItemLayout = {
18 | wrapperCol: {
19 | xs: {
20 | span: 24,
21 | offset: 0,
22 | },
23 | sm: {
24 | span: 16,
25 | offset: 6,
26 | },
27 | },
28 | }
29 |
30 | const SignIn = () => {
31 | const [loading, setLoading] = React.useState(false)
32 | const { signIn } = useAuth()
33 |
34 | const onSubmit = async (values: any) => {
35 | try {
36 | setLoading(true)
37 | let response = await signIn(values)
38 | message.success(response.message, 5)
39 | } catch (e) {
40 | message.error(e?.response?.data?.message || e?.message, 4)
41 | } finally {
42 | setLoading(false)
43 | }
44 | }
45 |
46 | return (
47 |
48 |
49 | Sign In
50 |
64 |
65 |
66 |
67 |
68 |
71 |
72 |
73 |
74 |
75 | )
76 | }
77 |
78 | export default SignIn
79 |
--------------------------------------------------------------------------------
/packages/frontend/src/config/auth.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Auth } from 'aws-amplify'
3 | import { requestMagicLink } from './api'
4 |
5 | type AC = {
6 | loggedIn: boolean | null
7 | isAuthenticated: () => Promise
8 | signIn: (args: { email: string }) => Promise
9 | answerCustomChallenge: (email: string, answer: string) => Promise
10 | signOut: typeof Auth.signOut
11 | }
12 |
13 | const AuthContext = React.createContext({
14 | loggedIn: null,
15 | isAuthenticated: () => Promise.resolve(false),
16 | signIn: () => Promise.resolve(null),
17 | answerCustomChallenge: () => Promise.resolve(true),
18 | signOut: () => Promise.resolve(),
19 | })
20 |
21 | type AuthProviderProps = {
22 | children: React.ReactNode
23 | }
24 |
25 | const AuthProvider = (props: AuthProviderProps) => {
26 | const [loggedIn, setLoggedIn] = React.useState(null)
27 |
28 | const isAuthenticated = React.useCallback(async () => {
29 | try {
30 | await Auth.currentSession()
31 | return true
32 | } catch (error) {
33 | return false
34 | }
35 | }, [])
36 |
37 | React.useEffect(() => {
38 | isAuthenticated().then((res) => setLoggedIn(res))
39 | }, [isAuthenticated])
40 |
41 | const signIn = React.useCallback(async ({ email }: { email: string }) => {
42 | try {
43 | await Auth.signUp({
44 | username: email,
45 | password: `password${Math.random().toString().slice(0, 8)}`,
46 | attributes: { email },
47 | })
48 | } catch (e) {
49 | // skip if user already exists
50 | }
51 |
52 | return requestMagicLink(email)
53 | }, [])
54 |
55 | const answerCustomChallenge = async (email: string, answer: string) => {
56 | let cognitoUser = await Auth.signIn(email)
57 | await Auth.sendCustomChallengeAnswer(cognitoUser, answer)
58 | setLoggedIn(true)
59 | return isAuthenticated()
60 | }
61 |
62 | const signOut = React.useCallback(async () => {
63 | await Auth.signOut()
64 | setLoggedIn(false)
65 | }, [])
66 |
67 | return (
68 |
77 | {props.children}
78 |
79 | )
80 | }
81 |
82 | const useAuth = () => React.useContext(AuthContext)
83 |
84 | export { AuthProvider, useAuth }
85 |
--------------------------------------------------------------------------------
/packages/backend/functions/signIn.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyHandler } from 'aws-lambda'
2 | import { randomDigits } from 'crypto-secure-random-digit'
3 | import { CognitoIdentityServiceProvider, SES } from 'aws-sdk'
4 |
5 | const cisp = new CognitoIdentityServiceProvider()
6 | const ses = new SES({ region: process.env.AWS_REGION })
7 |
8 | export const handler: APIGatewayProxyHandler = async (event) => {
9 | try {
10 | const { email } = JSON.parse(event.body || '{}')
11 | if (!email) throw Error()
12 |
13 | // set the code in custom attributes
14 | const authChallenge = randomDigits(6).join('')
15 | await cisp
16 | .adminUpdateUserAttributes({
17 | UserAttributes: [
18 | {
19 | Name: 'custom:authChallenge',
20 | Value: `${authChallenge},${Date.now()}`,
21 | },
22 | ],
23 | UserPoolId: process.env.USER_POOL_ID,
24 | Username: email,
25 | })
26 | .promise()
27 |
28 | await sendEmail(email, authChallenge)
29 |
30 | return {
31 | statusCode: 200,
32 | headers: {
33 | 'Access-Control-Allow-Origin': '*',
34 | },
35 | body: JSON.stringify({
36 | message: `A link has been sent to ${email}`,
37 | }),
38 | }
39 | } catch (e) {
40 | console.error(e)
41 | return {
42 | statusCode: 400,
43 | headers: {
44 | 'Access-Control-Allow-Origin': '*',
45 | },
46 | body: JSON.stringify({
47 | message: `Couldn't process the request. Please try after some time.`,
48 | }),
49 | }
50 | }
51 | }
52 |
53 | const BASE_URL = `http://localhost:3000/verify`
54 |
55 | async function sendEmail(emailAddress: string, authChallenge: string) {
56 | const MAGIC_LINK = `${BASE_URL}?email=${emailAddress}&code=${authChallenge}`
57 |
58 | const html = `
59 |
60 | Here's your link:
61 |
64 |
65 | `.trim()
66 |
67 | const params: SES.SendEmailRequest = {
68 | Destination: { ToAddresses: [emailAddress] },
69 | Message: {
70 | Body: {
71 | Html: {
72 | Charset: 'UTF-8',
73 | Data: html,
74 | },
75 | Text: {
76 | Charset: 'UTF-8',
77 | Data: `Here's your link (copy and paste in the browser): ${MAGIC_LINK}`,
78 | },
79 | },
80 | Subject: {
81 | Charset: 'UTF-8',
82 | Data: 'Login link',
83 | },
84 | },
85 | Source: process.env.SES_FROM_ADDRESS,
86 | }
87 | await ses.sendEmail(params).promise()
88 | }
89 |
--------------------------------------------------------------------------------
/packages/backend/lib/passwordless-login-stack.ts:
--------------------------------------------------------------------------------
1 | import * as cdk from '@aws-cdk/core'
2 | import * as cg from '@aws-cdk/aws-cognito'
3 | import * as iam from '@aws-cdk/aws-iam'
4 | import * as apiGw from '@aws-cdk/aws-apigateway'
5 | import { lambda } from './helpers'
6 |
7 | export class PasswordlessLoginStack extends cdk.Stack {
8 | constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
9 | super(scope, id, props)
10 | const postAuthentication = lambda(this, 'postAuthentication')
11 |
12 | // User Pool and client
13 | const userPool = new cg.UserPool(this, 'users', {
14 | standardAttributes: { email: { required: true, mutable: true } },
15 | customAttributes: {
16 | authChallenge: new cg.StringAttribute({ mutable: true }),
17 | },
18 | passwordPolicy: {
19 | requireDigits: false,
20 | requireUppercase: false,
21 | requireSymbols: false,
22 | },
23 | accountRecovery: cg.AccountRecovery.NONE,
24 | selfSignUpEnabled: true,
25 | signInAliases: { email: true },
26 | lambdaTriggers: {
27 | preSignUp: lambda(this, 'preSignup'),
28 | createAuthChallenge: lambda(this, 'createAuthChallenge'),
29 | defineAuthChallenge: lambda(this, 'defineAuthChallenge'),
30 | verifyAuthChallengeResponse: lambda(this, 'verifyAuthChallenge'),
31 | postAuthentication,
32 | },
33 | removalPolicy: cdk.RemovalPolicy.DESTROY,
34 | })
35 |
36 | postAuthentication.role?.attachInlinePolicy(
37 | new iam.Policy(this, 'allowConfirmingUser', {
38 | statements: [
39 | new iam.PolicyStatement({
40 | effect: iam.Effect.ALLOW,
41 | actions: ['cognito-idp:AdminUpdateUserAttributes'],
42 | resources: [userPool.userPoolArn],
43 | }),
44 | ],
45 | })
46 | )
47 |
48 | const webClient = userPool.addClient('webAppClient', {
49 | authFlows: { custom: true },
50 | })
51 |
52 | const api = new apiGw.RestApi(this, 'authApi', {
53 | endpointConfiguration: { types: [apiGw.EndpointType.REGIONAL] },
54 | defaultCorsPreflightOptions: { allowOrigins: ['*'] },
55 | deployOptions: { stageName: 'dev' },
56 | })
57 |
58 | const signIn = lambda(this, 'signIn')
59 | .addEnvironment('SES_FROM_ADDRESS', process.env.SES_FROM_ADDRESS)
60 | .addEnvironment('USER_POOL_ID', userPool.userPoolId)
61 |
62 | signIn.addToRolePolicy(
63 | new iam.PolicyStatement({
64 | effect: iam.Effect.ALLOW,
65 | actions: ['ses:SendEmail'],
66 | resources: ['*'],
67 | })
68 | )
69 | signIn.addToRolePolicy(
70 | new iam.PolicyStatement({
71 | effect: iam.Effect.ALLOW,
72 | actions: ['cognito-idp:AdminUpdateUserAttributes'],
73 | resources: [userPool.userPoolArn],
74 | })
75 | )
76 |
77 | const signInMethod = new apiGw.LambdaIntegration(signIn)
78 | api.root.addMethod('POST', signInMethod)
79 |
80 | new cdk.CfnOutput(this, 'userPoolId', {
81 | value: userPool.userPoolId,
82 | })
83 |
84 | new cdk.CfnOutput(this, 'clientId', {
85 | value: webClient.userPoolClientId,
86 | })
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/packages/backend/test/passwordless-login.test.ts:
--------------------------------------------------------------------------------
1 | import './helpers'
2 | import { expect as expectCDK, haveOutput } from '@aws-cdk/assert'
3 | import { TemplateAssertions } from '@aws-cdk/assertions'
4 | import * as cdk from '@aws-cdk/core'
5 | import { PasswordlessLoginStack } from '../lib/passwordless-login-stack'
6 |
7 | const synthStack = () => {
8 | const app = new cdk.App()
9 | return new PasswordlessLoginStack(app, 'PasswordlessLogin')
10 | }
11 |
12 | const OPTION_ENDPOINT = 1
13 | const ADDITIONAL_LAMBDAS = 2
14 |
15 | test('Cognito User pool and Lambda functions are created', () => {
16 | const assert = TemplateAssertions.fromStack(synthStack())
17 |
18 | assert.resourceCountIs('AWS::Lambda::Function', 5 + ADDITIONAL_LAMBDAS)
19 |
20 | assert.hasResourceProperties('AWS::Cognito::UserPool', {
21 | AccountRecoverySetting: {
22 | RecoveryMechanisms: [
23 | {
24 | Name: 'admin_only',
25 | Priority: 1,
26 | },
27 | ],
28 | },
29 | AdminCreateUserConfig: {
30 | AllowAdminCreateUserOnly: false,
31 | },
32 | AutoVerifiedAttributes: ['email'],
33 | EmailVerificationMessage:
34 | 'The verification code to your new account is {####}',
35 | EmailVerificationSubject: 'Verify your new account',
36 | Policies: {
37 | PasswordPolicy: {
38 | MinimumLength: 8,
39 | RequireNumbers: false,
40 | RequireSymbols: false,
41 | RequireUppercase: false,
42 | },
43 | },
44 | Schema: [
45 | {
46 | Mutable: true,
47 | Name: 'email',
48 | Required: true,
49 | },
50 | {
51 | AttributeDataType: 'String',
52 | Mutable: true,
53 | Name: 'authChallenge',
54 | },
55 | ],
56 | SmsVerificationMessage:
57 | 'The verification code to your new account is {####}',
58 | UsernameAttributes: ['email'],
59 | VerificationMessageTemplate: {
60 | DefaultEmailOption: 'CONFIRM_WITH_CODE',
61 | EmailMessage: 'The verification code to your new account is {####}',
62 | EmailSubject: 'Verify your new account',
63 | SmsMessage: 'The verification code to your new account is {####}',
64 | },
65 | })
66 |
67 | assert.hasResourceProperties('AWS::Cognito::UserPoolClient', {
68 | AllowedOAuthFlows: ['implicit', 'code'],
69 | AllowedOAuthFlowsUserPoolClient: true,
70 | AllowedOAuthScopes: [
71 | 'profile',
72 | 'phone',
73 | 'email',
74 | 'openid',
75 | 'aws.cognito.signin.user.admin',
76 | ],
77 | ExplicitAuthFlows: ['ALLOW_CUSTOM_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
78 | SupportedIdentityProviders: ['COGNITO'],
79 | })
80 | })
81 |
82 | test('API Gateway endpoint along with Lambda proxy integration is created', () => {
83 | const assert = TemplateAssertions.fromStack(synthStack())
84 |
85 | assert.hasResourceProperties('AWS::ApiGateway::RestApi', {
86 | EndpointConfiguration: {
87 | Types: ['REGIONAL'],
88 | },
89 | Name: 'authApi',
90 | })
91 |
92 | assert.resourceCountIs('AWS::ApiGateway::Method', 1 + OPTION_ENDPOINT)
93 |
94 | assert.hasResourceProperties('AWS::ApiGateway::Method', {
95 | HttpMethod: 'POST',
96 | AuthorizationType: 'NONE',
97 | Integration: {
98 | IntegrationHttpMethod: 'POST',
99 | Type: 'AWS_PROXY',
100 | },
101 | })
102 | })
103 |
104 | test('Outputs are generated correctly', () => {
105 | const stack = synthStack()
106 | expectCDK(stack).to(haveOutput({ outputName: 'userPoolId' }))
107 | expectCDK(stack).to(haveOutput({ outputName: 'clientId' }))
108 | })
109 |
--------------------------------------------------------------------------------