├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── tests.yml ├── .gitignore ├── .meteorignore ├── .versions ├── API.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── HISTORY.md ├── LICENSE ├── README.md ├── SECURITY.md ├── lib ├── defaults.js ├── middleware │ ├── getDebugMiddleware.js │ └── secureHandler.js ├── model │ ├── DefaultModelConfig.js │ ├── meteor-model.js │ └── model.js ├── oauth.js ├── utils │ ├── bind.js │ ├── console.js │ ├── createCollection.js │ ├── error.js │ └── isModelInterface.js ├── validation │ ├── OptionsSchema.js │ ├── UserValidation.js │ ├── nonEmptyString.js │ ├── requiredAccessTokenPostParams.js │ ├── requiredAuthorizeGetParams.js │ ├── requiredAuthorizePostParams.js │ ├── requiredRefreshTokenPostParams.js │ └── validateParams.js └── webapp.js ├── package.js ├── test-proxy ├── .eslintrc ├── .gitignore ├── .meteor │ ├── .finished-upgraders │ ├── .gitignore │ ├── .id │ ├── packages │ ├── platforms │ ├── release │ └── versions ├── package-lock.json ├── package.json └── packages │ └── oauth2-server └── tests ├── error-tests.js ├── model-tests.js ├── oauth-tests.js ├── test-helpers.tests.js ├── validation-tests.js └── webapp-tests.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | #schedule: 21 | # - cron: '22 5 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | #- name: Autobuild 53 | # uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test suite 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | name: StandardJS lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@v4 17 | - name: setup node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - name: Cache NPM dependencies 22 | id: cache-npm 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/.npm 26 | key: v3-npm-${{ hashFiles('package-lock.json') }} 27 | restore-keys: | 28 | v3-npm- 29 | 30 | - name: Run lint 31 | run: | 32 | cd test-proxy 33 | npm install 34 | npm run setup 35 | npm run lint 36 | 37 | tests: 38 | name: Meteor tests 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: checkout 42 | uses: actions/checkout@v4 43 | 44 | # CACHING 45 | - name: Install Meteor 46 | id: cache-meteor-install 47 | uses: actions/cache@v4 48 | with: 49 | path: ~/.meteor 50 | key: v3-meteor-${{ hashFiles('.meteor/versions') }} 51 | restore-keys: | 52 | v3-meteor- 53 | 54 | - name: Cache NPM dependencies 55 | id: cache-meteor-npm 56 | uses: actions/cache@v3 57 | with: 58 | path: ~/.npm 59 | key: v3-npm-${{ hashFiles('package-lock.json') }} 60 | restore-keys: | 61 | v3-npm- 62 | 63 | - name: Cache Meteor build 64 | id: cache-meteor-build 65 | uses: actions/cache@v3 66 | with: 67 | path: | 68 | .meteor/local/resolver-result-cache.json 69 | .meteor/local/plugin-cache 70 | .meteor/local/isopacks 71 | .meteor/local/bundler-cache/scanner 72 | key: v3-meteor_build_cache-${{ github.ref }}-${{ github.sha }} 73 | restore-key: | 74 | v3-meteor_build_cache- 75 | 76 | - name: Setup meteor 77 | uses: meteorengineer/setup-meteor@v1 78 | with: 79 | meteor-release: '2.8.1' 80 | 81 | - name: Run tests 82 | run: | 83 | cd test-proxy 84 | meteor npm install 85 | meteor npm run setup 86 | meteor npm run test 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # folders 2 | packages/ 3 | 4 | 5 | 6 | # config files 7 | productionSettings.json 8 | leaconfig.json 9 | i18n_routes.json 10 | routes.json 11 | 12 | # proprietary stuff 13 | public/fonts/ 14 | public/logos/ 15 | 16 | .deploy 17 | 18 | # Logs 19 | logs 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | lerna-debug.log* 25 | 26 | # Diagnostic reports (https://nodejs.org/api/report.html) 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Directory for instrumented libs generated by jscoverage/JSCover 36 | lib-cov 37 | 38 | # Coverage directory used by tools like istanbul 39 | coverage 40 | .coverage 41 | *.lcov 42 | 43 | # nyc test coverage 44 | .nyc_output 45 | 46 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 47 | .grunt 48 | 49 | # Bower dependency directory (https://bower.io/) 50 | bower_components 51 | 52 | # node-waf configuration 53 | .lock-wscript 54 | 55 | # Compiled binary addons (https://nodejs.org/api/addons.html) 56 | build/Release 57 | 58 | # Dependency directories 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # TypeScript v1 declaration files 63 | typings/ 64 | 65 | # TypeScript cache 66 | *.tsbuildinfo 67 | 68 | # Optional npm cache directory 69 | .npm 70 | 71 | # Optional eslint cache 72 | .eslintcache 73 | 74 | # Microbundle cache 75 | .rpt2_cache/ 76 | .rts2_cache_cjs/ 77 | .rts2_cache_es/ 78 | .rts2_cache_umd/ 79 | 80 | # Optional REPL history 81 | .node_repl_history 82 | 83 | # Output of 'npm pack' 84 | *.tgz 85 | 86 | # Yarn Integrity file 87 | .yarn-integrity 88 | 89 | # dotenv environment variables file 90 | .env 91 | .env.test 92 | 93 | # parcel-bundler cache (https://parceljs.org/) 94 | .cache 95 | 96 | # Next.js build output 97 | .next 98 | 99 | # Nuxt.js build / generate output 100 | .nuxt 101 | dist 102 | 103 | # Gatsby files 104 | .cache/ 105 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 106 | # https://nextjs.org/blog/next-9-1#public-directory-support 107 | # public 108 | 109 | # vuepress build output 110 | .vuepress/dist 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # private font 125 | SemikolonPlus* 126 | 127 | # ide specific 128 | .idea 129 | .atom 130 | .vscode 131 | 132 | # meteor package specific 133 | .meteor/local 134 | .meteor/meteorite 135 | -------------------------------------------------------------------------------- /.meteorignore: -------------------------------------------------------------------------------- 1 | test-proxy/ 2 | tests/ -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | accounts-base@3.0.4 2 | accounts-password@3.0.3 3 | allow-deny@2.1.0 4 | babel-compiler@7.11.3 5 | babel-runtime@1.5.2 6 | base64@1.0.13 7 | binary-heap@1.0.12 8 | boilerplate-generator@2.0.0 9 | callback-hook@1.6.0 10 | check@1.4.4 11 | core-runtime@1.0.0 12 | dburles:mongo-collection-instances@1.0.0 13 | ddp@1.4.2 14 | ddp-client@3.1.0 15 | ddp-common@1.4.4 16 | ddp-rate-limiter@1.2.2 17 | ddp-server@3.1.0 18 | diff-sequence@1.1.3 19 | dynamic-import@0.7.4 20 | ecmascript@0.16.10 21 | ecmascript-runtime@0.8.3 22 | ecmascript-runtime-client@0.12.2 23 | ecmascript-runtime-server@0.11.1 24 | ejson@1.1.4 25 | email@3.1.2 26 | facts-base@1.0.2 27 | fetch@0.1.5 28 | geojson-utils@1.0.12 29 | id-map@1.2.0 30 | inter-process-messaging@0.1.2 31 | jkuester:http@2.1.0 32 | lai:collection-extensions@1.0.0 33 | leaonline:oauth2-server@6.0.0 34 | local-test:leaonline:oauth2-server@6.0.0 35 | localstorage@1.2.1 36 | logging@1.3.5 37 | meteor@2.1.0 38 | meteortesting:browser-tests@1.7.0 39 | meteortesting:mocha@3.2.0 40 | meteortesting:mocha-core@8.2.0 41 | minimongo@2.0.2 42 | modern-browsers@0.2.0 43 | modules@0.20.3 44 | modules-runtime@0.13.2 45 | mongo@2.1.0 46 | mongo-decimal@0.2.0 47 | mongo-dev-server@1.1.1 48 | mongo-id@1.0.9 49 | npm-mongo@6.10.2 50 | ordered-dict@1.2.0 51 | promise@1.0.0 52 | random@1.2.2 53 | rate-limit@1.1.2 54 | react-fast-refresh@0.2.9 55 | reactive-var@1.0.13 56 | reload@1.3.2 57 | retry@1.1.1 58 | routepolicy@1.1.2 59 | sha@1.0.10 60 | socket-stream-client@0.6.0 61 | tracker@1.3.4 62 | typescript@5.6.3 63 | url@1.3.5 64 | webapp@2.0.5 65 | webapp-hashing@1.1.2 66 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | ## Classes 2 | 3 |
4 |
OAuthMeteorModel
5 |

Implements the OAuth2Server model with Meteor-Mongo bindings.

6 |
7 |
OAuth2Server
8 |

The base class of this package. 9 | Represents an oauth2-server with a default model setup for Meteor/Mongo.

10 |
11 |
12 | 13 | ## Constants 14 | 15 |
16 |
OAuth2ServerDefaults : Object
17 |

Default options, that are used to merge with the user 18 | defined options.

19 |
20 |
DefaultModelConfig : Object
21 |

Default collection names for the model collections.

22 |
23 |
bindfunction
24 |

Binds a function to the Meteor environment and Fiber

25 |
26 |
createCollectionMongo.Collection
27 |

If the given collection is already created or cached, returns the collection 28 | or creates a new one.

29 |
30 |
errorHandler
31 |

Unifies error handling as http response. 32 | Defaults to a 500 response, unless further details were added.

33 |
34 |
isModelInterfaceboolean
35 |

Since we allow projects to implement their own model (while providing ours 36 | as drop-in) we still need to validate, whether they implement the model 37 | correctly.

38 |

We duck-type check if the model implements the most important functions. 39 | Uses the following values to check:

40 | 51 |
52 |
UserValidation
53 |

Used to register handlers for different instances that validate users. 54 | This allows you to validate user access on a client-based level.

55 |
56 |
validateParamsboolean
57 |

Abstraction that checks given query/body params against a given schema

58 |
59 |
app : Object
60 |

Wrapped WebApp with express-style get/post and default use routes.

