├── .eslintrc.cjs ├── .github └── workflows │ ├── codeql.yml │ ├── public-package-npmjs.yml │ └── publish-github-pages.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── src ├── constants.ts ├── copy-dir.test.ts ├── copy-dir.ts ├── email-handler.ts ├── events.ts ├── index.ts ├── interfaces.ts ├── schema.ts ├── simple-auth-strategy.ts ├── simple-auth.module.ts ├── simple-auth.resolver.ts ├── simple-auth.service.ts ├── template │ └── onetimecode-requested │ │ ├── body.hbs │ │ └── body.hbs.json └── test │ ├── fixtures │ ├── e2e-initial-data.ts │ └── e2e-product-data-full.csv │ └── simple-auth.test.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | root: true, 6 | 7 | }; -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | test: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: yarn 23 | - run: yarn lint 24 | - run: yarn test 25 | -------------------------------------------------------------------------------- /.github/workflows/public-package-npmjs.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | branches: master 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 18 13 | - run: yarn 14 | - run: yarn lint 15 | - run: yarn test 16 | - run: yarn build 17 | - run: yarn postbuild 18 | bump_version: 19 | needs: build 20 | runs-on: ubuntu-latest 21 | outputs: 22 | tag_version: ${{ steps.bump.outputs.newTag }} 23 | steps: 24 | - id: checkout 25 | uses: actions/checkout@v3 26 | - id: bump 27 | uses: 'phips28/gh-action-bump-version@master' 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | minor-wording: 'add,Adds,new' 32 | major-wording: 'MAJOR,cut-major' 33 | patch-wording: 'patch,fixes' 34 | tag-prefix: 'v' 35 | 36 | publish: 37 | needs: bump_version 38 | runs-on: ubuntu-latest 39 | env: 40 | newTag: ${{ needs.bump_version.outputs.tag_version }} 41 | steps: 42 | - uses: actions/checkout@v3 43 | with: 44 | ref: ${{ env.newTag }} 45 | # Setup .npmrc file to publish to npm 46 | - uses: actions/setup-node@v3 47 | with: 48 | node-version: '18.x' 49 | registry-url: 'https://registry.npmjs.org' 50 | - run: yarn 51 | - run: yarn build 52 | - run: yarn postbuild 53 | - run: yarn publish --access public 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/publish-github-pages.yml: -------------------------------------------------------------------------------- 1 | name: publish github pages 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | commit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | ref: master 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | - run: yarn 16 | - run: yarn coverage 17 | - name: push 18 | run: | 19 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} 20 | git config --global user.name 'denz93' 21 | git config --global user.email 'bdnhan182@gmail.com' 22 | git fetch 23 | git checkout gb_pages 24 | mkdir -p ./docs 25 | cp -R ./coverage/* ./docs 26 | git add docs/* 27 | git commit -m "Update page" 28 | git push 29 | 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src/test/__data__ 3 | npm-debug.log 4 | yarn-error.log 5 | dist 6 | coverage -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": true, 4 | "source.fixAll": true 5 | }, 6 | "eslint.validate": ["typescript"], 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 denz93 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Auth Plugin for Vendure.io 2 | [![Test CI](https://github.com/denz93/vendure-plugin-simple-auth/actions/workflows/codeql.yml/badge.svg?branch=master)](https://github.com/denz93/vendure-plugin-simple-auth/actions/workflows/codeql.yml) 3 | [![Publish Package to npmjs](https://github.com/denz93/vendure-plugin-simple-auth/actions/workflows/public-package-npmjs.yml/badge.svg?branch=master&event=push)](https://github.com/denz93/vendure-plugin-simple-auth/actions/workflows/public-package-npmjs.yml) 4 | [![Coverage](https://denz93.github.io/vendure-plugin-simple-auth/badge.svg)](https://denz93.github.io/vendure-plugin-simple-auth/) 5 | 6 | A Vendure plugin allow users log in using email and verification code 7 | 8 | ## Use Case 9 | A lot of times we want visitors (aka customers) to complete their purchase order as quick as possilble. However, they usually hesitate to create a credential to a random online shop at checkout step. So we provide a way to quickly authenticate those visitors by their email and a verification code that is sent to their email. 10 | 11 | ## What it does 12 | 1. Expose a GraphQL Query "`requestOneTimeCode`". 13 | 2. Add an authentication strategy to GraphQL mutation "`authenticate`". 14 | 15 | --- 16 | 17 | ## How to use 18 | 19 | ### 1. Install 20 | 21 | `yarn add @denz93/vendure-plugin-simple-auth` 22 | 23 | or 24 | 25 | `npm i --save @denz93/vendure-plugin-simple-auth` 26 | 27 | ### 2. Add the plugin to ***vendure-config.ts*** file 28 | 29 | ```typescript 30 | import { SimpleAuthPlugin } from "@denz93/vendure-plugin-simple-auth"; 31 | ... 32 | export const config: VendureConfig = { 33 | ... 34 | plugins: [ 35 | ... 36 | SimpleAuthPlugin.init(options) //see Options 37 | ] 38 | } 39 | ``` 40 | 41 | ### 3. Options for `SimpleAuthPlugin.init` 42 | 43 | * attempts: `number` 44 | > Plugin will invalidate the verification code after user's `attempts`. 45 | **default**: 5 46 | * ttl: `number` 47 | > Time to live 48 | How long the verification code is valid for. 49 | **default**: 600 (seconds) 50 | * length: `number` 51 | > How many digits/alphabets the verification code should be. 52 | **default**: 6 53 | * includeAlphabet: `boolean` 54 | > Should allow alphabet characters. 55 | **default**: false (aka `digits only`) 56 | * isDev: `boolean` 57 | > If true, the verification will return along with the response of query. `requestOneTimeCode`. 58 | It's for debug and testing. 59 | **default**: false 60 | * cacheModuleOption: `CacheModuleOption` 61 | > By default, the plugin use `"memory"` for caching which is underlying using NestJs CacheModule. 62 | > To change cache store to `Redis`, `MongoDB`, *etc*, please see NestJs CacheModule docs [here](https://docs.nestjs.com/techniques/caching#different-stores). 63 | > You also want to see [here](https://github.com/node-cache-manager/node-cache-manager/tree/4.1.0) from `cache-manager` which is underlying used by NestJs. 64 | > **Note**: should use cache-manager 4.x if using Vendure under 2.x 65 | > **default**: {} 66 | * checkCrossStrategies: `boolean` 67 | > Strictly enforce unique email among all strategies 68 | 69 | > For example: 70 | - One day, user "John" sign in using Google authentication with "john@gmail.com". 71 | - Another day, user "John" sign in using One-time passcode authenication (this plugin) with the same email. 72 | - This plugin will throw an error if the flag is enabled. 73 | 74 | > **default**: false. 75 | > **Note**: This only works if Google authentication plugin using email as an identifier 76 | 77 | ### 4. Add `EmailHandler` to EmailPlugin 78 | 79 | ** **Note**: Since `v1.3.0` you don't need to config this step anymore. The plugin will automatically append the `handler` to `Email Plugin` 80 | 81 | ```typescript 82 | // vendure-config.ts 83 | 84 | import { oneTimeCodeRequestedEventHandler } from '@denz93/vendure-plugin-simple-auth'; 85 | 86 | ... 87 | 88 | export const config: VendureConfig = { 89 | ... 90 | 91 | plugins: [ 92 | ... 93 | 94 | EmailPlugin.init({ 95 | ... 96 | handlers: [...defaultEmailHandler, oneTimeCodeRequestedEventHandler] 97 | }) 98 | ] 99 | } 100 | ``` 101 | 102 | ## Future Updates 103 | 104 | - [x] Prevent cross authenticate (Ex: users use same email for GoogleAuth and SimpleAuth) 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@denz93/vendure-plugin-simple-auth", 3 | "version": "1.3.1", 4 | "description": "Allow customers login using email and verification code (One time login)", 5 | "main": "dist/index.js", 6 | "author": "Nhan Bach ", 7 | "license": "MIT", 8 | "keywords": [ 9 | "vendure", 10 | "plugin", 11 | "simple-auth", 12 | "simple", 13 | "auth", 14 | "vendure-plugin", 15 | "vendure.io", 16 | "one time login" 17 | ], 18 | "private": false, 19 | "types": "dist/index.d.ts", 20 | "files": [ 21 | "dist" 22 | ], 23 | "publishConfig": { 24 | "access": "public" 25 | }, 26 | "repository": { 27 | "url": "https://github.com/denz93/vendure-plugin-simple-auth.git", 28 | "type": "git" 29 | }, 30 | "scripts": { 31 | "lint": "eslint ./src --fix", 32 | "test": "vitest run", 33 | "test:watch": "vitest", 34 | "coverage": "vitest run --coverage && make-coverage-badge", 35 | "build": "rimraf dist && tsc", 36 | "postbuild": "cp -R ./src/template dist" 37 | }, 38 | "dependencies": { 39 | "cache-manager": "^4.1.0", 40 | "graphql-tag": "^2.12.6", 41 | "isemail": "^3.2.0" 42 | }, 43 | "devDependencies": { 44 | "@nestjs/common": "^7.6.17", 45 | "@nestjs/core": "^7.6.17", 46 | "@nestjs/graphql": "7.10.6", 47 | "@nestjs/platform-express": "^7.6.10", 48 | "@types/cache-manager": "^4.0.2", 49 | "@typescript-eslint/eslint-plugin": "^5.56.0", 50 | "@typescript-eslint/parser": "^5.56.0", 51 | "@vendure/admin-ui-plugin": "^1.9.3", 52 | "@vendure/asset-server-plugin": "^1.9.3", 53 | "@vendure/common": "^1.9.3", 54 | "@vendure/core": "^1.9.3", 55 | "@vendure/email-plugin": "^1.9.3", 56 | "@vendure/testing": "^1.9.3", 57 | "@vitest/coverage-istanbul": "^0.29.7", 58 | "eslint": "^8.36.0", 59 | "eslint-config-prettier": "^8.8.0", 60 | "make-coverage-badge": "^1.2.0", 61 | "rimraf": "^4.4.0", 62 | "sql.js": "^1.8.0", 63 | "typescript": "^4.3.5", 64 | "vitest": "^0.29.5" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ISimpleAuthPluginOptions } from "./interfaces"; 2 | 3 | export const SIMPLE_AUTH_PLUGIN_OPTIONS = Symbol("SIMPLE_AUTH_PLUGIN_OPTIONS"); 4 | 5 | export const SIMPLE_AUTH_PLUGIN_LOG_CONTEXT = 'SimpleAuthPlugin'; 6 | 7 | export const EMAIL_EVENT_NAME = 'onetimecode-requested'; 8 | 9 | export const EMAIL_TEMPLATE_NAME = EMAIL_EVENT_NAME; 10 | 11 | export const DEFAULT_OPTIONS: ISimpleAuthPluginOptions = { 12 | attempts: 5, 13 | isDev: false, 14 | ttl: 600, 15 | length: 6, 16 | includeAlphabet: false, 17 | cacheModuleOption: {}, 18 | preventCrossStrategies: false 19 | }; 20 | 21 | export const STRATEGY_NAME = 'simple'; -------------------------------------------------------------------------------- /src/copy-dir.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { beforeEach, describe, expect, test, vi } from 'vitest'; 4 | import { copyDir, isDir, isFile } from './copy-dir'; 5 | 6 | /* eslint-disable */ 7 | vi.mock('fs', () => { 8 | const fakeFs = {default: {} as any, fileTable: {} as any}; 9 | const fileTable: any = { 10 | "/parent": { 11 | isDir: true, 12 | isFile: false, 13 | children: ['c1', 'c2'], 14 | }, 15 | "/parent/c1": { 16 | isDir: false, 17 | isFile: true, 18 | children: [] 19 | }, 20 | "/parent/c2": { 21 | isDir: true, 22 | isFile: false, 23 | children: ['c3.txt', 'c4', 'c5.txt'] 24 | }, 25 | "/parent/c2/c3.txt": { 26 | isDir: false, 27 | isFile: true, 28 | children: [] 29 | }, 30 | "/parent/c2/c4":{ 31 | isDir: true, 32 | isFile: false, 33 | children: ['c5.txt'], 34 | }, 35 | 36 | "/parent/c2/c5.txt": { 37 | isDir: false, 38 | isFile: true, 39 | children: [], 40 | }, 41 | 42 | "/parent/c2/c4/c5.txt":{ 43 | isDir: false, 44 | isFile: true, 45 | children: [], 46 | }, 47 | 48 | "/parent2": { 49 | isDir: true, 50 | isFile: false, 51 | children: ["c2"], 52 | }, 53 | "/parent2/c2": { 54 | isDir: true, 55 | isFile: false, 56 | children: ["c5.txt"], 57 | }, 58 | "/parent2/c2/c5.txt": { 59 | isDir: false, 60 | isFile: true, 61 | children: [], 62 | } 63 | 64 | } as const; 65 | fakeFs.default = { 66 | existsSync: (src: string) => { 67 | return Object.keys(fakeFs.fileTable).includes(src) 68 | }, 69 | lstatSync: (src: string) => ({ 70 | isFile: () => fakeFs.fileTable[src].isFile, 71 | isDirectory: () => fakeFs.fileTable[src].isDir, 72 | }), 73 | copyFileSync: (src: string, dest: string) => { 74 | fakeFs.fileTable[dest] = fakeFs.fileTable[src]; 75 | const dirDest = path.dirname(dest); 76 | fakeFs.fileTable[dirDest].children.push(path.basename(dest)); 77 | }, 78 | mkdirSync: (src: string) => { 79 | const dirname = path.dirname(src); 80 | const base = path.basename(src); 81 | !fakeFs.fileTable[dirname].children.includes(base) && fakeFs.fileTable[dirname].children.push(base); 82 | fakeFs.fileTable[src] = { 83 | isDir: true, 84 | isFile: false, 85 | children: [], 86 | }; 87 | }, 88 | readdirSync: (src: string)=> { 89 | return fakeFs.fileTable[src].children; 90 | }, 91 | resetFileTable: () => { 92 | fakeFs.fileTable = JSON.parse(JSON.stringify(fileTable)); 93 | }, 94 | getFileTable: () => { 95 | return fakeFs.fileTable; 96 | } 97 | } as any; 98 | fakeFs.default.resetFileTable(); 99 | return fakeFs; 100 | }); 101 | 102 | describe('Test copyDir', () => { 103 | 104 | beforeEach(() => { 105 | (fs as any).resetFileTable(); 106 | }) 107 | 108 | test('should copy a file to a directory', () => { 109 | copyDir("/parent/c1", "/parent2"); 110 | expect(fs.existsSync("/parent2/c1")).toBeTruthy(); 111 | expect(fs.lstatSync("/parent2/c1").isFile()).toBeTruthy(); 112 | }) 113 | 114 | test('should copy a dicretory to a directory', () => { 115 | copyDir("/parent/c2", "/parent2"); 116 | expect(isDir("/parent2/c2")).toBeTruthy(); 117 | expect(isFile("/parent2/c2/c3.txt")).toBeTruthy(); 118 | expect(isFile("/parent2/c2/c4/c5.txt")).toBeTruthy(); 119 | expect(isFile("/parent2/c2/c5.txt")).toBeTruthy(); 120 | expect(isDir("/parent2/c2/c4")).toBeTruthy(); 121 | 122 | }) 123 | 124 | test('should not override existinng file', () => { 125 | let fileTable = (fs as any).getFileTable() 126 | const ref = fileTable["/parent2/c2/c5.txt"] 127 | copyDir("/parent/c2", "/parent2") 128 | expect(fileTable["/parent2/c2/c5.txt"]).toStrictEqual(ref) 129 | expect(fileTable["/parent2/c2"].children).to.containSubset(["c5.txt", "c3.txt", "c4"]) 130 | }) 131 | 132 | test('should raise error if dest/src not exist', () => { 133 | expect(() => copyDir('/parent/c2', '/notexist')).throw('Dest "/notexist" is not a directory') 134 | expect(() => copyDir('/notexist/c2', '/parent2')).throw('"/notexist/c2" not exist') 135 | 136 | }) 137 | }); -------------------------------------------------------------------------------- /src/copy-dir.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | export type IOptions = { 5 | overwrite?: boolean 6 | } 7 | 8 | export function copyDir(src: string, dest: string, { 9 | overwrite = false 10 | }: IOptions = {}) { 11 | if (!isDir(dest)) throw new Error(`Dest "${dest}" is not a directory`); 12 | if (!fs.existsSync(src)) throw new Error(`Src "${src}" not exist`); 13 | if (isFile(src) && (overwrite || !fs.existsSync(path.join(dest, path.basename(src))))) { 14 | fs.copyFileSync(src, path.join(dest, path.basename(src))); 15 | return; 16 | } 17 | 18 | const stack = [path.basename(src)]; 19 | 20 | const srcParent = path.dirname(src); 21 | 22 | while (stack.length > 0) { 23 | const relativePath = stack.pop() as string; 24 | const absPathSrc = path.join(srcParent, relativePath); 25 | const absPathDest = path.join(dest, relativePath); 26 | if (isFile(absPathSrc)) { 27 | if (isFile(absPathDest) && !overwrite) continue; 28 | fs.copyFileSync(absPathSrc, absPathDest); 29 | continue; 30 | } 31 | // absPathSrc is dir 32 | !isDir(absPathDest) && fs.mkdirSync(absPathDest); 33 | const subDirs = fs.readdirSync(absPathSrc); 34 | stack.push(...subDirs.map(sub => path.join(relativePath, sub))); 35 | 36 | } 37 | } 38 | 39 | export function isDir(dest: string) { 40 | return fs.existsSync(dest) && fs.lstatSync(dest).isDirectory(); 41 | } 42 | 43 | export function isFile(src: string) { 44 | return fs.existsSync(src) && fs.lstatSync(src).isFile(); 45 | } 46 | -------------------------------------------------------------------------------- /src/email-handler.ts: -------------------------------------------------------------------------------- 1 | import { EmailEventListener } from "@vendure/email-plugin"; 2 | import { EMAIL_EVENT_NAME } from "./constants"; 3 | import { OneTimeCodeRequestedEvent } from "./events"; 4 | 5 | export const oneTimeCodeRequestedEventHandler = 6 | new EmailEventListener(EMAIL_EVENT_NAME) 7 | .on(OneTimeCodeRequestedEvent) 8 | .setRecipient(event => event.email) 9 | .setSubject('One Time Code for website') 10 | .setFrom("{{ fromAddress }}") 11 | .setTemplateVars((event) => ({code: event.code})); -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { RequestContext, VendureEvent } from "@vendure/core"; 2 | 3 | export class OneTimeCodeRequestedEvent extends VendureEvent { 4 | 5 | constructor( 6 | public code: string, 7 | public email: string, 8 | public ctx: RequestContext 9 | ) { super(); } 10 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './email-handler'; 2 | export * from './simple-auth.module'; 3 | 4 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { CacheManagerOptions, CacheModuleOptions } from "@nestjs/common" 2 | 3 | export interface ISimpleAuthPluginOptions { 4 | /** 5 | * @description 6 | * How many times to allow User attempt the verification code. 7 | * After attempts reached, the code will be invalidated 8 | * @default 9 | * 5 10 | */ 11 | attempts: number 12 | 13 | /** 14 | * @description 15 | * Time to live in seconds once a code created. 16 | * If the code not being verified by users during the ttl window, it will be discarded 17 | * 18 | * @default 19 | * 600 - 10 minutes 20 | */ 21 | ttl: number 22 | 23 | /** 24 | * @description 25 | * How many digits/letters the code should be 26 | * 27 | * @example 28 | * 6 digits code: 340082 29 | * 30 | * @default 31 | * 6 32 | */ 33 | length: number 34 | 35 | /** 36 | * @description 37 | * Allow alphabets in code 38 | * 39 | * @default 40 | * false - Only digits allow 41 | */ 42 | includeAlphabet: boolean 43 | 44 | /** 45 | * @description 46 | * Developer mode 47 | * If enabled, the code will return along with the response "requestOneTimeCode" 48 | * 49 | * @default 50 | * false 51 | */ 52 | isDev: boolean 53 | 54 | /** 55 | * @description 56 | * By default, the plugin use 'memory' for caching using NestJs CacheModule 57 | * To change cache store to Redis, MongoDB, etc, please see NestJs CacheModule docs 58 | * @see https://docs.nestjs.com/techniques/caching#different-stores 59 | * You also want to @see https://github.com/node-cache-manager/node-cache-manager/tree/4.1.0 60 | * 61 | */ 62 | cacheModuleOption: Omit 63 | 64 | /** 65 | * @description 66 | * Strictly enforce unique email among all strategies 67 | * 68 | * For example: 69 | * - One day, user "John" sign in using Google authentication with "john@gmail.com" 70 | * - Another day, user "John" sign in using One-time passcode authenication (this plugin) with the same email 71 | * - This plugin will throw an error if the flag is enabled 72 | * 73 | * @default 74 | * false 75 | * 76 | * @note 77 | * This only works if Google authentication plugin using email as an identifier 78 | */ 79 | preventCrossStrategies: boolean; 80 | } 81 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const queryExtension = gql` 4 | extend enum ErrorCode { 5 | CROSS_EMAIL_AUTHENTICATION 6 | } 7 | type RequestOneTimeCodeError implements ErrorResult { 8 | errorCode: ErrorCode! 9 | message: String! 10 | } 11 | 12 | type OneTimeCode { 13 | value: String! 14 | } 15 | 16 | union RequestOneTimeCodeResult = OneTimeCode | RequestOneTimeCodeError 17 | 18 | extend type Query { 19 | requestOneTimeCode(email: String!): RequestOneTimeCodeResult! 20 | } 21 | `; -------------------------------------------------------------------------------- /src/simple-auth-strategy.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationStrategy, ExternalAuthenticationService, Injector, RequestContext, User } from "@vendure/core"; 2 | import { DocumentNode } from "graphql"; 3 | import gql from 'graphql-tag'; 4 | import { validate as isEmail } from "isemail"; 5 | import { STRATEGY_NAME } from "./constants"; 6 | import { SimpleAuthService } from "./simple-auth.service"; 7 | 8 | export type SimpleAuthData = { 9 | email: string 10 | code: string 11 | }; 12 | 13 | export class SimpleAuthStrategy implements AuthenticationStrategy { 14 | name = STRATEGY_NAME; 15 | simpleAuthService: SimpleAuthService; 16 | externalAuthenticationService: ExternalAuthenticationService; 17 | 18 | defineInputType(): DocumentNode { 19 | return gql` 20 | input SimpleAuthInput { 21 | email: String! 22 | code: String! 23 | } 24 | `; 25 | } 26 | 27 | async authenticate(ctx: RequestContext, data: SimpleAuthData): Promise { 28 | if (!isEmail(data.email)) { 29 | return "Email is invalid"; 30 | } 31 | const email = data.email.toLowerCase(); 32 | const isValidCode = await this.simpleAuthService.verifyCode(email, data.code); 33 | 34 | if (!isValidCode) return "Invalid verification code"; 35 | 36 | let user = await this.externalAuthenticationService.findCustomerUser(ctx, this.name, email); 37 | 38 | if (user) return user; 39 | 40 | user = await this.externalAuthenticationService.createCustomerAndUser(ctx, { 41 | emailAddress: data.email, 42 | externalIdentifier: data.email, 43 | strategy: this.name, 44 | verified: true, 45 | firstName: '', 46 | lastName: '', 47 | }); 48 | 49 | return user; 50 | } 51 | 52 | init(injector: Injector) { 53 | this.externalAuthenticationService = injector.get(ExternalAuthenticationService); 54 | this.simpleAuthService = injector.get(SimpleAuthService); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/simple-auth.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | https://docs.nestjs.com/modules 3 | */ 4 | 5 | import { CacheModule, Inject, OnApplicationBootstrap, OnModuleInit } from '@nestjs/common'; 6 | import { ConfigService, Logger, PluginCommonModule, Type, VendurePlugin } from '@vendure/core'; 7 | import { EmailPlugin, EmailPluginOptions } from '@vendure/email-plugin'; 8 | import { EMAIL_PLUGIN_OPTIONS } from '@vendure/email-plugin/lib/src/constants'; 9 | import path from 'path'; 10 | import { DEFAULT_OPTIONS, EMAIL_EVENT_NAME, SIMPLE_AUTH_PLUGIN_LOG_CONTEXT, SIMPLE_AUTH_PLUGIN_OPTIONS } from './constants'; 11 | import { copyDir } from './copy-dir'; 12 | import { oneTimeCodeRequestedEventHandler } from './email-handler'; 13 | import { ISimpleAuthPluginOptions } from './interfaces'; 14 | import { queryExtension } from './schema'; 15 | import { SimpleAuthStrategy } from './simple-auth-strategy'; 16 | import { SimpleAuthResolver } from './simple-auth.resolver'; 17 | import { SimpleAuthService } from './simple-auth.service'; 18 | 19 | 20 | 21 | @VendurePlugin({ 22 | imports: [ 23 | PluginCommonModule, 24 | CacheModule.registerAsync({ 25 | useFactory: () => { 26 | return SimpleAuthPlugin.options.cacheModuleOption; 27 | } 28 | }), 29 | EmailPlugin 30 | ], 31 | providers: [ 32 | SimpleAuthService, 33 | { 34 | provide: SIMPLE_AUTH_PLUGIN_OPTIONS, 35 | useFactory: () => SimpleAuthPlugin.options 36 | } 37 | ], 38 | shopApiExtensions: { 39 | schema: queryExtension, 40 | resolvers: [SimpleAuthResolver] 41 | }, 42 | configuration: (conf) => { 43 | const simpleAuthStrategy = new SimpleAuthStrategy(); 44 | conf.authOptions.shopAuthenticationStrategy.push(simpleAuthStrategy); 45 | 46 | return conf; 47 | }, 48 | }) 49 | 50 | export class SimpleAuthPlugin implements OnApplicationBootstrap, OnModuleInit { 51 | constructor( 52 | @Inject(ConfigService) private conf: ConfigService, 53 | @Inject(EMAIL_PLUGIN_OPTIONS) private emailConf: EmailPluginOptions) { 54 | 55 | } 56 | onModuleInit() { 57 | this.registerEventHandler() 58 | } 59 | onApplicationBootstrap() { 60 | this.cloneEmailTemplate(); 61 | } 62 | 63 | registerEventHandler() { 64 | const handlerExisted = 65 | this.emailConf.handlers.some(handler => handler.listener.type === EMAIL_EVENT_NAME) 66 | if (handlerExisted) { return } 67 | 68 | this.emailConf.handlers.push(oneTimeCodeRequestedEventHandler) 69 | } 70 | 71 | cloneEmailTemplate() { 72 | /* eslint-disable @typescript-eslint/no-explicit-any */ 73 | const plugins = this.conf.plugins as Type[]; 74 | const emailPlugin = plugins.find(plg => plg == EmailPlugin); 75 | if (emailPlugin) { 76 | const options = (emailPlugin as any)['options'] as EmailPluginOptions; 77 | const templatePath = options.templatePath; 78 | copyDir(path.join(__dirname, './template/onetimecode-requested'), 79 | templatePath); 80 | Logger.info(`Template for onetimecode-requested created at ${templatePath}`, SIMPLE_AUTH_PLUGIN_LOG_CONTEXT); 81 | } else { 82 | Logger.warn(`Cannot find EmailPlugin in Vendure Config. This pluginn might not work correctly."`, SIMPLE_AUTH_PLUGIN_LOG_CONTEXT); 83 | } 84 | } 85 | 86 | static options: NonNullable = DEFAULT_OPTIONS; 87 | static init(options: Partial) { 88 | SimpleAuthPlugin.options = { 89 | ...DEFAULT_OPTIONS, 90 | ...options 91 | }; 92 | 93 | return SimpleAuthPlugin; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/simple-auth.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Inject, PipeTransform } from '@nestjs/common'; 2 | import { Args, Query, Resolver } from '@nestjs/graphql'; 3 | import { Ctx, EventBus, RequestContext } from '@vendure/core'; 4 | import { validate as isEmail } from 'isemail'; 5 | import { SIMPLE_AUTH_PLUGIN_OPTIONS } from './constants'; 6 | import { OneTimeCodeRequestedEvent } from './events'; 7 | import { ISimpleAuthPluginOptions } from './interfaces'; 8 | import { SimpleAuthService } from './simple-auth.service'; 9 | 10 | class EmailValidation implements PipeTransform { 11 | transform(value: string): string { 12 | if (isEmail(value)) { 13 | return value.toLowerCase() 14 | } 15 | throw new Error(`${value} is not a valid email`); 16 | } 17 | 18 | } 19 | 20 | export class RequestOneTimeCodeError { 21 | readonly __typename = 'RequestOneTimeCodeError'; 22 | 23 | constructor(private message: string, private errorCode: string) { 24 | console.log('runhere'); 25 | } 26 | } 27 | 28 | @Resolver() 29 | export class SimpleAuthResolver { 30 | constructor( 31 | @Inject(SimpleAuthService) private service: SimpleAuthService, 32 | @Inject(EventBus) private eventBus: EventBus, 33 | @Inject(SIMPLE_AUTH_PLUGIN_OPTIONS) private pluginOptions: ISimpleAuthPluginOptions) { 34 | } 35 | 36 | @Query() 37 | async requestOneTimeCode(@Ctx() ctx: RequestContext, @Args('email', EmailValidation) email: string) { 38 | if (this.pluginOptions.preventCrossStrategies) { 39 | const foundStrategy = await this.service.checkCrossStrategies(ctx, email); 40 | if (foundStrategy) 41 | return new RequestOneTimeCodeError( 42 | `Email already used with "${foundStrategy}" authentication`, 43 | 'CROSS_EMAIL_AUTHENTICATION') 44 | } 45 | const value = await this.service.generateCode(email); 46 | const fiteredValue = this.pluginOptions.isDev ? value : 'A code sent to your email'; 47 | this.eventBus.publish(new OneTimeCodeRequestedEvent(value, email, ctx)); 48 | 49 | return { __typename: 'OneTimeCode', value: fiteredValue }; 50 | } 51 | } -------------------------------------------------------------------------------- /src/simple-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigService, ExternalAuthenticationService, RequestContext } from '@vendure/core'; 3 | import { Cache } from 'cache-manager'; 4 | import crypto from 'crypto'; 5 | import { SIMPLE_AUTH_PLUGIN_OPTIONS, STRATEGY_NAME } from './constants'; 6 | import { ISimpleAuthPluginOptions } from './interfaces'; 7 | 8 | @Injectable() 9 | export class SimpleAuthService { 10 | readonly prefix: string = 'simple-auth-service'; 11 | 12 | constructor( 13 | @Inject(CACHE_MANAGER) private cache: Cache, 14 | @Inject(SIMPLE_AUTH_PLUGIN_OPTIONS) private options: ISimpleAuthPluginOptions, 15 | @Inject(ConfigService) private configService: ConfigService, 16 | @Inject(ExternalAuthenticationService) private externalAuthService: ExternalAuthenticationService, 17 | ) { 18 | } 19 | 20 | private keyof(email: string) { 21 | return `${this.prefix}:${email}`; 22 | } 23 | 24 | async generateCode(email: string) { 25 | const ttl = this.options.ttl; 26 | const key = this.keyof(email); 27 | let code = await this.cache.get(key); 28 | 29 | if (typeof code === 'string') { 30 | return code; 31 | } 32 | 33 | const alphabets = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 34 | const digits = '0123456789'; 35 | const target = digits + (this.options.includeAlphabet ? alphabets : ''); 36 | const length = this.options.length; 37 | code = ''; 38 | for (let i = 0; i < length; i++) { 39 | const index = crypto.randomInt(0, target.length); 40 | code += target[index]; 41 | } 42 | 43 | await this.cache.set(key, code, ttl); 44 | return code; 45 | } 46 | 47 | async verifyCode(email: string, code: string) { 48 | const key = this.keyof(email); 49 | const savedCode = await this.cache.get(key); 50 | if (typeof savedCode === 'string' && code === savedCode) { 51 | await this.cache.del(key); 52 | return true; 53 | } 54 | return false; 55 | } 56 | 57 | getAllStrategyNames () { 58 | return this.configService 59 | .authOptions 60 | .shopAuthenticationStrategy 61 | .map(strategy => strategy.name) 62 | .filter(name => name !== STRATEGY_NAME); 63 | } 64 | 65 | async checkCrossStrategies(ctx: RequestContext, email: string) { 66 | for (const strategyName of this.getAllStrategyNames()) { 67 | const user = await this.externalAuthService.findCustomerUser( 68 | ctx, 69 | strategyName, 70 | email 71 | ); 72 | if (user) return strategyName; 73 | } 74 | return null; 75 | } 76 | } -------------------------------------------------------------------------------- /src/template/onetimecode-requested/body.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Your one time code 7 | 8 | 9 | 10 | {{code}} 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/template/onetimecode-requested/body.hbs.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "123456" 3 | } -------------------------------------------------------------------------------- /src/test/fixtures/e2e-initial-data.ts: -------------------------------------------------------------------------------- 1 | import { LanguageCode } from '@vendure/common/lib/generated-types'; 2 | import { InitialData } from '@vendure/core/dist/data-import/index'; 3 | 4 | export const initialData: InitialData = { 5 | defaultLanguage: LanguageCode.en, 6 | defaultZone: 'Europe', 7 | taxRates: [ 8 | { name: 'Standard Tax', percentage: 20 }, 9 | { name: 'Reduced Tax', percentage: 10 }, 10 | { name: 'Zero Tax', percentage: 0 }, 11 | ], 12 | shippingMethods: [ 13 | { name: 'Standard Shipping', price: 500 }, 14 | { name: 'Express Shipping', price: 1000 }, 15 | ], 16 | paymentMethods: [], 17 | countries: [ 18 | { name: 'Australia', code: 'AU', zone: 'Oceania' }, 19 | { name: 'Austria', code: 'AT', zone: 'Europe' }, 20 | { name: 'Canada', code: 'CA', zone: 'Americas' }, 21 | { name: 'China', code: 'CN', zone: 'Asia' }, 22 | { name: 'South Africa', code: 'ZA', zone: 'Africa' }, 23 | { name: 'United Kingdom', code: 'GB', zone: 'Europe' }, 24 | { name: 'United States of America', code: 'US', zone: 'Americas' }, 25 | ], 26 | collections: [ 27 | { 28 | name: 'Plants', 29 | filters: [ 30 | { code: 'facet-value-filter', args: { facetValueNames: ['plants'], containsAny: false } }, 31 | ], 32 | }, 33 | ], 34 | }; -------------------------------------------------------------------------------- /src/test/fixtures/e2e-product-data-full.csv: -------------------------------------------------------------------------------- 1 | name ,slug ,description ,assets ,facets ,optionGroups ,optionValues ,sku ,price ,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets 2 | Laptop ,laptop ,"Now equipped with seventh-generation Intel Core processors, Laptop is snappier than ever. From daily tasks like launching apps and opening files to more advanced computing, you can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz." ,derick-david-409858-unsplash.jpg ,category:electronics|category:computers,screen size|RAM,13 inch|8GB ,L2201308 ,1299.00,standard ,100 ,false , , 3 | , , , , , ,15 inch|8GB ,L2201508 ,1399.00,standard ,100 ,false , , 4 | , , , , , ,13 inch|16GB ,L2201316 ,2199.00,standard ,100 ,true , , 5 | , , , , , ,15 inch|16GB ,L2201516 ,2299.00,standard ,100 ,false , , 6 | Curvy Monitor ,curvy-monitor ,"Discover a truly immersive viewing experience with this monitor curved more deeply than any other. Wrapping around your field of vision the 1,800 R screencreates a wider field of view, enhances depth perception, and minimises peripheral distractions to draw you deeper in to your content." ,alexandru-acea-686569-unsplash.jpg,category:electronics|category:computers,monitor size ,24 inch ,C24F390 ,143.74 ,standard ,100 ,false , , 7 | , , , , , ,27 inch ,C27F390 ,169.94 ,standard ,100 ,false , , 8 | Gaming PC ,gaming-pc ,"This pc is optimised for gaming, and is also VR ready. The Intel Core-i7 CPU and High Performance GPU give the computer the raw power it needs to function at a high level." ,florian-olivo-1166419-unsplash.jpg,category:electronics|category:computers,cpu|HDD ,i7-8700|240GB SSD,CGS480VR1063,1087.20,standard ,100 ,false , , 9 | , , , , , ,R7-2700|240GB SSD,CGS480VR1064,1099.95,standard ,100 ,false , , 10 | , , , , , ,i7-8700|120GB SSD,CGS480VR1065,931.20 ,standard ,100 ,false , , 11 | , , , , , ,R7-2700|120GB SSD,CGS480VR1066,949.20 ,standard ,100 ,false , , 12 | Hard Drive ,hard-drive ,"Boost your PC storage with this internal hard drive, designed just for desktop and all-in-one PCs." ,vincent-botta-736919-unsplash.jpg ,category:electronics|category:computers,HDD capacity ,1TB ,IHD455T1 ,37.99 ,standard ,100 ,false , , 13 | , , , , , ,2TB ,IHD455T2 ,53.74 ,standard ,100 ,false , , 14 | , , , , , ,3TB ,IHD455T3 ,78.96 ,standard ,100 ,false , , 15 | , , , , , ,4TB ,IHD455T4 ,92.99 ,standard ,100 ,false , , 16 | , , , , , ,6TB ,IHD455T6 ,134.35 ,standard ,100 ,false , , 17 | Clacky Keyboard ,clacky-keyboard ,"Let all your colleagues know that you are typing on this exclusive, colorful klicky-klacky keyboard. Huge travel on each keypress ensures maximum klack on each and every keystroke." , ,category:electronics|category:computers, , ,A4TKLA45535 ,74.89 ,standard ,100 ,false , , 18 | USB Cable ,usb-cable ,"Solid conductors eliminate strand-interaction distortion and reduce jitter. As the surface is made of high-purity silver, the performance is very close to that of a solid silver cable, but priced much closer to solid copper cable." , ,category:electronics|category:computers, , ,USBCIN01.5MI,69.00 ,standard ,100 ,false , , 19 | Instant Camera ,instant-camera ,"With its nostalgic design and simple point-and-shoot functionality, the Instant Camera is the perfect pick to get started with instant photography." , ,category:electronics|category:photo , , ,IC22MWDD ,174.99 ,standard ,100 ,false , , 20 | Camera Lens ,camera-lens ,This lens is a Di type lens using an optical system with improved multi-coating designed to function with digital SLR cameras as well as film cameras. , ,category:electronics|category:photo , , ,B0012UUP02 ,104.00 ,standard ,100 ,false , , 21 | Tripod ,tripod ,"Capture vivid, professional-style photographs with help from this lightweight tripod. The adjustable-height tripod makes it easy to achieve reliable stability and score just the right angle when going after that award-winning shot." , ,category:electronics|category:photo , , ,B00XI87KV8 ,14.98 ,standard ,100 ,false , , 22 | Slr Camera ,slr-camera ,"Retro styled, portable in size and built around a powerful 24-megapixel APS-C CMOS sensor, this digital camera is the ideal companion for creative everyday photography. Packed full of high spec features such as an advanced hybrid autofocus system able to keep pace with even the most active subjects, a speedy 6fps continuous-shooting mode, high-resolution electronic viewfinder and intuitive swivelling touchscreen, it brings professional image making into everyone’s grasp.", ,category:electronics|category:photo , , ,B07D75V44S ,521.00 ,standard ,100 ,false , , 23 | Road Bike ,road-bike ,"Featuring a full carbon chassis - complete with cyclocross-specific carbon fork - and a component setup geared for hard use on the race circuit, it's got the low weight, exceptional efficiency and brilliant handling you'll need to stay at the front of the pack." , ,category:sports equipment , , ,RB000844334 ,2499.00,standard ,100 ,false , , 24 | Skipping Rope ,skipping-rope ,When you're working out you need a quality rope that doesn't tangle at every couple of jumps and with this sipping rope you won't have this problem. , ,category:sports equipment , , ,B07CNGXVXT ,7.99 ,standard ,100 ,false , , 25 | Boxing Gloves ,boxing-gloves ,"Training gloves designed for optimum training. Our gloves promote proper punching technique because they are conformed to the natural shape of your fist. Dense, innovative two-layer foam provides better shock absorbency and full padding on the front, back and wrist to promote proper punching technique." , ,category:sports equipment , , ,B000ZYLPPU ,33.04 ,standard ,100 ,false , , 26 | Tent ,tent ,"With tons of space inside (for max. 4 persons), full head height throughout the entire tent and an unusual and striking shape, this tent offers you everything you need." , ,category:sports equipment , , ,2000023510 ,214.93 ,standard ,100 ,false , , 27 | Cruiser Skateboard,cruiser-skateboard,"Based on the 1970s iconic shape, but made to a larger 69cm size, with updated, quality component, these skateboards are great for beginners to learn the foot spacing required, and are perfect for all-day cruising." , ,category:sports equipment , , ,799872520 ,24.99 ,standard ,100 ,false , , 28 | Football ,football ,"This football features high-contrast graphics for high-visibility during play, while its machine-stitched tpu casing offers consistent performance." , ,category:sports equipment , , ,SC3137-056 ,57.07 ,standard ,100 ,false , , 29 | Running Shoe ,running-shoe ,"With its ultra-light, uber-responsive magic foam and a carbon fiber plate that feels like it’s propelling you forward, the Running Shoe is ready to push you to victories both large and small" , ,category:sports equipment ,shoe size ,Size 40 ,RS0040 ,99.99 ,standard ,100 ,false , , 30 | , , , , , ,Size 42 ,RS0042 ,99.99 ,standard ,100 ,false , , 31 | , , , , , ,Size 44 ,RS0044 ,99.99 ,standard ,100 ,false , , 32 | , , , , , ,Size 46 ,RS0046 ,99.99 ,standard ,100 ,false , , 33 | Spiky Cactus ,spiky-cactus ,A spiky yet elegant house cactus - perfect for the home or office. Origin and habitat: Probably native only to the Andes of Peru , ,category:home & garden|category:plants , , ,SC011001 ,15.50 ,standard ,100 ,false , , 34 | Orchid ,orchid ,Gloriously elegant. It can go along with any interior as it is a neutral color and the most popular Phalaenopsis overall. 2 to 3 foot stems host large white flowers that can last for over 2 months. , ,category:home & garden|category:plants , , ,ROR00221 ,65.00 ,standard ,100 ,false , , 35 | Bonsai Tree ,bonsai-tree ,Excellent semi-evergreen bonsai. Indoors or out but needs some winter protection. All trees sent will leave the nursery in excellent condition and will be of equal quality or better than the photograph shown. , ,category:home & garden|category:plants , , ,B01MXFLUSV ,19.99 ,standard ,100 ,false , , -------------------------------------------------------------------------------- /src/test/simple-auth.test.ts: -------------------------------------------------------------------------------- 1 | import { EventBus, ExternalAuthenticationService, NativeAuthenticationMethod } from '@vendure/core'; 2 | import { EmailPlugin, EmailPluginOptions } from '@vendure/email-plugin'; 3 | import { EMAIL_PLUGIN_OPTIONS } from '@vendure/email-plugin/lib/src/constants'; 4 | import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing'; 5 | import fs from 'fs'; 6 | import gql from 'graphql-tag'; 7 | import path from 'path'; 8 | import { Subscription } from 'rxjs'; 9 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi, vitest } from 'vitest'; 10 | import { oneTimeCodeRequestedEventHandler } from '../email-handler'; 11 | import { OneTimeCodeRequestedEvent } from '../events'; 12 | import { SimpleAuthPlugin } from '../simple-auth.module'; 13 | import { SimpleAuthService } from '../simple-auth.service'; 14 | import { initialData } from './fixtures/e2e-initial-data'; 15 | 16 | async function sleep(ms = 1000) { 17 | return await new Promise((resolver) => { 18 | setTimeout(resolver, ms); 19 | }); 20 | } 21 | const sqliteDataDir = path.join(__dirname, '__data__'); 22 | 23 | const REQUEST_ONE_TIME_CODE = gql(` 24 | query requestCode($email: String!){ 25 | requestOneTimeCode(email: $email) { 26 | ...on OneTimeCode { 27 | value 28 | } 29 | ...on RequestOneTimeCodeError { 30 | message 31 | errorCode 32 | } 33 | } 34 | } 35 | `); 36 | const AUTHENTICATE = gql(` 37 | mutation authenticate($email: String!, $code: String!) { 38 | authenticate(input: { simple: { email: $email, code: $code } }) { 39 | ...on CurrentUser { 40 | identifier 41 | } 42 | 43 | ...on InvalidCredentialsError { 44 | errorCode 45 | message 46 | authenticationError 47 | } 48 | 49 | } 50 | } 51 | `); 52 | 53 | registerInitializer('sqljs', new SqljsInitializer(sqliteDataDir)); 54 | describe('SimpleAuthPlugin Testing', () => { 55 | const TEST_EMAIL = 'test@gmail.com' 56 | 57 | fs.mkdirSync(path.join(__dirname, '__data__/email/output'), { recursive: true }) 58 | fs.mkdirSync(path.join(__dirname, '__data__/email/mailbox'), { recursive: true }) 59 | fs.mkdirSync(path.join(__dirname, '__data__/email/templates/partials'), { recursive: true }) 60 | 61 | 62 | const { server, shopClient } = createTestEnvironment({ 63 | ...testConfig, 64 | plugins: [ 65 | EmailPlugin.init({ 66 | devMode: true, 67 | outputPath: path.join(__dirname, '__data__/email/output'), 68 | route: path.join(__dirname, '__data__/email/mailbox'), 69 | templatePath: path.join(__dirname, '__data__/email/templates'), 70 | handlers: [] 71 | }), 72 | SimpleAuthPlugin.init({ttl: 1, preventCrossStrategies: true}) 73 | ] 74 | }); 75 | 76 | beforeAll(async () => { 77 | try { 78 | await server.init({ 79 | productsCsvPath: path.join(__dirname, './fixtures/e2e-product-data-full.csv') , 80 | initialData: initialData 81 | }); 82 | } catch (err) { 83 | console.log('Error happens during init server'); 84 | console.log(err); 85 | } 86 | 87 | }, 60000) 88 | 89 | afterAll(async () => { 90 | try { 91 | await server.destroy(); 92 | } catch (err) { 93 | console.log('Error during destroy server'); 94 | console.log(err); 95 | } 96 | 97 | }) 98 | 99 | let code = ''; 100 | let email = ''; 101 | let subscription: Subscription | null; 102 | let options: typeof SimpleAuthPlugin.options; 103 | 104 | beforeEach(async () => { 105 | await shopClient.asAnonymousUser(); 106 | options = {...SimpleAuthPlugin.options }; 107 | 108 | const eventBus = await server.app.resolve(EventBus); 109 | subscription = eventBus.ofType(OneTimeCodeRequestedEvent).subscribe((event) => { 110 | code = event.code; 111 | email = event.email; 112 | }); 113 | }) 114 | afterEach(() => { 115 | Object.keys(options).forEach(k => SimpleAuthPlugin.options[k] = options[k]); 116 | 117 | subscription?.unsubscribe(); 118 | code = ''; 119 | email = ''; 120 | subscription = null; 121 | }) 122 | 123 | test('generate one time code and authenticate', async () => { 124 | 125 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL }); 126 | expect(res).toMatchObject({requestOneTimeCode: {value: 'A code sent to your email'}}); 127 | expect(email).toBe(TEST_EMAIL); 128 | 129 | const authRes = await shopClient.query(AUTHENTICATE, {code: code, email: TEST_EMAIL}); 130 | expect(authRes).toMatchObject({ 131 | authenticate: { 132 | 133 | identifier: TEST_EMAIL 134 | } 135 | }) 136 | }) 137 | 138 | test('code should renew after ttl reach', async () => { 139 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL }); 140 | expect(res).toMatchObject({requestOneTimeCode: {value: 'A code sent to your email'}}); 141 | expect(email).toBe(TEST_EMAIL); 142 | 143 | const saveCode = code; 144 | 145 | await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL}); 146 | expect(code).toBe(saveCode); 147 | 148 | await sleep(1100); 149 | 150 | await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL}); 151 | expect(code).not.toBe(saveCode); 152 | expect(code.length).toBe(6); 153 | }, {timeout: 3000}) 154 | 155 | test('code should invalidated after ttl reach', async () => { 156 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL }); 157 | expect(res).toMatchObject({requestOneTimeCode: {value: 'A code sent to your email'}}); 158 | expect(email).toBe(TEST_EMAIL); 159 | await sleep(1100); 160 | const authRes = await shopClient.query(AUTHENTICATE, {code: code, email: TEST_EMAIL}); 161 | expect(authRes).toEqual({ 162 | authenticate: expect.objectContaining({ 163 | message: 'The provided credentials are invalid', 164 | errorCode: 'INVALID_CREDENTIALS_ERROR' 165 | }) 166 | }) 167 | }, 3000) 168 | 169 | test('should return error if input wrong code', async () => { 170 | SimpleAuthPlugin.options.preventCrossStrategies = false 171 | 172 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL }); 173 | expect(res).toMatchObject({requestOneTimeCode: {value: 'A code sent to your email'}}); 174 | expect(email).toBe(TEST_EMAIL); 175 | 176 | const authRes = await shopClient.query(AUTHENTICATE, {code: code + 'X', email: TEST_EMAIL}); 177 | expect(authRes).toEqual({ 178 | authenticate: expect.objectContaining({ 179 | message: 'The provided credentials are invalid', 180 | errorCode: 'INVALID_CREDENTIALS_ERROR' 181 | }) 182 | }); 183 | 184 | }) 185 | 186 | test('should treat email as case-insensitive', async () => { 187 | await shopClient.query(REQUEST_ONE_TIME_CODE, { email: TEST_EMAIL.toUpperCase() }); 188 | expect(email).toBe(TEST_EMAIL); 189 | 190 | const authRes = await shopClient.query(AUTHENTICATE, {code: code, email: TEST_EMAIL[0].toUpperCase() + TEST_EMAIL.substring(1)}); 191 | expect(authRes).toMatchObject({ 192 | authenticate: { 193 | 194 | identifier: TEST_EMAIL 195 | } 196 | }) 197 | }) 198 | 199 | test('input invalid email should be handled', async () => { 200 | try { 201 | await shopClient 202 | .query(REQUEST_ONE_TIME_CODE, {email: 'Thisnotanemail'}) 203 | } catch (err) { 204 | expect(err).toBeInstanceOf(Error) 205 | expect(err.response.errors.length).toBeGreaterThan(0) 206 | expect(err.response.errors[0]).toMatchObject({ 207 | message: 'Thisnotanemail is not a valid email' 208 | }) 209 | } 210 | const res = await shopClient.query(AUTHENTICATE, { email: 'Thisisnotanemail', code: 'XXX' }) 211 | expect(res).toEqual({ 212 | authenticate: expect.objectContaining({ 213 | authenticationError: 'Email is invalid' 214 | }) 215 | }) 216 | 217 | }) 218 | 219 | test('raise error when cross authentication detected', async () => { 220 | const externalAuthService = server.app.get(ExternalAuthenticationService); 221 | const spy = vi.spyOn(externalAuthService, 'findCustomerUser'); 222 | 223 | spy.mockResolvedValue({ 224 | id: 1, 225 | authenticationMethods: [], 226 | createdAt: new Date(), 227 | customFields: [], 228 | deletedAt: new Date(), 229 | identifier: TEST_EMAIL, 230 | verified: true, 231 | roles: [], 232 | getNativeAuthenticationMethod: (strict?: boolean) => ({} as NativeAuthenticationMethod), 233 | lastLogin: null, 234 | updatedAt: new Date() 235 | 236 | }); 237 | 238 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, {email: TEST_EMAIL}) 239 | expect(res.requestOneTimeCode).toMatchObject({ 240 | errorCode: 'CROSS_EMAIL_AUTHENTICATION', 241 | message: 'Email already used with "native" authentication' 242 | }) 243 | spy.mockClear(); 244 | spy.mockRestore(); 245 | }) 246 | 247 | test('code should include alphabets', async () => { 248 | const simpleAuthService = server.app.get(SimpleAuthService); 249 | expect(simpleAuthService).toBeInstanceOf(SimpleAuthService); 250 | const tries = 10; 251 | SimpleAuthPlugin.options.includeAlphabet = true; 252 | const codes: string[] = []; 253 | 254 | for (let i = 0; i < tries; i++) { 255 | const code = await simpleAuthService.generateCode(`testing${i}@gmail.com`); 256 | codes.push(code); 257 | } 258 | expect(codes.some(code => code.match(/[a-z]+/i))).toBeTruthy(); 259 | }); 260 | 261 | test('code should return along with requestOneTimeCode in dev mode', async () => { 262 | SimpleAuthPlugin.options.isDev = true 263 | 264 | const res = await shopClient.query(REQUEST_ONE_TIME_CODE, {email: TEST_EMAIL}) 265 | expect(res.requestOneTimeCode).toMatchObject({ 266 | value: code 267 | }) 268 | 269 | }) 270 | 271 | test('email handler should trigger', async () => { 272 | const emailOptions = server.app.get(EMAIL_PLUGIN_OPTIONS) 273 | expect(emailOptions.handlers).contain(oneTimeCodeRequestedEventHandler) 274 | const spy = vitest.spyOn(oneTimeCodeRequestedEventHandler, 'handle') 275 | 276 | await shopClient.query(REQUEST_ONE_TIME_CODE, {email: TEST_EMAIL}) 277 | await sleep(100) 278 | expect(spy).toBeCalledTimes(1) 279 | 280 | expect(spy.mock.lastCall?.[0].code).toBe(code) 281 | expect(spy.mock.lastCall?.[0].email).toBe(TEST_EMAIL) 282 | 283 | spy.mockClear() 284 | }) 285 | }) 286 | 287 | describe('SimpleAuthPlugin load without EmailPlugin', () => { 288 | /** 289 | * Without saveOptions, this suite test will affect other suites 290 | * because SimpleAuthPlugin.options is static 291 | * 292 | * In real application, this is not the case because SimpleAuthPlugin 293 | * should only init once 294 | */ 295 | const saveOptions = SimpleAuthPlugin.options; 296 | 297 | const {server} = createTestEnvironment({ 298 | ...testConfig, 299 | plugins: [ 300 | SimpleAuthPlugin.init(saveOptions) 301 | ] 302 | }) 303 | beforeAll(async () => { 304 | await server.init({ 305 | productsCsvPath: path.join(__dirname, 'fixtures/e2e-product-data-full.csv'), 306 | initialData: initialData 307 | }) 308 | }) 309 | 310 | afterAll(async () => { 311 | await server.destroy(); 312 | }) 313 | 314 | test('server runs', () => { 315 | const plugin = server.app.get(SimpleAuthPlugin) 316 | expect(plugin).toBeInstanceOf(SimpleAuthPlugin) 317 | }) 318 | }) 319 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "allowSyntheticDefaultImports": true, 7 | "strictPropertyInitialization": false, 8 | "outDir": "./dist", 9 | "strict": true, 10 | "sourceMap": true, 11 | "skipLibCheck": true, 12 | "module": "CommonJS", 13 | "target": "ES2020", 14 | "declaration": true, 15 | "rootDir": "./src" 16 | }, 17 | "include": ["src/"], 18 | "exclude": ["src/test", "**/*.test.ts", "**/*.spec.ts"], 19 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // vite.config.ts 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | provider: 'istanbul', // or 'c8' 8 | reporter: ['json-summary', "html"] 9 | }, 10 | }, 11 | }) --------------------------------------------------------------------------------