├── packages ├── backend │ ├── .example.env │ ├── .npmignore │ ├── typings.d.ts │ ├── jest.config.js │ ├── functions │ │ ├── preSignup.ts │ │ ├── postAuthentication.ts │ │ ├── createAuthChallenge.ts │ │ ├── verifyAuthChallenge.ts │ │ ├── defineAuthChallenge.ts │ │ └── signIn.ts │ ├── bin │ │ └── passwordless-login.ts │ ├── test │ │ ├── helpers.ts │ │ └── passwordless-login.test.ts │ ├── lib │ │ ├── helpers.ts │ │ └── passwordless-login-stack.ts │ ├── tsconfig.json │ ├── cdk.json │ └── package.json └── frontend │ ├── .example.env │ ├── src │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── config │ │ ├── api.ts │ │ ├── routes.ts │ │ └── auth.tsx │ ├── pages │ │ ├── SignIn │ │ │ ├── SignIn.module.css │ │ │ └── SignIn.tsx │ │ ├── Home │ │ │ └── Home.tsx │ │ └── VerifyMagicLink │ │ │ └── VerifyMagicLink.tsx │ ├── App.test.tsx │ ├── index.css │ ├── reportWebVitals.ts │ ├── components │ │ ├── PublicRoute.tsx │ │ └── PrivateRoute.tsx │ ├── App.tsx │ └── index.tsx │ ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html │ ├── tsconfig.json │ └── package.json ├── .husky └── pre-commit ├── .gitignore ├── package.json └── README.md /packages/backend/.example.env: -------------------------------------------------------------------------------- 1 | SES_FROM_ADDRESS="sender-email" 2 | -------------------------------------------------------------------------------- /packages/frontend/.example.env: -------------------------------------------------------------------------------- 1 | AWS_REGION="region-where-the-stack-is-deployed-to" 2 | -------------------------------------------------------------------------------- /packages/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test:hook && yarn lint-staged 5 | -------------------------------------------------------------------------------- /packages/backend/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /packages/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryands17/passwordless-auth/HEAD/packages/frontend/public/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryands17/passwordless-auth/HEAD/packages/frontend/public/logo192.png -------------------------------------------------------------------------------- /packages/frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryands17/passwordless-auth/HEAD/packages/frontend/public/logo512.png -------------------------------------------------------------------------------- /packages/backend/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | AWS_REGION: string 4 | SES_FROM_ADDRESS: string 5 | USER_POOL_ID: string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/functions/preSignup.ts: -------------------------------------------------------------------------------- 1 | import { PreSignUpTriggerHandler } from 'aws-lambda' 2 | 3 | export const handler: PreSignUpTriggerHandler = async (event) => { 4 | event.response.autoConfirmUser = true 5 | return event 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /packages/frontend/src/config/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import config from '../cdk-exports.json' 3 | 4 | export const requestMagicLink = async (email: string) => { 5 | const res = await axios.post( 6 | config.PasswordlessLoginStack.authApiEndpointF052B0B5, 7 | { email } 8 | ) 9 | return res.data 10 | } 11 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/SignIn/SignIn.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | height: 100vh; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .card { 9 | width: 500px; 10 | } 11 | 12 | .icon { 13 | color: rgba(0, 0, 0, 0.25); 14 | } 15 | 16 | .submitBtn { 17 | width: 120px; 18 | } 19 | -------------------------------------------------------------------------------- /packages/backend/bin/passwordless-login.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register' 3 | import * as cdk from '@aws-cdk/core' 4 | import { PasswordlessLoginStack } from '../lib/passwordless-login-stack' 5 | 6 | const app = new cdk.App() 7 | new PasswordlessLoginStack(app, 'PasswordlessLoginStack', { 8 | env: { region: process.env.REGION || 'us-east-2' }, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | // import React from 'react' 2 | // import { render, screen } from '@testing-library/react' 3 | // import App from './App' 4 | 5 | test('renders learn react link', () => { 6 | // render() 7 | // const linkElement = screen.getByText(/Home Page/i) 8 | // expect(linkElement).toBeInTheDocument() 9 | expect(true).toBeTruthy() 10 | }) 11 | 12 | export {} 13 | -------------------------------------------------------------------------------- /packages/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | !typings.d.ts 5 | !react-app-env.d.ts 6 | node_modules 7 | 8 | # CDK asset staging directory 9 | .cdk.staging 10 | cdk.out 11 | cdk-exports.json 12 | 13 | # testing 14 | coverage 15 | 16 | # production 17 | build 18 | 19 | # misc 20 | .DS_Store 21 | .env 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | -------------------------------------------------------------------------------- /packages/frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals' 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /packages/backend/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Code, CodeConfig } from '@aws-cdk/aws-lambda' 2 | 3 | let fromAssetMock: jest.SpyInstance 4 | 5 | beforeAll(() => { 6 | fromAssetMock = jest.spyOn(Code, 'fromAsset').mockReturnValue({ 7 | isInline: false, 8 | bind: (): CodeConfig => { 9 | return { 10 | s3Location: { 11 | bucketName: 'my-bucket', 12 | objectKey: 'my-key', 13 | }, 14 | } 15 | }, 16 | bindToResource: () => { 17 | return 18 | }, 19 | } as any) 20 | }) 21 | 22 | afterAll(() => { 23 | fromAssetMock?.mockRestore() 24 | }) 25 | -------------------------------------------------------------------------------- /packages/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "baseUrl": "./src", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "passwordless-login", 3 | "version": "1.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "author": { 9 | "name": "Ryan Dsouza" 10 | }, 11 | "license": "ISC", 12 | "scripts": { 13 | "test:hook": "yarn workspaces run test:hook" 14 | }, 15 | "devDependencies": { 16 | "husky": "7.0.1", 17 | "lint-staged": "11.1.1", 18 | "prettier": "2.3.2" 19 | }, 20 | "resolutions": { 21 | "jest": "26.6.0" 22 | }, 23 | "prettier": { 24 | "semi": false, 25 | "singleQuote": true 26 | }, 27 | "lint-staged": { 28 | "*.{js,ts,json,md}": "prettier --write" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PublicRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Route, Redirect } from 'react-router-dom' 3 | import { useAuth } from 'config/auth' 4 | import { routes } from 'config/routes' 5 | 6 | export const PublicRoute = ({ 7 | children, 8 | ...rest 9 | }: React.ComponentProps) => { 10 | const { loggedIn } = useAuth() 11 | 12 | if (loggedIn === null) return
13 | 14 | return ( 15 | 18 | !loggedIn ? ( 19 | children 20 | ) : ( 21 | 22 | ) 23 | } 24 | /> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Route, Redirect } from 'react-router-dom' 3 | import { useAuth } from 'config/auth' 4 | import { routes } from 'config/routes' 5 | 6 | export const PrivateRoute = ({ 7 | children, 8 | ...rest 9 | }: React.ComponentProps) => { 10 | const { loggedIn } = useAuth() 11 | 12 | if (loggedIn === null) return
13 | 14 | return ( 15 | 18 | loggedIn ? ( 19 | children 20 | ) : ( 21 | 22 | ) 23 | } 24 | /> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/backend/functions/postAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { PostAuthenticationTriggerHandler } from 'aws-lambda' 2 | import { CognitoIdentityServiceProvider } from 'aws-sdk' 3 | 4 | const cisp = new CognitoIdentityServiceProvider() 5 | 6 | export const handler: PostAuthenticationTriggerHandler = async (event) => { 7 | if (event.request.userAttributes?.email_verified !== 'true') { 8 | await cisp 9 | .adminUpdateUserAttributes({ 10 | UserPoolId: event.userPoolId, 11 | UserAttributes: [ 12 | { 13 | Name: 'email_verified', 14 | Value: 'true', 15 | }, 16 | ], 17 | Username: event.userName, 18 | }) 19 | .promise() 20 | } 21 | return event 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs' 2 | import { Duration } from '@aws-cdk/core' 3 | import { Runtime } from '@aws-cdk/aws-lambda' 4 | import { RetentionDays } from '@aws-cdk/aws-logs' 5 | 6 | export const lambda = ( 7 | ...[scope, id, props]: ConstructorParameters 8 | ) => { 9 | return new NodejsFunction(scope, id, { 10 | timeout: Duration.seconds(5), 11 | reservedConcurrentExecutions: 20, 12 | memorySize: 256, 13 | entry: `./functions/${id}.ts`, 14 | runtime: Runtime.NODEJS_14_X, 15 | logRetention: RetentionDays.ONE_WEEK, 16 | bundling: { 17 | nodeModules: ['aws-sdk'], 18 | externalModules: [], 19 | }, 20 | ...props, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "files": true 4 | }, 5 | "compilerOptions": { 6 | "target": "ES2018", 7 | "module": "commonjs", 8 | "lib": ["ESNext"], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "skipLibCheck": true 24 | }, 25 | "exclude": ["node_modules", "cdk.out"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/passwordless-login.ts", 3 | "context": { 4 | "@aws-cdk/core:newStyleStackSynthesis": true, 5 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 6 | "@aws-cdk/core:enableStackNameDuplicates": "true", 7 | "aws-cdk:enableDiffNoFail": "true", 8 | "@aws-cdk/core:stackRelativeExports": "true", 9 | "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true, 10 | "@aws-cdk/aws-secretsmanager:parseOwnedSecretName": true, 11 | "@aws-cdk/aws-kms:defaultKeyPolicies": true, 12 | "@aws-cdk/aws-s3:grantWriteWithoutAcl": true, 13 | "@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount": true, 14 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 15 | "@aws-cdk/aws-efs:defaultEncryptionAtRest": true, 16 | "@aws-cdk/aws-lambda:recognizeVersionProps": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useAuth } from 'config/auth' 3 | import { useHistory } from 'react-router-dom' 4 | import { Button, message } from 'antd' 5 | import { routes } from 'config/routes' 6 | 7 | const Home = () => { 8 | const [loading, setLoading] = React.useState(false) 9 | const { signOut } = useAuth() 10 | const history = useHistory() 11 | 12 | const userSignOut = async () => { 13 | try { 14 | setLoading(true) 15 | await signOut() 16 | history.replace(routes.signIn.routePath()) 17 | } catch (e) { 18 | message.error(e.message) 19 | } 20 | } 21 | 22 | return ( 23 |
24 |

Welcome!

25 | 28 |
29 | ) 30 | } 31 | 32 | export default Home 33 | -------------------------------------------------------------------------------- /packages/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import { BrowserRouter, Switch } from 'react-router-dom' 3 | import { renderRoutes } from 'config/routes' 4 | import { AuthProvider } from 'config/auth' 5 | 6 | const Routes = () => { 7 | return ( 8 | 9 | 10 | }> 11 | {renderRoutes.map(([key, value]) => ( 12 | 17 | 18 | 19 | ))} 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | function App() { 27 | return ( 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default App 35 | -------------------------------------------------------------------------------- /packages/backend/functions/createAuthChallenge.ts: -------------------------------------------------------------------------------- 1 | import { CreateAuthChallengeTriggerHandler } from 'aws-lambda' 2 | 3 | export const handler: CreateAuthChallengeTriggerHandler = async (event) => { 4 | // This is sent back to the client app 5 | event.response.publicChallengeParameters = { 6 | email: event.request.userAttributes.email, 7 | } 8 | 9 | // Add the secret login code to the private challenge parameters 10 | // so it can be verified by the "Verify Auth Challenge Response" trigger 11 | event.response.privateChallengeParameters = { 12 | challenge: event.request.userAttributes['custom:authChallenge'], 13 | } 14 | 15 | // Add the secret login code to the session so it is available 16 | // in a next invocation of the "Create Auth Challenge" trigger 17 | event.response.challengeMetadata = `CODE-${event.request.userAttributes['custom:authChallenge']}` 18 | return event 19 | } 20 | -------------------------------------------------------------------------------- /packages/backend/functions/verifyAuthChallenge.ts: -------------------------------------------------------------------------------- 1 | import { VerifyAuthChallengeResponseTriggerHandler } from 'aws-lambda' 2 | 3 | const MAGIC_LINK_TIMEOUT = 3 * 60 * 1000 4 | 5 | export const handler: VerifyAuthChallengeResponseTriggerHandler = async ( 6 | event 7 | ) => { 8 | const [authChallenge, timestamp] = ( 9 | event.request.privateChallengeParameters.challenge || '' 10 | ).split(',') 11 | 12 | // fail if any one of the parameters is missing 13 | if (!authChallenge || !timestamp) { 14 | event.response.answerCorrect = false 15 | return event 16 | } 17 | 18 | // is the correct challenge and is not expired 19 | if ( 20 | event.request.challengeAnswer === authChallenge && 21 | Date.now() <= Number(timestamp) + MAGIC_LINK_TIMEOUT 22 | ) { 23 | event.response.answerCorrect = true 24 | return event 25 | } 26 | 27 | event.response.answerCorrect = false 28 | return event 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Amplify from 'aws-amplify' 4 | import './index.css' 5 | import App from './App' 6 | import reportWebVitals from './reportWebVitals' 7 | import authConfig from './cdk-exports.json' 8 | 9 | Amplify.configure({ 10 | Auth: { 11 | region: process.env.AWS_REGION || 'us-east-2', 12 | userPoolId: authConfig.PasswordlessLoginStack.userPoolId, 13 | userPoolWebClientId: authConfig.PasswordlessLoginStack.clientId, 14 | }, 15 | }) 16 | 17 | ReactDOM.render( 18 | 19 | 20 | , 21 | document.getElementById('root') 22 | ) 23 | 24 | // If you want to start measuring performance in your app, pass a function 25 | // to log results (for example: reportWebVitals(console.log)) 26 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 27 | reportWebVitals(console.info) 28 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/VerifyMagicLink/VerifyMagicLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useAuth } from 'config/auth' 3 | import { useHistory, useLocation } from 'react-router-dom' 4 | import { message } from 'antd' 5 | import { routes } from 'config/routes' 6 | 7 | const VerifyMagicLink = () => { 8 | const { answerCustomChallenge } = useAuth() 9 | const location = useLocation() 10 | const history = useHistory() 11 | 12 | React.useEffect(() => { 13 | const params = new URLSearchParams(location.search) 14 | const [email, answer] = [ 15 | params.get('email') || '', 16 | params.get('code') || '', 17 | ] 18 | 19 | answerCustomChallenge(email, answer) 20 | .then(() => { 21 | history.replace(routes.home.routePath()) 22 | }) 23 | .catch((e) => { 24 | console.log(e) 25 | message.error('Invalid login link!') 26 | }) 27 | }, []) 28 | 29 | return
30 | } 31 | 32 | export default VerifyMagicLink 33 | -------------------------------------------------------------------------------- /packages/frontend/src/config/routes.ts: -------------------------------------------------------------------------------- 1 | import { PrivateRoute } from 'components/PrivateRoute' 2 | import { PublicRoute } from 'components/PublicRoute' 3 | import * as React from 'react' 4 | import { Route } from 'react-router-dom' 5 | 6 | export type RouteItem = { 7 | path: string 8 | routePath: (args?: any) => string 9 | routeComponent: (props: React.ComponentProps) => JSX.Element 10 | component: any 11 | exact?: boolean 12 | } 13 | 14 | export const routes: Record = { 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 |
{ 55 | message.error('Please check your email', 4) 56 | }} 57 | > 58 | 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 |

62 | Click to sign-in 63 |

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 | --------------------------------------------------------------------------------