├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.ts ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── index.ts └── shopify-auth │ ├── constants.ts │ ├── controllers │ ├── authHandler.ts │ ├── callbackHandler.ts │ ├── graphql.controller.ts │ ├── index.ts │ ├── offline.controller.spec.ts │ ├── offline.controller.ts │ ├── online.controller.spec.ts │ └── online.controller.ts │ ├── decorators.ts │ ├── exceptions.spec.ts │ ├── exceptions.ts │ ├── guard.spec.ts │ ├── guard.ts │ ├── interfaces.ts │ ├── module.ts │ ├── providers.ts │ └── utils │ ├── add-leading-slash.util.ts │ ├── join-url.util.spec.ts │ ├── join-url.util.ts │ └── strip-end-slash.util.ts ├── test ├── jest-e2e.json ├── shopify-auth.e2e-spec.ts └── test-helper.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 17.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm test 31 | - run: npm run test:e2e 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | ### VisualStudio template 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | ## 56 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 57 | 58 | # User-specific files 59 | *.suo 60 | *.user 61 | *.userosscache 62 | *.sln.docstates 63 | 64 | # User-specific files (MonoDevelop/Xamarin Studio) 65 | *.userprefs 66 | 67 | # Build results 68 | [Dd]ebug/ 69 | [Dd]ebugPublic/ 70 | [Rr]elease/ 71 | [Rr]eleases/ 72 | x64/ 73 | x86/ 74 | bld/ 75 | [Bb]in/ 76 | [Oo]bj/ 77 | [Ll]og/ 78 | 79 | # Visual Studio 2015 cache/options directory 80 | .vs/ 81 | # Uncomment if you have tasks that create the project's static files in wwwroot 82 | #wwwroot/ 83 | 84 | # MSTest test Results 85 | [Tt]est[Rr]esult*/ 86 | [Bb]uild[Ll]og.* 87 | 88 | # NUNIT 89 | *.VisualState.xml 90 | TestResult.xml 91 | 92 | # Build Results of an ATL Project 93 | [Dd]ebugPS/ 94 | [Rr]eleasePS/ 95 | dlldata.c 96 | 97 | # Benchmark Results 98 | BenchmarkDotNet.Artifacts/ 99 | 100 | # .NET Core 101 | project.lock.json 102 | project.fragment.lock.json 103 | artifacts/ 104 | **/Properties/launchSettings.json 105 | 106 | *_i.c 107 | *_p.c 108 | *_i.h 109 | *.ilk 110 | *.meta 111 | *.obj 112 | *.pch 113 | *.pdb 114 | *.pgc 115 | *.pgd 116 | *.rsp 117 | *.sbr 118 | *.tlb 119 | *.tli 120 | *.tlh 121 | *.tmp 122 | *.tmp_proj 123 | *.log 124 | *.vspscc 125 | *.vssscc 126 | .builds 127 | *.pidb 128 | *.svclog 129 | *.scc 130 | 131 | # Chutzpah Test files 132 | _Chutzpah* 133 | 134 | # Visual C++ cache files 135 | ipch/ 136 | *.aps 137 | *.ncb 138 | *.opendb 139 | *.opensdf 140 | *.sdf 141 | *.cachefile 142 | *.VC.db 143 | *.VC.VC.opendb 144 | 145 | # Visual Studio profiler 146 | *.psess 147 | *.vsp 148 | *.vspx 149 | *.sap 150 | 151 | # Visual Studio Trace Files 152 | *.e2e 153 | 154 | # TFS 2012 Local Workspace 155 | $tf/ 156 | 157 | # Guidance Automation Toolkit 158 | *.gpState 159 | 160 | # ReSharper is a .NET coding add-in 161 | _ReSharper*/ 162 | *.[Rr]e[Ss]harper 163 | *.DotSettings.user 164 | 165 | # JustCode is a .NET coding add-in 166 | .JustCode 167 | 168 | # TeamCity is a build add-in 169 | _TeamCity* 170 | 171 | # DotCover is a Code Coverage Tool 172 | *.dotCover 173 | 174 | # AxoCover is a Code Coverage Tool 175 | .axoCover/* 176 | !.axoCover/settings.json 177 | 178 | # Visual Studio code coverage results 179 | *.coverage 180 | *.coveragexml 181 | 182 | # NCrunch 183 | _NCrunch_* 184 | .*crunch*.local.xml 185 | nCrunchTemp_* 186 | 187 | # MightyMoose 188 | *.mm.* 189 | AutoTest.Net/ 190 | 191 | # Web workbench (sass) 192 | .sass-cache/ 193 | 194 | # Installshield output folder 195 | [Ee]xpress/ 196 | 197 | # DocProject is a documentation generator add-in 198 | DocProject/buildhelp/ 199 | DocProject/Help/*.HxT 200 | DocProject/Help/*.HxC 201 | DocProject/Help/*.hhc 202 | DocProject/Help/*.hhk 203 | DocProject/Help/*.hhp 204 | DocProject/Help/Html2 205 | DocProject/Help/html 206 | 207 | # Click-Once directory 208 | publish/ 209 | 210 | # Publish Web Output 211 | *.[Pp]ublish.xml 212 | *.azurePubxml 213 | # Note: Comment the next line if you want to checkin your web deploy settings, 214 | # but database connection strings (with potential passwords) will be unencrypted 215 | *.pubxml 216 | *.publishproj 217 | 218 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 219 | # checkin your Azure Web App publish settings, but sensitive information contained 220 | # in these scripts will be unencrypted 221 | PublishScripts/ 222 | 223 | # NuGet Packages 224 | *.nupkg 225 | # The packages folder can be ignored because of Package Restore 226 | **/[Pp]ackages/* 227 | # except build/, which is used as an MSBuild target. 228 | !**/[Pp]ackages/build/ 229 | # Uncomment if necessary however generally it will be regenerated when needed 230 | #!**/[Pp]ackages/repositories.config 231 | # NuGet v3's project.json files produces more ignorable files 232 | *.nuget.props 233 | *.nuget.targets 234 | 235 | # Microsoft Azure Build Output 236 | csx/ 237 | *.build.csdef 238 | 239 | # Microsoft Azure Emulator 240 | ecf/ 241 | rcf/ 242 | 243 | # Windows Store app package directories and files 244 | AppPackages/ 245 | BundleArtifacts/ 246 | Package.StoreAssociation.xml 247 | _pkginfo.txt 248 | *.appx 249 | 250 | # Visual Studio cache files 251 | # files ending in .cache can be ignored 252 | *.[Cc]ache 253 | # but keep track of directories ending in .cache 254 | !*.[Cc]ache/ 255 | 256 | # Others 257 | ClientBin/ 258 | ~$* 259 | *~ 260 | *.dbmdl 261 | *.dbproj.schemaview 262 | *.jfm 263 | *.pfx 264 | *.publishsettings 265 | orleans.codegen.cs 266 | 267 | # Since there are multiple workflows, uncomment next line to ignore bower_components 268 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 269 | #bower_components/ 270 | 271 | # RIA/Silverlight projects 272 | Generated_Code/ 273 | 274 | # Backup & report files from converting an old project file 275 | # to a newer Visual Studio version. Backup files are not needed, 276 | # because we have git ;-) 277 | _UpgradeReport_Files/ 278 | Backup*/ 279 | UpgradeLog*.XML 280 | UpgradeLog*.htm 281 | 282 | # SQL Server files 283 | *.mdf 284 | *.ldf 285 | *.ndf 286 | 287 | # Business Intelligence projects 288 | *.rdl.data 289 | *.bim.layout 290 | *.bim_*.settings 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Typescript v1 declaration files 303 | typings/ 304 | 305 | # Visual Studio 6 build log 306 | *.plg 307 | 308 | # Visual Studio 6 workspace options file 309 | *.opt 310 | 311 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 312 | *.vbw 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # JetBrains Rider 330 | .idea/ 331 | *.sln.iml 332 | 333 | # IDE - VSCode 334 | .vscode/* 335 | !.vscode/settings.json 336 | !.vscode/tasks.json 337 | !.vscode/launch.json 338 | !.vscode/extensions.json 339 | 340 | # CodeRush 341 | .cr/ 342 | 343 | # Python Tools for Visual Studio (PTVS) 344 | __pycache__/ 345 | *.pyc 346 | 347 | # Cake - Uncomment if you are using it 348 | # tools/** 349 | # !tools/packages.config 350 | 351 | # Tabs Studio 352 | *.tss 353 | 354 | # Telerik's JustMock configuration file 355 | *.jmconfig 356 | 357 | # BizTalk build output 358 | *.btp.cs 359 | *.btm.cs 360 | *.odx.cs 361 | *.xsd.cs 362 | 363 | # OpenCover UI analysis results 364 | OpenCover/ 365 | coverage/ 366 | 367 | ### macOS template 368 | # General 369 | .DS_Store 370 | .AppleDouble 371 | .LSOverride 372 | 373 | # Icon must end with two \r 374 | Icon 375 | 376 | # Thumbnails 377 | ._* 378 | 379 | # Files that might appear in the root of a volume 380 | .DocumentRevisions-V100 381 | .fseventsd 382 | .Spotlight-V100 383 | .TemporaryItems 384 | .Trashes 385 | .VolumeIcon.icns 386 | .com.apple.timemachine.donotpresent 387 | 388 | # Directories potentially created on remote AFP share 389 | .AppleDB 390 | .AppleDesktop 391 | Network Trash Folder 392 | Temporary Items 393 | .apdisk 394 | 395 | ======= 396 | # Local 397 | .env 398 | dist 399 | .next 400 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | # 2.1.5 9 | 10 | ### Fixed 11 | 12 | - Fixed missing export for `UseShopifyAuth` 13 | 14 | # 2.1.4 15 | 16 | ### Fixed 17 | 18 | - Fixed reauth URL in header not containing global app prefix 19 | 20 | # 2.1.3 21 | 22 | ### Fixed 23 | 24 | - Fixed not being able to resolve auth mode options when an Shopify auth exception occurs 25 | 26 | # 2.1.2 27 | 28 | ### Fixed 29 | 30 | - Fixed callback url joining when no global prefix, or empty global prefix was used 31 | 32 | # 2.1.1 33 | 34 | ### Fixed 35 | 36 | - Global prefix is now respected when generating callback URL when redirecting to OAuth screen 37 | 38 | # 2.1.0 39 | 40 | ### Added 41 | 42 | - Add metadata to figure out AccessMode of a controller 43 | - Add `@UseShopifyAuth` decorator for usage in controllers to check for Online or Offline sessions 44 | 45 | ### Other 46 | 47 | - Refactoring to simplify `ShopfiyAuthExceptionFilter` testing 48 | 49 | # 2.0.1 50 | 51 | ### Fixed 52 | 53 | - Remove console.log statement cluttering the log output 54 | - Remove old explainer from README regarding `bodyParser` usage 55 | 56 | # 2.0.0 57 | 58 | ### Fixed 59 | 60 | - ⚠️ [Breaking] Rewrite GraphQL handler to not take over the entirety of the request and response. 61 | 62 | This removes the requirement of installing `body-parser` and disabling the `bodyParser` logic of NextJS just to use this package. 63 | 64 | Before: 65 | 66 | ```ts 67 | // main.ts 68 | import { NestFactory } from '@nestjs/core'; 69 | import { json } from 'body-parser'; 70 | import { AppModule } from './app.module'; 71 | 72 | async function bootstrap() { 73 | const jsonParseMiddleware = json(); 74 | const app = await NestFactory.create(AppModule, { bodyParser: false }); 75 | app.use((req, res, next) => { 76 | // NOTE: Make sure this is the same `path` you pass to the `ShopifyAuthModule.registerOnlineAsync`. 77 | if (req.path.indexOf('/graphql') === 0) { 78 | next(); 79 | } else { 80 | jsonParseMiddleware(req, res, next); 81 | } 82 | }); 83 | 84 | await app.listen(3000); 85 | } 86 | bootstrap(); 87 | ``` 88 | 89 | After: 90 | 91 | ```ts 92 | import { NestFactory } from '@nestjs/core'; 93 | import { AppModule } from './app.module'; 94 | 95 | async function bootstrap() { 96 | const app = await NestFactory.create(AppModule); 97 | await app.listen(3000); 98 | } 99 | bootstrap(); 100 | ``` 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tolga Paksoy 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 | # DEPRECATED ⚠️ 2 | 3 | This package is deprecated and has been moved to a monorepo structure at [nestjs-shopify/nestjs-shopify](https://github.com/nestjs-shopify/nestjs-shopify). 4 | 5 | # nestjs-shopify-auth 6 | 7 | [![Node.js CI](https://github.com/tolgap/nestjs-shopify-auth/actions/workflows/node.js.yml/badge.svg)](https://github.com/tolgap/nestjs-shopify-auth/actions/workflows/node.js.yml) 8 | 9 | An OAuth setup for NestJS using Shopify's [`shopify-node-api`] package. Allows for online and offline auth using this module. Also adds a GraphQL proxy so you can use online tokens to proxy your GraphQL requests to Shopify, without exposing your Shopify Admin access token to the frontend. 10 | 11 | ## Installation 12 | 13 | Install package using NPM: 14 | 15 | ``` 16 | npm install @shopify/shopify-api nestjs-shopify-auth 17 | ``` 18 | 19 | or using Yarn: 20 | 21 | ``` 22 | yarn add @shopify/shopify-api nestjs-shopify-auth 23 | ``` 24 | 25 | Make sure you have your Shopify context initialized, [as required in `@shopify/shopify-api`](https://github.com/Shopify/shopify-node-api/blob/main/docs/getting_started.md#set-up-context). You can use the following package to do this is a neat way in NestJS that I've developed as well: 26 | 27 | ``` 28 | npm install shopify-nestjs-api 29 | ``` 30 | 31 | See usage: https://github.com/tolgap/shopify-nestjs-api . 32 | 33 | ## Usage 34 | 35 | From any module, import the `ShopifyAuthModule` using `registerOnlineAuthAsync` and/or `registerOfflineAuthAsync`: 36 | 37 | ```ts 38 | // app.module.ts 39 | @Module({ 40 | imports: [ 41 | ShopifyAuthModule.registerOnlineAuthAsync({ 42 | useFactory: () => ({ 43 | basePath: 'user', 44 | }), 45 | }), 46 | ], 47 | }) 48 | export class AppModule {} 49 | ``` 50 | 51 | You can provide an injectable that can handle the redirection or any other setup you want after an offline or online auth was successful: 52 | 53 | ```ts 54 | // my-shopify-auth.handler.ts 55 | @Injectable() 56 | export class MyShopifyAuthHandler implements ShopifyAuthAfterHandler { 57 | async afterAuth(req: Request, res: Response, session: SessionInterface) { 58 | // implement your logic after a successful auth. 59 | // you can check `session.isOnline` to see if it was an online auth or offline auth. 60 | } 61 | } 62 | ``` 63 | 64 | and provide and inject it to your `ShopifyAuthModule`: 65 | 66 | ```ts 67 | // app.module.ts 68 | import { MyShopifyAuthHandler } from './my-shopify-auth.handler'; 69 | 70 | @Module({ 71 | imports: [ 72 | ShopifyAuthModule.registerOnlineAuthAsync({ 73 | useFactory: (afterAuthHandler: MyShopifyAuthHandler) => ({ 74 | basePath: 'user', 75 | afterAuthHandler, 76 | }), 77 | provide: [MyShopifyAuthHandler] 78 | inject: [MyShopifyAuthHandler], 79 | }), 80 | ], 81 | }) 82 | export class AppModule {} 83 | ``` 84 | 85 | You can also use `useClass` and `useExisting` to register the `ShopifyAuthModule`. You can even register both auth modes using the same Module: 86 | 87 | ```ts 88 | // app.module.ts 89 | import { MyShopifyAuthHandler } from './my-shopify-auth.handler'; 90 | 91 | @Module({ 92 | imports: [ 93 | ShopifyAuthModule.registerOnlineAuthAsync({ 94 | useFactory: (afterAuthHandler: MyShopifyAuthHandler) => ({ 95 | basePath: 'user', 96 | afterAuthHandler, 97 | }), 98 | provide: [MyShopifyAuthHandler] 99 | inject: [MyShopifyAuthHandler], 100 | }), 101 | ShopifyAuthModule.registerOfflineAuthAsync({ 102 | useFactory: (afterAuthHandler: MyShopifyAuthHandler) => ({ 103 | basePath: 'shop', 104 | afterAuthHandler, 105 | }), 106 | provide: [MyShopifyAuthHandler] 107 | inject: [MyShopifyAuthHandler], 108 | }), 109 | ], 110 | }) 111 | export class AppModule {} 112 | ``` 113 | 114 | Now, if you want to install an App and store the offline access token in your DB, or Redis, or whatever storage you prefer, just visit `/shop/auth?shop=.myshopify.com`. And if you want to create short-lived online access token, for instance, to only perform one-off requests to Shopify Admin GraphQL, you can visit `/user/auth?shop=.myshopify.com`. 115 | 116 | ### Authentication 117 | 118 | When `ShopifyAuthModule` is setup, you can use `@UseShopifyAuth()` to require online or offline session in Controllers or specific routes. Example: 119 | 120 | ```ts 121 | import { AccessMode } from '@shopify/shopify-api'; 122 | import { Controller, Get } from '@nestjs/common'; 123 | 124 | @UseShopifyAuth(AccessMode.Online) 125 | @Controller() 126 | export class AppController { 127 | @Get('online-route') 128 | hello() { 129 | return 'you are using online auth!'; 130 | } 131 | 132 | @Get('offline-route') 133 | // Overriding the controller access mode: 134 | @UseShopifyAuth(AccessMode.Offline) 135 | offline() { 136 | return 'you are using offline auth!'; 137 | } 138 | } 139 | ``` 140 | 141 | ## GraphQL proxy 142 | 143 | This module automatically attaches a GraphQL endpoint to `/graphql` if you register online auth. You will need valid online auth tokens to make use of it. 144 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | moduleFileExtensions: ['js', 'json', 'ts'], 5 | modulePaths: ['.'], 6 | testRegex: '.*\\.spec\\.ts$', 7 | transform: { 8 | '^.+\\.(t|j)s$': 'ts-jest', 9 | }, 10 | collectCoverageFrom: ['src/**/*.(t|j)s'], 11 | coveragePathIgnorePatterns: [], 12 | coverageDirectory: 'coverage', 13 | testEnvironment: 'node', 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-shopify-auth", 3 | "version": "2.1.5", 4 | "description": "Enable Shopify OAuth in NestJS. Wraps @shopify/shopify-api under the hood.", 5 | "main": "dist/index.js", 6 | "readme": "README.md", 7 | "homepage": "https://github.com/tolgap/nestjs-shopify-auth", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/tolgap/nestjs-shopify-auth.git" 11 | }, 12 | "files": [ 13 | "dist/**/*", 14 | "*.md" 15 | ], 16 | "scripts": { 17 | "prepublishOnly": "npm run build", 18 | "prebuild": "rimraf dist", 19 | "build": "nest build", 20 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 21 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 22 | "test": "jest", 23 | "test:watch": "jest --watch", 24 | "test:cov": "jest --coverage", 25 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 26 | "test:e2e": "jest --config ./test/jest-e2e.json" 27 | }, 28 | "author": "Tolga Paksoy ", 29 | "license": "MIT", 30 | "keywords": [ 31 | "nestjs", 32 | "shopify", 33 | "auth" 34 | ], 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "peerDependencies": { 39 | "@nestjs/common": ">7", 40 | "@nestjs/core": ">7", 41 | "@shopify/shopify-api": "*" 42 | }, 43 | "devDependencies": { 44 | "@jest/types": "^27.4.2", 45 | "@nestjs/cli": "^8.1.6", 46 | "@nestjs/common": "^8.2.4", 47 | "@nestjs/core": "^8.2.4", 48 | "@nestjs/testing": "^8.2.4", 49 | "@shopify/shopify-api": "^2.0.0", 50 | "@types/express": "^4.17.13", 51 | "@types/node": "^17.0.4", 52 | "@types/supertest": "^2.0.11", 53 | "@typescript-eslint/eslint-plugin": "^5.8.0", 54 | "@typescript-eslint/parser": "^5.8.0", 55 | "eslint": "^8.5.0", 56 | "eslint-config-prettier": "^8.3.0", 57 | "eslint-plugin-prettier": "^4.0.0", 58 | "jest": "^27.4.5", 59 | "prettier": "^2.5.1", 60 | "reflect-metadata": "^0.1.13", 61 | "rimraf": "^3.0.1", 62 | "source-map-support": "^0.5.21", 63 | "supertest": "^6.1.6", 64 | "ts-jest": "^27.1.2", 65 | "ts-loader": "^9.2.6", 66 | "ts-node": "^10.4.0", 67 | "tsconfig-paths": "^3.12.0", 68 | "typescript": "^4.5.4" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shopify-auth/interfaces'; 2 | export { getShopifyAuthProviderToken } from './shopify-auth/constants'; 3 | export { ShopifyAuthGuard } from './shopify-auth/guard'; 4 | export { 5 | ShopifyAuthException, 6 | ReauthRedirectException, 7 | ReauthHeaderException, 8 | } from './shopify-auth/exceptions'; 9 | export * from './shopify-auth/decorators'; 10 | export { ShopifyAuthModule } from './shopify-auth/module'; 11 | -------------------------------------------------------------------------------- /src/shopify-auth/constants.ts: -------------------------------------------------------------------------------- 1 | import { AccessMode } from './interfaces'; 2 | 3 | export const AUTH_MODE_KEY = 'shopify:authMode'; 4 | 5 | export const SHOPIFY_AUTH_ONLINE = 'SHOPIFY_AUTH_ONLINE'; 6 | export const SHOPIFY_AUTH_OFFLINE = 'SHOPIFY_AUTH_OFFLINE'; 7 | 8 | export const SHOPIFY_AUTH_ONLINE_CONTROLLER_HACK = 9 | 'SHOPIFY_AUTH_ONLINE_CONTROLLER_HACK'; 10 | export const SHOPIFY_AUTH_OFFLINE_CONTROLLER_HACK = 11 | 'SHOPIFY_AUTH_OFFLINE_CONTROLLER_HACK'; 12 | 13 | export const getShopifyAuthProviderToken = (accessMode: AccessMode) => { 14 | return accessMode !== AccessMode.Offline 15 | ? SHOPIFY_AUTH_ONLINE 16 | : SHOPIFY_AUTH_OFFLINE; 17 | }; 18 | 19 | export const getShopifyAuthControllerHackToken = (accessMode: AccessMode) => { 20 | return accessMode !== AccessMode.Offline 21 | ? SHOPIFY_AUTH_ONLINE_CONTROLLER_HACK 22 | : SHOPIFY_AUTH_OFFLINE_CONTROLLER_HACK; 23 | }; 24 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/authHandler.ts: -------------------------------------------------------------------------------- 1 | import Shopify from '@shopify/shopify-api'; 2 | import { Request, Response } from 'express'; 3 | import { ShopifyAuthModuleOptions } from '../interfaces'; 4 | import { joinUrl } from '../utils/join-url.util'; 5 | 6 | export async function authHandler( 7 | req: Request, 8 | res: Response, 9 | domain: string, 10 | options: ShopifyAuthModuleOptions, 11 | isOnline = true, 12 | globalPrefix = '', 13 | ) { 14 | const { basePath } = options; 15 | 16 | const redirectUrl = joinUrl(globalPrefix, basePath, 'callback'); 17 | 18 | const oauthUrl = await Shopify.Auth.beginAuth( 19 | req, 20 | res, 21 | domain, 22 | redirectUrl, 23 | isOnline, 24 | ); 25 | 26 | res.redirect(oauthUrl); 27 | } 28 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/callbackHandler.ts: -------------------------------------------------------------------------------- 1 | import Shopify, { AuthQuery } from '@shopify/shopify-api'; 2 | import { Request, Response } from 'express'; 3 | import { ShopifyAuthModuleOptions } from '../interfaces'; 4 | 5 | export async function callbackHandler( 6 | req: Request, 7 | res: Response, 8 | options: ShopifyAuthModuleOptions, 9 | ) { 10 | const query = req.query as unknown as AuthQuery; 11 | const session = await Shopify.Auth.validateAuthCallback(req, res, query); 12 | 13 | if (session) { 14 | if (options.afterAuthHandler) { 15 | await options.afterAuthHandler.afterAuth(req, res, session); 16 | return; 17 | } 18 | 19 | res.redirect(`/?shop=${query.shop}&host=${query.host}`); 20 | return; 21 | } 22 | 23 | res.sendStatus(401); 24 | } 25 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/graphql.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Req, 5 | Res, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import Shopify from '@shopify/shopify-api'; 9 | import { Request, Response } from 'express'; 10 | import { UseShopifyAuth } from '../decorators'; 11 | 12 | @Controller('graphql') 13 | export class ShopifyGraphQLController { 14 | @Post() 15 | @UseShopifyAuth() 16 | async graphql(@Req() req: Request, @Res() res: Response) { 17 | const session = await Shopify.Utils.loadCurrentSession(req, res); 18 | 19 | if (!session) { 20 | throw new UnauthorizedException('Cannot proxy query. No session found.'); 21 | } 22 | 23 | const { shop, accessToken } = session; 24 | if (!accessToken) { 25 | throw new UnauthorizedException( 26 | 'Cannot proxy query. Session not authenticated.', 27 | ); 28 | } 29 | 30 | const data = req.body; 31 | const client = new Shopify.Clients.Graphql(shop, accessToken); 32 | const response = await client.query({ 33 | data, 34 | }); 35 | 36 | res.status(200).send(response.body); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { ShopifyOnlineAuthController } from './online.controller'; 2 | export { ShopifyOfflineAuthController } from './offline.controller'; 3 | export { ShopifyGraphQLController } from './graphql.controller'; 4 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/offline.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@nestjs/core'; 2 | import Shopify from '@shopify/shopify-api'; 3 | import { Session } from '@shopify/shopify-api/dist/auth/session'; 4 | import { Request, Response } from 'express'; 5 | import { mocked } from 'ts-jest/utils'; 6 | import { ShopifyAuthModuleOptions } from '../interfaces'; 7 | import { ShopifyOfflineAuthController } from './offline.controller'; 8 | 9 | const TEST_SHOP = 'testing-shop.myshopify.com'; 10 | const TEST_HOST = `https://${TEST_SHOP}/admin`; 11 | 12 | const options: ShopifyAuthModuleOptions = { 13 | basePath: 'shop', 14 | }; 15 | 16 | const mockReq = { 17 | query: { shop: TEST_SHOP, host: TEST_HOST }, 18 | }; 19 | const mockRedirect = jest.fn(); 20 | const mockRes = { 21 | redirect: mockRedirect, 22 | }; 23 | const req = mocked(mockReq as unknown as Request); 24 | const res = mocked(mockRes as unknown as Response); 25 | 26 | const authUrl = `https://${TEST_SHOP}/admin/authenticate`; 27 | 28 | describe('ShopifyOfflineAuthController', () => { 29 | let beginAuthSpy: jest.SpyInstance; 30 | let validateAuthSpy: jest.SpyInstance; 31 | let controller: ShopifyOfflineAuthController; 32 | const appConfig = new ApplicationConfig(); 33 | 34 | beforeEach(() => { 35 | controller = new ShopifyOfflineAuthController(options, appConfig); 36 | beginAuthSpy = jest 37 | .spyOn(Shopify.Auth, 'beginAuth') 38 | .mockResolvedValue(authUrl); 39 | validateAuthSpy = jest 40 | .spyOn(Shopify.Auth, 'validateAuthCallback') 41 | .mockResolvedValue({} as unknown as Session); 42 | }); 43 | 44 | afterEach(() => { 45 | mockRedirect.mockClear(); 46 | beginAuthSpy.mockClear(); 47 | validateAuthSpy.mockClear(); 48 | }); 49 | 50 | test('#auth', async () => { 51 | await controller.auth(TEST_SHOP, req, res); 52 | 53 | expect(beginAuthSpy).toHaveBeenCalledWith( 54 | req, 55 | res, 56 | TEST_SHOP, 57 | '/shop/callback', 58 | false, 59 | ); 60 | 61 | expect(mockRedirect).toHaveBeenCalledWith(authUrl); 62 | }); 63 | 64 | test('#callback', async () => { 65 | await controller.callback(req, res); 66 | 67 | expect(validateAuthSpy).toHaveBeenCalledWith(req, res, { 68 | shop: TEST_SHOP, 69 | host: TEST_HOST, 70 | }); 71 | 72 | expect(mockRedirect).toHaveBeenCalledWith( 73 | `/?shop=${TEST_SHOP}&host=${TEST_HOST}`, 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/offline.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { ShopifyAuthModuleOptions } from '../interfaces'; 4 | import { SHOPIFY_AUTH_OFFLINE } from '../constants'; 5 | import { authHandler } from './authHandler'; 6 | import { callbackHandler } from './callbackHandler'; 7 | import { ApplicationConfig } from '@nestjs/core'; 8 | 9 | @Controller('shopify/offline') 10 | export class ShopifyOfflineAuthController { 11 | constructor( 12 | @Inject(SHOPIFY_AUTH_OFFLINE) 13 | private options: ShopifyAuthModuleOptions, 14 | private appConfig: ApplicationConfig, 15 | ) {} 16 | 17 | @Get('auth') 18 | async auth( 19 | @Query('shop') domain: string, 20 | @Req() req: Request, 21 | @Res() res: Response, 22 | ) { 23 | await authHandler( 24 | req, 25 | res, 26 | domain, 27 | this.options, 28 | false, 29 | this.appConfig.getGlobalPrefix(), 30 | ); 31 | } 32 | 33 | @Get('callback') 34 | async callback(@Req() req: Request, @Res() res: Response) { 35 | await callbackHandler(req, res, this.options); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/online.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@nestjs/core'; 2 | import Shopify from '@shopify/shopify-api'; 3 | import { Session } from '@shopify/shopify-api/dist/auth/session'; 4 | import { Request, Response } from 'express'; 5 | import { mocked } from 'ts-jest/utils'; 6 | import { ShopifyAuthModuleOptions } from '../interfaces'; 7 | import { ShopifyOnlineAuthController } from './online.controller'; 8 | 9 | const TEST_SHOP = 'testing-shop.myshopify.com'; 10 | const TEST_HOST = 'testing-shop.myshopify.com/admin'; 11 | 12 | const options: ShopifyAuthModuleOptions = { 13 | basePath: 'user', 14 | }; 15 | 16 | const mockReq = { 17 | query: { shop: TEST_SHOP, host: TEST_HOST }, 18 | }; 19 | const mockRedirect = jest.fn(); 20 | const mockRes = { 21 | redirect: mockRedirect, 22 | }; 23 | const req = mocked(mockReq as unknown as Request); 24 | const res = mocked(mockRes as unknown as Response); 25 | 26 | const authUrl = `https://${TEST_SHOP}/admin/authenticate`; 27 | 28 | describe('ShopifyOnlineAuthController', () => { 29 | let beginAuthSpy: jest.SpyInstance; 30 | let validateAuthSpy: jest.SpyInstance; 31 | let controller: ShopifyOnlineAuthController; 32 | const appConfig = new ApplicationConfig(); 33 | 34 | beforeEach(() => { 35 | controller = new ShopifyOnlineAuthController(options, appConfig); 36 | beginAuthSpy = jest 37 | .spyOn(Shopify.Auth, 'beginAuth') 38 | .mockResolvedValue(authUrl); 39 | validateAuthSpy = jest 40 | .spyOn(Shopify.Auth, 'validateAuthCallback') 41 | .mockResolvedValue({} as unknown as Session); 42 | }); 43 | 44 | afterEach(() => { 45 | mockRedirect.mockClear(); 46 | beginAuthSpy.mockClear(); 47 | validateAuthSpy.mockClear(); 48 | }); 49 | 50 | test('#auth', async () => { 51 | await controller.auth(TEST_SHOP, req, res); 52 | 53 | expect(beginAuthSpy).toHaveBeenCalledWith( 54 | req, 55 | res, 56 | TEST_SHOP, 57 | '/user/callback', 58 | true, 59 | ); 60 | 61 | expect(mockRedirect).toHaveBeenCalledWith(authUrl); 62 | }); 63 | 64 | test('#callback', async () => { 65 | await controller.callback(req, res); 66 | 67 | expect(validateAuthSpy).toHaveBeenCalledWith(req, res, { 68 | shop: TEST_SHOP, 69 | host: TEST_HOST, 70 | }); 71 | 72 | expect(mockRedirect).toHaveBeenCalledWith( 73 | `/?shop=${TEST_SHOP}&host=${TEST_HOST}`, 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/shopify-auth/controllers/online.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { ShopifyAuthModuleOptions } from '../interfaces'; 4 | import { SHOPIFY_AUTH_ONLINE } from '../constants'; 5 | import { authHandler } from './authHandler'; 6 | import { callbackHandler } from './callbackHandler'; 7 | import { ApplicationConfig } from '@nestjs/core'; 8 | 9 | @Controller('shopify/online') 10 | export class ShopifyOnlineAuthController { 11 | constructor( 12 | @Inject(SHOPIFY_AUTH_ONLINE) 13 | private options: ShopifyAuthModuleOptions, 14 | private appConfig: ApplicationConfig, 15 | ) {} 16 | 17 | @Get('auth') 18 | async auth( 19 | @Query('shop') domain: string, 20 | @Req() req: Request, 21 | @Res() res: Response, 22 | ) { 23 | await authHandler( 24 | req, 25 | res, 26 | domain, 27 | this.options, 28 | true, 29 | this.appConfig.getGlobalPrefix(), 30 | ); 31 | } 32 | 33 | @Get('callback') 34 | async callback(@Req() req: Request, @Res() res: Response) { 35 | await callbackHandler(req, res, this.options); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shopify-auth/decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyDecorators, 3 | createParamDecorator, 4 | ExecutionContext, 5 | SetMetadata, 6 | UseFilters, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import Shopify from '@shopify/shopify-api'; 10 | import { Request, Response } from 'express'; 11 | import { AUTH_MODE_KEY } from './constants'; 12 | import { ShopifyAuthExceptionFilter } from './exceptions'; 13 | import { ShopifyAuthGuard } from './guard'; 14 | import { AccessMode } from './interfaces'; 15 | 16 | export const Shop = createParamDecorator< 17 | unknown, 18 | ExecutionContext, 19 | Promise 20 | >(async (_data: unknown, ctx: ExecutionContext) => { 21 | const req = ctx.switchToHttp().getRequest(); 22 | const res = ctx.switchToHttp().getResponse(); 23 | 24 | const session = await Shopify.Utils.loadCurrentSession(req, res); 25 | return session.shop; 26 | }); 27 | 28 | export const UseShopifyAuth = (accessMode?: AccessMode) => 29 | applyDecorators( 30 | SetMetadata(AUTH_MODE_KEY, accessMode || AccessMode.Online), 31 | UseGuards(ShopifyAuthGuard), 32 | UseFilters(ShopifyAuthExceptionFilter), 33 | ); 34 | -------------------------------------------------------------------------------- /src/shopify-auth/exceptions.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, ModuleRef } from '@nestjs/core'; 2 | import { SHOPIFY_AUTH_OFFLINE, SHOPIFY_AUTH_ONLINE } from './constants'; 3 | import { 4 | ReauthHeaderException, 5 | ReauthRedirectException, 6 | ShopifyAuthExceptionFilter, 7 | } from './exceptions'; 8 | import { ShopifyAuthModuleOptions } from './interfaces'; 9 | 10 | const TEST_SHOP = 'testing-shop.myshopify.com'; 11 | 12 | const onlineOptions: ShopifyAuthModuleOptions = { 13 | basePath: 'user', 14 | }; 15 | const offlineOptions: ShopifyAuthModuleOptions = { 16 | basePath: 'shop', 17 | }; 18 | 19 | const mockStatus = jest.fn().mockReturnThis(); 20 | const mockSetHeader = jest.fn().mockReturnThis(); 21 | const mockJson = jest.fn().mockReturnThis(); 22 | const mockRedirect = jest.fn().mockReturnThis(); 23 | 24 | const mockGetResponse = jest.fn().mockImplementation(() => ({ 25 | status: mockStatus, 26 | setHeader: mockSetHeader, 27 | json: mockJson, 28 | redirect: mockRedirect, 29 | })); 30 | const mockGetRequest = jest.fn().mockImplementation(() => ({ 31 | hostname: 'localhost', 32 | })); 33 | 34 | const mockHttpArgumentsHost = jest.fn().mockImplementation(() => ({ 35 | getRequest: mockGetRequest, 36 | getResponse: mockGetResponse, 37 | })); 38 | 39 | const mockArgumentsHost = { 40 | switchToHttp: mockHttpArgumentsHost, 41 | getArgByIndex: jest.fn(), 42 | getArgs: jest.fn(), 43 | getType: jest.fn(), 44 | switchToRpc: jest.fn(), 45 | switchToWs: jest.fn(), 46 | }; 47 | 48 | const moduleRef = { 49 | get: jest.fn().mockImplementation((token: string) => { 50 | if (token === SHOPIFY_AUTH_ONLINE) { 51 | return onlineOptions; 52 | } else if (token === SHOPIFY_AUTH_OFFLINE) { 53 | return offlineOptions; 54 | } else { 55 | throw new Error('Unknown token asked from exception filter'); 56 | } 57 | }), 58 | }; 59 | 60 | describe('ShopifyAuthExceptionFilter', () => { 61 | const appConfig = new ApplicationConfig(); 62 | const filter = new ShopifyAuthExceptionFilter( 63 | moduleRef as unknown as ModuleRef, 64 | appConfig, 65 | ); 66 | 67 | it('should be defined', () => { 68 | expect(filter).toBeDefined(); 69 | }); 70 | 71 | afterEach(() => { 72 | mockStatus.mockClear(); 73 | mockSetHeader.mockClear(); 74 | mockJson.mockClear(); 75 | mockRedirect.mockClear(); 76 | }); 77 | 78 | describe('online auth', () => { 79 | beforeEach(() => { 80 | const exception = new ReauthHeaderException(TEST_SHOP); 81 | filter.catch(exception, mockArgumentsHost); 82 | }); 83 | 84 | it('should have status 401', () => { 85 | expect(mockStatus).toHaveBeenCalledWith(401); 86 | }); 87 | 88 | it('should contain headers to reauthorize', () => { 89 | expect(mockSetHeader.mock.calls).toEqual([ 90 | ['X-Shopify-Api-Request-Failure-Reauthorize', '1'], 91 | [ 92 | 'X-Shopify-API-Request-Failure-Reauthorize-Url', 93 | `https://localhost/user/auth?shop=${TEST_SHOP}`, 94 | ], 95 | ]); 96 | }); 97 | }); 98 | 99 | describe('offline auth', () => { 100 | beforeEach(() => { 101 | const exception = new ReauthRedirectException(TEST_SHOP); 102 | filter.catch(exception, mockArgumentsHost); 103 | }); 104 | 105 | it('should redirect to offline auth', () => { 106 | expect(mockRedirect).toHaveBeenCalledWith( 107 | `https://localhost/shop/auth?shop=${TEST_SHOP}`, 108 | ); 109 | }); 110 | 111 | it('should not set custom headers', () => { 112 | expect(mockSetHeader).not.toHaveBeenCalled(); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/shopify-auth/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | } from '@nestjs/common'; 8 | import { ApplicationConfig, ModuleRef } from '@nestjs/core'; 9 | import { Request, Response } from 'express'; 10 | import { SHOPIFY_AUTH_OFFLINE, SHOPIFY_AUTH_ONLINE } from './constants'; 11 | import { ShopifyAuthModuleOptions } from './interfaces'; 12 | import { joinUrl } from './utils/join-url.util'; 13 | 14 | export class ShopifyAuthException extends HttpException { 15 | constructor(message = 'Unauthorized') { 16 | super(message, HttpStatus.UNAUTHORIZED); 17 | } 18 | } 19 | 20 | export class ReauthHeaderException extends ShopifyAuthException { 21 | constructor(public shop: string) { 22 | super('Reauthorization Required (See Headers)'); 23 | } 24 | } 25 | export class ReauthRedirectException extends ShopifyAuthException { 26 | constructor(public shop: string) { 27 | super('Reauthorization Required (See Redirect)'); 28 | } 29 | } 30 | 31 | @Catch(ShopifyAuthException) 32 | export class ShopifyAuthExceptionFilter implements ExceptionFilter { 33 | constructor( 34 | private readonly moduleRef: ModuleRef, 35 | private readonly appConfig: ApplicationConfig, 36 | ) {} 37 | 38 | catch(exception: ShopifyAuthException, host: ArgumentsHost) { 39 | const context = host.switchToHttp(); 40 | 41 | const req = context.getRequest(); 42 | const res = context.getResponse(); 43 | 44 | const domain = `https://${req.hostname}`; 45 | 46 | if (exception instanceof ReauthHeaderException) { 47 | const onlineOptions = this.moduleRef.get( 48 | SHOPIFY_AUTH_ONLINE, 49 | { strict: false }, 50 | ); 51 | 52 | const status = exception.getStatus(); 53 | const prefix = this.appConfig.getGlobalPrefix(); 54 | const basePath = onlineOptions.basePath || ''; 55 | const authPath = `auth?shop=${exception.shop}`; 56 | const redirectPath = joinUrl(prefix, basePath, authPath); 57 | const authUrl = new URL(redirectPath, domain).toString(); 58 | 59 | res 60 | .status(status) 61 | .setHeader('X-Shopify-Api-Request-Failure-Reauthorize', '1') 62 | .setHeader('X-Shopify-API-Request-Failure-Reauthorize-Url', authUrl) 63 | .json({ 64 | statusCode: status, 65 | timestamp: new Date().toISOString(), 66 | message: exception.message, 67 | }); 68 | } else if (exception instanceof ReauthRedirectException) { 69 | const offlineOptions = this.moduleRef.get( 70 | SHOPIFY_AUTH_OFFLINE, 71 | { strict: true }, 72 | ); 73 | 74 | const prefix = this.appConfig.getGlobalPrefix(); 75 | const basePath = offlineOptions.basePath || ''; 76 | const authPath = `auth?shop=${exception.shop}`; 77 | const redirectPath = joinUrl(prefix, basePath, authPath); 78 | const authUrl = new URL(redirectPath, domain).toString(); 79 | 80 | res.redirect(authUrl); 81 | } else { 82 | res.json({ 83 | message: 'No session found', 84 | }); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/shopify-auth/guard.spec.ts: -------------------------------------------------------------------------------- 1 | import '../../test/test-helper'; 2 | 3 | import { ExecutionContext } from '@nestjs/common'; 4 | import { mocked } from 'ts-jest/utils'; 5 | import Shopify, { SessionInterface } from '@shopify/shopify-api'; 6 | import { ShopifyAuthGuard } from './guard'; 7 | import { ReauthHeaderException, ReauthRedirectException } from './exceptions'; 8 | import { Reflector } from '@nestjs/core'; 9 | import { AccessMode } from './interfaces'; 10 | 11 | const TEST_SHOP = 'testing-shop.myshopify.com'; 12 | const TEST_USER = '1'; 13 | 14 | let headers = {}; 15 | const query = { 16 | shop: TEST_SHOP, 17 | }; 18 | 19 | const req = jest.fn().mockReturnValue({ 20 | headers, 21 | query, 22 | }); 23 | const res = jest.fn(); 24 | const mockExecutionContext = { 25 | getHandler: jest.fn(), 26 | getClass: jest.fn(), 27 | switchToHttp: jest.fn().mockReturnValue({ 28 | getRequest: req, 29 | getResponse: res, 30 | }), 31 | }; 32 | const executionContext = mocked( 33 | mockExecutionContext as unknown as ExecutionContext, 34 | true, 35 | ); 36 | 37 | const reflector = new Reflector(); 38 | 39 | const jwtToken = 40 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rlc3Rpbmctc2hvcC5teXNob3BpZnkuY29tL2FkbWluIiwiZGVzdCI6Imh0dHBzOi8vdGVzdGluZy1zaG9wLm15c2hvcGlmeS5jb20iLCJhdWQiOiJmb28iLCJzdWIiOiIxIiwiZXhwIjo1MDAwMDAwMDAwMDAwLCJuYmYiOjEyMzQsImlhdCI6MTIzNCwianRpIjoiNDMyMSIsInNpZCI6ImFiYzEyMyJ9.eD6-h8ZBWckH0xOCMJ5818oAkR9uQ050uu_638X_lfI'; 41 | 42 | describe('ShopifyAuthGuard', () => { 43 | let jwtSessionId: string; 44 | let session: SessionInterface; 45 | let reflectorSpy: jest.SpyInstance; 46 | const guard = new ShopifyAuthGuard(reflector); 47 | 48 | beforeEach(() => { 49 | jwtSessionId = Shopify.Auth.getJwtSessionId(TEST_SHOP, TEST_USER); 50 | session = new Shopify.Session.Session(jwtSessionId, TEST_SHOP, '', true); 51 | session.scope = 'write_shipping'; 52 | session.expires = new Date(5000000000000); 53 | session.accessToken = 'test_token'; 54 | 55 | reflectorSpy = jest.spyOn(reflector, 'getAllAndOverride'); 56 | }); 57 | 58 | afterEach(() => { 59 | reflectorSpy.mockClear(); 60 | }); 61 | 62 | it('should be defined', () => { 63 | expect(guard).toBeDefined(); 64 | }); 65 | 66 | it('should throw ReauthRedirectException if no session found', async () => { 67 | reflectorSpy.mockReturnValue(AccessMode.Offline); 68 | 69 | await expect(guard.canActivate(executionContext)).rejects.toThrowError( 70 | ReauthRedirectException, 71 | ); 72 | }); 73 | 74 | it('should throw ReauthHeaderException if session auth token is empty', async () => { 75 | reflectorSpy.mockReturnValue(AccessMode.Online); 76 | 77 | session.accessToken = undefined; 78 | await Shopify.Utils.storeSession(session); 79 | 80 | headers = { 81 | authorization: `Bearer ${jwtToken}`, 82 | }; 83 | req.mockClear(); 84 | req.mockReturnValue({ 85 | headers: { 86 | authorization: `Bearer ${jwtToken}`, 87 | }, 88 | query, 89 | }); 90 | 91 | await expect(guard.canActivate(executionContext)).rejects.toThrowError( 92 | ReauthHeaderException, 93 | ); 94 | }); 95 | 96 | it('should throw ReauthHeaderException if session is not found', async () => { 97 | reflectorSpy.mockReturnValue(AccessMode.Online); 98 | 99 | // we never call `Shopify.Utils.storeSession` 100 | headers = { 101 | authorization: `Bearer ${jwtToken}`, 102 | }; 103 | req.mockClear(); 104 | req.mockReturnValue({ 105 | headers: { 106 | authorization: `Bearer ${jwtToken}`, 107 | }, 108 | query, 109 | }); 110 | 111 | await expect(guard.canActivate(executionContext)).rejects.toThrowError( 112 | ReauthHeaderException, 113 | ); 114 | }); 115 | 116 | it('should returns true when session is valid', async () => { 117 | reflectorSpy.mockReturnValue(AccessMode.Online); 118 | 119 | await Shopify.Utils.storeSession(session); 120 | 121 | headers = { 122 | authorization: `Bearer ${jwtToken}`, 123 | }; 124 | req.mockClear(); 125 | req.mockReturnValue({ 126 | headers: { 127 | authorization: `Bearer ${jwtToken}`, 128 | }, 129 | query, 130 | }); 131 | 132 | await expect(guard.canActivate(executionContext)).resolves.toBe(true); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/shopify-auth/guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import Shopify from '@shopify/shopify-api'; 4 | import { Request, Response } from 'express'; 5 | import { AUTH_MODE_KEY } from './constants'; 6 | import { ReauthHeaderException, ReauthRedirectException } from './exceptions'; 7 | import { AccessMode } from './interfaces'; 8 | 9 | @Injectable() 10 | export class ShopifyAuthGuard implements CanActivate { 11 | constructor(private readonly reflector: Reflector) {} 12 | 13 | async canActivate(ctx: ExecutionContext): Promise { 14 | const http = ctx.switchToHttp(); 15 | const req = http.getRequest(); 16 | const res = http.getResponse(); 17 | 18 | const requiredAccessMode = this.reflector.getAllAndOverride( 19 | AUTH_MODE_KEY, 20 | [ctx.getHandler(), ctx.getClass()], 21 | ); 22 | const isOnline = requiredAccessMode === AccessMode.Online; 23 | const session = await Shopify.Utils.loadCurrentSession(req, res, isOnline); 24 | 25 | if (session) { 26 | const scopesChanged = !Shopify.Context.SCOPES.equals(session.scope); 27 | 28 | if ( 29 | !scopesChanged && 30 | session.accessToken && 31 | (!session.expires || new Date(session.expires) >= new Date()) 32 | ) { 33 | return true; 34 | } 35 | } 36 | 37 | const authHeader: string | undefined = 38 | Shopify.Context.IS_EMBEDDED_APP && req.headers.authorization; 39 | let shop: string | undefined = undefined; 40 | 41 | if (authHeader) { 42 | if (session) { 43 | shop = session.shop; 44 | } else if (authHeader) { 45 | const matches = authHeader?.match(/Bearer (.*)/); 46 | if (matches) { 47 | const payload = Shopify.Utils.decodeSessionToken(matches[1]); 48 | shop = payload.dest.replace('https://', ''); 49 | } 50 | } 51 | 52 | if (shop) { 53 | throw new ReauthHeaderException(shop); 54 | } 55 | } else if (!isOnline) { 56 | shop = req.query.shop?.toString() || process.env.SHOP; 57 | 58 | if (shop) { 59 | throw new ReauthRedirectException(shop); 60 | } 61 | } 62 | 63 | return false; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/shopify-auth/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata, Type } from '@nestjs/common'; 2 | import { SessionInterface } from '@shopify/shopify-api'; 3 | import { IncomingMessage, ServerResponse } from 'http'; 4 | 5 | export enum AccessMode { 6 | Online = 'online', 7 | Offline = 'offline', 8 | } 9 | 10 | export interface ShopifyAuthModuleOptions { 11 | basePath?: string; 12 | afterAuthHandler?: ShopifyAuthAfterHandler; 13 | } 14 | 15 | export interface ShopifyAuthOptionsFactory { 16 | createShopifyAuthOptions(): 17 | | Promise 18 | | ShopifyAuthModuleOptions; 19 | } 20 | 21 | export interface ShopifyAuthAfterHandler< 22 | T extends IncomingMessage = IncomingMessage, 23 | R extends ServerResponse = ServerResponse, 24 | > { 25 | afterAuth(req: T, res: R, session: SessionInterface): Promise; 26 | } 27 | 28 | export interface ShopifyAuthModuleAsyncOptions 29 | extends Pick { 30 | useExisting?: Type; 31 | useClass?: Type; 32 | useFactory?: ( 33 | ...args: any[] 34 | ) => Promise | ShopifyAuthModuleOptions; 35 | inject?: any[]; 36 | } 37 | -------------------------------------------------------------------------------- /src/shopify-auth/module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module, OnModuleInit } from '@nestjs/common'; 2 | import Shopify from '@shopify/shopify-api'; 3 | import { AccessMode, ShopifyAuthModuleAsyncOptions } from './interfaces'; 4 | import { createShopifyAuthAsyncOptionsProviders } from './providers'; 5 | import { 6 | ShopifyGraphQLController, 7 | ShopifyOnlineAuthController, 8 | ShopifyOfflineAuthController, 9 | } from './controllers'; 10 | import { getShopifyAuthProviderToken } from './constants'; 11 | 12 | @Module({}) 13 | export class ShopifyAuthModule implements OnModuleInit { 14 | static registerOnlineAuthAsync( 15 | options: ShopifyAuthModuleAsyncOptions, 16 | ): Promise | DynamicModule { 17 | return { 18 | module: ShopifyAuthModule, 19 | global: true, 20 | imports: options.imports || [], 21 | providers: [ 22 | ...(options.providers || []), 23 | ...createShopifyAuthAsyncOptionsProviders(options, AccessMode.Online), 24 | ], 25 | controllers: [ShopifyOnlineAuthController, ShopifyGraphQLController], 26 | exports: [getShopifyAuthProviderToken(AccessMode.Online)], 27 | }; 28 | } 29 | 30 | static registerOfflineAuthAsync( 31 | options: ShopifyAuthModuleAsyncOptions, 32 | ): Promise | DynamicModule { 33 | return { 34 | module: ShopifyAuthModule, 35 | global: true, 36 | imports: options.imports || [], 37 | providers: [ 38 | ...(options.providers || []), 39 | ...createShopifyAuthAsyncOptionsProviders(options, AccessMode.Offline), 40 | ], 41 | controllers: [ShopifyOfflineAuthController], 42 | exports: [getShopifyAuthProviderToken(AccessMode.Offline)], 43 | }; 44 | } 45 | 46 | onModuleInit() { 47 | Shopify.Context.throwIfUninitialized(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/shopify-auth/providers.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common'; 2 | import { PATH_METADATA } from '@nestjs/common/constants'; 3 | import { AccessMode } from './interfaces'; 4 | import { 5 | getShopifyAuthProviderToken, 6 | getShopifyAuthControllerHackToken, 7 | } from './constants'; 8 | import { 9 | ShopifyAuthModuleAsyncOptions, 10 | ShopifyAuthOptionsFactory, 11 | ShopifyAuthModuleOptions, 12 | } from './interfaces'; 13 | import { 14 | ShopifyOnlineAuthController, 15 | ShopifyOfflineAuthController, 16 | } from './controllers'; 17 | 18 | export function createShopifyAuthAsyncOptionsProviders( 19 | options: ShopifyAuthModuleAsyncOptions, 20 | accessMode: AccessMode, 21 | ): Provider[] { 22 | if (options.useExisting || options.useFactory) { 23 | return [ 24 | createShopifyAuthAsyncOptionsProvider(options, accessMode), 25 | createShopifyAuthControllerPathProvider(accessMode), 26 | ]; 27 | } 28 | 29 | if (options.useClass) { 30 | return [ 31 | createShopifyAuthAsyncOptionsProvider(options, accessMode), 32 | { provide: options.useClass, useClass: options.useClass }, 33 | ]; 34 | } 35 | 36 | throw new Error( 37 | 'Invalid ShopifyAuth options: one of `useClass`, `useExisting` or `useFactory` should be defined.', 38 | ); 39 | } 40 | 41 | export function createShopifyAuthAsyncOptionsProvider( 42 | options: ShopifyAuthModuleAsyncOptions, 43 | accessMode: AccessMode, 44 | ): Provider { 45 | if (options.useFactory) { 46 | return { 47 | provide: getShopifyAuthProviderToken(accessMode), 48 | useFactory: options.useFactory, 49 | inject: options.inject || [], 50 | }; 51 | } 52 | 53 | const inject = []; 54 | 55 | if (options.useClass || options.useExisting) { 56 | inject.push(options.useClass ?? options.useExisting); 57 | } 58 | 59 | return { 60 | provide: getShopifyAuthProviderToken(accessMode), 61 | useFactory: async (optionsFactory: ShopifyAuthOptionsFactory) => 62 | await optionsFactory.createShopifyAuthOptions(), 63 | inject, 64 | }; 65 | } 66 | 67 | export function createShopifyAuthControllerPathProvider( 68 | accessMode: AccessMode, 69 | ): Provider { 70 | let controller: 71 | | typeof ShopifyOnlineAuthController 72 | | typeof ShopifyOfflineAuthController = ShopifyOnlineAuthController; 73 | 74 | if (accessMode === AccessMode.Offline) { 75 | controller = ShopifyOfflineAuthController; 76 | } 77 | 78 | return { 79 | provide: getShopifyAuthControllerHackToken(accessMode), 80 | useFactory: (options: ShopifyAuthModuleOptions) => { 81 | if (options.basePath) { 82 | Reflect.defineMetadata(PATH_METADATA, options.basePath, controller); 83 | } 84 | }, 85 | inject: [getShopifyAuthProviderToken(accessMode)], 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/shopify-auth/utils/add-leading-slash.util.ts: -------------------------------------------------------------------------------- 1 | export const addLeadingSlash = (path?: string): string => 2 | path && typeof path === 'string' 3 | ? path.charAt(0) !== '/' 4 | ? '/' + path 5 | : path 6 | : ''; 7 | -------------------------------------------------------------------------------- /src/shopify-auth/utils/join-url.util.spec.ts: -------------------------------------------------------------------------------- 1 | import { joinUrl } from './join-url.util'; 2 | 3 | describe('joinUrl', () => { 4 | it('joins urls with a leading slash', () => { 5 | const url = joinUrl('shopify', 'online', 'callback'); 6 | expect(url).toStrictEqual('/shopify/online/callback'); 7 | }); 8 | 9 | it('skips empty paths', () => { 10 | const url = joinUrl('', '/shopify', '/online/', 'callback/'); 11 | expect(url).toStrictEqual('/shopify/online/callback'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/shopify-auth/utils/join-url.util.ts: -------------------------------------------------------------------------------- 1 | import { addLeadingSlash } from './add-leading-slash.util'; 2 | import { stripEndSlash } from './strip-end-slash.util'; 3 | 4 | export const joinUrl = (...paths: string[]) => 5 | paths 6 | .filter(Boolean) 7 | .map((path) => addLeadingSlash(path || '/')) 8 | .map((path) => (path !== '/' ? stripEndSlash(path) : path)) 9 | .join(''); 10 | -------------------------------------------------------------------------------- /src/shopify-auth/utils/strip-end-slash.util.ts: -------------------------------------------------------------------------------- 1 | export const stripEndSlash = (path: string) => 2 | path[path.length - 1] === '/' ? path.slice(0, path.length - 1) : path; 3 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/shopify-auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import './test-helper'; 2 | 3 | import * as request from 'supertest'; 4 | import { Test } from '@nestjs/testing'; 5 | import { INestApplication, Injectable } from '@nestjs/common'; 6 | import { ShopifyAuthAfterHandler, ShopifyAuthModule } from '../src'; 7 | import Shopify, { SessionInterface } from '@shopify/shopify-api'; 8 | import { Request, Response } from 'express'; 9 | import { Session } from '@shopify/shopify-api/dist/auth/session'; 10 | import { AuthScopes } from '@shopify/shopify-api/dist/auth/scopes'; 11 | 12 | const mockQuery = jest.fn(); 13 | 14 | jest.mock('@shopify/shopify-api/dist/clients/graphql', () => ({ 15 | __esModule: true, 16 | GraphqlClient: class MockGraphqlClient { 17 | constructor(readonly domain: string, readonly accessToken: string) {} 18 | 19 | query = mockQuery; 20 | }, 21 | })); 22 | 23 | const TEST_SHOP = 'testing-shop.myshopify.com'; 24 | const nonce = '888491362182521'; 25 | 26 | const onlineSession = new Shopify.Session.Session( 27 | 'online_test_session', 28 | TEST_SHOP, 29 | nonce, 30 | true, 31 | ); 32 | onlineSession.shop = TEST_SHOP; 33 | onlineSession.isOnline = true; 34 | onlineSession.scope = 'write_shipping'; 35 | onlineSession.accessToken = 'foobar'; 36 | 37 | const offlineSession = new Shopify.Session.Session( 38 | 'offline_test_session', 39 | TEST_SHOP, 40 | nonce, 41 | false, 42 | ); 43 | offlineSession.shop = TEST_SHOP; 44 | offlineSession.isOnline = false; 45 | offlineSession.scope = 'write_shipping'; 46 | offlineSession.accessToken = 'foocrux'; 47 | 48 | @Injectable() 49 | class AfterAuthHandler implements ShopifyAuthAfterHandler { 50 | async afterAuth( 51 | _req: Request, 52 | res: Response, 53 | session: SessionInterface, 54 | ): Promise { 55 | if (session.isOnline) { 56 | res.redirect(`/?shop=${session.shop}&access-mode=online`); 57 | return; 58 | } 59 | 60 | res.redirect(`/?shop=${session.shop}&access-mode=offline`); 61 | } 62 | } 63 | 64 | describe('ShopifyAuthModule', () => { 65 | let app: INestApplication; 66 | let beginAuthSpy: jest.SpyInstance; 67 | let validateAuthSpy: jest.SpyInstance; 68 | 69 | beforeEach(() => { 70 | beginAuthSpy = jest.spyOn(Shopify.Auth, 'beginAuth'); 71 | validateAuthSpy = jest.spyOn(Shopify.Auth, 'validateAuthCallback'); 72 | }); 73 | 74 | afterEach(() => { 75 | beginAuthSpy.mockClear(); 76 | validateAuthSpy.mockClear(); 77 | }); 78 | 79 | describe('#registerOnlineAuthAsync', () => { 80 | const authRedirectUrl = 81 | `https://${TEST_SHOP}` + 82 | '/admin/oauth/authorize' + 83 | '?client_id=foo' + 84 | '&scope=write_shipping' + 85 | '&redirect_uri=https%3A%2F%2Flocalhost%3A8082user%2Fcallback' + 86 | `&state=${nonce}` + 87 | '&grant_options%5B%5D=per-user'; 88 | 89 | beforeAll(async () => { 90 | const moduleRef = await Test.createTestingModule({ 91 | imports: [ 92 | ShopifyAuthModule.registerOnlineAuthAsync({ 93 | useFactory: async (afterAuthHandler: AfterAuthHandler) => ({ 94 | basePath: 'user', 95 | afterAuthHandler, 96 | }), 97 | providers: [AfterAuthHandler], 98 | inject: [AfterAuthHandler], 99 | }), 100 | ], 101 | }).compile(); 102 | 103 | app = moduleRef.createNestApplication(); 104 | await app.init(); 105 | }); 106 | 107 | afterAll(async () => { 108 | await app.close(); 109 | }); 110 | 111 | test('/GET /user/auth', async () => { 112 | beginAuthSpy.mockResolvedValue(authRedirectUrl); 113 | 114 | const res = await request(app.getHttpServer()) 115 | .get('/user/auth') 116 | .query({ shop: TEST_SHOP }) 117 | .expect(302); 118 | 119 | expect(res.headers.location).toEqual(authRedirectUrl); 120 | }); 121 | 122 | test('/GET /user/callback', async () => { 123 | validateAuthSpy.mockResolvedValue(onlineSession); 124 | 125 | const res = await request(app.getHttpServer()) 126 | .get('/user/callback') 127 | .query({ 128 | shop: TEST_SHOP, 129 | state: nonce, 130 | code: 'foobar', 131 | hmac: 'abc', 132 | }) 133 | .expect(302); 134 | 135 | const redirectUrl = `/?shop=${TEST_SHOP}&access-mode=online`; 136 | 137 | expect(res.headers.location).toEqual(redirectUrl); 138 | }); 139 | 140 | describe('POST /graphql', () => { 141 | test('rejects without session', async () => { 142 | await request(app.getHttpServer()).post('/graphql').expect(403); 143 | }); 144 | 145 | describe('with session', () => { 146 | let sessionSpy: jest.SpyInstance; 147 | const successResponse = { 148 | data: { 149 | shop: { 150 | name: 'Shop', 151 | }, 152 | }, 153 | }; 154 | const shopQuery = `{ 155 | shop { 156 | name 157 | } 158 | }`; 159 | const objectQuery = { 160 | query: shopQuery, 161 | variables: `{ 162 | foo: bar 163 | }`, 164 | }; 165 | 166 | beforeEach(() => { 167 | sessionSpy = jest 168 | .spyOn(Shopify.Utils, 'loadCurrentSession') 169 | .mockResolvedValue({ 170 | scope: new AuthScopes(['write_shipping']), 171 | accessToken: 'foobar', 172 | expires: new Date().valueOf() + 5000, 173 | } as unknown as Session); 174 | }); 175 | 176 | afterEach(() => { 177 | sessionSpy.mockClear(); 178 | mockQuery.mockReset(); 179 | }); 180 | 181 | test('passes request to graphql proxy', async () => { 182 | mockQuery.mockResolvedValue({ 183 | body: JSON.stringify(successResponse), 184 | }); 185 | 186 | const response = await request(app.getHttpServer()) 187 | .post('/graphql') 188 | .send(objectQuery) 189 | .set('Content-Type', 'application/json') 190 | .expect(200); 191 | 192 | expect(JSON.parse(response.text)).toEqual(successResponse); 193 | }); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('#registerOfflineAuthAsync', () => { 199 | const authRedirectUrl = 200 | `https://${TEST_SHOP}` + 201 | '/admin/oauth/authorize' + 202 | '?client_id=foo' + 203 | '&scope=write_shipping' + 204 | '&redirect_uri=https%3A%2F%2Flocalhost%3A8082%2Fshopify%2Fshop%2Fcallback' + 205 | `&state=${nonce}`; 206 | 207 | beforeAll(async () => { 208 | const moduleRef = await Test.createTestingModule({ 209 | imports: [ 210 | ShopifyAuthModule.registerOfflineAuthAsync({ 211 | useFactory: async (afterAuthHandler: AfterAuthHandler) => ({ 212 | basePath: 'shop', 213 | afterAuthHandler, 214 | }), 215 | providers: [AfterAuthHandler], 216 | inject: [AfterAuthHandler], 217 | }), 218 | ], 219 | }).compile(); 220 | 221 | app = moduleRef.createNestApplication(); 222 | app.setGlobalPrefix('shopify'); 223 | await app.init(); 224 | }); 225 | 226 | afterAll(async () => { 227 | await app.close(); 228 | }); 229 | 230 | test('/GET /shopify/shop/auth', async () => { 231 | beginAuthSpy.mockResolvedValue(authRedirectUrl); 232 | 233 | const res = await request(app.getHttpServer()) 234 | .get('/shopify/shop/auth') 235 | .query({ shop: TEST_SHOP }) 236 | .expect(302); 237 | 238 | expect(res.headers.location).toEqual(authRedirectUrl); 239 | }); 240 | 241 | test('/GET /shopify/shop/callback', async () => { 242 | validateAuthSpy.mockResolvedValue(offlineSession); 243 | 244 | const res = await request(app.getHttpServer()) 245 | .get('/shopify/shop/callback') 246 | .query({ 247 | shop: TEST_SHOP, 248 | state: nonce, 249 | code: 'foobar', 250 | hmac: 'abc', 251 | }) 252 | .expect(302); 253 | 254 | const redirectUrl = `/?shop=${TEST_SHOP}&access-mode=offline`; 255 | 256 | expect(res.headers.location).toEqual(redirectUrl); 257 | }); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /test/test-helper.ts: -------------------------------------------------------------------------------- 1 | import Shopify, { ApiVersion } from '@shopify/shopify-api'; 2 | import { MemorySessionStorage } from '@shopify/shopify-api/dist/auth/session'; 3 | 4 | beforeAll(() => { 5 | Shopify.Context.initialize({ 6 | API_KEY: 'foo', 7 | API_SECRET_KEY: 'bar', 8 | SCOPES: ['write_shipping'], 9 | API_VERSION: ApiVersion.Unstable, 10 | HOST_NAME: 'localhost:8082', 11 | IS_EMBEDDED_APP: true, 12 | SESSION_STORAGE: new MemorySessionStorage(), 13 | }); 14 | Shopify.Context.USER_AGENT_PREFIX = undefined; 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts", "jest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./" 13 | }, 14 | "ts-node": { 15 | "files": true 16 | }, 17 | "include": ["src", "test", "jest.config.ts"] 18 | } 19 | --------------------------------------------------------------------------------