├── .gitignore ├── .env ├── public ├── images │ ├── stars-and-stripes.png │ └── tropica-background.png └── css │ └── styles.css ├── CODEOWNERS ├── README.md ├── package.json ├── src ├── loginOrSignUpUtils.js └── authenticateUtils.js ├── LICENSE ├── views ├── loggedIn.ejs ├── loginOrSignUp.ejs └── authenticate.ejs ├── server.js └── .github └── workflows └── codeql-analysis.yml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PORT='4567' 2 | STYTCH_PROJECT_ID='' 3 | STYTCH_SECRET='' 4 | -------------------------------------------------------------------------------- /public/images/stars-and-stripes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stytchauth/stytch-node-sms/HEAD/public/images/stars-and-stripes.png -------------------------------------------------------------------------------- /public/images/tropica-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stytchauth/stytch-node-sms/HEAD/public/images/tropica-background.png -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Stytch code owners file 2 | 3 | # These owners will be the default owners for everything in 4 | # the repo. Unless a later match takes precedence, 5 | # @stytchauth/client-libraries will be requested for 6 | # review when someone opens a pull request. 7 | * @stytchauth/client-libraries 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stytch-node-sms 2 | 3 | This example app uses the [Stytch API](https://stytch.com/docs/api) to send and authenticate 4 | one-time passcodes (OTPs). 5 | 6 | ## Running the app 7 | 8 | For name continuity, create a new project called "Tropica" and use it for these setup steps. That 9 | way, the SMS message will say "Tropica verification code: xxxxxx", matching the app branding. 10 | 11 | 1. Fill in `STYTCH_PROJECT_ID` and `STYTCH_SECRET` in the `.env` file. Get your credentials from 12 | your [Stytch dashboard](https://stytch.com/dashboard/api-keys). 13 | 2. Run `npm install` 14 | 3. Run `npm start` 15 | 4. Visit `http://localhost:4567` and login by SMS passcode! 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stytch-node-sms", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/stytchauth/stytch-node-magic-links.git" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/stytchauth/stytch-node-magic-links/issues" 18 | }, 19 | "homepage": "https://github.com/stytchauth/stytch-node-magic-links#readme", 20 | "dependencies": { 21 | "dotenv": "^8.2.0", 22 | "ejs": "^3.1.6", 23 | "express": "^4.17.1", 24 | "stytch": "^3.4.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/loginOrSignUpUtils.js: -------------------------------------------------------------------------------- 1 | function isValidNumber() { 2 | // Regex validates phone numbers in (xxx)xxx-xxxx, xxx-xxx-xxxx, xxxxxxxxxx, and xxx.xxx.xxxx format 3 | const regex = /^[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4}$/g; 4 | const inputValue = document.getElementById(`phoneNumber`).value; 5 | if (inputValue.match(regex)) { 6 | return true; 7 | } 8 | return false; 9 | } 10 | 11 | function onPhoneNumberChange() { 12 | // Update styling once phone number is valid. 13 | const inputs = document.getElementsByTagName('input'); 14 | const button = document.getElementById('button'); 15 | if (!isValidNumber()) { 16 | for (i = 0; i < inputs.length; i++) { 17 | inputs[i].style.borderColor = '#ADBCC5'; 18 | button.disabled = true; 19 | } 20 | } else { 21 | for (i = 0; i < inputs.length; i++) { 22 | inputs[i].style.borderColor = '#19303D'; 23 | button.disabled = false; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 stytchauth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/loggedIn.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tropica | Stytch example 5 | 6 | 7 | 9 | 10 | 11 | 12 |
13 | 24 |
25 |
26 |
27 |

Mahalo!

28 |

We look forward to serving you next time.

29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /views/loginOrSignUp.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tropica | Stytch example 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 |
14 | 25 |
26 |
27 |
28 |

Aloha!

29 |

Please enter your phone number to place an order.