61 |
62 |
63 | 64 | 65 | 66 | ## OAuthMeteorModel 67 | Implements the OAuth2Server model with Meteor-Mongo bindings. 68 | 69 | **Kind**: global class 70 | 71 | * [OAuthMeteorModel](#OAuthMeteorModel) 72 | * [.log(...args)](#OAuthMeteorModel+log) 73 | * [.getAccessToken()](#OAuthMeteorModel+getAccessToken) 74 | * [.createClient(title, homepage, description, privacyLink, redirectUris, grants, clientId, secret)](#OAuthMeteorModel+createClient) ⇒ Promise.<Object> 75 | * [.getClient()](#OAuthMeteorModel+getClient) 76 | * [.saveToken()](#OAuthMeteorModel+saveToken) 77 | * [.getAuthorizationCode()](#OAuthMeteorModel+getAuthorizationCode) ⇒ 78 | * [.saveAuthorizationCode(code, client, user)](#OAuthMeteorModel+saveAuthorizationCode) ⇒ Promise.<Object> 79 | * [.revokeAuthorizationCode()](#OAuthMeteorModel+revokeAuthorizationCode) 80 | * [.saveRefreshToken(token, clientId, expires, user)](#OAuthMeteorModel+saveRefreshToken) ⇒ Promise.<\*> 81 | * [.getRefreshToken()](#OAuthMeteorModel+getRefreshToken) 82 | * [.grantTypeAllowed(clientId, grantType)](#OAuthMeteorModel+grantTypeAllowed) ⇒ boolean 83 | * [.verifyScope(accessToken, scope)](#OAuthMeteorModel+verifyScope) ⇒ Promise.<boolean> 84 | * [.revokeToken()](#OAuthMeteorModel+revokeToken) 85 | 86 | 87 | 88 | ### oAuthMeteorModel.log(...args) 89 | Logs to console if debug is set to true 90 | 91 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 92 | 93 | | Param | Description | 94 | | --- | --- | 95 | | ...args | arbitrary list of params | 96 | 97 | 98 | 99 | ### oAuthMeteorModel.getAccessToken() 100 | getAccessToken(token) should return an object with: 101 | accessToken (String) 102 | accessTokenExpiresAt (Date) 103 | client (Object), containing at least an id property that matches the supplied client 104 | scope (optional String) 105 | user (Object) 106 | 107 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 108 | 109 | 110 | ### oAuthMeteorModel.createClient(title, homepage, description, privacyLink, redirectUris, grants, clientId, secret) ⇒ Promise.<Object> 111 | Registers a new client app in the {Clients} collection 112 | 113 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 114 | 115 | | Param | 116 | | --- | 117 | | title | 118 | | homepage | 119 | | description | 120 | | privacyLink | 121 | | redirectUris | 122 | | grants | 123 | | clientId | 124 | | secret | 125 | 126 | 127 | 128 | ### oAuthMeteorModel.getClient() 129 | getClient(clientId, clientSecret) should return an object with, at minimum: 130 | redirectUris (Array) 131 | grants (Array) 132 | 133 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 134 | 135 | 136 | ### oAuthMeteorModel.saveToken() 137 | saveToken(token, client, user) and should return: 138 | accessToken (String) 139 | accessTokenExpiresAt (Date) 140 | client (Object) 141 | refreshToken (optional String) 142 | refreshTokenExpiresAt (optional Date) 143 | user (Object) 144 | 145 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 146 | 147 | 148 | ### oAuthMeteorModel.getAuthorizationCode() ⇒ 149 | getAuthCode() was renamed to getAuthorizationCode(code) and should return: 150 | client (Object), containing at least an id property that matches the supplied client 151 | expiresAt (Date) 152 | redirectUri (optional String) 153 | 154 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 155 | **Returns**: An Object representing the authorization code and associated data. 156 | 157 | 158 | ### oAuthMeteorModel.saveAuthorizationCode(code, client, user) ⇒ Promise.<Object> 159 | should return an Object representing the authorization code and associated data. 160 | 161 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 162 | 163 | | Param | 164 | | --- | 165 | | code | 166 | | client | 167 | | user | 168 | 169 | 170 | 171 | ### oAuthMeteorModel.revokeAuthorizationCode() 172 | revokeAuthorizationCode(code) is required and should return true 173 | 174 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 175 | 176 | 177 | ### oAuthMeteorModel.saveRefreshToken(token, clientId, expires, user) ⇒ Promise.<\*> 178 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 179 | 180 | | Param | 181 | | --- | 182 | | token | 183 | | clientId | 184 | | expires | 185 | | user | 186 | 187 | 188 | 189 | ### oAuthMeteorModel.getRefreshToken() 190 | getRefreshToken(token) should return an object with: 191 | refreshToken (String) 192 | client (Object), containing at least an id property that matches the supplied client 193 | refreshTokenExpiresAt (optional Date) 194 | scope (optional String) 195 | user (Object) 196 | 197 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 198 | 199 | 200 | ### oAuthMeteorModel.grantTypeAllowed(clientId, grantType) ⇒ boolean 201 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 202 | 203 | | Param | 204 | | --- | 205 | | clientId | 206 | | grantType | 207 | 208 | 209 | 210 | ### oAuthMeteorModel.verifyScope(accessToken, scope) ⇒ Promise.<boolean> 211 | Compares expected scope from token with actual scope from request 212 | 213 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 214 | 215 | | Param | 216 | | --- | 217 | | accessToken | 218 | | scope | 219 | 220 | 221 | 222 | ### oAuthMeteorModel.revokeToken() 223 | revokeToken(refreshToken) is required and should return true 224 | 225 | **Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) 226 | 227 | 228 | ## OAuth2ServerDefaults : Object 229 | Default options, that are used to merge with the user 230 | defined options. 231 | 232 | **Kind**: global constant 233 | 234 | 235 | ## DefaultModelConfig : Object 236 | Default collection names for the model collections. 237 | 238 | **Kind**: global constant 239 | 240 | 241 | ## bind ⇒ function 242 | Binds a function to the Meteor environment and Fiber 243 | 244 | **Kind**: global constant 245 | **Returns**: function - the bound function 246 | 247 | | Param | Type | 248 | | --- | --- | 249 | | fn | function | 250 | 251 | 252 | 253 | ## createCollection ⇒ Mongo.Collection 254 | If the given collection is already created or cached, returns the collection 255 | or creates a new one. 256 | 257 | **Kind**: global constant 258 | 259 | | Param | Type | 260 | | --- | --- | 261 | | passedCollection | Mongo.Collection \| undefined | 262 | | collectionName | string | 263 | 264 | 265 | 266 | ## errorHandler 267 | Unifies error handling as http response. 268 | Defaults to a 500 response, unless further details were added. 269 | 270 | **Kind**: global constant 271 | 272 | | Param | Type | Description | 273 | | --- | --- | --- | 274 | | res | | | 275 | | options | Object | options with error information | 276 | | options.error | String | Error name | 277 | | options.logError | boolean | optional flag to log the erroe to the console | 278 | | options.description | String | Error description | 279 | | options.uri | String | Optional uri to redirect to when error occurs | 280 | | options.status | Number | Optional statuscode, defaults to 500 | 281 | | options.state | String | State object vor validation | 282 | | options.debug | Boolean \| undefined | State object vor validation | 283 | | options.originalError | Error \| undefined | original Error instance | 284 | 285 | 286 | 287 | ## isModelInterface ⇒ boolean 288 | Since we allow projects to implement their own model (while providing ours 289 | as drop-in) we still need to validate, whether they implement the model 290 | correctly. 291 | 292 | We duck-type check if the model implements the most important functions. 293 | Uses the following values to check: 294 | - 'getAuthorizationCode', 295 | - 'getClient', 296 | - 'getRefreshToken', 297 | - 'revokeAuthorizationCode', 298 | - 'saveAuthorizationCode', 299 | - 'saveRefreshToken', 300 | - 'saveToken', 301 | - 'getAccessToken' 302 | - 'revokeToken' 303 | 304 | **Kind**: global constant 305 | **Returns**: boolean - true if valid, otherwise false 306 | 307 | | Param | Type | Description | 308 | | --- | --- | --- | 309 | | model | Object | the model implementation | 310 | 311 | 312 | 313 | ## UserValidation 314 | Used to register handlers for different instances that validate users. 315 | This allows you to validate user access on a client-based level. 316 | 317 | **Kind**: global constant 318 | 319 | * [UserValidation](#UserValidation) 320 | * [.register(instance, validationHandler)](#UserValidation.register) 321 | * [.isValid(instance, handlerArgs)](#UserValidation.isValid) ⇒ \* 322 | 323 | 324 | 325 | ### UserValidation.register(instance, validationHandler) 326 | Registers a validation method that allows 327 | to validate users on custom logic. 328 | 329 | **Kind**: static method of [UserValidation](#UserValidation) 330 | 331 | | Param | Type | Description | 332 | | --- | --- | --- | 333 | | instance | [OAuth2Server](#OAuth2Server) | | 334 | | validationHandler | function | sync or async function that performs the validation | 335 | 336 | 337 | 338 | ### UserValidation.isValid(instance, handlerArgs) ⇒ \* 339 | Delegates `handlerArgs` to the registered validation handler. 340 | 341 | **Kind**: static method of [UserValidation](#UserValidation) 342 | **Returns**: \* - should return truthy/falsy value 343 | 344 | | Param | Type | 345 | | --- | --- | 346 | | instance | [OAuth2Server](#OAuth2Server) | 347 | | handlerArgs | \* | 348 | 349 | 350 | 351 | ## validateParams ⇒ boolean 352 | Abstraction that checks given query/body params against a given schema 353 | 354 | **Kind**: global constant 355 | 356 | | Param | 357 | | --- | 358 | | actualParams | 359 | | requiredParams | 360 | | debug | 361 | 362 | 363 | 364 | ## app : Object 365 | Wrapped `WebApp` with express-style get/post and default use routes. 366 | 367 | **Kind**: global constant 368 | **See**: https://docs.meteor.com/packages/webapp.html 369 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | into [at] jankuester [dot] com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | Thank you for your interest in this project and your aims to improving it. 4 | This guide will give you the most important info on how to contribute properly 5 | in order to get your pull requests accepted. 6 | 7 | ## Disclose security vulnerabilities 8 | 9 | First things first: 10 | This project has strong security implications and we appreciate every help to 11 | improve security. 12 | 13 | **However, please read our [security policy](./SECURITY.md), before taking 14 | actions.** 15 | 16 | 17 | 18 | ## Guiding principles 19 | 20 | Before contributing to this project it is important to understand how this 21 | project and it's collaborators views itself regarding it's scope and purpose. 22 | 23 | ### OAuth2 standard compliance 24 | 25 | This project aims full standard compliance. All improvements on functionality, 26 | as well as security implications, are done in a way that the standard remains 27 | as the highest reference of choice. 28 | 29 | If you are not familiar with the OAuth2 standards, please consult at least the 30 | following documents: 31 | 32 | - [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) 33 | - [RFC 8252 - OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252) 34 | 35 | Extended readings: 36 | 37 | - [RFC 6819 - OAuth 2.0 Threat Model and Security Considerations](https://datatracker.ietf.org/doc/html/rfc6819) 38 | - [RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636) 39 | - [RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) 40 | 41 | ### Meteor specific 42 | 43 | All contributions should be Meteor-specific but general enough to allow custom `accounts-*` implementations. 44 | 45 | ### Reference integration 46 | 47 | All contributions should use `accounts-lea` as reference integration. 48 | 49 | The repos are: 50 | 51 | - https://github.com/leaonline/meteor-accounts-lea 52 | - https://github.com/leaonline/meteor-accounts-oauth-lea 53 | - https://github.com/leaonline/leaonline-accounts 54 | 55 | ## Development 56 | 57 | If you want to fix bugs or add new features, **please read this chapter and it's 58 | sections carefully!** 59 | 60 | ### No PR without issue 61 | 62 | Please make sure your commitment will be appreciated by first opening an issue 63 | and discuss, whether this is a useful addition to the project. 64 | 65 | ### Work on a bug or a new feature 66 | 67 | First, clone and install this project from source via 68 | 69 | ```bash 70 | $ git clone git@github.com:leaonline/oauth2-server.git 71 | $ cd oauth2-server 72 | $ cd test-proxy 73 | $ meteor npm install 74 | $ meteor npm run setup # requred to link package to test-proxy project 75 | ``` 76 | 77 | From here you can run several scripts for development purposes: 78 | 79 | ```bash 80 | $ meteor cd test-proxy 81 | $ meteor npm run test # runs the tests once 82 | $ meteor npm run test:coverage # runs the tests including coverage 83 | $ meteor npm run lint # runs the linter 84 | $ meteor npm run build:docs # updates API.md 85 | ``` 86 | 87 | To work on a new feature or a fix please create a new branch: 88 | 89 | ```bash 90 | $ git checkout -b feature-xyz # or fix-xyz 91 | ``` 92 | 93 | ### Coding rules 94 | 95 | - Unit-testing: all features or bug fixes must be tested by specs 96 | - Documentation: all public API methods must be documented 97 | - StandardJs: linter mmuss pass 98 | 99 | ### Commit message convention 100 | 101 | We use a commit convention, inspired by [angular commit message format](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-format) 102 | with ticket number at the end of summary: 103 | 104 | ``` 105 | (): # 106 | ``` 107 | Summary in present tense. Not capitalized. No period at the end. 108 | The and fields are mandatory, the () and # field is optional. 109 | 110 | ### Run the tests before committing 111 | 112 | Please always make sure your code is passing linter and tests **before 113 | committing**. By doing so you help to make reviews much easier and don't pollute 114 | the history with commits, that are solely targeting lint fixes. 115 | 116 | You can run the tests via 117 | 118 | ```bash 119 | $ npm run test 120 | ``` 121 | 122 | or 123 | 124 | ```bash 125 | $ npm run test:coverage 126 | ``` 127 | 128 | to see your coverage. 129 | 130 | ### Open a pull request (PR) 131 | 132 | Once you have implemented your changes and tested them locally, please open 133 | a [pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). 134 | 135 | Note: sometimes a pull request (PR) is also referred to as merge request (MR). 136 | 137 | #### Fundamental PR requirements 138 | 139 | There are a few basic requirements for your pull request to become accepted: 140 | 141 | - Make sure to open your pull request to target the `development` branch and not 142 | `master` 143 | - Make sure you are working on a branch, other than `development`; usually you 144 | can name the branch after the feature or fix you want to provide 145 | - Resolve any merge conflicts (usually by keeping your branch updated with 146 | `development`) 147 | - Have a clear description on what the PR does, including any steps necessary 148 | for testing, reviewing, reproduction etc. 149 | - Link to the existing issue 150 | - Added functions or changed functions need to get documented in compliance with 151 | JSDoc 152 | - Make sure all CI Tests are passing 153 | 154 | Also make sure, to comply with the following list: 155 | 156 | - Do not work on `development` directly 157 | - Do not implement multiple features in one pull request (this includes bumping 158 | versions of dependencies that are not related to the PR/issue) 159 | - Do not bump the release version (unless you are a maintainer) 160 | - Do not edit the Changelog as this will be done after your PR is merged 161 | - Do not introduce tight dependencies to a certain package that has not been 162 | approved during the discussion in the issue 163 | 164 | #### Review process 165 | 166 | Finally your PR needs to pass the review process: 167 | 168 | - A certain amount of maintainers needs to review and accept your PR 169 | - Please **expect change requests**! They will occur and are intended to improve 170 | the overall code quality. 171 | - If your changes have been updated please re-assign the reviewer who asked for 172 | the changes 173 | - Once all reviewers have approved your PR it will be merged by one of the 174 | maintainers :tada: 175 | 176 | #### After merge 177 | 178 | Please delete your branch after merge. 179 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ### 6.0.0 4 | - Meteor 3 / Express compatibility 5 | - added scope verification in authenticated routes 6 | - improved internal logging 7 | - fix bug in validation for custom models 8 | - fix support for explicit `client.id` field 9 | 10 | ## 5.0.0 11 | - sync support for @node-oauth/oauth2-server 5.x by 12 | 13 | ## 4.2.1 14 | - this is a patch release, fixing a syntax error 15 | (that never got picked up, due to wrong linter config) 16 | 17 | ### Code fixes 18 | 19 | - fix(core): standard lint fixed 20 | - fix(core): oauth.js fix wrong syntax error in import 21 | 22 | ### Dev fixes 23 | 24 | - fix(ci): run npm setup before lint and test to link package 25 | - fix(build): remove .coverage from git 26 | (URLSearchParam missing s at the end) 27 | - fix(tests): test project linter settings fixed 28 | - fix(ci): remove dependencies from single job 29 | - update(ci): test workflow update to use test project 30 | 31 | 32 | ## 4.2.0 33 | - updated `@node-oauth/oauth2-server` to `4.2.0` 34 | - correctly setup coverage for test project and package 35 | - added documentation and generate docs via jsdoc2md (see [API.md](./API.md)) 36 | - fix(core): extracted initRoutes from OAuth2Server into standalone function to 37 | prevent re-init 38 | 39 | ## 4.1.0 40 | - this version has not been released publicly and superseded by `4.2.0` 41 | - removed `redirectUris` to find client (now searches only by clientId) in 42 | `createClient` 43 | - hardened check failsafety in `UserValidation` 44 | - hardened check against empty Strings in `requiredAccessTokenPostParams`, 45 | `requiredAuthorizeGetParams` and `requiredAuthorizePostParams` 46 | - updated `@node-oauth/oauth2-server` to `4.1.1` 47 | 48 | ## 4.0.0 49 | - use (actively maintained) @node-oauth/oauth2-server 50 | - improve console output readability 51 | - support Meteor versions `['1.6', '2.3']`; because 2.3 was a breaking release 52 | - update tests to use latest Accounts and jkuester:http 53 | - update follow redirect rules 54 | - maybe-breaking due to update to Accounts 2.x 55 | 56 | ## 3.3.0 57 | 58 | - updated `oauth2-server` to `4.0.0-dev.3` 59 | - removed dependency to `dburles:mongo-collection-instances` (fix dependeny 60 | issues with major accounts packages bumps) 61 | 62 | ## 3.2.1 63 | 64 | - bumped `oauth2-server` 65 | 66 | ## 3.1.0 - 2019/10/09 67 | 68 | - 69 | 70 | ## 3.0.0 - 2019/10/01 71 | 72 | **Summary** 73 | 74 | - migrated to latest node oauth2 server (v3) 75 | - wrap model in async Meteor environment 76 | - use builtin `WebApp` instead of `express` 77 | - built complete `authorization_code` workflow to make this usable with a custom `Accounts` package 78 | - all routes fallback with an error handler to return the respective OAuth error 79 | 80 | **Commits** 81 | 82 | - lint fix to comply standardjs 83 | - oauth server implemented token handler and authenticated (token-required) routes 84 | - model implemented token adapters 85 | - model simplified logging 86 | - model fixed getClient async access and logging behavior 87 | - oauth fixed references to this via self property 88 | - redirect after authorizazion success 89 | - implement authorization stack 90 | - fix saveAuthorizationCode 91 | - model simplified functions and removed unneccesary Promises 92 | - model saveAuthorizationCode conform with node2 server 3.x API 93 | - model updated debug logging format 94 | - reusable validation for client_id and redirect_url 95 | - extended validation in POST authUrl 96 | - extended error handler 97 | - cleanup imports 98 | - use facaded app that can be replaced if desired 99 | - add input validation utils 100 | - use get and post route for WebApp connect 101 | - model im proved console.log 102 | - improved error handling on auth route 103 | - model improve debug messages 104 | - introduced oauth app registration 105 | - fixed lint errors 106 | - model rewritten using promises 107 | - update README 108 | - removed express dependency and v3 compatibility 109 | - extended model config and v3 compatibility 110 | - decaffeinated project 111 | - added .npm to gitignore 112 | 113 | 114 | ## 2.1.0 - 2019/07/11 115 | 116 | - Support multiple `redirect_uri` #10 117 | 118 | ## 2.0.0 - 2016/01/08 119 | 120 | - Rename all athorizedClients to authorizedClients (please update your users DB too) 121 | - Allow `refresh_token` as a Grant Type 122 | - Transform any requests to `/oauth/token` that is `POST` and isn't `application/x-www-form-urlencoded`, merging the body and the query strings. See [pull request #5](https://github.com/RocketChat/rocketchat-oauth2-server/pull/5) for more details. 123 | 124 | ## 1.4.0 - 2016/01/08 125 | 126 | - Redirect user to `/oauth/error/404` instead of `/oauth/404` 127 | - Redirect user to `/oauth/error/invalid_redirect_uri` if uri does not match 128 | 129 | ## 1.3.0 - 2016/01/08 130 | 131 | - Redirect user to `/oauth/404` if client does not exists or is inactive 132 | 133 | ## 1.2.0 - 2016/01/07 134 | 135 | - Return only clients with `active: true` 136 | 137 | ## 1.1.1 - 2015/01/06 138 | 139 | - Only process errors for oauth routes 140 | 141 | ## 1.1.0 - 2015/01/05 142 | 143 | - Allow pass collection object instead collection name 144 | 145 | ## 1.0.1 - 2015/12/31 146 | 147 | - Added more debug logs 148 | 149 | ## 1.0.0 - 2015/12/31 150 | 151 | - Initial implementation 152 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rocket.Chat, 2019 Jan Küster 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meteor OAuth2 Server 2 | 3 | [![Test suite](https://github.com/leaonline/oauth2-server/actions/workflows/tests.yml/badge.svg)](https://github.com/leaonline/oauth2-server/actions/workflows/tests.yml) 4 | [![CodeQL](https://github.com/leaonline/oauth2-server/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/leaonline/oauth2-server/actions/workflows/codeql-analysis.yml) 5 | [![built with Meteor](https://img.shields.io/badge/Meteor-package-green?logo=meteor&logoColor=white)](https://atmospherejs.com/leaonline/oauth2-server) 6 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 7 | [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 8 | ![GitHub](https://img.shields.io/github/license/leaonline/oauth2-server) 9 | [![DOI](https://zenodo.org/badge/202368067.svg)](https://zenodo.org/doi/10.5281/zenodo.10817036) 10 | 11 | 12 | This package is a implementation of the package 13 | [@node-oauth/oauth2-server](https://github.com/node-oauth/node-oauth2-server) 14 | for Meteor. 15 | It can run without `express` (we use Meteor's builtin `WebApp`) and implements 16 | the `authorization_code` workflow and works like the Facebook's OAuth popup. 17 | 18 | ## Changelog 19 | 20 | View the full changelog in the [history page](./HISTORY.md). 21 | 22 | ## Install 23 | 24 | This package is a full scale drop-in, so you just need to add it via 25 | 26 | ```bash 27 | $ meteor add leaonline:oauth2-server 28 | ``` 29 | 30 | ## Implementation 31 | 32 | The package comes with a default config, so you can start immediately. 33 | However, we made it all configurable for you. 34 | 35 | You can change various flags, routes and expiration times and collection names. 36 | The following sections will show you how to setup the server with a full 37 | configuration. 38 | 39 | ### Server implementation 40 | 41 | The following example uses the full configuration. 42 | The used values represent the current default values. 43 | 44 | `server/oauth2server.js` 45 | ```javascript 46 | import { Meteor } from "meteor/meteor" 47 | import { OAuth2Server } from 'meteor/leaonline:oauth2-server' 48 | 49 | const oauth2server = new OAuth2Server({ 50 | serverOptions: { 51 | addAcceptedScopesHeader: true, 52 | addAuthorizedScopesHeader: true, 53 | allowBearerTokensInQueryString: false, 54 | allowEmptyState: false, 55 | authorizationCodeLifetime: 300, 56 | accessTokenLifetime: 3600, 57 | refreshTokenLifetime: 1209600, 58 | allowExtendedTokenAttributes: false, 59 | requireClientAuthentication: true 60 | }, 61 | model: { 62 | accessTokensCollectionName: 'oauth_access_tokens', 63 | refreshTokensCollectionName: 'oauth_refresh_tokens', 64 | clientsCollectionName: 'oauth_clients', 65 | authCodesCollectionName: 'oauth_auth_codes', 66 | debug: true 67 | }, 68 | routes: { 69 | accessTokenUrl: '/oauth/token', 70 | authorizeUrl: '/oauth/authorize', 71 | errorUrl: '/oauth/error', 72 | fallbackUrl: '/oauth/*' 73 | } 74 | }) 75 | 76 | // this is a "secret" route that is only accessed with 77 | // a valid token, which has been generated 78 | // by the authorization_code grant flow 79 | // You will have to implement it to allow your remote apps 80 | // to retrieve the user credentials after successful 81 | // authentication. 82 | oauth2server.authenticatedRoute().get('/oauth/ident', function (req, res, next) { 83 | const user = Meteor.users.findOne(req.data.user.id) 84 | 85 | res.writeHead(200, { 86 | 'Content-Type': 'application/json', 87 | }) 88 | const body = JSON.stringify({ 89 | id: user._id, 90 | login: user.username, 91 | email: user.emails[0].address, 92 | firstName: user.firstName, 93 | lastName: user.lastName, 94 | name: `${user.firstName} ${user.lastName}` 95 | }) 96 | res.end(body) 97 | }) 98 | 99 | // create some fallback for all undefined routes 100 | oauth2server.app.use('*', function (req, res, next) { 101 | res.writeHead(404) 102 | res.end('route not found') 103 | }) 104 | ``` 105 | 106 | ### Additional validation 107 | 108 | Often, you want to restrict who of your users can access which client / service. 109 | In order to decide to give permission or not, you can register a handler that 110 | receives the authenticated user and the client she aims to access: 111 | 112 | ```javascript 113 | oauth2server.validateUser(function({ user, client }) { 114 | // the following example uses alanning:roles to check, whether a user 115 | // has been assigned a role that indicates she can access the client. 116 | // It is up to you how you implement this logic. If all users can access 117 | // all registered clients, you can simply omit this call at all. 118 | const { clientId } = client 119 | const { _id } = user 120 | 121 | return Roles.userIsInRoles(_id, 'manage-app', clientId) 122 | }) 123 | ``` 124 | 125 | 126 | 127 | ### Client/Popup implementation 128 | 129 | You should install a router to handle client side routing independently 130 | from the WebApp routes. You can for example use: 131 | 132 | ```bash 133 | $ meteor add ostrio:flow-router-extra 134 | ``` 135 | 136 | and then define a client route for your popup dialog (we use Blaze in this example 137 | but it will work with any of your preferred and loved frontends): 138 | 139 | `client/main.html` 140 | ```javascript 141 | 142 | authserver 143 | 144 | 145 | 148 | ``` 149 | 150 | `client/main.js` 151 | ```javascript 152 | import { FlowRouter } from 'meteor/ostrio:flow-router-extra' 153 | import './authorize.html' 154 | import './authorize' 155 | import './main.html' 156 | 157 | // Define the route to render the popup view 158 | FlowRouter.route('/dialog/oauth', { 159 | action: function (params, queryParams) { 160 | this.render('layout', 'authorize', queryParams) 161 | } 162 | }) 163 | ``` 164 | 165 | `client/authorize.js` 166 | ```javascript 167 | // Subscribe the list of already authorized clients 168 | // to auto accept 169 | Template.authorize.onCreated(function() { 170 | this.subscribe('authorizedOAuth'); 171 | }); 172 | 173 | // Get the login token to pass to oauth 174 | // This is the best way to identify the logged user 175 | Template.authorize.helpers({ 176 | getToken: function() { 177 | return localStorage.getItem('Meteor.loginToken'); 178 | } 179 | }); 180 | 181 | // Auto click the submit/accept button if user already 182 | // accepted this client 183 | Template.authorize.onRendered(function() { 184 | var data = this.data; 185 | this.autorun(function(c) { 186 | var user = Meteor.user(); 187 | if (user && user.oauth && user.oauth.authorizedClients && user.oauth.authorizedClients.indexOf(data.client_id()) > -1) { 188 | c.stop(); 189 | $('button').click(); 190 | } 191 | }); 192 | }); 193 | ``` 194 | 195 | `client/authorize.html` 196 | ```html 197 | 215 | ``` 216 | 217 | `client/style.css` 218 | ```css 219 | .hidden { 220 | display: none; 221 | } 222 | ``` 223 | 224 | ## API and Documentation 225 | 226 | We also have an [API documentation](./API.md) with further info on the 227 | package internals. 228 | 229 | Furthermore we suggest you to consult the RFC docs on OAuth2: 230 | 231 | - [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749.html) 232 | - [RFC 6750 - The OAuth 2.0 Authorization Framework: Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750.html) 233 | 234 | ## Testing 235 | 236 | We use mocha with `meteortesting:mocha` to run the tests. 237 | We have now a full scale test project inside this one and you can use it 238 | extensively to lint and test this project. 239 | 240 | ### Setup 241 | 242 | The setup is already prepared, so you just need to run a few commands: 243 | 244 | ```bash 245 | $ cd test-proxy 246 | $ meteor npm install # install npm dependencies 247 | $ meteor npm run setup # link with package 248 | ``` 249 | 250 | ### Run the linter 251 | 252 | After the setup from the previous section you can run the linter via 253 | 254 | ```bash 255 | $ meteor npm run lint 256 | ``` 257 | 258 | or auto-fix code via 259 | 260 | ```bash 261 | $ meteor npm run lint:fix 262 | ``` 263 | 264 | Note, that we use `standardx`, which is `standard` code style with a few extra 265 | tweaks. We also use `eslint-plugin-security`, which can sometimes create lots 266 | of false-positives. If you need assistance, feel free to create an issue. 267 | 268 | ### Run the tests 269 | 270 | After the setup from the previous section you can run the tests via 271 | 272 | ```bash 273 | $ meteor npm run test 274 | ``` 275 | 276 | or in watch mode via 277 | 278 | ```bash 279 | $ meteor npm run test:watch 280 | ``` 281 | 282 | or with coverage report (+ watch mode) via 283 | 284 | ```bash 285 | $ meteor npm run test:coverage 286 | ``` 287 | 288 | ### Build the docs 289 | 290 | We use jsDoc and jsdoc2md to create a markdown file. To build the docs use 291 | 292 | ```bash 293 | $ meteor npm run build:docs 294 | ``` 295 | 296 | ## License 297 | 298 | MIT, see [license file](./LICENSE) 299 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 4.x | :white_check_mark: | 8 | | 3.x | :white_check_mark: but only very critical security issues | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Report security vulnerabilities to info [at] jankuester [dot] com 13 | 14 | Please specify exactly how the vulnerability is to be exploited so we can estimate how severe the consequences can be (unless you also can specify them, too). 15 | 16 | Please note that we need to reproduce the vulnerability (as like with bugs) in order to safely fix it. 17 | 18 | A fix will be implemented in private until we can ensure the vulnerability is closed. A new release will immediately be published. If you want to provide a fix please let us know in the e-mail so we can setup a completely private repository to work on it together. 19 | 20 | Finally, all security fixes will also require to pass all tests and audits. 21 | -------------------------------------------------------------------------------- /lib/defaults.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default options, that are used to merge with the user 3 | * defined options. 4 | * @type {{serverOptions: {addAcceptedScopesHeader: boolean, addAuthorizedScopesHeader: boolean, allowBearerTokensInQueryString: boolean, allowEmptyState: boolean, authorizationCodeLifetime: number, accessTokenLifetime: number, refreshTokenLifetime: number, allowExtendedTokenAttributes: boolean, requireClientAuthentication: boolean}, responseTypes: {}, model: {accessTokensCollectionName: string, refreshTokensCollectionName: string, clientsCollectionName: string, authCodesCollectionName: string, debug: boolean}, routes: {accessTokenUrl: string, authorizeUrl: string, errorUrl: string, fallbackUrl: string}}} 5 | */ 6 | export const OAuth2ServerDefaults = { 7 | serverOptions: { 8 | addAcceptedScopesHeader: true, 9 | addAuthorizedScopesHeader: true, 10 | allowBearerTokensInQueryString: false, 11 | allowEmptyState: false, 12 | authorizationCodeLifetime: 300, 13 | accessTokenLifetime: 3600, 14 | refreshTokenLifetime: 1209600, 15 | allowExtendedTokenAttributes: false, 16 | requireClientAuthentication: true 17 | }, 18 | responseTypes: { 19 | 20 | }, 21 | model: { 22 | accessTokensCollectionName: 'oauth_access_tokens', 23 | refreshTokensCollectionName: 'oauth_refresh_tokens', 24 | clientsCollectionName: 'oauth_clients', 25 | authCodesCollectionName: 'oauth_auth_codes', 26 | debug: false 27 | }, 28 | routes: { 29 | accessTokenUrl: '/oauth/token', 30 | authorizeUrl: '/oauth/authorize', 31 | errorUrl: '/oauth/error', 32 | fallbackUrl: '/oauth/*' 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/middleware/getDebugMiddleware.js: -------------------------------------------------------------------------------- 1 | import { debug } from '../utils/console' 2 | 3 | /** 4 | * Creates a middleware to debug routes on an instance level 5 | * @private 6 | * @param instance 7 | * @param options {object?} optional options 8 | * @param options.description {string?} optional way to descrive the next handler 9 | * @param options.data {boolean?} optional flag to log body/query 10 | */ 11 | export const getDebugMiddleWare = (instance, options = {}) => { 12 | if (!instance.debug) { 13 | return function (req, res, next) { next() } 14 | } 15 | 16 | return function (req, res, next) { 17 | const baseUrl = req.originalUrl.split('?')[0] 18 | let message = `${req.method} ${baseUrl}` 19 | 20 | if (options.description) { 21 | message = `${message} (${options.description})` 22 | } 23 | 24 | if (options.data) { 25 | const data = { query: req.query, body: req.body } 26 | message = `${message} data: ${data}` 27 | } 28 | 29 | debug(message) 30 | next() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/middleware/secureHandler.js: -------------------------------------------------------------------------------- 1 | import { errorHandler } from '../utils/error' 2 | import { bind } from '../utils/bind' 3 | 4 | /** 5 | * Creates a failsafe route handler to prevent server-halting errors 6 | * @private 7 | * @param self 8 | * @param handler 9 | * @return {Function} 10 | */ 11 | export const secureHandler = (self, handler) => bind(async function (req, res, next) { 12 | const that = this 13 | try { 14 | return handler.call(that, req, res, next) 15 | } catch (anyError) { 16 | // to avoid server-crashes we wrap all request handlers and 17 | // catch the error here, creating a default 500 response 18 | const state = req && req.query && req.query.state 19 | errorHandler(res, { 20 | error: 'server_error', 21 | status: 500, 22 | description: 'An internal server error occurred', 23 | state, 24 | debug: self.debug, 25 | originalError: anyError 26 | }) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /lib/model/DefaultModelConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default collection names for the model collections. 3 | * @type {{accessTokensCollectionName: string, refreshTokensCollectionName: string, clientsCollectionName: string, authCodesCollectionName: string, debug: boolean}} 4 | */ 5 | export const DefaultModelConfig = { 6 | accessTokensCollectionName: 'oauth_access_tokens', 7 | refreshTokensCollectionName: 'oauth_refresh_tokens', 8 | clientsCollectionName: 'oauth_clients', 9 | authCodesCollectionName: 'oauth_auth_codes', 10 | debug: false 11 | } 12 | -------------------------------------------------------------------------------- /lib/model/meteor-model.js: -------------------------------------------------------------------------------- 1 | import { Random } from 'meteor/random' 2 | 3 | export const collections = { 4 | /** @type {Mongo.Collection} */ 5 | AccessTokens: undefined, 6 | /** @type {Mongo.Collection} */ 7 | RefreshTokens: undefined, 8 | /** @type {Mongo.Collection} */ 9 | Clients: undefined, 10 | /** @type {Mongo.Collection} */ 11 | AuthCodes: undefined 12 | } 13 | 14 | /** 15 | * used by OAuthMeteorModel.prototype.getAccessToken 16 | * @private 17 | */ 18 | 19 | export const getAccessToken = async (accessToken) => { 20 | return collections.AccessTokens.findOneAsync({ accessToken }) 21 | } 22 | 23 | /** 24 | * used by OAuthMeteorModel.prototype.createClient 25 | * @private 26 | */ 27 | 28 | export const createClient = async ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) => { 29 | const existingClient = await collections.Clients.findOneAsync({ title }) 30 | 31 | if (existingClient) { 32 | const updateValues = { description, privacyLink, redirectUris, grants } 33 | if (clientId) updateValues.clientId = clientId 34 | if (secret) updateValues.secret = secret 35 | return collections.Clients.updateAsync(existingClient._id, { 36 | $set: updateValues 37 | }) 38 | } 39 | const id = clientId ?? Random.id(16) 40 | const clientDocId = await collections.Clients.insertAsync({ 41 | title, 42 | homepage, 43 | description, 44 | privacyLink, 45 | redirectUris, 46 | clientId: id, 47 | id, // required by oauth-2-server which 48 | secret: secret || Random.id(32), 49 | grants 50 | }) 51 | return collections.Clients.findOneAsync(clientDocId) 52 | } 53 | 54 | /** 55 | * used by OAuthMeteorModel.prototype.getClient 56 | * @private 57 | */ 58 | 59 | export const getClient = async (clientId, secret) => { 60 | const clientDoc = await collections.Clients.findOneAsync({ 61 | clientId, 62 | secret: secret || undefined // secret can be undefined or null but should act as the same 63 | }) 64 | return clientDoc || false 65 | } 66 | 67 | /** 68 | * used by OAuthMeteorModel.prototype.saveToken 69 | * @private 70 | */ 71 | 72 | export const saveToken = async (tokenDoc, clientDoc, userDoc) => { 73 | const tokenDocId = await collections.AccessTokens.insertAsync({ 74 | accessToken: tokenDoc.accessToken, 75 | accessTokenExpiresAt: tokenDoc.accessTokenExpiresAt, 76 | refreshToken: tokenDoc.refreshToken, 77 | refreshTokenExpiresAt: tokenDoc.refreshTokenExpiresAt, 78 | scope: tokenDoc.scope, 79 | client: { 80 | id: clientDoc.clientId 81 | }, 82 | user: { 83 | id: userDoc.id 84 | } 85 | }) 86 | return collections.AccessTokens.findOneAsync(tokenDocId) 87 | } 88 | 89 | /** 90 | * used by OAuthMeteorModel.prototype.getAuthorizationCode 91 | * @private 92 | */ 93 | 94 | export const getAuthorizationCode = async (authorizationCode) => { 95 | return collections.AuthCodes.findOneAsync({ authorizationCode }) 96 | } 97 | 98 | /** 99 | * used by OAuthMeteorModel.prototype.saveAuthorizationCode 100 | * @private 101 | */ 102 | 103 | export const saveAuthorizationCode = async (code, client, user) => { 104 | const { authorizationCode } = code 105 | const { expiresAt } = code 106 | const { redirectUri } = code 107 | 108 | await collections.AuthCodes.upsertAsync({ authorizationCode }, { 109 | authorizationCode, 110 | expiresAt, 111 | redirectUri, 112 | scope: code.scope, 113 | client: { 114 | // xxx: fix for newer oauth2-server versions 115 | id: client.id ?? client.clientId 116 | }, 117 | user: { 118 | id: user.id 119 | } 120 | }) 121 | return collections.AuthCodes.findOneAsync({ authorizationCode }) 122 | } 123 | 124 | /** 125 | * used by OAuthMeteorModel.prototype.revokeAuthorizationCode 126 | * @private 127 | */ 128 | 129 | export const revokeAuthorizationCode = async ({ authorizationCode }) => { 130 | const docCount = await collections.AuthCodes.countDocuments({ authorizationCode }) 131 | if (docCount === 0) { 132 | return true 133 | } 134 | const removeCount = await collections.AuthCodes.removeAsync({ authorizationCode }) 135 | return removeCount === docCount 136 | } 137 | 138 | /** 139 | * used by OAuthMeteorModel.prototype.saveRefreshToken 140 | * @private 141 | */ 142 | 143 | export const saveRefreshToken = async (token, clientId, expires, user) => { 144 | return collections.RefreshTokens.insertAsync({ 145 | refreshToken: token, 146 | clientId, 147 | userId: user.id, 148 | expires 149 | }) 150 | } 151 | 152 | /** 153 | * used by OAuthMeteorModel.prototype.getRefreshToken 154 | * @private 155 | */ 156 | export const getRefreshToken = async (refreshToken) => { 157 | return collections.RefreshTokens.findOneAsync({ refreshToken }) 158 | } 159 | 160 | export const revokeToken = async (token) => { 161 | const result = await collections.AccessTokens.removeAsync({ refreshToken: token.refreshToken }) 162 | return !!result 163 | } 164 | -------------------------------------------------------------------------------- /lib/model/model.js: -------------------------------------------------------------------------------- 1 | import { DefaultModelConfig } from './DefaultModelConfig' 2 | import { createCollection } from '../utils/createCollection' 3 | import { 4 | collections, 5 | createClient, 6 | getAuthorizationCode, 7 | getClient, 8 | getRefreshToken, 9 | revokeAuthorizationCode, 10 | saveAuthorizationCode, 11 | saveRefreshToken, 12 | saveToken, 13 | getAccessToken, 14 | revokeToken 15 | } from './meteor-model' 16 | 17 | /** 18 | * Implements the OAuth2Server model with Meteor-Mongo bindings. 19 | */ 20 | class OAuthMeteorModel { 21 | constructor (config = {}) { 22 | const modelConfig = { ...DefaultModelConfig, ...config } 23 | this.debug = modelConfig.debug 24 | collections.AccessTokens = createCollection(modelConfig.accessTokensCollection, modelConfig.accessTokensCollectionName) 25 | collections.RefreshTokens = createCollection(modelConfig.refreshTokensCollection, modelConfig.refreshTokensCollectionName) 26 | collections.AuthCodes = createCollection(modelConfig.authCodesCollection, modelConfig.authCodesCollectionName) 27 | collections.Clients = createCollection(modelConfig.clientsCollection, modelConfig.clientsCollectionName) 28 | } 29 | 30 | /** 31 | * Logs to console if debug is set to true 32 | * @param args arbitrary list of params 33 | */ 34 | 35 | log (...args) { 36 | if (this.debug === true) { 37 | console.debug('[OAuth2Server][model]:', ...args) 38 | } 39 | } 40 | 41 | /** 42 | getAccessToken(token) should return an object with: 43 | accessToken (String) 44 | accessTokenExpiresAt (Date) 45 | client (Object), containing at least an id property that matches the supplied client 46 | scope (optional String) 47 | user (Object) 48 | */ 49 | async getAccessToken (bearerToken) { 50 | this.log(`getAccessToken (bearerToken: '${bearerToken}')`) 51 | return getAccessToken(bearerToken) 52 | } 53 | 54 | /** 55 | * Registers a new client app in the {Clients} collection 56 | * @param title 57 | * @param homepage 58 | * @param description 59 | * @param privacyLink 60 | * @param redirectUris 61 | * @param grants 62 | * @param clientId 63 | * @param secret 64 | * @return {Promise} 65 | */ 66 | 67 | async createClient ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) { 68 | this.log(`createClient (${redirectUris})`) 69 | return createClient({ 70 | id: clientId, // xxx: fix for newer oauth2-server versions that explicitly check for .id presence 71 | title, 72 | homepage, 73 | description, 74 | privacyLink, 75 | redirectUris, 76 | grants, 77 | clientId, 78 | secret 79 | }) 80 | } 81 | 82 | /** 83 | getClient(clientId, clientSecret) should return an object with, at minimum: 84 | redirectUris (Array) 85 | grants (Array) 86 | */ 87 | async getClient (clientId, secret) { 88 | this.log(`getClient (clientId: ${clientId}) (secret: ${secret})`) 89 | const clientDoc = await getClient(clientId, secret) 90 | if (!clientDoc) return clientDoc 91 | 92 | // xxx: fixes compatibility with newer versions of oauth2-server 93 | // which checks for the client.id value, instead of client.clientId 94 | if (!clientDoc.id) { 95 | clientDoc.id = clientDoc.clientId 96 | } 97 | 98 | return clientDoc 99 | } 100 | 101 | /** 102 | saveToken(token, client, user) and should return: 103 | accessToken (String) 104 | accessTokenExpiresAt (Date) 105 | client (Object) 106 | refreshToken (optional String) 107 | refreshTokenExpiresAt (optional Date) 108 | user (Object) 109 | */ 110 | async saveToken (tokenDoc, clientDoc, userDoc) { 111 | this.log('saveAccessToken:') 112 | this.log('with token ', tokenDoc) 113 | this.log('with client ', clientDoc) 114 | this.log('with user ', userDoc) 115 | return saveToken(tokenDoc, clientDoc, userDoc) 116 | } 117 | 118 | /** 119 | getAuthCode() was renamed to getAuthorizationCode(code) and should return: 120 | client (Object), containing at least an id property that matches the supplied client 121 | expiresAt (Date) 122 | redirectUri (optional String) 123 | @returns An Object representing the authorization code and associated data. 124 | */ 125 | async getAuthorizationCode (authorizationCode) { 126 | this.log('MODEL getAuthorizationCode (authCode: ' + authorizationCode + ')') 127 | return getAuthorizationCode(authorizationCode) 128 | } 129 | 130 | /** 131 | * should return an Object representing the authorization code and associated data. 132 | * @param code 133 | * @param client 134 | * @param user 135 | * @returns {Promise} 136 | */ 137 | async saveAuthorizationCode (code, client, user) { 138 | this.log('saveAuthorizationCode (code:', code, 'client: ', client, 'user: ', user, ')') 139 | return saveAuthorizationCode(code, client, user) 140 | } 141 | 142 | /** 143 | * revokeAuthorizationCode(code) is required and should return true 144 | */ 145 | async revokeAuthorizationCode (code) { 146 | this.log(`revokeAuthorizationCode (code: ${code})`) 147 | return revokeAuthorizationCode(code) 148 | } 149 | 150 | /** 151 | * 152 | * @param token 153 | * @param clientId 154 | * @param expires 155 | * @param user 156 | * @return {Promise<*>} 157 | */ 158 | async saveRefreshToken (token, clientId, expires, user) { 159 | this.log('saveRefreshToken (token:', token, ', clientId:', clientId, ', user:', user, ', expires:', expires, ')') 160 | return saveRefreshToken(token, clientId, expires, user) 161 | } 162 | 163 | /** 164 | getRefreshToken(token) should return an object with: 165 | refreshToken (String) 166 | client (Object), containing at least an id property that matches the supplied client 167 | refreshTokenExpiresAt (optional Date) 168 | scope (optional String) 169 | user (Object) 170 | */ 171 | async getRefreshToken (refreshToken) { 172 | this.log('getRefreshToken (refreshToken: ' + refreshToken + ')') 173 | return getRefreshToken(refreshToken) 174 | } 175 | 176 | /** 177 | * 178 | * @param clientId 179 | * @param grantType 180 | * @return {boolean} 181 | */ 182 | async grantTypeAllowed (clientId, grantType) { 183 | this.log('grantTypeAllowed (clientId:', clientId, ', grantType:', grantType + ')') 184 | return ['authorization_code', 'refresh_token'].includes(grantType) 185 | } 186 | 187 | /** 188 | * Compares expected scope from token with actual scope from request 189 | * @param accessToken 190 | * @param scope 191 | * @return {Promise} 192 | */ 193 | async verifyScope (accessToken, scope) { 194 | return accessToken.scope.sort().join(',') === scope.sort().join(',') 195 | } 196 | 197 | /** 198 | * revokeToken(refreshToken) is required and should return true 199 | */ 200 | async revokeToken (refreshToken) { 201 | this.log(`revokeToken (refreshToken: ${refreshToken})`) 202 | return revokeToken(refreshToken) 203 | } 204 | } 205 | 206 | export { OAuthMeteorModel } 207 | -------------------------------------------------------------------------------- /lib/oauth.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { Meteor } from 'meteor/meteor' 3 | import { check } from 'meteor/check' 4 | import { Accounts } from 'meteor/accounts-base' 5 | import * as Log from './utils/console' 6 | 7 | // utils 8 | import { getDebugMiddleWare } from './middleware/getDebugMiddleware' 9 | 10 | // model 11 | import { OAuthMeteorModel } from './model/model' 12 | import { isModelInterface } from './utils/isModelInterface' 13 | import { OAuth2ServerDefaults } from './defaults' 14 | 15 | // validation 16 | import { validateParams } from './validation/validateParams' 17 | import { requiredAuthorizeGetParams } from './validation/requiredAuthorizeGetParams' 18 | import { requiredAuthorizePostParams } from './validation/requiredAuthorizePostParams' 19 | import { requiredAccessTokenPostParams } from './validation/requiredAccessTokenPostParams' 20 | import { requiredRefreshTokenPostParams } from './validation/requiredRefreshTokenPostParams' 21 | import { UserValidation } from './validation/UserValidation' 22 | import { OptionsSchema } from './validation/OptionsSchema' 23 | 24 | // webapp / http 25 | import { app } from './webapp' 26 | import { errorHandler } from './utils/error' 27 | import { Random } from 'meteor/random' 28 | import { URLSearchParams } from 'url' 29 | import { secureHandler } from './middleware/secureHandler' 30 | 31 | // oauth 32 | import OAuthserver from '@node-oauth/oauth2-server' 33 | 34 | const { Request, Response } = OAuthserver 35 | 36 | /** 37 | * Publishes all authorized clients for the current authenticated user. 38 | * Does not touch any other user-related fields. 39 | * Allows to inject a custom publication-name. 40 | * @param pubName {string} 41 | * @return {function():Mongo.Cursor} 42 | * @private 43 | */ 44 | const publishAuthorizedClients = (pubName, debug) => { 45 | if (debug) { 46 | Log.debug('publish authorized clients as', pubName) 47 | } 48 | return Meteor.publish(pubName, function () { 49 | if (!this.userId) { 50 | return this.ready() 51 | } 52 | return Meteor.users.find({ _id: this.userId }, { fields: { 'oauth.authorizedClients': 1 } }) 53 | }) 54 | } 55 | 56 | /** 57 | * The base class of this package. 58 | * Represents an oauth2-server with a default model setup for Meteor/Mongo. 59 | */ 60 | export class OAuth2Server { 61 | /** 62 | * Creates a new OAuth2 server instance. 63 | * @param serverOptions 64 | * @param model 65 | * @param routes 66 | * @param debug 67 | * @param logError 68 | * @return {OAuth2Server} 69 | */ 70 | constructor ({ serverOptions = {}, model, routes, debug, logError } = {}) { 71 | check(serverOptions, OptionsSchema.serverOptions) 72 | if (debug) { 73 | Log.debug('create new instance') 74 | Log.debug('serveroptions', serverOptions) 75 | } 76 | this.instanceId = Random.id() 77 | this.config = { 78 | serverOptions: Object.assign({}, OAuth2ServerDefaults.serverOptions, serverOptions), 79 | routes: Object.assign({}, OAuth2ServerDefaults.routes, routes) 80 | } 81 | 82 | if (isModelInterface(model)) { 83 | // if we have passed our own model instance we directly assign it as model, 84 | this.config.model = null 85 | this.model = model 86 | } else { 87 | // otherwise we save the config and instantiate our default model 88 | this.config.model = Object.assign({}, OAuth2ServerDefaults.model, model) 89 | this.model = new OAuthMeteorModel(this.config.model) 90 | } 91 | 92 | this.app = app 93 | this.debug = debug 94 | this.logError = logError 95 | 96 | const oauthOptions = Object.assign({ model: this.model }, serverOptions) 97 | this.oauth = new OAuthserver(oauthOptions) 98 | 99 | const authorizedPubName = (serverOptions && serverOptions.authorizedPublicationName) || 'authorizedOAuth' 100 | publishAuthorizedClients(authorizedPubName, this.debug) 101 | initRoutes(this, routes) 102 | return this 103 | } 104 | 105 | /** 106 | * Registers a function to validate a user. The handler receives the current 107 | * authenticated user and client and should return a truthy or falsy value to 108 | * determine, whether the given user is valid for a given client. 109 | * Notation: `{ user, client } => Boolean` 110 | * @param fct {function} 111 | */ 112 | validateUser (fct) { 113 | check(fct, Function) 114 | const self = this 115 | UserValidation.register(self, fct) 116 | } 117 | 118 | /** 119 | * Registers a new client app. Make sure that only users with permission 120 | * (ie devs, admins) can call this function. 121 | * @param title 122 | * @param homepage 123 | * @param description 124 | * @param privacyLink 125 | * @param redirectUris 126 | * @param grants 127 | * @param clientId 128 | * @param secret 129 | * @returns {object} 130 | */ 131 | async registerClient ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) { 132 | return this.model.createClient({ 133 | title, 134 | homepage, 135 | description, 136 | privacyLink, 137 | redirectUris, 138 | grants, 139 | clientId, 140 | secret 141 | }) 142 | } 143 | 144 | /** 145 | * @private 146 | */ 147 | authorizeHandler (options) { 148 | const self = this 149 | return async function (req, res, next) { 150 | const request = new Request(req) 151 | const response = new Response(res) 152 | 153 | try { 154 | const code = await self.oauth.authorize(request, response, options) 155 | Log.debug('authorization code', code) 156 | res.locals.oauth = { code: code } 157 | next() 158 | } catch (err) { 159 | res.status(500).json(err) 160 | } 161 | } 162 | } 163 | 164 | /** 165 | * @private 166 | */ 167 | authenticateHandler (options) { 168 | const self = this 169 | return async function (req, res, next) { 170 | const request = new Request(req) 171 | const response = new Response(res) 172 | 173 | try { 174 | const token = await self.oauth.authenticate(request, response, options) 175 | req.data = Object.assign({}, req.data, token) 176 | next() 177 | } catch (err) { 178 | return errorHandler(res, { 179 | status: err.status, 180 | error: err.name, 181 | description: err.message, 182 | debug: self.debug, 183 | logError: self.logError 184 | }) 185 | } 186 | } 187 | } 188 | 189 | /** 190 | * Allows to create `get` or `post` routes, that are only 191 | * accessible to authenticated users. 192 | * @param options {object?} optional options 193 | * @param options.scope {string} optional scope to check. Model must implement {verifyScope} if used! 194 | * @return {{get:function, post:function}} 195 | */ 196 | authenticatedRoute (options = {}) { 197 | const self = this 198 | let authOptions 199 | if (options.scope) { 200 | authOptions = { 201 | addAcceptedScopesHeader: true, 202 | addAuthorizedScopesHeader: true, 203 | scope: options.scope 204 | } 205 | } 206 | const authHandler = self.authenticateHandler(authOptions) 207 | return { 208 | get (route, fn) { 209 | app.get(route, authHandler, secureHandler(self, fn)) 210 | }, 211 | post (route, fn) { 212 | app.post(route, authHandler, secureHandler(self, fn)) 213 | } 214 | } 215 | } 216 | } 217 | 218 | const initRoutes = (self, { 219 | accessTokenUrl = '/oauth/token', 220 | authorizeUrl = '/oauth/authorize' 221 | } = {}) => { 222 | const validateResponseType = (req, res) => { 223 | const responseType = req.method.toLowerCase() === 'get' 224 | ? req.query.response_type 225 | : req.body.response_type 226 | if (responseType !== 'code' && responseType !== 'token') { 227 | return errorHandler(res, { 228 | status: 415, 229 | error: 'unsupported_response_type', 230 | description: 'The response type is not supported by the authorization server.', 231 | state: req.query.state, 232 | debug: self.debug 233 | }) 234 | } 235 | return true 236 | } 237 | 238 | const getValidatedClient = async (req, res) => { 239 | const clientId = req.method.toLowerCase() === 'get' ? req.query.client_id : req.body.client_id 240 | const secret = req.method.toLowerCase() === 'get' ? req.query.client_secret : req.body.client_secret 241 | const client = await self.model.getClient(clientId, secret) 242 | 243 | if (!client) { 244 | // unauthorized_client - The client is not authorized to request an authorization code using this method. 245 | return errorHandler(res, { 246 | status: 401, 247 | error: 'unauthorized_client', 248 | description: 'This client is not authorized to use this service', 249 | state: req.query.state, 250 | debug: self.debug 251 | }) 252 | } 253 | 254 | return client 255 | } 256 | 257 | const getValidatedRedirectUri = (req, res, client) => { 258 | const redirectUris = [].concat(client.redirectUris) 259 | const redirectUri = req.method.toLowerCase() === 'get' ? req.query.redirect_uri : req.body.redirect_uri 260 | if (redirectUris.indexOf(redirectUri) === -1) { 261 | return errorHandler(res, { 262 | error: 'invalid_request', 263 | description: `Invalid redirection uri ${redirectUri}`, 264 | state: req.query.state, 265 | debug: self.debug, 266 | status: 400 267 | }) 268 | } 269 | return redirectUri 270 | } 271 | 272 | const route = ({ method, url, description, handler }) => { 273 | const wrapper = async function (req, res, next) { 274 | const that = this 275 | try { 276 | return handler.call(that, req, res, next) 277 | } catch (unknownException) { 278 | const state = req && req.query && req.query.state 279 | errorHandler(res, { 280 | error: 'server_error', 281 | status: 500, 282 | description: 'An internal server error occurred', 283 | state, 284 | debug: self.debug, 285 | originalError: unknownException 286 | }) 287 | } 288 | } 289 | 290 | const handlers = [] 291 | 292 | if (self.debug) { 293 | const debugMiddleware = getDebugMiddleWare(self, { description }) 294 | handlers.push(debugMiddleware) 295 | Log.debug('Create route', method, url) 296 | } 297 | 298 | handlers.push(wrapper) 299 | 300 | switch (method) { 301 | case 'get': return app.get(url, ...handlers) 302 | case 'post': return app.post(url, ...handlers) 303 | default: return app.use(url, ...handlers) 304 | } 305 | } 306 | 307 | // STEP 1: VALIDATE CLIENT REQUEST 308 | // Note from https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/ 309 | // If there is something wrong with the syntax of the request, such as the redirect_uri or client_id is invalid, 310 | // then it’s important not to redirect the user and instead you should show the error message directly. 311 | // This is to avoid letting your authorization server be used as an open redirector. 312 | route({ 313 | method: 'get', 314 | url: authorizeUrl, 315 | description: 'step 1 - validate initial request', 316 | handler: async function (req, res, next) { 317 | if (!validateParams(req.query, requiredAuthorizeGetParams, self.debug)) { 318 | return errorHandler(res, { 319 | status: 400, 320 | error: 'invalid_request', 321 | description: 'One or more request parameters are invalid', 322 | state: req.query.state, 323 | debug: self.debug 324 | }) 325 | } 326 | 327 | const validResponseType = validateResponseType(req, res) 328 | if (!validResponseType) return res.end() 329 | 330 | const client = await getValidatedClient(req, res) 331 | if (!client) return res.end() 332 | 333 | const redirectUri = getValidatedRedirectUri(req, res, client) 334 | if (!redirectUri) return res.end() 335 | 336 | next() 337 | } 338 | }) 339 | 340 | // STEP 2: ADD USER TO THE REQUEST 341 | // validate all inputs again, since all inputs 342 | // could have been manipulated within the form 343 | route({ 344 | method: 'post', 345 | url: authorizeUrl, 346 | description: 'step 2 - add user to request', 347 | handler: async function (req, res, next) { 348 | if (!validateParams(req.body, requiredAuthorizePostParams, self.debug)) { 349 | return errorHandler(res, { 350 | error: 'invalid_request', 351 | description: 'One or more request parameters are invalid', 352 | state: req.body.state, 353 | debug: self.debug, 354 | status: 400 355 | }) 356 | } 357 | 358 | const client = await getValidatedClient(req, res) 359 | if (!client) return 360 | 361 | const validRedirectUri = getValidatedRedirectUri(req, res, client) 362 | if (!validRedirectUri) return 363 | 364 | // token refers here to the Meteor.loginToken, 365 | // which is assigned, once the user has been validly logged-in 366 | // only valid tokens can be used to find a user 367 | // in the Meteor.users collection 368 | const user = await Meteor.users.findOneAsync({ 369 | 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(req.body.token) 370 | }) 371 | 372 | // we fail already here if no user has been found 373 | // since the oauth-node sever would repsond with a 374 | // 503 error, while it should be a 400 375 | const validateUserCredentials = { user, client } 376 | 377 | if (!user || !(await UserValidation.isValid(self, validateUserCredentials))) { 378 | return errorHandler(res, { 379 | status: 400, 380 | error: 'access_denied', 381 | description: 'You are no valid user', 382 | state: req.body.state, 383 | debug: self.debug 384 | }) 385 | } 386 | 387 | const id = user._id 388 | req.user = { id } // TODO add fields from scope 389 | 390 | const updateDoc = req.body.allowed === 'false' 391 | ? { $pull: { 'oauth.authorizedClients': client.clientId } } 392 | : { $addToSet: { 'oauth.authorizedClients': client.clientId } } 393 | 394 | await Meteor.users.updateAsync(id, updateDoc) 395 | 396 | // make this work on a post route 397 | req.query.allowed = req.body.allowed 398 | 399 | return next() 400 | } 401 | }) 402 | 403 | // STEP 3: GENERATE AUTHORIZATION CODE RESPONSE 404 | // - use the user form the prior middleware for the authentication handler 405 | // - on allow, assign the client_id to the user's authorized clients 406 | // - on deny, ...? 407 | // - construct the redirect query and redirect to the redirect_uri 408 | route({ 409 | method: 'post', 410 | url: authorizeUrl, 411 | description: 'step 3 - authorization code response', 412 | handler: async function (req, res /*, next */) { 413 | const request = new Request(req) 414 | const response = new Response(res) 415 | const authorizeOptions = { 416 | authenticateHandler: { 417 | handle: function (request, response) { 418 | return request.user 419 | } 420 | } 421 | } 422 | 423 | try { 424 | const code = await self.oauth.authorize(request, response, authorizeOptions) 425 | const query = new URLSearchParams({ 426 | code: code.authorizationCode, 427 | user: req.user.id, 428 | state: req.body.state 429 | }) 430 | 431 | const finalRedirectUri = `${req.body.redirect_uri}?${query}` 432 | res.redirect(302, finalRedirectUri) 433 | } catch (err) { 434 | errorHandler(res, { 435 | originalError: err, 436 | error: err.name, 437 | description: err.message, 438 | status: err.statusCode, 439 | state: req.body.state, 440 | debug: self.debug 441 | }) 442 | } 443 | } 444 | }) 445 | 446 | // STEP 4: GENERATE ACCESS TOKEN RESPONSE 447 | // - validate params 448 | // - validate authorization code 449 | // - issue accessToken and refreshToken 450 | route({ 451 | method: 'post', 452 | url: accessTokenUrl, 453 | description: 'step 4 - generate access token response', 454 | handler: async function (req, res /*, next */) { 455 | if (!validateParams(req.body, req.body?.refresh_token ? requiredRefreshTokenPostParams : requiredAccessTokenPostParams, self.debug)) { 456 | return errorHandler(res, { 457 | status: 400, 458 | error: 'invalid_request', 459 | description: 'One or more request parameters are invalid', 460 | state: req.body.state, 461 | debug: self.debug 462 | }) 463 | } 464 | 465 | // XXX: conformity for the token endpoint 466 | req.headers['Content-Type'] = 'application/x-www-form-urlencoded' 467 | 468 | const request = new Request(req) 469 | const response = new Response(res) 470 | 471 | try { 472 | const token = await self.oauth.token(request, response) 473 | res 474 | .set({ 475 | 'Content-Type': 'application/json', 476 | 'Cache-Control': 'no-store', 477 | Pragma: 'no-cache' 478 | }) 479 | .status(200) 480 | .json({ 481 | access_token: token.accessToken, 482 | token_type: 'bearer', 483 | expires_in: token.accessTokenExpiresAt, 484 | refresh_token: token.refreshToken 485 | }) 486 | } catch (err) { 487 | return errorHandler(res, { 488 | error: 'unauthorized_client', 489 | description: err.message, 490 | state: req.body.state, 491 | debug: self.debug, 492 | status: err.statusCode 493 | }) 494 | } 495 | } 496 | }) 497 | } 498 | -------------------------------------------------------------------------------- /lib/utils/bind.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor' 2 | 3 | /** 4 | * Binds a function to the Meteor environment and Fiber 5 | * @param fn {function} 6 | * @return {function} the bound function 7 | */ 8 | export const bind = fn => Meteor.bindEnvironment(fn) 9 | -------------------------------------------------------------------------------- /lib/utils/console.js: -------------------------------------------------------------------------------- 1 | const name = '[OAuth2Server]' 2 | 3 | const getHandler = type => { 4 | const target = console[type] 5 | return (...args) => target(name, ...args) 6 | } 7 | 8 | const log = getHandler('log') 9 | const info = getHandler('info') 10 | const debug = getHandler('debug') 11 | const warn = getHandler('warn') 12 | const error = getHandler('error') 13 | /** 14 | * 15 | * A wrapper for console-based logs 16 | * @type {{log: function, info: function, debug: function, warn: function, error: function}} 17 | */ 18 | export { log, info, debug, warn, error } 19 | -------------------------------------------------------------------------------- /lib/utils/createCollection.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | 3 | /** 4 | * References to created collections, in-mem 5 | * @private 6 | */ 7 | const cache = new Map() 8 | 9 | /** 10 | * If the given collection is already created or cached, returns the collection 11 | * or creates a new one. 12 | * @param passedCollection {Mongo.Collection|undefined} 13 | * @param collectionName {string} 14 | * @return {Mongo.Collection} 15 | */ 16 | export const createCollection = (passedCollection, collectionName) => { 17 | if (passedCollection) { 18 | return passedCollection 19 | } 20 | 21 | if (!cache.has(collectionName)) { 22 | const collection = new Mongo.Collection(collectionName) 23 | cache.set(collectionName, collection) 24 | } 25 | 26 | return cache.get(collectionName) 27 | } 28 | -------------------------------------------------------------------------------- /lib/utils/error.js: -------------------------------------------------------------------------------- 1 | import { error } from './console' 2 | 3 | /** 4 | * Unifies error handling as http response. 5 | * Defaults to a 500 response, unless further details were added. 6 | * @param res 7 | * @param options {Object} options with error information 8 | * @param options.error {String} Error name 9 | * @param options.logError {boolean} optional flag to log the erroe to the console 10 | * @param options.description {String} Error description 11 | * @param options.uri {String?} Optional uri to redirect to when error occurs 12 | * @param options.status {Number?} Optional statuscode, defaults to 500 13 | * @param options.state {String} State object vor validation 14 | * @param options.debug {Boolean|undefined} State object vor validation 15 | * @param options.originalError {Error|undefined} original Error instance 16 | */ 17 | 18 | export const errorHandler = function (res, options) { 19 | // { error, description, uri, status, state, debug, originalError } 20 | const errCode = options.status || 500 21 | res.status(errCode) 22 | res.set({ 'Content-Type': 'application/json' }) 23 | 24 | // by default we log the error that will be used as response 25 | if (options.logError) { 26 | error(`[error] ${errCode} - ${options.error} - ${options.description}`) 27 | } 28 | 29 | if (options.debug && options.originalError) { 30 | error('[original error]:') 31 | error(options.originalError) 32 | } 33 | 34 | res.json({ 35 | error: options.error, 36 | error_description: options.description, 37 | error_uri: options.uri, 38 | state: options.state 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /lib/utils/isModelInterface.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains all valid model names 3 | * @private 4 | */ 5 | const modelNames = [ 6 | 'getAuthorizationCode', 7 | 'getClient', 8 | 'getRefreshToken', 9 | 'revokeAuthorizationCode', 10 | 'saveAuthorizationCode', 11 | 'saveRefreshToken', 12 | 'saveToken', 13 | 'getAccessToken', 14 | 'revokeToken' 15 | ] 16 | 17 | /** 18 | * Since we allow projects to implement their own model (while providing ours 19 | * as drop-in) we still need to validate, whether they implement the model 20 | * correctly. 21 | * 22 | * We duck-type check if the model implements the most important functions. 23 | * Uses the following values to check: 24 | * - 'getAuthorizationCode', 25 | * - 'getClient', 26 | * - 'getRefreshToken', 27 | * - 'revokeAuthorizationCode', 28 | * - 'saveAuthorizationCode', 29 | * - 'saveRefreshToken', 30 | * - 'saveToken', 31 | * - 'getAccessToken' 32 | * - 'revokeToken' 33 | * @param model {Object} the model implementation 34 | * @return {boolean} true if valid, otherwise false 35 | */ 36 | export const isModelInterface = model => { 37 | return model && modelNames.every(name => { 38 | return typeof model[name] === 'function' 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /lib/validation/OptionsSchema.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | 3 | export const OptionsSchema = { 4 | serverOptions: { 5 | addAcceptedScopesHeader: Match.Maybe(Boolean), 6 | addAuthorizedScopesHeader: Match.Maybe(Boolean), 7 | allowBearerTokensInQueryString: Match.Maybe(Boolean), 8 | allowEmptyState: Match.Maybe(Boolean), 9 | authorizationCodeLifetime: Match.Maybe(Number), 10 | accessTokenLifetime: Match.Maybe(Number), 11 | refreshTokenLifetime: Match.Maybe(Number), 12 | allowExtendedTokenAttributes: Match.Maybe(Boolean), 13 | requireClientAuthentication: Match.Maybe(Boolean) 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/validation/UserValidation.js: -------------------------------------------------------------------------------- 1 | import { check, Match } from 'meteor/check' 2 | import { warn } from '../utils/console' 3 | 4 | /** 5 | * Used to register handlers for different instances that validate users. 6 | * This allows you to validate user access on a client-based level. 7 | */ 8 | export const UserValidation = {} 9 | 10 | /** @private */ 11 | const validationHandlers = new WeakMap() 12 | 13 | /** 14 | * Registers a validation method that allows 15 | * to validate users on custom logic. 16 | * @param instance {OAuth2Server} 17 | * @param validationHandler {function} sync or async function that performs the validation 18 | */ 19 | UserValidation.register = function (instance, validationHandler) { 20 | const instanceCheck = { instanceId: instance.instanceId } 21 | check(instanceCheck, Match.ObjectIncluding({ 22 | instanceId: String 23 | })) 24 | check(validationHandler, Function) 25 | 26 | validationHandlers.set(instance, validationHandler) 27 | } 28 | 29 | /** 30 | * Delegates `handlerArgs` to the registered validation handler. 31 | * @param instance {OAuth2Server} 32 | * @param handlerArgs {*} 33 | * @return {*} should return truthy/falsy value 34 | */ 35 | UserValidation.isValid = async function (instance, handlerArgs) { 36 | // we assume, that if there is no validation handler registered 37 | // then the developers intended to do so. However, we will print an info. 38 | if (!validationHandlers.has(instance)) { 39 | if (instance.debug) { 40 | warn(`skip user validation, no handler found for instance ${instance.instanceId}`) 41 | } 42 | 43 | return true 44 | } 45 | 46 | const validationHandler = validationHandlers.get(instance) 47 | 48 | return validationHandler(handlerArgs) 49 | } 50 | -------------------------------------------------------------------------------- /lib/validation/nonEmptyString.js: -------------------------------------------------------------------------------- 1 | export const nonEmptyString = s => typeof s === 'string' && s.length > 0 2 | -------------------------------------------------------------------------------- /lib/validation/requiredAccessTokenPostParams.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | import { nonEmptyString } from './nonEmptyString' 3 | 4 | const isNonEmptyString = Match.Where(nonEmptyString) 5 | 6 | export const requiredAccessTokenPostParams = { 7 | grant_type: isNonEmptyString, 8 | code: isNonEmptyString, 9 | redirect_uri: isNonEmptyString, 10 | client_id: isNonEmptyString, 11 | client_secret: isNonEmptyString, 12 | state: Match.Maybe(String) 13 | } 14 | -------------------------------------------------------------------------------- /lib/validation/requiredAuthorizeGetParams.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | import { nonEmptyString } from './nonEmptyString' 3 | 4 | const isNonEmptyString = Match.Where(nonEmptyString) 5 | 6 | export const requiredAuthorizeGetParams = { 7 | response_type: isNonEmptyString, 8 | client_id: isNonEmptyString, 9 | scope: Match.Maybe(String), 10 | redirect_uri: isNonEmptyString, 11 | state: Match.Maybe(String) 12 | } 13 | -------------------------------------------------------------------------------- /lib/validation/requiredAuthorizePostParams.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | import { nonEmptyString } from './nonEmptyString' 3 | 4 | const isNonEmptyString = Match.Where(nonEmptyString) 5 | 6 | export const requiredAuthorizePostParams = { 7 | token: isNonEmptyString, 8 | client_id: isNonEmptyString, 9 | redirect_uri: isNonEmptyString, 10 | response_type: isNonEmptyString, 11 | state: Match.Maybe(String), 12 | scope: Match.Maybe(String), 13 | allowed: Match.Maybe(String) 14 | } 15 | -------------------------------------------------------------------------------- /lib/validation/requiredRefreshTokenPostParams.js: -------------------------------------------------------------------------------- 1 | import { Match } from 'meteor/check' 2 | import { nonEmptyString } from './nonEmptyString' 3 | 4 | const isNonEmptyString = Match.Where(nonEmptyString) 5 | 6 | export const requiredRefreshTokenPostParams = { 7 | grant_type: isNonEmptyString, 8 | refresh_token: isNonEmptyString, 9 | client_id: Match.Maybe(String), 10 | client_secret: Match.Maybe(String) 11 | } 12 | -------------------------------------------------------------------------------- /lib/validation/validateParams.js: -------------------------------------------------------------------------------- 1 | import { check } from 'meteor/check' 2 | import { error } from '../utils/console' 3 | 4 | /** 5 | * Abstraction that checks given query/body params against a given schema 6 | * @param actualParams 7 | * @param requiredParams 8 | * @param debug 9 | * @return {boolean} 10 | */ 11 | export const validateParams = (actualParams, requiredParams, debug) => { 12 | if (!actualParams || !requiredParams) { 13 | return false 14 | } 15 | const checkParam = requiredParamKey => { 16 | actual = actualParams[requiredParamKey] 17 | expected = requiredParams[requiredParamKey] 18 | try { 19 | check(actual, expected) // use console.log(requiredParamKey, actual, expected) 20 | return true 21 | } catch (e) { 22 | if (debug) { 23 | error(`[validation error]: key <${requiredParamKey}> => expected <${expected}>, got <${actual}>`) 24 | } 25 | return false 26 | } 27 | } 28 | let expected, actual 29 | return Object.keys(requiredParams).every(checkParam) 30 | } 31 | -------------------------------------------------------------------------------- /lib/webapp.js: -------------------------------------------------------------------------------- 1 | import { WebApp } from 'meteor/webapp' 2 | import bodyParser from 'body-parser' 3 | 4 | /** 5 | * Wrapped `WebApp` with express-style get/post and default use routes. 6 | * @see https://docs.meteor.com/packages/webapp.html 7 | * @type {{get: get, post: post, use: use}} 8 | */ 9 | export const app = WebApp.handlers 10 | 11 | app.use(bodyParser.json()) 12 | app.use(bodyParser.urlencoded({ extended: true })) 13 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | /* eslint-env meteor */ 2 | Package.describe({ 3 | name: 'leaonline:oauth2-server', 4 | version: '6.0.0', 5 | summary: 'Node OAuth2 Server (v4) with Meteor bindings', 6 | git: 'https://github.com/leaonline/oauth2-server.git' 7 | }) 8 | 9 | Package.onUse(function (api) { 10 | api.versionsFrom(['3.0']) 11 | api.use('ecmascript') 12 | api.mainModule('lib/oauth.js', 'server') 13 | }) 14 | 15 | Npm.depends({ 16 | '@node-oauth/oauth2-server': '5.2.0', 17 | 'body-parser': '1.20.3' 18 | }) 19 | 20 | Package.onTest(function (api) { 21 | api.use([ 22 | // FIXME: include, once we have a working coverage for Meteor 3 23 | // 'lmieulet:meteor-legacy-coverage@0.4.0', 24 | // 'lmieulet:meteor-coverage@4.3.0', 25 | 'meteortesting:mocha@3.2.0' 26 | ]) 27 | api.use('ecmascript') 28 | api.use('mongo') 29 | api.use('jkuester:http@2.1.0') 30 | api.use('dburles:mongo-collection-instances@1.0.0') 31 | api.use('accounts-base') 32 | api.use('accounts-password') 33 | 34 | api.addFiles([ 35 | 'tests/error-tests.js', 36 | 'tests/validation-tests.js', 37 | 'tests/model-tests.js', 38 | 'tests/webapp-tests.js', 39 | 'tests/oauth-tests.js' 40 | ], 'server') 41 | }) 42 | -------------------------------------------------------------------------------- /test-proxy/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "allowImportExportEverywhere": true 6 | }, 7 | "plugins": [ 8 | "security" 9 | ], 10 | "extends": [ 11 | "plugin:security/recommended" 12 | ] 13 | } -------------------------------------------------------------------------------- /test-proxy/.gitignore: -------------------------------------------------------------------------------- 1 | # folders 2 | node_modules/ 3 | packages/ 4 | 5 | # ide specific 6 | .idea 7 | .vscode 8 | 9 | # config files 10 | productionSettings.json 11 | leaconfig.json 12 | i18n_routes.json 13 | routes.json 14 | 15 | # proprietary stuff 16 | public/fonts/ 17 | public/logos/ 18 | 19 | .deploy 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | lerna-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | .coverage 44 | *.lcov 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (https://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # TypeScript v1 declaration files 66 | typings/ 67 | 68 | # TypeScript cache 69 | *.tsbuildinfo 70 | 71 | # Optional npm cache directory 72 | .npm 73 | 74 | # Optional eslint cache 75 | .eslintcache 76 | 77 | # Microbundle cache 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | .node_repl_history 85 | 86 | # Output of 'npm pack' 87 | *.tgz 88 | 89 | # Yarn Integrity file 90 | .yarn-integrity 91 | 92 | # dotenv environment variables file 93 | .env 94 | .env.test 95 | 96 | # parcel-bundler cache (https://parceljs.org/) 97 | .cache 98 | 99 | # Next.js build output 100 | .next 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # private font 128 | SemikolonPlus* 129 | 130 | # ide specific 131 | .idea 132 | .atom 133 | .vscode 134 | 135 | # meteor package specific 136 | .meteor/local 137 | .meteor/meteorite 138 | .npm 139 | -------------------------------------------------------------------------------- /test-proxy/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | 1.7-split-underscore-from-meteor-base 19 | 1.8.3-split-jquery-from-blaze 20 | -------------------------------------------------------------------------------- /test-proxy/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /test-proxy/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | igc3vk63b6a.n9p91g9x49r 8 | -------------------------------------------------------------------------------- /test-proxy/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.5.2 # Packages every Meteor app needs to have 8 | mobile-experience@1.1.2 # Packages for a great mobile UX 9 | mongo@2.0.0 # The database Meteor supports right now 10 | static-html@1.3.3 # Define static page content in .html files 11 | reactive-var@1.0.13 # Reactive variable for tracker 12 | tracker@1.3.4 # Meteor's client-side reactive programming library 13 | 14 | standard-minifier-css@1.9.3 # CSS minifier run for production mode 15 | standard-minifier-js@3.0.0 # JS minifier run for production mode 16 | es5-shim@4.8.1 # ECMAScript 5 compatibility for older browsers 17 | ecmascript@0.16.9 # Enable ECMAScript2015+ syntax in app code 18 | typescript@5.4.3 # Enable TypeScript syntax in .ts and .tsx modules 19 | shell-server@0.6.0 # Server-side component of the `meteor shell` command 20 | -------------------------------------------------------------------------------- /test-proxy/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /test-proxy/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@3.0.1 2 | -------------------------------------------------------------------------------- /test-proxy/.meteor/versions: -------------------------------------------------------------------------------- 1 | allow-deny@2.0.0 2 | autoupdate@2.0.0 3 | babel-compiler@7.11.0 4 | babel-runtime@1.5.2 5 | base64@1.0.13 6 | binary-heap@1.0.12 7 | blaze-tools@2.0.0 8 | boilerplate-generator@2.0.0 9 | caching-compiler@2.0.0 10 | caching-html-compiler@2.0.0 11 | callback-hook@1.6.0 12 | check@1.4.2 13 | core-runtime@1.0.0 14 | ddp@1.4.2 15 | ddp-client@3.0.0 16 | ddp-common@1.4.3 17 | ddp-server@3.0.0 18 | diff-sequence@1.1.3 19 | dynamic-import@0.7.4 20 | ecmascript@0.16.9 21 | ecmascript-runtime@0.8.2 22 | ecmascript-runtime-client@0.12.2 23 | ecmascript-runtime-server@0.11.1 24 | ejson@1.1.4 25 | es5-shim@4.8.1 26 | facts-base@1.0.2 27 | fetch@0.1.5 28 | geojson-utils@1.0.12 29 | hot-code-push@1.0.5 30 | html-tools@2.0.0 31 | htmljs@2.0.1 32 | id-map@1.2.0 33 | inter-process-messaging@0.1.2 34 | launch-screen@2.0.1 35 | logging@1.3.5 36 | meteor@2.0.0 37 | meteor-base@1.5.2 38 | minifier-css@2.0.0 39 | minifier-js@3.0.0 40 | minimongo@2.0.0 41 | mobile-experience@1.1.2 42 | mobile-status-bar@1.1.1 43 | modern-browsers@0.1.11 44 | modules@0.20.1 45 | modules-runtime@0.13.2 46 | mongo@2.0.0 47 | mongo-decimal@0.1.4-beta300.7 48 | mongo-dev-server@1.1.1 49 | mongo-id@1.0.9 50 | npm-mongo@4.17.3 51 | ordered-dict@1.2.0 52 | promise@1.0.0 53 | random@1.2.2 54 | react-fast-refresh@0.2.9 55 | reactive-var@1.0.13 56 | reload@1.3.2 57 | retry@1.1.1 58 | routepolicy@1.1.2 59 | shell-server@0.6.0 60 | socket-stream-client@0.5.3 61 | spacebars-compiler@2.0.0 62 | standard-minifier-css@1.9.3 63 | standard-minifier-js@3.0.0 64 | static-html@1.3.3 65 | templating-tools@2.0.0 66 | tracker@1.3.4 67 | typescript@5.4.3 68 | underscore@1.6.4 69 | webapp@2.0.0 70 | webapp-hashing@1.1.2 71 | -------------------------------------------------------------------------------- /test-proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-proxy", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run", 6 | "setup": "mkdir -p packages && ln -sfn ../../ ./packages/oauth2-server", 7 | "lint": "standardx -v ../ | snazzy", 8 | "lint:fix": "standardx -v --fix ../ | snazzy", 9 | "build:docs": "jsdoc2md '../!(node_modules|coverage|tests|test-proxy)/**/*.js' > ../API.md", 10 | "test": "METEOR_PACKAGE_DIRS='../' TEST_CLIENT=0 meteor test-packages --once --raw-logs --driver-package meteortesting:mocha ../", 11 | "test:watch": "METEOR_PACKAGE_DIRS='../' TEST_CLIENT=0 TEST_WATCH=1 meteor test-packages --raw-logs --driver-package meteortesting:mocha ../", 12 | "test:coverage": "BABEL_ENV=COVERAGE TEST_CLIENT=0 TEST_WATCH=1 COVERAGE=1 COVERAGE_OUT_HTML=1 COVERAGE_APP_FOLDER=$(pwd)/ meteor test-packages --driver-package meteortesting:mocha ./packages/oauth2-server" 13 | }, 14 | "dependencies": { 15 | "@babel/runtime": "^7.15.4", 16 | "meteor-node-stubs": "^1.2.3" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.24.4", 20 | "@babel/eslint-parser": "^7.24.1", 21 | "babel-plugin-istanbul": "^6.1.1", 22 | "chai": "^4.3.6", 23 | "eslint-plugin-security": "^1.7.1", 24 | "jsdoc": "^3.6.10", 25 | "jsdoc-to-markdown": "^7.1.1", 26 | "puppeteer": "^11.0.0", 27 | "sinon": "^12.0.1", 28 | "snazzy": "^9.0.0", 29 | "standardx": "^7.0.0" 30 | }, 31 | "babel": { 32 | "env": { 33 | "COVERAGE": { 34 | "plugins": [ 35 | "istanbul" 36 | ] 37 | } 38 | } 39 | }, 40 | "standardx": { 41 | "ignore": [ 42 | "**/test-proxy/" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test-proxy/packages/oauth2-server: -------------------------------------------------------------------------------- 1 | ../../ -------------------------------------------------------------------------------- /tests/error-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { Random } from 'meteor/random' 3 | import { assert } from 'chai' 4 | import { errorHandler } from '../lib/utils/error' 5 | 6 | class Res { 7 | status (httpStatus) { 8 | this.httpStatus = httpStatus 9 | } 10 | 11 | set (options) { 12 | this.options = options 13 | } 14 | 15 | send (body) { 16 | this.body = body 17 | } 18 | 19 | json (body) { 20 | this.body = JSON.stringify(body) 21 | } 22 | } 23 | 24 | describe('errorHandler', function () { 25 | it('writes the error into a result body', function () { 26 | const res = new Res() 27 | 28 | const error = Random.id() 29 | const description = Random.id() 30 | const uri = Random.id() 31 | const status = Random.id() 32 | const state = Random.id() 33 | 34 | const originalError = new Error() 35 | originalError.name = error 36 | originalError.code = status 37 | 38 | errorHandler(res, { 39 | error, 40 | description, 41 | uri, 42 | status, 43 | state, 44 | originalError, 45 | debug: false 46 | }) 47 | 48 | const { httpStatus } = res 49 | assert.equal(httpStatus, status) 50 | 51 | const { options } = res 52 | assert.deepEqual(options, { 'Content-Type': 'application/json' }) 53 | 54 | const expectedBody = { 55 | error, 56 | error_description: description, 57 | error_uri: uri, 58 | state 59 | } 60 | const { body } = res 61 | assert.deepEqual(JSON.parse(body), expectedBody) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /tests/model-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { Meteor } from 'meteor/meteor' 3 | import { Mongo } from 'meteor/mongo' 4 | import { Random } from 'meteor/random' 5 | import { assert, expect } from 'chai' 6 | import { OAuthMeteorModel } from '../lib/model/model' 7 | import { DefaultModelConfig } from '../lib/model/DefaultModelConfig' 8 | 9 | const GrantTypes = { 10 | authorization_code: 'authorization_code', 11 | client_credentials: 'client_credentials', 12 | implicit: 'implicit', 13 | refresh_token: 'refresh_token', 14 | password: 'password' 15 | } 16 | 17 | const assertCollection = name => { 18 | const collection = Mongo.Collection.get(name) 19 | assert.isDefined(collection) 20 | assert.instanceOf(collection, Mongo.Collection) 21 | } 22 | 23 | describe('model', function () { 24 | let randomAccessTokenName = Random.id() 25 | let randomRefreshTokenName = Random.id() 26 | let randomAuthCodeName = Random.id() 27 | let randomClientsName = Random.id() 28 | 29 | beforeEach(function () { 30 | randomAccessTokenName = Random.id() 31 | randomRefreshTokenName = Random.id() 32 | randomAuthCodeName = Random.id() 33 | randomClientsName = Random.id() 34 | }) 35 | 36 | afterEach(async () => { 37 | await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).removeAsync({}) 38 | await Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName).removeAsync({}) 39 | await Mongo.Collection.get(DefaultModelConfig.refreshTokensCollectionName).removeAsync({}) 40 | await Mongo.Collection.get(DefaultModelConfig.authCodesCollectionName).removeAsync({}) 41 | }) 42 | 43 | describe('constructor', function () { 44 | it('can be created with defaults', function () { 45 | assert.isDefined(new OAuthMeteorModel()) 46 | assertCollection(DefaultModelConfig.accessTokensCollectionName) 47 | assertCollection(DefaultModelConfig.refreshTokensCollectionName) 48 | assertCollection(DefaultModelConfig.authCodesCollectionName) 49 | assertCollection(DefaultModelConfig.clientsCollectionName) 50 | }) 51 | 52 | it('can be created with custom collection names', function () { 53 | assert.isDefined(new OAuthMeteorModel({ 54 | accessTokensCollectionName: randomAccessTokenName, 55 | refreshTokensCollectionName: randomRefreshTokenName, 56 | authCodesCollectionName: randomAuthCodeName, 57 | clientsCollectionName: randomClientsName 58 | })) 59 | assertCollection(randomAccessTokenName) 60 | assertCollection(randomRefreshTokenName) 61 | assertCollection(randomAuthCodeName) 62 | assertCollection(randomClientsName) 63 | }) 64 | 65 | it('can be created with custom collections passed', function () { 66 | const AccessTokens = new Mongo.Collection(randomAccessTokenName) 67 | const RefreshTokens = new Mongo.Collection(randomRefreshTokenName) 68 | const AuthCodes = new Mongo.Collection(randomAuthCodeName) 69 | const Clients = new Mongo.Collection(randomClientsName) 70 | assert.isDefined(new OAuthMeteorModel({ 71 | accessTokensCollection: AccessTokens, 72 | refreshTokensCollection: RefreshTokens, 73 | authCodesCollection: AuthCodes, 74 | clientsCollection: Clients 75 | })) 76 | assertCollection(randomAccessTokenName) 77 | assertCollection(randomRefreshTokenName) 78 | assertCollection(randomAuthCodeName) 79 | assertCollection(randomClientsName) 80 | }) 81 | }) 82 | 83 | describe('createClient', function () { 84 | it('creates a client with minimum required credentials', async () => { 85 | const model = new OAuthMeteorModel() 86 | const title = Random.id() 87 | const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] 88 | const grants = [GrantTypes.authorization_code] 89 | const clientDocId = await (model.createClient({ title, redirectUris, grants })) 90 | const clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) 91 | 92 | assert.isDefined(clientDoc) 93 | assert.isDefined(clientDoc.clientId) 94 | assert.isDefined(clientDoc.secret) 95 | assert.equal(clientDoc.title, title) 96 | assert.deepEqual(clientDoc.redirectUris, redirectUris) 97 | assert.deepEqual(clientDoc.grants, grants) 98 | }) 99 | 100 | it('creates a client with an already given clientId and secret', async () => { 101 | const model = new OAuthMeteorModel() 102 | const title = Random.id() 103 | const clientId = Random.id(16) 104 | const secret = Random.id(32) 105 | const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] 106 | const grants = [GrantTypes.authorization_code] 107 | const clientDocId = await (model.createClient({ title, redirectUris, grants, clientId, secret })) 108 | const clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) 109 | 110 | assert.isDefined(clientDoc) 111 | assert.equal(clientDoc.clientId, clientId) 112 | assert.equal(clientDoc.secret, secret) 113 | assert.equal(clientDoc.title, title) 114 | assert.deepEqual(clientDoc.redirectUris, redirectUris) 115 | assert.deepEqual(clientDoc.grants, grants) 116 | }) 117 | }) 118 | 119 | describe('getClient', function () { 120 | let model 121 | let clientDoc 122 | 123 | beforeEach(async () => { 124 | model = new OAuthMeteorModel() 125 | const title = Random.id() 126 | const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] 127 | const grants = [GrantTypes.authorization_code] 128 | const clientDocId = await (model.createClient({ title, redirectUris, grants })) 129 | clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) 130 | }) 131 | 132 | it('returns a client by clientId', async () => { 133 | const { clientId } = clientDoc 134 | const actualClientDoc = await (model.getClient(clientId)) 135 | assert.deepEqual(actualClientDoc, clientDoc) 136 | }) 137 | 138 | it('returns a client on null secret', async () => { 139 | const { clientId } = clientDoc 140 | const actualClientDoc = await (model.getClient(clientId, null)) 141 | assert.deepEqual(actualClientDoc, clientDoc) 142 | }) 143 | 144 | it('returns false if no client is found', async () => { 145 | const falsey = await (model.getClient(Random.id())) 146 | assert.isFalse(falsey) 147 | }) 148 | 149 | it('returns a client by clientId and clientSecret', async () => { 150 | const { clientId } = clientDoc 151 | const { secret } = clientDoc 152 | const actualClientDoc = await (model.getClient(clientId, secret)) 153 | assert.deepEqual(actualClientDoc, clientDoc) 154 | }) 155 | 156 | it('returns false if clientSecret is incorrect', async () => { 157 | const { clientId } = clientDoc 158 | const falsey = await (model.getClient(clientId, Random.id())) 159 | assert.isFalse(falsey) 160 | }) 161 | }) 162 | 163 | describe('saveToken', function () { 164 | let model 165 | 166 | beforeEach(function () { 167 | model = new OAuthMeteorModel() 168 | }) 169 | 170 | it('saves an access token', async () => { 171 | const insertTokenDoc = { 172 | accessToken: Random.id(), 173 | accessTokenExpiresAt: new Date(), 174 | refreshToken: Random.id(), 175 | refreshTokenExpiresAt: new Date(), 176 | scope: ['foo', 'bar'] 177 | } 178 | const clientDoc = { clientId: Random.id() } 179 | const userDoc = { id: Random.id() } 180 | const tokenDoc = await model.saveToken(insertTokenDoc, clientDoc, userDoc) 181 | expect(tokenDoc).to.deep.equal({ 182 | ...tokenDoc, 183 | client: { id: clientDoc.clientId }, 184 | user: userDoc 185 | }) 186 | }) 187 | }) 188 | 189 | describe('getAccessToken', function () { 190 | let model 191 | 192 | beforeEach(function () { 193 | model = new OAuthMeteorModel() 194 | }) 195 | 196 | it('returns a saved token', async () => { 197 | const collection = Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName) 198 | const accessToken = Random.id() 199 | const docId = await collection.insertAsync({ accessToken }) 200 | const tokenDoc = await model.getAccessToken(accessToken) 201 | expect(tokenDoc).to.deep.equal({ 202 | _id: docId, 203 | accessToken 204 | }) 205 | }) 206 | }) 207 | 208 | describe('verifyScope', () => { 209 | let model 210 | 211 | beforeEach(function () { 212 | model = new OAuthMeteorModel() 213 | }) 214 | 215 | it('returns true if the access token scope meets the expected scope', async () => { 216 | expect(await model.verifyScope({ scope: ['foo'] }, ['foo'])).to.equal(true) 217 | expect(await model.verifyScope({ scope: ['foo'] }, ['foo', 'bar'])).to.equal(false) 218 | expect(await model.verifyScope({ scope: ['foo'] }, [])).to.equal(false) 219 | expect(await model.verifyScope({ scope: [] }, ['foo'])).to.equal(false) 220 | expect(await model.verifyScope({ scope: ['foo', 'bar'] }, ['foo'])).to.equal(false) 221 | }) 222 | }) 223 | 224 | describe('revokeRefreshToken', () => { 225 | let model 226 | 227 | beforeEach(function () { 228 | model = new OAuthMeteorModel() 229 | }) 230 | 231 | it('returns true if the refresh token was revoked', async () => { 232 | const collection = Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName) 233 | const refreshToken = Random.id() 234 | await collection.insertAsync({ refreshToken }) 235 | const tokenDoc = await model.revokeToken({ refreshToken }) 236 | assert.isTrue(tokenDoc) 237 | }) 238 | 239 | it('returns false if the refresh token was not found', async () => { 240 | const refreshToken = Random.id() 241 | const tokenDoc = await model.revokeToken({ refreshToken }) 242 | assert.isFalse(tokenDoc) 243 | }) 244 | }) 245 | 246 | describe('saveAuthorizationCode', function () { 247 | it('is not yet implemented') 248 | }) 249 | 250 | describe('getAuthorizationCode', function () { 251 | it('returns a saved authorization code') 252 | }) 253 | 254 | describe('revokeAuthorizationCode', function () { 255 | it('is not yet implemented') 256 | }) 257 | 258 | describe('saveRefreshToken', function () { 259 | it('is not yet implemented') 260 | }) 261 | 262 | describe('getRefreshToken', function () { 263 | it('is not yet implemented') 264 | }) 265 | 266 | describe('grantTypeAllowed', function () { 267 | it('is not yet implemented') 268 | }) 269 | 270 | /* optional: 271 | generateAccessToken 272 | generateAuthorizationCode 273 | generateRefreshToken 274 | getUser 275 | getUserFromClient 276 | grantTypeAllowed 277 | revokeToken 278 | validateScope 279 | */ 280 | }) 281 | -------------------------------------------------------------------------------- /tests/oauth-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { Meteor } from 'meteor/meteor' 3 | import { Mongo } from 'meteor/mongo' 4 | import { assert } from 'chai' 5 | import { Random } from 'meteor/random' 6 | import { Accounts } from 'meteor/accounts-base' 7 | import { HTTP } from 'meteor/jkuester:http' 8 | import { OAuth2Server } from '../lib/oauth' 9 | import { OAuth2ServerDefaults } from '../lib/defaults' 10 | import { OAuthMeteorModel } from '../lib/model/model' 11 | import { DefaultModelConfig } from '../lib/model/DefaultModelConfig' 12 | import { assertCollection } from './test-helpers.tests' 13 | 14 | describe('constructor', function () { 15 | it('can be instantiated without any parameter', function () { 16 | const server = new OAuth2Server() 17 | assert.isDefined(server) 18 | assert.deepEqual(server.config.serverOptions, OAuth2ServerDefaults.serverOptions) 19 | assert.deepEqual(server.config.model, OAuth2ServerDefaults.model) 20 | assert.deepEqual(server.config.routes, OAuth2ServerDefaults.routes) 21 | 22 | const model = new OAuthMeteorModel() 23 | assert.deepEqual(server.model, model) 24 | }) 25 | 26 | it('can be created with serverOptions', function () { 27 | const serverOptions = OAuth2ServerDefaults.serverOptions 28 | const server = new OAuth2Server({ serverOptions }) 29 | assert.isDefined(server) 30 | assert.isDefined(server.config) 31 | assert.isDefined(server.model) 32 | assert.deepEqual(server.config.serverOptions, serverOptions) 33 | }) 34 | 35 | it('throws if server options include properties that are not in schema', function () { 36 | assert.throws(function () { 37 | (() => new OAuth2Server({ serverOptions: { foo: 'bar' } }))() 38 | }) 39 | }) 40 | 41 | it('can be created with custom config for default model', function () { 42 | const model = { authCodesCollectionName: 'ourCustomAuthCodes' } 43 | const server = new OAuth2Server({ model }) 44 | assert.isDefined(server) 45 | assertCollection(model.authCodesCollectionName) 46 | }) 47 | 48 | it('can be created with a custom model', function () { 49 | const model = { 50 | getAccessToken: async () => true, 51 | getAuthorizationCode: async () => true, 52 | getClient: async () => true, 53 | getRefreshToken: async () => true, 54 | revokeAuthorizationCode: async () => true, 55 | saveAuthorizationCode: async () => true, 56 | saveRefreshToken: async () => true, 57 | saveToken: async () => true, 58 | revokeToken: async () => true 59 | } 60 | const server = new OAuth2Server({ model }) 61 | assert.isDefined(server) 62 | assert.deepEqual(server.model, model) 63 | }) 64 | 65 | it('can be created with custom routes', function () { 66 | const routes = { 67 | authorizeUrl: '/oauth/authorization' 68 | } 69 | const server = new OAuth2Server({ routes }) 70 | assert.deepEqual(server.config.routes, Object.assign({}, OAuth2ServerDefaults.routes, routes)) 71 | }) 72 | }) 73 | 74 | describe('integration tests of OAuth2 workflows', function () { 75 | describe('Authorization code workflow', function () { 76 | const routes = { 77 | accessTokenUrl: `/${Random.id()}`, 78 | authorizeUrl: `/${Random.id()}`, 79 | errorUrl: `/${Random.id()}`, 80 | fallbackUrl: `/${Random.id()}` 81 | } 82 | 83 | const debug = false 84 | const logErrors = false 85 | const authCodeServer = new OAuth2Server({ debug, model: { debug }, routes }) 86 | 87 | const get = (url, params, cb) => new Promise((resolve, reject) => { 88 | const fullUrl = Meteor.absoluteUrl(url) 89 | HTTP.get(fullUrl, params, (err, res) => { 90 | if (err && logErrors) return reject(err) 91 | try { 92 | cb(res) 93 | resolve() 94 | } catch (e) { 95 | reject(e) 96 | } 97 | }) 98 | }) 99 | 100 | const post = (url, params, cb) => new Promise((resolve, reject) => { 101 | const fullUrl = Meteor.absoluteUrl(url) 102 | HTTP.post(fullUrl, params, (err, res) => { 103 | if (err && logErrors) return reject(err) 104 | try { 105 | cb(res) 106 | resolve() 107 | } catch (e) { 108 | reject(e) 109 | } 110 | }) 111 | }) 112 | 113 | let ClientCollection 114 | let clientDoc 115 | let user 116 | 117 | beforeEach(async function () { 118 | ClientCollection = Mongo.Collection.get(DefaultModelConfig.clientsCollectionName) 119 | const clientDocId = await authCodeServer.registerClient({ 120 | title: Random.id(), 121 | redirectUris: [Meteor.absoluteUrl(`/${Random.id()}`)], 122 | grants: ['authorization_code'] 123 | }) 124 | clientDoc = await ClientCollection.findOneAsync(clientDocId) 125 | assert.isDefined(clientDoc) 126 | 127 | // for the user we are faking the 128 | // login token to simulare a user, that is 129 | // currently logged in 130 | const userId = await Accounts.createUserAsync({ username: Random.id(), password: Random.id() }) 131 | const token = Random.id() 132 | const hashedToken = Accounts._hashLoginToken(token) 133 | await Meteor.users.updateAsync(userId, { 134 | $set: { 135 | token: token, 136 | 'services.resume.loginTokens.hashedToken': hashedToken 137 | } 138 | }) 139 | user = await Meteor.users.findOneAsync(userId) 140 | }) 141 | 142 | describe('Authorization Request', function () { 143 | it('returns a valid response for a valid request', async () => { 144 | const params = { 145 | client_id: clientDoc.clientId, 146 | response_type: 'code', 147 | redirect_uri: clientDoc.redirectUris[0], 148 | state: Random.id() 149 | } 150 | await get(routes.authorizeUrl, { params }, res => { 151 | assert.equal(res.statusCode, 200) 152 | assert.equal(res.data, null) 153 | }) 154 | }) 155 | 156 | it('returns an invalid_request error for invalid formed requests', async () => { 157 | const params = { state: Random.id() } 158 | await get(routes.authorizeUrl, { params }, (res) => { 159 | assert.equal(res.statusCode, 400) 160 | assert.equal(res.data.error, 'invalid_request') 161 | assert.equal(res.data.state, params.state) 162 | }) 163 | }) 164 | 165 | it('returns unsupported_response_type if the response method is not supported by the server', async () => { 166 | const params = { 167 | client_id: clientDoc.clientId, 168 | response_type: Random.id(), 169 | redirect_uri: clientDoc.redirectUris[0], 170 | state: Random.id() 171 | } 172 | await get(routes.authorizeUrl, { params }, res => { 173 | assert.equal(res.statusCode, 415) 174 | assert.equal(res.data.error, 'unsupported_response_type') 175 | assert.equal(res.data.state, params.state) 176 | }) 177 | }) 178 | 179 | it('returns an unauthorized_client error for invalid clients', async () => { 180 | const params = { 181 | client_id: Random.id(), 182 | response_type: 'code', 183 | redirect_uri: clientDoc.redirectUris[0], 184 | state: Random.id() 185 | } 186 | await get(routes.authorizeUrl, { params }, (res) => { 187 | assert.equal(res.statusCode, 401) 188 | assert.equal(res.data.error, 'unauthorized_client') 189 | assert.equal(res.data.state, params.state) 190 | }) 191 | }) 192 | 193 | it('returns an invalid_request on invalid redirect_uri', async () => { 194 | const invalidRedirectUri = Meteor.absoluteUrl(`/${Random.id()}`) 195 | const params = { 196 | client_id: clientDoc.clientId, 197 | response_type: 'code', 198 | redirect_uri: invalidRedirectUri, 199 | state: Random.id() 200 | } 201 | await get(routes.authorizeUrl, { params }, (res) => { 202 | assert.equal(res.statusCode, 400) 203 | assert.equal(res.data.error, 'invalid_request') 204 | assert.equal(res.data.error_description, `Invalid redirection uri ${invalidRedirectUri}`) 205 | assert.equal(res.data.state, params.state) 206 | }) 207 | }) 208 | }) 209 | 210 | describe('Authorization Response', function () { 211 | [true, false].forEach(followRedirects => { 212 | it(`issues an authorization code and delivers it to the client via redirect follow=${followRedirects}`, async () => { 213 | const params = { 214 | client_id: clientDoc.clientId, 215 | response_type: 'code', 216 | redirect_uri: clientDoc.redirectUris[0], 217 | state: Random.id(), 218 | token: user.token, 219 | allowed: undefined 220 | } 221 | 222 | // depending on our fetch options we either immediately follow the 223 | // redirect and expect a 200 repsonse or, if we don't follow, 224 | // we expect a 302 response with location header, which can be used 225 | // by the client to manually follow 226 | await post(routes.authorizeUrl, { params, followRedirects }, res => { 227 | if (followRedirects) { 228 | assert.equal(res.statusCode, 200) 229 | assert.equal(res.headers.location, undefined) 230 | } else { 231 | assert.equal(res.statusCode, 302) 232 | 233 | const location = res.headers.location.split('?') 234 | assert.equal(location[0], clientDoc.redirectUris[0]) 235 | 236 | const queryParamsRegex = new RegExp(`code=.+&user=${user._id}&state=${params.state}`, 'g') 237 | assert.isTrue(queryParamsRegex.test(location[1])) 238 | } 239 | }) 240 | }) 241 | }) 242 | 243 | it('returns an access_denied error when no user exists for the given token', async () => { 244 | const params = { 245 | client_id: clientDoc.clientId, 246 | response_type: 'code', 247 | redirect_uri: clientDoc.redirectUris[0], 248 | state: Random.id(), 249 | token: Random.id(), 250 | allowed: undefined 251 | } 252 | await post(routes.authorizeUrl, { params }, res => { 253 | assert.equal(res.statusCode, 400) 254 | assert.equal(res.data.error, 'access_denied') 255 | assert.equal(res.data.state, params.state) 256 | }) 257 | }) 258 | 259 | it('returns an access_denied error when the user denied the request', async () => { 260 | const params = { 261 | client_id: clientDoc.clientId, 262 | response_type: 'code', 263 | redirect_uri: clientDoc.redirectUris[0], 264 | state: Random.id(), 265 | token: user.token, 266 | allowed: 'false' 267 | } 268 | await post(routes.authorizeUrl, { params }, res => { 269 | assert.equal(res.statusCode, 400) 270 | assert.equal(res.data.error, 'access_denied') 271 | assert.equal(res.data.state, params.state) 272 | }) 273 | }) 274 | }) 275 | 276 | describe('Access Token Request', function () { 277 | it('returns an invalid_request error on missing credentials', async () => { 278 | const params = { 279 | state: Random.id() 280 | } 281 | await post(routes.accessTokenUrl, { params }, res => { 282 | assert.equal(res.statusCode, 400) 283 | assert.equal(res.data.error, 'invalid_request') 284 | assert.equal(res.data.state, params.state) 285 | }) 286 | }) 287 | 288 | it('returns an invalid_request error if the redirect uri is not correct', async function () { 289 | const authorizationCode = Random.id() 290 | const expiresAt = new Date(new Date().getTime() + 30000) 291 | await authCodeServer.model.saveAuthorizationCode({ 292 | authorizationCode, 293 | expiresAt, 294 | redirectUri: clientDoc.redirectUris[0] 295 | }, clientDoc, { id: user._id }) 296 | 297 | const params = { 298 | code: authorizationCode, 299 | client_id: clientDoc.clientId, 300 | client_secret: clientDoc.secret, 301 | redirect_uri: Random.id(), 302 | state: Random.id(), 303 | grant_type: 'authorization_code' 304 | } 305 | 306 | await post(routes.accessTokenUrl, { params }, res => { 307 | assert.equal(res.statusCode, 400) 308 | assert.equal(res.data.error, 'unauthorized_client') 309 | assert.equal(res.data.state, params.state) 310 | }) 311 | }) 312 | 313 | it('returns an unauthorized_client error when the client does not provide a secret', async () => { 314 | const authorizationCode = Random.id() 315 | const expiresAt = new Date(new Date().getTime() + 30000) 316 | await authCodeServer.model.saveAuthorizationCode({ 317 | authorizationCode, 318 | expiresAt, 319 | redirectUri: clientDoc.redirectUris[0] 320 | }, clientDoc, { id: user._id }) 321 | 322 | const params = { 323 | code: authorizationCode, 324 | client_id: clientDoc.clientId, 325 | client_secret: Random.id(), 326 | redirect_uri: clientDoc.redirectUris[0], 327 | state: Random.id(), 328 | grant_type: 'authorization_code' 329 | } 330 | 331 | await post(routes.accessTokenUrl, { params }, res => { 332 | assert.equal(res.statusCode, 400) 333 | assert.equal(res.data.error, 'unauthorized_client') 334 | assert.equal(res.data.error_description, 'Invalid client: client is invalid') 335 | assert.equal(res.data.state, params.state) 336 | }) 337 | }) 338 | 339 | it('returns an unauthorized_client error when the given code is not found', async () => { 340 | const params = { 341 | code: Random.id(), 342 | client_id: clientDoc.clientId, 343 | client_secret: clientDoc.secret, 344 | redirect_uri: clientDoc.redirectUris[0], 345 | state: Random.id(), 346 | grant_type: 'authorization_code' 347 | } 348 | 349 | await post(routes.accessTokenUrl, { params }, res => { 350 | assert.equal(res.statusCode, 400) 351 | assert.equal(res.data.error, 'unauthorized_client') 352 | assert.equal(res.data.error_description, 'Invalid grant: authorization code is invalid') 353 | assert.equal(res.data.state, params.state) 354 | }) 355 | }) 356 | }) 357 | 358 | describe('Access Token Response', function () { 359 | it('issues an access token for a valid request', async () => { 360 | const authorizationCode = Random.id() 361 | const expiresAt = new Date(new Date().getTime() + 30000) 362 | const code = { 363 | authorizationCode, 364 | expiresAt, 365 | redirectUri: clientDoc.redirectUris[0] 366 | } 367 | 368 | await authCodeServer.model.saveAuthorizationCode(code, clientDoc, { id: user._id }) 369 | 370 | const params = { 371 | code: authorizationCode, 372 | client_id: clientDoc.clientId, 373 | client_secret: clientDoc.secret, 374 | redirect_uri: clientDoc.redirectUris[0], 375 | state: Random.id(), 376 | grant_type: 'authorization_code' 377 | } 378 | 379 | await post(routes.accessTokenUrl, { params }, res => { 380 | assert.equal(res.statusCode, 200) 381 | 382 | const headers = res.headers 383 | assert.equal(headers['content-type'].includes('application/json'), true) 384 | assert.equal(headers['cache-control'], 'no-store') 385 | assert.equal(headers.pragma, 'no-cache') 386 | 387 | const body = res.data 388 | assert.isDefined(body.access_token) 389 | assert.isDefined(body.expires_in) 390 | assert.isDefined(body.refresh_token) 391 | }) 392 | }) 393 | }) 394 | }) 395 | }) 396 | -------------------------------------------------------------------------------- /tests/test-helpers.tests.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo' 2 | import { assert } from 'chai' 3 | 4 | export const assertCollection = name => { 5 | const collection = Mongo.Collection.get(name) 6 | assert.isDefined(collection) 7 | assert.instanceOf(collection, Mongo.Collection) 8 | } 9 | -------------------------------------------------------------------------------- /tests/validation-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { assert, expect } from 'chai' 3 | import { Random } from 'meteor/random' 4 | import { validateParams } from '../lib/validation/validateParams' 5 | import { UserValidation } from '../lib/validation/UserValidation' 6 | 7 | describe('validation', function () { 8 | describe('validate', function () { 9 | it('returns false if one of the params is falsey', function () { 10 | [ 11 | validateParams(), 12 | validateParams(null, {}), 13 | validateParams({}, null), 14 | validateParams(undefined, {}), 15 | validateParams({}, undefined) 16 | ].forEach(value => assert.isFalse(value)) 17 | }) 18 | 19 | it('returns true if the params math', function () { 20 | [ 21 | validateParams({}, {}), 22 | validateParams({ a: 'a' }, { a: String }), 23 | validateParams({ b: 1 }, { b: Number }), 24 | validateParams({ c: new Date() }, { c: Date }), 25 | validateParams({ d: true }, { d: Boolean }) 26 | ].forEach(value => assert.isTrue(value)) 27 | }) 28 | }) 29 | 30 | describe('UserValidation', function () { 31 | let instanceId 32 | 33 | beforeEach(function () { 34 | instanceId = Random.id() 35 | }) 36 | describe(UserValidation.register.name, function () { 37 | it('throws if key is not an instance with instanceId', function () { 38 | expect(() => UserValidation.register({})).to.throw('Match error: Expected string, got undefined in field instanceId') 39 | }) 40 | it('throws if fct ist not a function', function () { 41 | expect(() => UserValidation.register({ instanceId })).to.throw('Match error: Expected function, got undefined') 42 | }) 43 | }) 44 | describe(UserValidation.isValid.name, function () { 45 | it('returns true if not registered (skips)', async function () { 46 | const instance = { instanceId } 47 | expect(await UserValidation.isValid({})).to.equal(true) 48 | expect(await UserValidation.isValid(instance)).to.equal(true) 49 | }) 50 | it('returns true if registered and handler passes', async function () { 51 | const instance = { instanceId } 52 | const handler = () => true 53 | UserValidation.register(instance, handler) 54 | expect(await UserValidation.isValid(instance)).to.equal(true) 55 | }) 56 | it('returns false if registered and handler denies', async function () { 57 | const instance = { instanceId } 58 | const handler = () => false 59 | UserValidation.register(instance, handler) 60 | expect(await UserValidation.isValid(instance)).to.equal(false) 61 | }) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /tests/webapp-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { Meteor } from 'meteor/meteor' 3 | import { HTTP } from 'meteor/jkuester:http' 4 | import { Random } from 'meteor/random' 5 | import { assert } from 'chai' 6 | import { app } from '../lib/webapp' 7 | 8 | const toUrl = path => Meteor.absoluteUrl(path) 9 | 10 | const finish = (res, done, err) => { 11 | res.writeHead(200) 12 | res.end() 13 | done(err) 14 | } 15 | 16 | describe('webapp', function () { 17 | it('creates a GET route using .get', function (done) { 18 | const route = Random.id() 19 | const test = Random.id() 20 | const url = toUrl(route) 21 | 22 | app.get(`/${route}`, function (req, res, next) { 23 | try { 24 | assert.equal(req.query.test, test) 25 | finish(res, done) 26 | } catch (e) { 27 | finish(res, done, e) 28 | } 29 | }) 30 | 31 | HTTP.get(url, { 32 | params: { test } 33 | }) 34 | }) 35 | 36 | it('creates a GET route which is not reachable via POST request', function (done) { 37 | const route = Random.id() 38 | const test = Random.id() 39 | const url = toUrl(route) 40 | 41 | app.get(`/${route}`, function (req, res, next) { 42 | finish(res, done, new Error('expected GET route to not be callable via POST request')) 43 | }) 44 | 45 | app.post(`/${route}`, function (req, res) { 46 | finish(res, done) 47 | }) 48 | 49 | HTTP.post(url, { 50 | params: { test } 51 | }) 52 | }) 53 | 54 | it('creates a POST route using .post', function (done) { 55 | const route = Random.id() 56 | const test = Random.id() 57 | const url = toUrl(route) 58 | 59 | app.post(`/${route}`, function (req, res, next) { 60 | try { 61 | assert.equal(req.body.test, test) 62 | finish(res, done) 63 | } catch (e) { 64 | finish(res, done, e) 65 | } 66 | }) 67 | 68 | HTTP.post(url, { 69 | params: { test } 70 | }) 71 | }) 72 | 73 | it('creates a POST route which is not reachable via GET request', function (done) { 74 | const route = Random.id() 75 | const url = toUrl(route) 76 | 77 | app.post(`/${route}`, function (req, res, next) { 78 | finish(res, done, new Error('expected GET route to not be callable via POST request')) 79 | }) 80 | 81 | app.get(`/${route}`, function (req, res) { 82 | finish(res, done) 83 | }) 84 | 85 | HTTP.get(url) 86 | }) 87 | 88 | it('creates a POST AND GET route using .use', function (done) { 89 | const route = Random.id() 90 | const url = toUrl(route) 91 | 92 | const finished = { get: false, post: false } 93 | const checkDone = method => { 94 | finished[method] = true 95 | return (finished.get && finished.post) 96 | } 97 | 98 | app.use(`/${route}`, function (req, res, next) { 99 | if (checkDone(req.method.toLowerCase())) { 100 | return finish(res, done) 101 | } else { 102 | return next() 103 | } 104 | }) 105 | 106 | HTTP.get(url) 107 | Meteor.setTimeout(function () { 108 | HTTP.post(url) 109 | }, 500) 110 | }) 111 | }) 112 | --------------------------------------------------------------------------------