30 |
31 |
32 |
33 | 34 | 36 |
37 |
38 | 39 | 40 | 41 | 42 |
43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/authenticateUtils.js: -------------------------------------------------------------------------------- 1 | function isValidPasscodeDigit(digitValue) { 2 | const regex = /^[0-9]$/g; 3 | if (digitValue.match(regex)) { 4 | return true; 5 | } 6 | return false; 7 | } 8 | 9 | function isValidPasscode() { 10 | const passcodeInputs = document.getElementsByClassName('passcodeInput'); 11 | const regex = /^[0-9]$/g; 12 | for (i = 0; i < passcodeInputs.length; i++) { 13 | if (!isValidPasscodeDigit(passcodeInputs[i].value)) { 14 | return false; 15 | } 16 | } 17 | return true; 18 | } 19 | 20 | // Handles auto tabbing to next passcode digit input. 21 | // Logic from https://stackoverflow.com/questions/15595652/focus-next-input-once-reaching-maxlength-value. 22 | function autoTab(target) { 23 | if (target.value.length >= target.maxLength) { 24 | var next = target; 25 | while (next = next.nextElementSibling) { 26 | if (next == null) 27 | break; 28 | if (next.tagName.toLowerCase() === "input") { 29 | next.focus(); 30 | break; 31 | } 32 | } 33 | } 34 | // Move to previous field if empty (user pressed backspace) 35 | else if (target.value.length === 0) { 36 | var previous = target; 37 | while (previous = previous.previousElementSibling) { 38 | if (previous == null) 39 | break; 40 | if (previous.tagName.toLowerCase() === "input") { 41 | previous.focus(); 42 | break; 43 | } 44 | } 45 | } 46 | } 47 | 48 | function onPasscodeDigitEnter(e) { 49 | document.getElementById('errorText').style.visibility = 'hidden'; 50 | if (isValidPasscodeDigit(e.target.value) || e.target.value === '') { 51 | autoTab(e.target); 52 | } 53 | 54 | // Update styling once passcode is valid. 55 | const inputs = document.getElementsByTagName('input'); 56 | const button = document.getElementById('button') 57 | if (!isValidPasscode()) { 58 | for (i = 0; i < inputs.length; i++) { 59 | inputs[i].style.borderColor = '#ADBCC5'; 60 | button.disabled = true; 61 | } 62 | } else { 63 | for (i = 0; i < inputs.length; i++) { 64 | inputs[i].style.borderColor = '#19303D'; 65 | button.disabled = false; 66 | } 67 | } 68 | } 69 | 70 | function handleError() { 71 | const passcodeInputs = document.getElementsByClassName('passcodeInput'); 72 | for (i = 0; i < passcodeInputs.length; i++) { 73 | passcodeInputs[i].value = ''; 74 | passcodeInputs[i].style.borderColor = 'red'; 75 | button.disabled = true; 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const bodyParser = require("body-parser"); 3 | const stytch = require("stytch"); 4 | 5 | require("dotenv").config() 6 | 7 | const app = express(); 8 | const port = process.env.PORT; 9 | const path = `http://localhost:${port}` 10 | 11 | // bodyParser allows us to access the body of the post request 12 | app.use(bodyParser.urlencoded({ extended: true })); 13 | app.use(bodyParser.json()); 14 | // defines the directory where the static assets are so images & css render correctly 15 | app.use(express.static('public')); 16 | // defines the directory where the js files are so that external js scripts can be added 17 | app.use(express.static('src')); 18 | // set app to use ejs so we can use html templates 19 | app.set('view engine', 'ejs'); 20 | 21 | const client = new stytch.Client({ 22 | project_id: process.env.STYTCH_PROJECT_ID, 23 | secret: process.env.STYTCH_SECRET, 24 | env: stytch.envs.test, 25 | } 26 | ); 27 | 28 | // define the homepage route 29 | app.get("/", (req, res) => { 30 | res.render('loginOrSignUp'); 31 | }); 32 | 33 | app.post("/login_or_create_user", function (req, res) { 34 | const phoneNumber = req.body.phoneNumber.replace(/\D/g, ''); 35 | 36 | // params are of type stytch.LoginOrCreateUserBySMSRequest 37 | const params = { 38 | phone_number: `${req.body.intlCode}${phoneNumber}`, 39 | }; 40 | 41 | if (req.body.telType === 'whatsApp') { 42 | client.otps.whatsapp.loginOrCreate(params) 43 | .then(resp => { 44 | res.render('authenticate', { phoneId: resp.phone_id, hasErrored: false }); 45 | }) 46 | .catch(err => { 47 | res.render('loginOrSignUp'); 48 | }); 49 | } else { 50 | client.otps.sms.loginOrCreate(params) 51 | .then(resp => { 52 | res.render('authenticate', { phoneId: resp.phone_id, hasErrored: false }); 53 | }) 54 | .catch(err => { 55 | res.render('loginOrSignUp'); 56 | }); 57 | } 58 | }); 59 | 60 | app.post("/authenticate", function (req, res) { 61 | let code = ''; 62 | for (let i = 1; i <= 6; i++) { 63 | code += req.body[`digit-${i}`]; 64 | } 65 | 66 | // params are of type stytch.AuthenticateOTPRequest 67 | const params = { 68 | code, 69 | method_id: req.body.phoneId, 70 | }; 71 | 72 | client.otps.authenticate(params) 73 | .then(resp => { 74 | res.render('loggedIn'); 75 | }).catch(err => { 76 | console.log(err) 77 | res.render('authenticate', { phoneId: req.body.phoneId, hasErrored: true }); 78 | }); 79 | }); 80 | 81 | // run the server 82 | app.listen(port, () => { 83 | console.log(`Listening to requests on ${path}`); 84 | }); 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '20 3 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /views/authenticate.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tropica | Stytch example 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 |
14 | 25 |
26 |
27 |
28 |

The passcode is...pizza!

29 |

Just kidding. Check your phone for the 6-digit passcode.

30 |
31 |
32 |

Invalid code. Please try again.

33 |
34 | 35 | 36 | 37 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 |
47 |
48 |
49 |
50 |
51 |
52 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | position: fixed; 4 | width: 100%; 5 | } 6 | 7 | h1 { 8 | font-family: 'Gloria Hallelujah'; 9 | font-size: 40px; 10 | font-weight: normal; 11 | letter-spacing: 0.04em; 12 | } 13 | 14 | .app { 15 | display: flex; 16 | flex-direction: column; 17 | font-family: "Josefin Sans", sans-serif; 18 | height: 100vh; 19 | width: 100%; 20 | text-align: center; 21 | } 22 | 23 | .nav { 24 | align-items: center; 25 | background-color: white; 26 | color: black; 27 | display: flex; 28 | flex-direction: row; 29 | height: 77px; 30 | justify-content: space-between; 31 | padding-left: 20px; 32 | padding-right: 20px; 33 | } 34 | 35 | .logo { 36 | align-items: center; 37 | display: flex; 38 | justify-content: space-between; 39 | } 40 | 41 | .sublogo { 42 | margin-left: 30px; 43 | margin-top: 20px; 44 | } 45 | 46 | .navMenu { 47 | margin-top: 20px; 48 | } 49 | 50 | .navItem { 51 | font-size: 20px; 52 | margin-right: 30px; 53 | } 54 | 55 | .content { 56 | align-items: center; 57 | background-image: url("/images/tropica-background.png"); 58 | background-repeat: no-repeat; 59 | background-size: cover; 60 | display: flex; 61 | flex-grow: 1; 62 | justify-content: center; 63 | } 64 | 65 | .card { 66 | align-items: center; 67 | background-color: white; 68 | display: flex; 69 | height: 415px; 70 | justify-content: center; 71 | padding-left: 45px; 72 | padding-right: 45px; 73 | width: 100%; 74 | } 75 | 76 | .cardContent { 77 | text-align: center; 78 | } 79 | 80 | .contentHeader { 81 | margin-top: -30px; 82 | } 83 | 84 | .instructions { 85 | font-size: 20px; 86 | line-height: 26px; 87 | margin-bottom: 10px; 88 | } 89 | 90 | .actions { 91 | align-items: center; 92 | display: flex; 93 | flex-direction: column; 94 | justify-content: center; 95 | } 96 | 97 | #errorText { 98 | color: #AD2E30; 99 | font-size: 14px; 100 | width: 320px; 101 | text-align: left; 102 | margin: 0 0 5px 0; 103 | } 104 | 105 | input { 106 | border: 1px solid #ADBCC5; 107 | box-sizing: border-box; 108 | border-radius: 3px; 109 | color: #19303D; 110 | font-family: "Josefin Sans", sans-serif; 111 | font-size: 18px; 112 | height: 45px; 113 | line-height: 25px; 114 | padding: 10px; 115 | } 116 | 117 | input:disabled { 118 | background-color: white; 119 | color: #19303D; 120 | } 121 | 122 | input:focus { 123 | outline: 0; 124 | } 125 | 126 | input[type="radio"] { 127 | vertical-align: middle; 128 | margin-top: -3px; 129 | } 130 | 131 | ::placeholder { 132 | color: #E5E8EB; 133 | } 134 | 135 | .telInput { 136 | display: flex; 137 | white-space: nowrap; 138 | max-width: 320px; 139 | width: 100%; 140 | } 141 | 142 | .flag { 143 | background: url('/images/stars-and-stripes.png') no-repeat scroll 7px 15px; 144 | border-right: none; 145 | border-radius: 3px 0 0 3px; 146 | width: 80px; 147 | padding-left: 52px; 148 | } 149 | 150 | #phoneNumber { 151 | border-left: none; 152 | border-radius: 0 3px 3px 0; 153 | margin-left: -6px; 154 | flex-grow: 1; 155 | width: 100%; 156 | } 157 | 158 | .passcodeInputContainer { 159 | display: flex; 160 | justify-content: space-between; 161 | align-items: center; 162 | width: 320px; 163 | } 164 | 165 | .passcodeInput { 166 | border-radius: 10px; 167 | font-size: 20px; 168 | width: 48px; 169 | height: 45px; 170 | text-align: center; 171 | } 172 | 173 | .button { 174 | background-color: #F96719; 175 | border: none; 176 | border-radius: 3px; 177 | color: white; 178 | height: 45px; 179 | margin-bottom: 12px; 180 | margin-top: 12px; 181 | padding-top: 12px; 182 | max-width: 320px; 183 | width: 100%; 184 | } 185 | 186 | .button:hover { 187 | background-color: #f06216; 188 | } 189 | 190 | .button:disabled { 191 | background-color: #F9671940; 192 | color: #ADBCC5; 193 | } 194 | 195 | a, a:hover { 196 | text-decoration: none; 197 | color: black; 198 | } 199 | 200 | .resend { 201 | color: #5C727D; 202 | font-style: normal; 203 | font-weight: normal; 204 | font-size: 16px; 205 | line-height: 20px; 206 | } 207 | 208 | .resend:hover { 209 | cursor: pointer; 210 | text-decoration: underline; 211 | } 212 | 213 | @media only screen and (max-width: 780px) { 214 | h1 { 215 | font-size: 25px; 216 | } 217 | 218 | .nav { 219 | height: 47px; 220 | } 221 | 222 | .sublogo { 223 | font-size: 16px; 224 | margin-left: 15px; 225 | margin-top: 10px; 226 | } 227 | 228 | .navMenu { 229 | display: none; 230 | } 231 | 232 | .card { 233 | height: 312px; 234 | } 235 | 236 | .contentHeader { 237 | margin-top: -10px; 238 | } 239 | 240 | .instructions { 241 | font-size: 16px; 242 | padding: 0 10px; 243 | } 244 | 245 | .button { 246 | font-size: 14px; 247 | } 248 | 249 | .passcodeInput { 250 | font-size: 16px; 251 | } 252 | } 253 | 254 | @media only screen and (max-width: 400px) { 255 | .sublogo { 256 | display: none; 257 | } 258 | } 259 | --------------------------------------------------------------------------------