├── .editorconfig ├── .gitattributes ├── .github ├── .markdownlint.json ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── clear_closed_pr_cache.yml │ ├── code_ql.yml │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MIGRATION.md ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── index.ts ├── lib │ ├── helpers.ts │ ├── http-server.ts │ ├── jwk-store.ts │ ├── oauth2-issuer.ts │ ├── oauth2-server.ts │ ├── oauth2-service.ts │ ├── types-internals.ts │ └── types.ts └── oauth2-mock-server.ts ├── test ├── cli.test.ts ├── helpers.test.ts ├── http-server.test.ts ├── jwk-store.test.ts ├── keys │ ├── index.ts │ ├── localhost-cert.pem │ ├── localhost-key.pem │ ├── test-eddsa-key.json │ ├── test-es256-key.json │ └── test-rs256-key.json ├── lib │ ├── child-script.ts │ └── test_helpers.ts ├── oauth2-issuer.test.ts ├── oauth2-server.test.ts └── oauth2-service.test.ts ├── tsconfig.eslint.json ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "first-heading-h1": { "level": 2 }, 3 | "first-line-h1": { "level": 2 } 4 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | ## Summary 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | ## Steps to Reproduce 12 | 13 | 1. Go to '...' 14 | 2. Click on '...' 15 | 3. Scroll down to '...' 16 | 4. See error 17 | 18 | ## Expected Behavior 19 | 20 | A clear and concise description of what you expected to happen. 21 | 22 | ## Screenshots 23 | 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | ## Environment 27 | 28 | - `oauth2-mock-server` version: _(e.g. 1.0.0)_ 29 | - Noje.JS version: _(e.g. 8.0.0)_ 30 | - NPM version: _(e.g. 5.0.0)_ 31 | - Operating System: _(e.g. Windows 10)_ 32 | 33 | ## Additional Context 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | ## Summary 8 | 9 | A clear and concise description of what the problem is. 10 | (e.g. I'm always frustrated when ...) 11 | 12 | ## Desired solution 13 | 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Alternative solutions 17 | 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | ## Additional Context 21 | 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | 3 | - [ ] I have run `npm test` locally and all tests are passing. 4 | - [ ] I have added/updated tests for any new behavior. 5 | - [ ] If this is a significant change, an issue has already been created where the problem / solution was discussed: [N/A, or add link to issue here] 6 | 7 | ## PR Description 8 | 9 | Describe Your PR Here! 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | versioning-strategy: "increase" 10 | commit-message: 11 | prefix: "chore" 12 | include: "scope" 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "monthly" 18 | -------------------------------------------------------------------------------- /.github/workflows/clear_closed_pr_cache.yml: -------------------------------------------------------------------------------- 1 | name: Clear caches of closed PRs 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | 7 | jobs: 8 | cleanup: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Cleanup 12 | run: | 13 | gh extension install actions/gh-actions-cache 14 | 15 | echo "Fetching list of cache key" 16 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) 17 | 18 | ## Setting this to not fail the workflow while deleting cache keys. 19 | set +e 20 | echo "Deleting caches..." 21 | for cacheKey in $cacheKeysForPR 22 | do 23 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 24 | done 25 | echo "Done" 26 | env: 27 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | REPO: ${{ github.repository }} 29 | BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge 30 | -------------------------------------------------------------------------------- /.github/workflows/code_ql.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: '19 14 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | queries: security-and-quality 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | 53 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 54 | # queries: security-extended,security-and-quality 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | with: 74 | category: '/language:${{matrix.language}}' 75 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build & test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | node-version: [^20.19, ^22.12, ^24] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: npm 24 | cache-dependency-path: package-lock.json 25 | 26 | - name: Npm install 27 | run: npm ci 28 | 29 | - name: Transpile 30 | run: npm run build 31 | 32 | - name: Lint and run tests 33 | run: npm run test 34 | 35 | - name: Pack 36 | run: npm pack 37 | 38 | - name: Check package exports 39 | run: npx publint 40 | 41 | - name: Check dependencies exports 42 | run: npx publint deps --prod 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage/ 3 | /.vscode/ 4 | /.cache/ 5 | /.rollup.cache/ 6 | /dist/ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "semi": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | "editor.formatOnSave": true, 6 | "typescript.tsdk": "./node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). 6 | 7 | ## [8.1.0](https://github.com/axa-group/oauth2-mock-server/compare/v8.0.1...v8.1.0) — 2025-06-06 8 | 9 | ### Added 10 | 11 | - Export `HttpServer` and `OAuth2Service` (reported in [#344](https://github.com/axa-group/oauth2-mock-server/issues/344) by [jraoult](https://github.com/jraoult)) 12 | 13 | ### Changed 14 | 15 | - Update dependencies 16 | 17 | ## [8.0.1](https://github.com/axa-group/oauth2-mock-server/compare/v8.0.0...v8.0.1) — 2025-05-28 18 | 19 | ### Fixed 20 | 21 | - Fix crash when running `npx oauth2-mock-server --help` (reported in [#337](https://github.com/axa-group/oauth2-mock-server/issues/337) by [robcresswell](https://github.com/robcresswell)) 22 | 23 | ## [8.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v7.2.1...v8.0.0) — 2025-05-18 24 | 25 | ### Fixed 26 | 27 | - Fix wrong typescript annotation (by [sviande](https://github.com/sviande) in [#313](https://github.com/axa-group/oauth2-mock-server/pull/313)) 28 | 29 | ### Changed 30 | 31 | - **Breaking:** No longer support Node.js 18 32 | - Switched to "Universal" ESM. CommonJS `require()` usage pattern still supported for Nodejs ^20.19 & ^22.12 33 | - Add support for Node.js 24 34 | - Update dependencies 35 | 36 | ## [7.2.1](https://github.com/axa-group/oauth2-mock-server/compare/v7.2.0...v7.2.1) — 2025-04-30 37 | 38 | ### Fixed 39 | 40 | - Fix paths of well known endpoints when issuer ends with a forward slash (reported in [#331](https://github.com/axa-group/oauth2-mock-server/issues/331) by [kikisaeba](https://github.com/kikisaeba)) 41 | 42 | ### Changed 43 | 44 | - Update dependencies 45 | 46 | ## [7.2.0](https://github.com/axa-group/oauth2-mock-server/compare/v7.1.2...v7.2.0) — 2024-11-25 47 | 48 | ### Added 49 | 50 | - Include scope in token for authorization_code and refresh_token grants (by [PetrasJaug](https://github.com/PetrasJaug)) 51 | - Add PKCE support (by [tanettrimas](https://github.com/tanettrimas)) 52 | 53 | ### Changed 54 | 55 | - Update dependencies 56 | 57 | ## [7.1.2](https://github.com/axa-group/oauth2-mock-server/compare/v7.1.1...v7.1.2) — 2024-05-21 58 | 59 | ### Changed 60 | 61 | - Add support for Node.js 22 (by [sheinbergon](https://github.com/sheinbergon)) 62 | 63 | ## [7.1.1](https://github.com/axa-group/oauth2-mock-server/compare/v7.1.0...v7.1.1) — 2023-10-24 64 | 65 | ### Fixed 66 | 67 | - Be a better citizen in an ECMAScript modules world 68 | 69 | ## [7.1.0](https://github.com/axa-group/oauth2-mock-server/compare/v7.0.0...v7.1.0) — 2023-10-23 70 | 71 | ### Added 72 | 73 | - Add support for "aud" claim in "client_credentials" grants (by [kadams54](https://github.com/kadams54)) 74 | 75 | ### Changed 76 | 77 | - Update dependencies 78 | 79 | ## [7.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v6.0.1...v7.0.0) — 2023-10-04 80 | 81 | ### Changed 82 | 83 | - **Breaking:** No longer support Node.js 16 84 | 85 | ## [6.0.1](https://github.com/axa-group/oauth2-mock-server/compare/v6.0.0...v6.0.1) — 2023-10-03 86 | 87 | ### Security 88 | 89 | - Update dependencies to fix: 90 | - [CVE-2022-25883](https://github.com/advisories/GHSA-c2qf-rxjj-qqgw) 91 | - [CVE-2023-26115](https://github.com/advisories/GHSA-j8xg-fqg3-53r7) 92 | - [CVE-2023-43646](https://github.com/advisories/GHSA-4q6p-r6v2-jvc5) 93 | 94 | ## [6.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v5.0.2...v6.0.0) — 2023-06-19 95 | 96 | ### Changed 97 | 98 | - **Breaking:** No longer support Node.js 14 99 | - Fix authorize endpoint compliance (remove scope requirement, make state optional) (by [jirutka](https://github.com/jirutka)) 100 | - Add support for Node.js 20 101 | - Update dependencies 102 | 103 | ## [5.0.2](https://github.com/axa-group/oauth2-mock-server/compare/v5.0.1...v5.0.2) — 2023-02-20 104 | 105 | ### Security 106 | 107 | - Update dependencies to fix: 108 | - [CVE-2022-46175](https://github.com/advisories/GHSA-9c47-m6qq-7p4h) 109 | - [CVE-2022-24999](https://github.com/advisories/GHSA-hrpp-h998-j3pp) 110 | - [CVE-2022-25901](https://github.com/advisories/GHSA-h452-7996-h45h) 111 | 112 | ## [5.0.1](https://github.com/axa-group/oauth2-mock-server/compare/v5.0.0...v5.0.1) — 2022-10-04 113 | 114 | ### Security 115 | 116 | - Update dependencies to fix: 117 | - [CVE-2022-36083](https://github.com/panva/jose/security/advisories/GHSA-jv3g-j58f-9mq9) 118 | 119 | ## [5.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v4.3.2...v5.0.0) — 2022-06-27 120 | 121 | ### Changed 122 | 123 | - **Breaking:** No longer support Node.js 12 124 | - Add support for Node.js 18 125 | 126 | ## [4.3.2](https://github.com/axa-group/oauth2-mock-server/compare/v4.3.1...v4.3.2) — 2022-06-27 127 | 128 | ### Changed 129 | 130 | - Update dependencies 131 | 132 | ## [4.3.1](https://github.com/axa-group/oauth2-mock-server/compare/v4.3.0...v4.3.1) — 2022-03-29 133 | 134 | ### Security 135 | 136 | - Update dependencies to fix: 137 | - [CVE-2021-44906](https://github.com/advisories/GHSA-xvch-5gv4-984h) 138 | 139 | ## [4.3.0](https://github.com/axa-group/oauth2-mock-server/compare/v4.2.0...v4.3.0) — 2022-02-01 140 | 141 | ### Added 142 | 143 | - Support the token introspection endpoint (by [cfman](https://github.com/cfman)) 144 | 145 | ## [4.2.0](https://github.com/axa-group/oauth2-mock-server/compare/v4.1.1...v4.2.0) — 2022-01-28 146 | 147 | ### Added 148 | 149 | - Add support for custom endpoint pathnames (by [roskh](https://github.com/roskh)) 150 | - Teach `/token` endpoint to support JSON content type (by [roskh](https://github.com/roskh)) 151 | 152 | ## [4.1.1](https://github.com/axa-group/oauth2-mock-server/compare/v4.1.0...v4.1.1) — 2021-11-18 153 | 154 | ### Fixed 155 | 156 | - Fix regression: Prevent unhandled rejected promises when incorrectly invoking the /token endpoint 157 | 158 | ## [4.1.0](https://github.com/axa-group/oauth2-mock-server/compare/v4.0.0...v4.1.0) — 2021-11-15 159 | 160 | ### Added 161 | 162 | - HTTPS support (by [lbestftr](https://github.com/lbestftr)) 163 | 164 | ## [4.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v3.2.0...v4.0.0) — 2021-10-25 165 | 166 | ### Added 167 | 168 | - Add `/endsession` endpoint (by [AndTem](https://github.com/AndTem)) 169 | - Support `EdDSA` algorithm 170 | 171 | ### Removed 172 | 173 | - **Breaking:** Drop support for Node.js 10 174 | - No longer accepts PEM encoded keys 175 | - No longer supports generating unsigned JWTs 176 | 177 | ### Changed 178 | 179 | - **Breaking:** Reworked exposed API. Please refer to the [migration guide](./MIGRATION.md) for more information. 180 | - Add support for Node.js 16 181 | 182 | ## [3.2.0](https://github.com/axa-group/oauth2-mock-server/compare/v3.1.0...v3.2.0) — 2021-08-03 183 | 184 | ### Added 185 | 186 | - Add `subject_types_supported` OpenID Provider Metadata field (by [jjbooth74](https://github.com/jjbooth74)) 187 | 188 | ## [3.1.0](https://github.com/axa-group/oauth2-mock-server/compare/v3.0.3...v3.1.0) — 2020-11-30 189 | 190 | ### Added 191 | 192 | - Add authorize redirect event (by [markwallsgrove](https://github.com/markwallsgrove)) 193 | 194 | ## [3.0.3](https://github.com/axa-group/oauth2-mock-server/compare/v3.0.2...v3.0.3) — 2020-11-12 195 | 196 | ### Fixed 197 | 198 | - Fix regression: When adding a key to the KeyStore, do not normalize key "use" value to "sig" when already defined 199 | 200 | ## [3.0.2](https://github.com/axa-group/oauth2-mock-server/compare/v3.0.1...v3.0.2) — 2020-10-29 201 | 202 | ### Added 203 | 204 | - Support Nodejs 14.15 LTS 205 | 206 | ## [3.0.1](https://github.com/axa-group/oauth2-mock-server/compare/v3.0.0...v3.0.1) — 2020-10-23 207 | 208 | ### Fixed 209 | 210 | - Include missing files on pack/publish 211 | 212 | ## [3.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v2.0.0...v3.0.0) — 2020-10-22 213 | 214 | ### Added 215 | 216 | - TypeScript type definitions ([#48](https://github.com/axa-group/oauth2-mock-server/pull/48)) 217 | 218 | ### Changed 219 | 220 | - Straightened definitions of optional parameters: `null` is no longer considered as a non valued parameter value; `undefined` bears that meaning. 221 | 222 | ## [2.0.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.5.1...v2.0.0) — 2020-10-01 223 | 224 | ### Added 225 | 226 | - Honor OpenID Connect `nonce` ([#34](https://github.com/axa-group/oauth2-mock-server/pull/34) by [@HASHIMOTO-Takafumi](https://github.com/HASHIMOTO-Takafumi)) 227 | 228 | ### Removed 229 | 230 | - No longer support Node 8 231 | 232 | ## [1.5.1](https://github.com/axa-group/oauth2-mock-server/compare/v1.5.0...v1.5.1) — 2020-04-06 233 | 234 | ### Security 235 | 236 | - Update `npm` dependencies to fix: 237 | - [CVE-2020-7598](https://github.com/advisories/GHSA-vh95-rmgr-6w4m) 238 | 239 | ## [1.5.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.4.0...v1.5.0) — 2020-01-23 240 | 241 | ### Added 242 | 243 | - Add HTTP request object to `OAuth2Service`'s events 244 | - Add `beforeTokenSigning` event to `OAuth2Service` 245 | 246 | ## [1.4.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.3.3...v1.4.0) — 2020-01-15 247 | 248 | ### Security 249 | 250 | - Update `npm` dependencies to fix: 251 | - [NPM Security Advisory 1164](https://www.npmjs.com/advisories/1164) 252 | - [NPM Security Advisory 1300](https://www.npmjs.com/advisories/1300) 253 | - [NPM Security Advisory 1316](https://www.npmjs.com/advisories/1316) 254 | - [NPM Security Advisory 1324](https://www.npmjs.com/advisories/1324) 255 | - [NPM Security Advisory 1325](https://www.npmjs.com/advisories/1325) 256 | 257 | ### Fixed 258 | 259 | - Add missing `aud` claim under Authorization Code Flow 260 | 261 | ### Added 262 | 263 | - Add CORS support 264 | 265 | ## [1.3.3](https://github.com/axa-group/oauth2-mock-server/compare/v1.3.2...v1.3.3) — 2019-09-25 266 | 267 | ### Security 268 | 269 | - Update `npm` dependencies to fix: 270 | - [CVE-2019-15657](https://nvd.nist.gov/vuln/detail/CVE-2019-15657) 271 | - [CVE-2019-10746](https://nvd.nist.gov/vuln/detail/CVE-2019-10746) 272 | - [CVE-2019-10747](https://nvd.nist.gov/vuln/detail/CVE-2019-10747) 273 | 274 | ### Changed 275 | 276 | - Update license's legal entity. 277 | 278 | ## [1.3.2](https://github.com/axa-group/oauth2-mock-server/compare/v1.3.1...v1.3.2) — 2019-08-09 279 | 280 | ### Security 281 | 282 | - Update `npm` dependencies to fix: 283 | - [CVE-2019-10744](https://github.com/lodash/lodash/pull/4336) 284 | 285 | ## [1.3.1](https://github.com/axa-group/oauth2-mock-server/compare/v1.3.0...v1.3.1) — 2019-06-07 286 | 287 | ### Security 288 | 289 | - Update `npm` dependencies to fix: 290 | - [WS-2019-0032](https://github.com/nodeca/js-yaml/issues/475) 291 | - [WS-2019-0063](https://github.com/nodeca/js-yaml/pull/480) 292 | - [WS-2019-0064](https://github.com/wycats/handlebars.js/compare/v4.1.1...v4.1.2) 293 | 294 | ## [1.3.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.2.0...v1.3.0) — 2019-06-03 295 | 296 | ### Added 297 | 298 | - Add revocation endpoint 299 | 300 | ## [1.2.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.1.0...v1.2.0) — 2019-03-19 301 | 302 | ### Added 303 | 304 | - Add Authorization code grant 305 | - Add Refresh token grant 306 | - Add Userinfo endpoint 307 | 308 | ### Security 309 | 310 | - Update `npm` dependencies to fix [CVE-2018-16469](https://nvd.nist.gov/vuln/detail/CVE-2018-16469) 311 | 312 | ## [1.1.0](https://github.com/axa-group/oauth2-mock-server/compare/v1.0.0...v1.1.0) — 2018-08-02 313 | 314 | ### Added 315 | 316 | - Add Resource Owner Password Credentials grant 317 | 318 | ### Fixed 319 | 320 | - Add missing cache control headers on `/token` responses 321 | 322 | ## 1.0.0 — 2018-08-01 323 | 324 | Initial release. 325 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][cc], version 1.4, 71 | available at 72 | 73 | [cc]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | ## Reporting Issues 4 | 5 | Should you run into issues with the project, please don't hesitate to let us know by 6 | [filing an issue](https://github.com/axa-group/oauth2-mock-server/issues/new). 7 | 8 | Pull requests containing only failing tests demonstrating the issue are welcomed 9 | and this also helps ensure that your issue won't regress in the future once it's fixed. 10 | 11 | ## Pull Requests 12 | 13 | We accept [pull requests](https://github.com/axa-group/oauth2-mock-server/pull/new/master)! 14 | 15 | Generally we like to see pull requests that 16 | 17 | - Maintain the existing code style 18 | - Are focused on a single change (i.e. avoid large refactoring or style adjustments in untouched code if not the primary goal of the pull request) 19 | - Have [good commit messages](https://chris.beams.io/posts/git-commit/) 20 | - Have tests 21 | - Don't decrease the current code coverage (see `TestResults/coverage/index.html`) 22 | 23 | ## Running tests 24 | 25 | To run tests locally, first install all dependencies. 26 | 27 | ```shell 28 | npm install 29 | ``` 30 | 31 | From the root directory, run the tests. 32 | 33 | ```shell 34 | npm run test 35 | ``` 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) AXA Assistance France 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration guide 2 | 3 | ## From 3.2.0 to 4.0.0 4 | 5 | Complete diff available in pull requests [#80](https://github.com/axa-group/oauth2-mock-server/pull/80) and [#118](https://github.com/axa-group/oauth2-mock-server/pull/118). 6 | 7 | ### High level impact 8 | 9 | - Removed PEM handling related functions. As such, the cli no longer supports 10 | `--save-pem ` nor ` --pem ` options. One can leverage external 11 | tooling to convert PEM to JWK format 12 | (eg: https://www.npmjs.com/search?q=pem%20jwk). 13 | 14 | - When feeding the store with existing keys, serialized JWK format expects the 15 | `alg` property to be defined and valued. (Refer to [README.md](./README.md) 16 | for the complete list of supported algorithms). 17 | 18 | - Although not previously documented, the store was previously supporting 19 | symmetric algorithms (eg. `HS256`). This is no longer the case. 20 | 21 | - Generation of unsigned tokens (`alg`: `none`) is no longer supported 22 | 23 | ### Low level impact 24 | 25 | Most of the changes impact the lowest layers of the library. However, some of 26 | them eventually altered the higher ones. 27 | 28 | Below a quick recap of the most impactful changes would you use the library 29 | programatically. For a more detailed view of all the changes, please refer to 30 | the pull request mentioned above. 31 | 32 | - Key generation has been made a little more versatile and can now issue keys 33 | that are not only RSA based. 34 | 35 | ```diff 36 | -const key = await authServer.issuer.keys.generateRSA(); 37 | +const key = await authServer.issuer.keys.generate("RS256") 38 | ``` 39 | 40 | - Token generation method `buildToken()` now returns a promise 41 | 42 | ```diff 43 | -const jwt = authServer.issuer.buildToken(true, undefined, jwtTransformer); 44 | +const jwt = await authServer.issuer.buildToken({ scopesOrTransform: jwtTransformer }); 45 | ``` 46 | 47 | - Keys were previously being type [defined](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/2d2c4ced74bb356ec1c7b931dedd263bcfb5c4a1/types/node-jose/index.d.ts#L254-L265) 48 | as `JWK.Key` from the `@types/node-jose` package. 49 | 50 | They're now type defined as `JWK` and properly exported from by this package 51 | 52 | - `JWKStore.toJSON()` now directly returns a `JWK[]` rather than a Json object 53 | exposing a `keys` property. 54 | 55 | - From a TypeScript standpoint, inner type definitions are now exported 56 | from the root. This means that you can safely turn those lines 57 | 58 | ```ts 59 | import { OAuth2Server } from 'oauth2-mock-server'; 60 | import { Payload } from 'oauth2-mock-server/dist/lib/types'; 61 | ``` 62 | 63 | into 64 | 65 | ```ts 66 | import { OAuth2Server, Payload } from 'oauth2-mock-server'; 67 | ``` 68 | 69 | - Type `MutableAuthorizeRedirectUri` has been renamed into `MutableRedirectUri` 70 | 71 | ```diff 72 | -service.once('beforeAuthorizeRedirect', (authorizeRedirectUri: MutableAuthorizeRedirectUri, req) => { 73 | +service.once('beforeAuthorizeRedirect', (authorizeRedirectUri: MutableRedirectUri, req) => { 74 | ... 75 | }); 76 | ``` 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `oauth2-mock-server` 2 | 3 | [![npm package](https://img.shields.io/npm/v/oauth2-mock-server.svg?logo=npm)](https://www.npmjs.com/package/oauth2-mock-server) 4 | [![Node.js version](https://img.shields.io/node/v/oauth2-mock-server.svg)](https://nodejs.org/) 5 | 6 | > _OAuth 2 mock server. Intended to be used for development or testing purposes._ 7 | 8 | When developing an application that exposes or consumes APIs that are secured with an [OAuth 2](https://oauth.net/2/) authorization scheme, a mechanism for issuing access tokens is needed. Frequently, a developer needs to create custom code that fakes the creation of tokens for testing purposes, and these tokens cannot be properly verified, since there is no actual entity issuing those tokens. 9 | 10 | The purpose of this package is to provide an easily configurable OAuth 2 server, that can be set up and teared down at will, and can be programmatically run while performing automated tests. 11 | 12 | > **Warning:** This tool is _not_ intended to be used as an actual production grade OAuth 2 server. It lacks many features that would be required in a proper implementation. 13 | 14 | ## Development prerequisites 15 | 16 | - [Node.js 20.19+](https://nodejs.org/) 17 | 18 | ## How to use 19 | 20 | ### Installation 21 | 22 | Add it to your Node.js project as a development dependency: 23 | 24 | ```shell 25 | npm install --save-dev oauth2-mock-server 26 | ``` 27 | 28 | ### Quickstart 29 | 30 | Here is an example for creating and running a server instance with a single random RSA key: 31 | 32 | ```js 33 | import { OAuth2Server } from 'oauth2-mock-server'; 34 | // ...or in CommonJS style: 35 | // const { OAuth2Server } = require('oauth2-mock-server'); 36 | 37 | let server = new OAuth2Server(); 38 | 39 | // Generate a new RSA key and add it to the keystore 40 | await server.issuer.keys.generate('RS256'); 41 | 42 | // Start the server 43 | await server.start(8080, 'localhost'); 44 | console.log('Issuer URL:', server.issuer.url); // -> http://localhost:8080 45 | 46 | // Do some work with the server 47 | // ... 48 | 49 | // Stop the server 50 | await server.stop(); 51 | ``` 52 | 53 | Any number of existing JSON-formatted keys can be added to the keystore. 54 | 55 | ```js 56 | // Add an existing JWK key to the keystore 57 | await server.issuer.keys.add({ 58 | kid: 'some-key', 59 | alg: 'RS256', 60 | kty: 'RSA', 61 | // ... 62 | }); 63 | ``` 64 | 65 | JSON Web Tokens (JWT) can be built programmatically: 66 | 67 | ```js 68 | import axios from 'axios'; 69 | 70 | // Build a new token 71 | let token = await server.issuer.buildToken(); 72 | 73 | // Call a remote API with the token 74 | axios 75 | .get('https://server.example.com/api/endpoint', { 76 | headers: { 77 | authorization: `Bearer ${token}`, 78 | }, 79 | }) 80 | .then((response) => { 81 | /* ... */ 82 | }) 83 | .catch((error) => { 84 | /* ... */ 85 | }); 86 | ``` 87 | 88 | ### Supported grant types 89 | 90 | - No authentication 91 | - Client Credentials grant 92 | - Resource Owner Password Credentials grant 93 | - Authorization Code grant, with Proof Key for Code Exchange (PKCE) support 94 | - Refresh token grant 95 | 96 | ### Supported JWK formats 97 | 98 | | Algorithm | kty | alg | 99 | | ----------------- | --- | ------------------- | 100 | | RSASSA-PKCS1-v1_5 | RSA | RS256, RS384, RS512 | 101 | | RSASSA-PSS | RSA | PS256, PS384, PS512 | 102 | | ECDSA | EC | ES256, ES384, ES512 | 103 | | EdDSA | OKP | Ed25519 | 104 | 105 | ### Customization hooks 106 | 107 | It also provides a convenient way, through event emitters, to programmatically customize the server processing. This is particularly useful when expecting the OIDC service to behave in a specific way on one single test: 108 | 109 | - The JWT access token 110 | 111 | ```js 112 | // Modify the expiration time on next token produced 113 | service.once('beforeTokenSigning', (token, req) => { 114 | const timestamp = Math.floor(Date.now() / 1000); 115 | token.payload.exp = timestamp + 400; 116 | }); 117 | ``` 118 | 119 | ```js 120 | const basicAuth = require('basic-auth'); 121 | 122 | // Add the client ID to a token 123 | service.once('beforeTokenSigning', (token, req) => { 124 | const credentials = basicAuth(req); 125 | const clientId = credentials ? credentials.name : req.body.client_id; 126 | token.payload.client_id = clientId; 127 | }); 128 | ``` 129 | 130 | - The token endpoint response body and status 131 | 132 | ```js 133 | // Force the oidc service to provide an invalid_grant response 134 | // on next call to the token endpoint 135 | service.once('beforeResponse', (tokenEndpointResponse, req) => { 136 | tokenEndpointResponse.body = { 137 | error: 'invalid_grant', 138 | }; 139 | tokenEndpointResponse.statusCode = 400; 140 | }); 141 | ``` 142 | 143 | - The userinfo endpoint response body and status 144 | 145 | ```js 146 | // Force the oidc service to provide an error 147 | // on next call to userinfo endpoint 148 | service.once('beforeUserinfo', (userInfoResponse, req) => { 149 | userInfoResponse.body = { 150 | error: 'invalid_token', 151 | error_message: 'token is expired', 152 | }; 153 | userInfoResponse.statusCode = 401; 154 | }); 155 | ``` 156 | 157 | - The revoke endpoint response body and status 158 | 159 | ```js 160 | // Simulates a custom token revocation body 161 | service.once('beforeRevoke', (revokeResponse, req) => { 162 | revokeResponse.body = { 163 | result: 'revoked', 164 | }; 165 | }); 166 | ``` 167 | 168 | - The authorization endpoint redirect uri and query parameters 169 | 170 | ```js 171 | // Modify the uri and query parameters 172 | // before the authorization redirect 173 | service.once('beforeAuthorizeRedirect', (authorizeRedirectUri, req) => { 174 | authorizeRedirectUri.url.searchParams.set('foo', 'bar'); 175 | }); 176 | ``` 177 | 178 | - The end session endpoint post logout redirect uri 179 | 180 | ```js 181 | // Modify the uri and query parameters 182 | // before the post_logout_redirect_uri redirect 183 | service.once('beforePostLogoutRedirect', (postLogoutRedirectUri, req) => { 184 | postLogoutRedirectUri.url.searchParams.set('foo', 'bar'); 185 | }); 186 | ``` 187 | 188 | - The introspect endpoint response body 189 | 190 | ```js 191 | // Simulate a custom token introspection response body 192 | service.once('beforeIntrospect', (introspectResponse, req) => { 193 | introspectResponse.body = { 194 | active: true, 195 | scope: 'read write email', 196 | client_id: '', 197 | username: 'dummy', 198 | exp: 1643712575, 199 | }; 200 | }); 201 | ``` 202 | 203 | ### HTTPS support 204 | 205 | It also provides basic HTTPS support, an optional cert and key can be supplied to start the server with SSL/TLS using the in-built NodeJS [HTTPS](https://nodejs.org/api/https.html) module. 206 | 207 | We recommend using a package to create a locally trusted certificate, like [mkcert](https://github.com/FiloSottile/mkcert). 208 | 209 | ```js 210 | let server = new OAuth2Server( 211 | 'test-assets/mock-auth/key.pem', 212 | 'test-assets/mock-auth/cert.pem' 213 | ); 214 | ``` 215 | 216 | NOTE: Enabling HTTPS will also update the issuer URL to reflect the current protocol. 217 | 218 | ## Supported endpoints 219 | 220 | ### GET `/.well-known/openid-configuration` 221 | 222 | Returns the [OpenID Provider Configuration Information](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) for the server. 223 | 224 | ### GET `/jwks` 225 | 226 | Returns the JSON Web Key Set (JWKS) of all the keys configured in the server. 227 | 228 | ### POST `/token` 229 | 230 | Issues access tokens. 231 | 232 | ### GET `/authorize` 233 | 234 | It simulates the user authentication. It will automatically redirect to the callback endpoint sent as parameter. 235 | It currently supports only 'code' response_type. 236 | 237 | ### GET `/userinfo` 238 | 239 | It provides extra userinfo claims. 240 | 241 | ### POST `/revoke` 242 | 243 | It simulates a token revocation. This endpoint should always return 200 as stated by [RFC 7009](https://tools.ietf.org/html/rfc7009#section-2.2). 244 | 245 | ### GET `/endsession` 246 | 247 | It simulates the end session endpoint. It will automatically redirect to the post_logout_redirect_uri sent as parameter. 248 | 249 | ### POST `/introspect` 250 | 251 | It simulates the [token introspection endpoint](https://www.oauth.com/oauth2-servers/token-introspection-endpoint/). 252 | 253 | ## Command-Line Interface 254 | 255 | The server can be run from the command line. 256 | 257 | ```shell 258 | npx oauth2-mock-server --help 259 | ``` 260 | 261 | ## Attributions 262 | 263 | - [`jose`](https://www.npmjs.com/package/jose) 264 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import jsdoc from 'eslint-plugin-jsdoc'; 3 | import tseslint from 'typescript-eslint'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | import eslintPluginPrettierRecommendedConfig from 'eslint-plugin-prettier/recommended'; 6 | import importPlugin from 'eslint-plugin-import-x'; 7 | import vitest from '@vitest/eslint-plugin'; 8 | 9 | export default tseslint.config( 10 | eslint.configs.recommended, 11 | tseslint.configs.strictTypeChecked, 12 | tseslint.configs.stylisticTypeChecked, 13 | prettierConfig, 14 | eslintPluginPrettierRecommendedConfig, 15 | importPlugin.flatConfigs.recommended, 16 | importPlugin.flatConfigs.typescript, 17 | jsdoc.configs['flat/recommended-typescript'], 18 | { 19 | languageOptions: { 20 | parserOptions: { 21 | project: './tsconfig.eslint.json', 22 | }, 23 | }, 24 | }, 25 | { 26 | ignores: ['node_modules/*', 'dist/*', 'coverage/*', '.vscode/*'], 27 | }, 28 | { 29 | rules: { 30 | curly: 'warn', 31 | eqeqeq: 'warn', 32 | semi: 'warn', 33 | '@typescript-eslint/consistent-type-imports': 'warn', 34 | 'jsdoc/require-jsdoc': [ 35 | 'warn', 36 | { 37 | publicOnly: true, 38 | }, 39 | ], 40 | 'import-x/order': [ 41 | 'error', 42 | { 43 | groups: [ 44 | 'builtin', 45 | 'external', 46 | 'internal', 47 | 'parent', 48 | 'sibling', 49 | 'index', 50 | ], 51 | 'newlines-between': 'always', 52 | }, 53 | ], 54 | }, 55 | }, 56 | { 57 | files: ['test/**/*.test.ts'], 58 | plugins: { 59 | vitest, 60 | }, 61 | rules: { 62 | ...vitest.configs.recommended.rules, 63 | }, 64 | settings: { 65 | vitest: { 66 | typecheck: true, 67 | }, 68 | }, 69 | languageOptions: { 70 | globals: { 71 | ...vitest.environments.env.globals, 72 | }, 73 | }, 74 | }, 75 | { 76 | files: ['test/**/*.ts'], 77 | rules: { 78 | '@typescript-eslint/no-non-null-assertion': 'off', 79 | '@typescript-eslint/no-unsafe-assignment': 'off', 80 | '@typescript-eslint/no-unsafe-member-access': 'off', 81 | 'jsdoc/require-jsdoc': 'off', 82 | }, 83 | }, 84 | ); 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth2-mock-server", 3 | "version": "8.1.0", 4 | "description": "OAuth 2 mock server", 5 | "type": "module", 6 | "keywords": [ 7 | "oauth", 8 | "oauth2", 9 | "oauth 2", 10 | "mock", 11 | "fake", 12 | "stub", 13 | "server", 14 | "cli", 15 | "jwt", 16 | "oidc", 17 | "openid", 18 | "connect" 19 | ], 20 | "author": { 21 | "name": "Jorge Poveda", 22 | "email": "jorge.poveda@axa-assistance.es" 23 | }, 24 | "license": "MIT", 25 | "engines": { 26 | "node": "^20.19 || ^22.12 || ^24" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/axa-group/oauth2-mock-server.git" 31 | }, 32 | "main": "./dist/index.js", 33 | "types": "./dist/types/index.d.ts", 34 | "exports": { 35 | ".": { 36 | "types": "./dist/types/index.d.ts", 37 | "default": "./dist/index.js" 38 | } 39 | }, 40 | "bin": { 41 | "oauth2-mock-server": "./dist/oauth2-mock-server.js" 42 | }, 43 | "files": [ 44 | "CHANGELOG.md", 45 | "MIGRATION.md", 46 | "LICENSE.md", 47 | "README.md", 48 | "dist/**/*.*" 49 | ], 50 | "scripts": { 51 | "build": "rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 52 | "prelint": "tsc --noEmit", 53 | "lint": "eslint --cache --cache-location .cache/ --ext=.ts src test --max-warnings 0", 54 | "prepack": "npm run build", 55 | "pretest": "npm run lint", 56 | "test": "vitest --run --coverage", 57 | "test:watch": "vitest --watch" 58 | }, 59 | "dependencies": { 60 | "basic-auth": "^2.0.1", 61 | "cors": "^2.8.5", 62 | "express": "^5.1.0", 63 | "is-plain-obj": "^4.1.0", 64 | "jose": "^6.0.11" 65 | }, 66 | "devDependencies": { 67 | "@eslint/js": "^9.28.0", 68 | "@rollup/plugin-typescript": "^12.1.2", 69 | "@types/basic-auth": "^1.1.6", 70 | "@types/cors": "^2.8.17", 71 | "@types/express": "^5.0.2", 72 | "@types/node": "^20.17.57", 73 | "@types/supertest": "^6.0.3", 74 | "@typescript-eslint/eslint-plugin": "^8.33.1", 75 | "@typescript-eslint/parser": "^8.33.1", 76 | "@vitest/coverage-v8": "^3.2.1", 77 | "@vitest/eslint-plugin": "^1.2.1", 78 | "eslint": "^9.28.0", 79 | "eslint-config-prettier": "^10.1.3", 80 | "eslint-import-resolver-typescript": "^4.4.2", 81 | "eslint-plugin-import-x": "^4.15.0", 82 | "eslint-plugin-jsdoc": "^50.7.1", 83 | "eslint-plugin-prettier": "^5.4.1", 84 | "prettier": "^3.5.3", 85 | "rollup": "^4.41.1", 86 | "rollup-plugin-dts": "^6.1.0", 87 | "supertest": "^7.1.1", 88 | "tslib": "^2.8.1", 89 | "typescript": "^5.8.3", 90 | "typescript-eslint": "^8.33.1", 91 | "vitest": "^3.2.1" 92 | }, 93 | "overrides": { 94 | "@types/node": "$@types/node" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { rmSync, readFileSync } from 'fs'; 2 | 3 | import typescript from '@rollup/plugin-typescript'; 4 | import { dts } from 'rollup-plugin-dts'; 5 | 6 | const extractDirectDependencies = (): string[] => { 7 | const { dependencies } = JSON.parse( 8 | readFileSync('package.json', 'utf8'), 9 | ) as Record; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 12 | return Object.keys(dependencies!); 13 | }; 14 | 15 | const dependencies = extractDirectDependencies(); 16 | 17 | const external = (id: string): boolean => { 18 | if (id.startsWith('node:')) { 19 | return true; 20 | } 21 | 22 | if (dependencies.includes(id)) { 23 | return true; 24 | } 25 | 26 | return false; 27 | }; 28 | 29 | export default [ 30 | { 31 | external, 32 | plugins: [ 33 | { 34 | name: 'Pre/post cleanup', 35 | buildStart() { 36 | rmSync(new URL('dist/', import.meta.url), { 37 | recursive: true, 38 | force: true, 39 | }); 40 | }, 41 | }, 42 | typescript(), 43 | ], 44 | input: { 45 | index: 'src/index.ts', 46 | 'oauth2-mock-server': 'src/oauth2-mock-server.ts', 47 | }, 48 | output: [ 49 | { 50 | entryFileNames: '[name].js', 51 | chunkFileNames: 'shared/[name].js', 52 | dir: 'dist', 53 | format: 'es', 54 | }, 55 | ], 56 | }, 57 | { 58 | external, 59 | plugins: [ 60 | dts({ 61 | compilerOptions: { 62 | declaration: true, 63 | emitDeclarationOnly: true, 64 | removeComments: false, 65 | }, 66 | }), 67 | ], 68 | input: 'src/index.ts', 69 | output: { 70 | file: 'dist/types/index.d.ts', 71 | format: 'es', 72 | }, 73 | }, 74 | ]; 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | export { HttpServer } from './lib/http-server'; 17 | export { JWKStore } from './lib/jwk-store'; 18 | export { OAuth2Issuer } from './lib/oauth2-issuer'; 19 | export { OAuth2Server } from './lib/oauth2-server'; 20 | export { OAuth2Service } from './lib/oauth2-service'; 21 | export * from './lib/types'; 22 | -------------------------------------------------------------------------------- /src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /* eslint-disable jsdoc/require-jsdoc */ 17 | 18 | import { AssertionError } from 'node:assert'; 19 | import type { AddressInfo } from 'node:net'; 20 | import { readFileSync } from 'node:fs'; 21 | import { webcrypto as crypto } from 'node:crypto'; 22 | 23 | import isPlainObject from 'is-plain-obj'; 24 | 25 | import type { CodeChallenge, JWK, PKCEAlgorithm, TokenRequest } from './types'; 26 | 27 | export const defaultTokenTtl = 3600; 28 | 29 | export function assertIsString( 30 | input: unknown, 31 | errorMessage: string, 32 | ): asserts input is string { 33 | if (typeof input !== 'string') { 34 | throw new AssertionError({ message: errorMessage }); 35 | } 36 | } 37 | 38 | export function assertIsStringOrUndefined( 39 | input: unknown, 40 | errorMessage: string, 41 | ): asserts input is string | undefined { 42 | if (typeof input !== 'string' && input !== undefined) { 43 | throw new AssertionError({ message: errorMessage }); 44 | } 45 | } 46 | 47 | export function assertIsAddressInfo( 48 | input: string | null | AddressInfo, 49 | ): asserts input is AddressInfo { 50 | if (input === null || typeof input === 'string') { 51 | throw new AssertionError({ message: 'Unexpected address type' }); 52 | } 53 | } 54 | 55 | export function assertIsPlainObject( 56 | obj: unknown, 57 | errMessage: string, 58 | ): asserts obj is Record { 59 | if (!isPlainObject(obj)) { 60 | throw new AssertionError({ message: errMessage }); 61 | } 62 | } 63 | 64 | export async function pkceVerifierMatchesChallenge( 65 | verifier: string, 66 | challenge: CodeChallenge, 67 | ) { 68 | const generatedChallenge = await createPKCECodeChallenge( 69 | verifier, 70 | challenge.method, 71 | ); 72 | return generatedChallenge === challenge.challenge; 73 | } 74 | 75 | export function assertIsValidTokenRequest( 76 | body: unknown, 77 | ): asserts body is TokenRequest { 78 | assertIsPlainObject(body, 'Invalid token request body'); 79 | 80 | if ('scope' in body) { 81 | assertIsString(body['scope'], "Invalid 'scope' type"); 82 | } 83 | 84 | assertIsString(body['grant_type'], "Invalid 'grant_type' type"); 85 | 86 | if ('code' in body) { 87 | assertIsString(body['code'], "Invalid 'code' type"); 88 | } 89 | 90 | if ('aud' in body) { 91 | const aud = body['aud']; 92 | if (Array.isArray(aud)) { 93 | aud.forEach((a) => { 94 | assertIsString(a, "Invalid 'aud' type"); 95 | }); 96 | } else { 97 | assertIsString(aud, "Invalid 'aud' type"); 98 | } 99 | } 100 | } 101 | 102 | export function shift(arr: (string | undefined)[]): string { 103 | if (arr.length === 0) { 104 | throw new AssertionError({ message: 'Empty array' }); 105 | } 106 | 107 | const val = arr.shift(); 108 | 109 | if (val === undefined) { 110 | throw new AssertionError({ message: 'Empty value' }); 111 | } 112 | 113 | return val; 114 | } 115 | 116 | export const readJsonFromFile = (filepath: string): Record => { 117 | const content = readFileSync(filepath, 'utf8'); 118 | 119 | const maybeJson = JSON.parse(content) as unknown; 120 | 121 | assertIsPlainObject( 122 | maybeJson, 123 | `File "${filepath}" doesn't contain a properly JSON serialized object.`, 124 | ); 125 | 126 | return maybeJson; 127 | }; 128 | 129 | export const isValidPkceCodeVerifier = (verifier: string) => { 130 | const PKCE_CHALLENGE_REGEX = /^[A-Za-z0-9\-._~]{43,128}$/; 131 | return PKCE_CHALLENGE_REGEX.test(verifier); 132 | }; 133 | 134 | export const createPKCEVerifier = () => { 135 | const randomBytes = crypto.getRandomValues(new Uint8Array(32)); 136 | return Buffer.from(randomBytes).toString('base64url'); 137 | }; 138 | 139 | export const supportedPkceAlgorithms = ['plain', 'S256'] as const; 140 | 141 | export const createPKCECodeChallenge = async ( 142 | verifier: string = createPKCEVerifier(), 143 | algorithm: PKCEAlgorithm = 'plain', 144 | ) => { 145 | let challenge: string; 146 | 147 | switch (algorithm) { 148 | case 'plain': { 149 | challenge = verifier; 150 | break; 151 | } 152 | case 'S256': { 153 | const buffer = await crypto.subtle.digest( 154 | 'SHA-256', 155 | new TextEncoder().encode(verifier), 156 | ); 157 | challenge = Buffer.from(buffer).toString('base64url'); 158 | break; 159 | } 160 | default: 161 | throw new Error(`Unsupported PKCE method ("${algorithm as string}")`); 162 | } 163 | return challenge; 164 | }; 165 | 166 | type JwkTransformer = (jwk: JWK) => JWK; 167 | 168 | const RsaPrivateFieldsRemover: JwkTransformer = (jwk) => { 169 | const x = { ...jwk }; 170 | 171 | delete x.d; 172 | delete x.p; 173 | delete x.q; 174 | delete x.dp; 175 | delete x.dq; 176 | delete x.qi; 177 | 178 | return x; 179 | }; 180 | 181 | const EcdsaPrivateFieldsRemover: JwkTransformer = (jwk) => { 182 | const x = { ...jwk }; 183 | 184 | delete x.d; 185 | 186 | return x; 187 | }; 188 | 189 | const EddsaPrivateFieldsRemover: JwkTransformer = (jwk) => { 190 | const x = { ...jwk }; 191 | 192 | delete x.d; 193 | 194 | return x; 195 | }; 196 | 197 | const privateToPublicTransformerMap: Record = { 198 | // RSASSA-PKCS1-v1_5 199 | RS256: RsaPrivateFieldsRemover, 200 | RS384: RsaPrivateFieldsRemover, 201 | RS512: RsaPrivateFieldsRemover, 202 | 203 | // RSASSA-PSS 204 | PS256: RsaPrivateFieldsRemover, 205 | PS384: RsaPrivateFieldsRemover, 206 | PS512: RsaPrivateFieldsRemover, 207 | 208 | // ECDSA 209 | ES256: EcdsaPrivateFieldsRemover, 210 | ES384: EcdsaPrivateFieldsRemover, 211 | ES512: EcdsaPrivateFieldsRemover, 212 | 213 | // Edwards-curve DSA 214 | EdDSA: EddsaPrivateFieldsRemover, 215 | }; 216 | 217 | export const supportedAlgs = Object.keys(privateToPublicTransformerMap); 218 | 219 | export const privateToPublicKeyTransformer = (privateKey: JWK): JWK => { 220 | const transformer = privateToPublicTransformerMap[privateKey.alg]; 221 | 222 | if (transformer === undefined) { 223 | throw new Error(`Unsupported algo '${privateKey.alg}'`); 224 | } 225 | 226 | return transformer(privateKey); 227 | }; 228 | -------------------------------------------------------------------------------- /src/lib/http-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /** 17 | * HTTP Server library 18 | * @module lib/http-server 19 | */ 20 | 21 | import type { Server, RequestListener } from 'node:http'; 22 | import { createServer } from 'node:http'; 23 | import { createServer as createHttpsServer } from 'node:https'; 24 | import type { AddressInfo } from 'node:net'; 25 | import { isIP } from 'node:net'; 26 | import { URL } from 'node:url'; 27 | 28 | import { assertIsAddressInfo } from './helpers'; 29 | import type { HttpServerOptions } from './types'; 30 | 31 | /** 32 | * Provides a restartable wrapper for http.CreateServer(). 33 | */ 34 | export class HttpServer { 35 | #server: Server; 36 | #isSecured: boolean; 37 | 38 | /** 39 | * Creates a new instance of HttpServer. 40 | * @param requestListener The function that will handle the server's requests. 41 | * @param options Optional HttpServerOptions to start the server with https. 42 | */ 43 | constructor(requestListener: RequestListener, options?: HttpServerOptions) { 44 | this.#isSecured = false; 45 | 46 | if (options?.key && options.cert) { 47 | this.#server = createHttpsServer(options, requestListener); 48 | this.#isSecured = true; 49 | } else { 50 | this.#server = createServer(requestListener); 51 | } 52 | } 53 | 54 | /** 55 | * Returns a value indicating whether or not the server is listening for connections. 56 | * @returns A boolean value indicating whether the server is listening. 57 | */ 58 | get listening(): boolean { 59 | return this.#server.listening; 60 | } 61 | 62 | /** 63 | * Returns the bound address, family name and port where the server is listening, 64 | * or null if the server has not been started. 65 | * @returns The server bound address information. 66 | */ 67 | address(): AddressInfo { 68 | if (!this.listening) { 69 | throw new Error('Server is not started.'); 70 | } 71 | 72 | const address = this.#server.address(); 73 | 74 | assertIsAddressInfo(address); 75 | 76 | return address; 77 | } 78 | 79 | /** 80 | * Starts the server. 81 | * @param port Port number. If omitted, it will be assigned by the operating system. 82 | * @param host Host name. 83 | * @returns A promise that resolves when the server has been started. 84 | */ 85 | async start(port?: number, host?: string): Promise { 86 | if (this.listening) { 87 | throw new Error('Server has already been started.'); 88 | } 89 | 90 | return new Promise((resolve, reject) => { 91 | this.#server 92 | .listen(port, host) 93 | .on('listening', resolve) 94 | .on('error', reject); 95 | }); 96 | } 97 | 98 | /** 99 | * Stops the server. 100 | * @returns Resolves when the server has been stopped. 101 | */ 102 | async stop(): Promise { 103 | if (!this.listening) { 104 | throw new Error('Server is not started.'); 105 | } 106 | 107 | return new Promise((resolve, reject) => { 108 | this.#server.close((err) => { 109 | if (err) { 110 | reject(err); 111 | return; 112 | } 113 | 114 | resolve(); 115 | }); 116 | }); 117 | } 118 | 119 | protected buildIssuerUrl(host: string | undefined, port: number): string { 120 | const url = new URL( 121 | `${this.#isSecured ? 'https' : 'http'}://localhost:${port.toString()}`, 122 | ); 123 | 124 | if (host && !coversLocalhost(host)) { 125 | url.hostname = host.includes(':') ? `[${host}]` : host; 126 | } 127 | 128 | return url.origin; 129 | } 130 | } 131 | 132 | const coversLocalhost = (address: string) => { 133 | switch (isIP(address)) { 134 | case 4: 135 | return address === '0.0.0.0' || address.startsWith('127.'); 136 | case 6: 137 | return address === '::' || address === '::1'; 138 | default: 139 | return false; 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /src/lib/jwk-store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /** 17 | * JWK Store library 18 | * @module lib/jwk-store 19 | */ 20 | 21 | import { randomBytes } from 'node:crypto'; 22 | import { AssertionError } from 'node:assert'; 23 | 24 | import type { GenerateKeyPairOptions } from 'jose'; 25 | import { exportJWK, importJWK, generateKeyPair } from 'jose'; 26 | 27 | import type { JWK } from './types'; 28 | import type { JWKWithKid } from './types-internals'; 29 | import { 30 | assertIsPlainObject, 31 | privateToPublicKeyTransformer, 32 | supportedAlgs, 33 | } from './helpers'; 34 | 35 | const generateRandomKid = () => { 36 | return randomBytes(40).toString('hex'); 37 | }; 38 | 39 | function normalizeKeyKid( 40 | jwk: unknown, 41 | opts?: { kid?: string }, 42 | ): asserts jwk is JWKWithKid { 43 | assertIsPlainObject(jwk, 'Invalid jwk format'); 44 | 45 | if (jwk['kid'] !== undefined) { 46 | return; 47 | } 48 | 49 | if (opts?.kid !== undefined) { 50 | jwk['kid'] = opts.kid; 51 | } else { 52 | jwk['kid'] = generateRandomKid(); 53 | } 54 | } 55 | 56 | /** 57 | * Simple JWK store 58 | */ 59 | export class JWKStore { 60 | #keyRotator: KeyRotator; 61 | 62 | /** 63 | * Creates a new instance of the keystore. 64 | */ 65 | constructor() { 66 | this.#keyRotator = new KeyRotator(); 67 | } 68 | 69 | /** 70 | * Generates a new random key and adds it into this keystore. 71 | * @param alg The selected algorithm. 72 | * @param opts The options. 73 | * @param opts.kid The key identifier to use. 74 | * @param opts.crv The OKP "crv" to be used for "EdDSA" algorithm. 75 | * @returns The promise for the generated key. 76 | */ 77 | async generate( 78 | alg: string, 79 | opts?: { kid?: string; crv?: string }, 80 | ): Promise { 81 | const generateOpts: GenerateKeyPairOptions = 82 | opts?.crv !== undefined ? { crv: opts.crv } : {}; 83 | 84 | generateOpts.extractable = true; 85 | 86 | if ( 87 | alg === 'EdDSA' && 88 | generateOpts.crv !== undefined && 89 | generateOpts.crv !== 'Ed25519' 90 | ) { 91 | throw new Error( 92 | 'Invalid or unsupported crv option provided, supported values are: Ed25519', 93 | ); 94 | } 95 | 96 | const pair = await generateKeyPair(alg, generateOpts); 97 | const joseJwk = await exportJWK(pair.privateKey); 98 | normalizeKeyKid(joseJwk, opts); 99 | joseJwk.alg = alg; 100 | 101 | const jwk = joseJwk as JWK; 102 | this.#keyRotator.add(jwk); 103 | return jwk; 104 | } 105 | 106 | /** 107 | * Adds a JWK key to this keystore. 108 | * @param maybeJwk The JWK key to add. 109 | * @returns The promise for the added key. 110 | */ 111 | async add(maybeJwk: Record): Promise { 112 | const tempJwk = { ...maybeJwk }; 113 | 114 | normalizeKeyKid(tempJwk); 115 | 116 | if (!('alg' in tempJwk)) { 117 | throw new Error('Unspecified JWK "alg" property'); 118 | } 119 | 120 | if (!supportedAlgs.includes(tempJwk.alg)) { 121 | throw new Error(`Unsupported JWK "alg" value ("${tempJwk.alg}")`); 122 | } 123 | 124 | const jwk = tempJwk as JWK; 125 | 126 | const privateKey = await importJWK(jwk, jwk.alg, { extractable: false }); 127 | 128 | if (privateKey instanceof Uint8Array || privateKey.type !== 'private') { 129 | throw new Error( 130 | `Invalid JWK type. No "private" key related data has been found.`, 131 | ); 132 | } 133 | 134 | this.#keyRotator.add(jwk); 135 | 136 | return jwk; 137 | } 138 | 139 | /** 140 | * Gets a key from the keystore in a round-robin fashion. 141 | * If a 'kid' is provided, only keys that match will be taken into account. 142 | * @param kid The optional key identifier to match keys against. 143 | * @returns The retrieved key. 144 | */ 145 | get(kid?: string): JWK | undefined { 146 | return this.#keyRotator.next(kid); 147 | } 148 | 149 | /** 150 | * Generates a JSON representation of this keystore, which conforms 151 | * to a JWK Set from {I-D.ietf-jose-json-web-key}. 152 | * @param [includePrivateFields] `true` if the private fields 153 | * of stored keys are to be included. 154 | * @returns The JSON representation of this keystore. 155 | */ 156 | toJSON(includePrivateFields = false): JWK[] { 157 | return this.#keyRotator.toJSON(includePrivateFields); 158 | } 159 | } 160 | 161 | class KeyRotator { 162 | #keys: JWK[] = []; 163 | 164 | add(key: JWK): void { 165 | const pos = this.findNext(key.kid); 166 | 167 | if (pos > -1) { 168 | this.#keys.splice(pos, 1); 169 | } 170 | 171 | this.#keys.push(key); 172 | } 173 | 174 | next(kid?: string): JWK | undefined { 175 | const i = this.findNext(kid); 176 | 177 | if (i === -1) { 178 | return undefined; 179 | } 180 | 181 | return this.moveToTheEnd(i); 182 | } 183 | 184 | toJSON(includePrivateFields: boolean): JWK[] { 185 | const keys: JWK[] = []; 186 | 187 | for (const key of this.#keys) { 188 | if (includePrivateFields) { 189 | keys.push({ ...key }); 190 | continue; 191 | } 192 | 193 | keys.push(privateToPublicKeyTransformer(key)); 194 | } 195 | 196 | return keys; 197 | } 198 | 199 | private findNext(kid?: string): number { 200 | if (this.#keys.length === 0) { 201 | return -1; 202 | } 203 | 204 | if (kid === undefined) { 205 | return 0; 206 | } 207 | 208 | return this.#keys.findIndex((x) => x.kid === kid); 209 | } 210 | 211 | private moveToTheEnd(i: number): JWK { 212 | const [key] = this.#keys.splice(i, 1); 213 | 214 | if (key === undefined) { 215 | throw new AssertionError({ 216 | message: 'Unexpected error. key is supposed to exist', 217 | }); 218 | } 219 | 220 | this.#keys.push(key); 221 | 222 | return key; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/lib/oauth2-issuer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /** 17 | * OAuth2 Issuer library 18 | * @module lib/oauth2-issuer 19 | */ 20 | 21 | import { EventEmitter } from 'node:events'; 22 | 23 | import { importJWK, SignJWT } from 'jose'; 24 | 25 | import { JWKStore } from './jwk-store'; 26 | import { assertIsString, defaultTokenTtl } from './helpers'; 27 | import type { Header, MutableToken, Payload, TokenBuildOptions } from './types'; 28 | import { InternalEvents } from './types-internals'; 29 | 30 | /** 31 | * Represents an OAuth 2 issuer. 32 | */ 33 | export class OAuth2Issuer extends EventEmitter { 34 | /** 35 | * Sets or returns the issuer URL. 36 | */ 37 | url: string | undefined; 38 | 39 | #keys: JWKStore; 40 | 41 | /** 42 | * Creates a new instance of HttpServer. 43 | */ 44 | constructor() { 45 | super(); 46 | this.url = undefined; 47 | 48 | this.#keys = new JWKStore(); 49 | } 50 | 51 | /** 52 | * Returns the key store. 53 | * @returns The key store. 54 | */ 55 | get keys(): JWKStore { 56 | return this.#keys; 57 | } 58 | 59 | /** 60 | * Builds a JWT. 61 | * @param opts JWT token building overrides 62 | * @returns The produced JWT. 63 | * @fires OAuth2Issuer#beforeSigning 64 | */ 65 | async buildToken(opts?: TokenBuildOptions): Promise { 66 | const key = this.keys.get(opts?.kid); 67 | 68 | if (key === undefined) { 69 | throw new Error('Cannot build token: Unknown key.'); 70 | } 71 | 72 | const timestamp = Math.floor(Date.now() / 1000); 73 | 74 | const header: Header = { 75 | kid: key.kid, 76 | }; 77 | 78 | assertIsString(this.url, 'Unknown issuer url'); 79 | 80 | const payload: Payload = { 81 | iss: this.url, 82 | iat: timestamp, 83 | exp: timestamp + (opts?.expiresIn ?? defaultTokenTtl), 84 | nbf: timestamp - 10, 85 | }; 86 | 87 | if (opts?.scopesOrTransform !== undefined) { 88 | const scopesOrTransform = opts.scopesOrTransform; 89 | 90 | if (typeof scopesOrTransform === 'string') { 91 | payload['scope'] = scopesOrTransform; 92 | } else if (Array.isArray(scopesOrTransform)) { 93 | payload['scope'] = scopesOrTransform.join(' '); 94 | } else if (typeof scopesOrTransform === 'function') { 95 | scopesOrTransform(header, payload); 96 | } 97 | } 98 | 99 | const token: MutableToken = { 100 | header, 101 | payload, 102 | }; 103 | 104 | /** 105 | * Before signing event. 106 | * @event OAuth2Issuer#beforeSigning 107 | * @param {MutableToken} token The JWT header and payload. 108 | */ 109 | this.emit(InternalEvents.BeforeSigning, token); 110 | 111 | const privateKey = await importJWK(key); 112 | 113 | const jwt = await new SignJWT(token.payload) 114 | .setProtectedHeader({ ...token.header, typ: 'JWT', alg: key.alg }) 115 | .sign(privateKey); 116 | 117 | return jwt; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/lib/oauth2-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /** 17 | * OAuth2 HTTP Server library 18 | * @module lib/oauth2-server 19 | */ 20 | 21 | import { readFileSync } from 'node:fs'; 22 | import type { AddressInfo } from 'node:net'; 23 | 24 | import { HttpServer } from './http-server'; 25 | import { OAuth2Issuer } from './oauth2-issuer'; 26 | import { OAuth2Service } from './oauth2-service'; 27 | import { assertIsAddressInfo } from './helpers'; 28 | import type { HttpServerOptions, OAuth2Options } from './types'; 29 | 30 | /** 31 | * Represents an OAuth2 HTTP server. 32 | */ 33 | export class OAuth2Server extends HttpServer { 34 | private _service: OAuth2Service; 35 | private _issuer: OAuth2Issuer; 36 | 37 | /** 38 | * Creates a new instance of OAuth2Server. 39 | * @param key Optional key file path for ssl 40 | * @param cert Optional cert file path for ssl 41 | * @param oauth2Options Optional additional settings 42 | * @returns A new instance of OAuth2Server. 43 | */ 44 | constructor(key?: string, cert?: string, oauth2Options?: OAuth2Options) { 45 | if ((key && !cert) || (!key && cert)) { 46 | throw new Error( 47 | 'Both key and cert need to be supplied to start the server with https', 48 | ); 49 | } 50 | 51 | const iss = new OAuth2Issuer(); 52 | const serv = new OAuth2Service(iss, oauth2Options?.endpoints); 53 | 54 | let options: HttpServerOptions | undefined = undefined; 55 | if (key && cert) { 56 | options = { 57 | key: readFileSync(key), 58 | cert: readFileSync(cert), 59 | }; 60 | } 61 | 62 | super(serv.requestHandler, options); 63 | 64 | this._issuer = iss; 65 | this._service = serv; 66 | } 67 | 68 | /** 69 | * Returns the OAuth2Issuer instance used by the server. 70 | * @returns The OAuth2Issuer instance. 71 | */ 72 | get issuer(): OAuth2Issuer { 73 | return this._issuer; 74 | } 75 | 76 | /** 77 | * Returns the OAuth2Service instance used by the server. 78 | * @returns The OAuth2Service instance. 79 | */ 80 | get service(): OAuth2Service { 81 | return this._service; 82 | } 83 | 84 | /** 85 | * Returns a value indicating whether or not the server is listening for connections. 86 | * @returns A boolean value indicating whether the server is listening. 87 | */ 88 | override get listening(): boolean { 89 | return super.listening; 90 | } 91 | 92 | /** 93 | * Returns the bound address, family name and port where the server is listening, 94 | * or null if the server has not been started. 95 | * @returns The server bound address information. 96 | */ 97 | override address(): AddressInfo { 98 | const address = super.address(); 99 | 100 | assertIsAddressInfo(address); 101 | 102 | return address; 103 | } 104 | 105 | /** 106 | * Starts the server. 107 | * @param port Port number. If omitted, it will be assigned by the operating system. 108 | * @param host Host name. 109 | * @returns A promise that resolves when the server has been started. 110 | */ 111 | override async start(port?: number, host?: string): Promise { 112 | await super.start(port, host); 113 | this.issuer.url ??= super.buildIssuerUrl(host, this.address().port); 114 | } 115 | 116 | /** 117 | * Stops the server. 118 | * @returns Resolves when the server has been stopped. 119 | */ 120 | override async stop(): Promise { 121 | await super.stop(); 122 | this._issuer.url = undefined; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/lib/oauth2-service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) AXA Assistance France 3 | * 4 | * Licensed under the AXA Assistance France License (the "License"); you 5 | * may not use this file except in compliance with the License. 6 | * A copy of the License can be found in the LICENSE.md file distributed 7 | * together with this file. 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | /** 17 | * OAuth2 Service library 18 | * @module lib/oauth2-service 19 | */ 20 | 21 | import type { IncomingMessage, RequestListener } from 'node:http'; 22 | import { URL } from 'node:url'; 23 | import { randomUUID } from 'node:crypto'; 24 | import { EventEmitter } from 'node:events'; 25 | import { AssertionError } from 'node:assert'; 26 | 27 | import express, { json, urlencoded, type RequestHandler } from 'express'; 28 | import cors from 'cors'; 29 | import basicAuth from 'basic-auth'; 30 | 31 | import type { OAuth2Issuer } from './oauth2-issuer'; 32 | import { 33 | assertIsString, 34 | assertIsStringOrUndefined, 35 | assertIsValidTokenRequest, 36 | defaultTokenTtl, 37 | isValidPkceCodeVerifier, 38 | pkceVerifierMatchesChallenge, 39 | supportedPkceAlgorithms, 40 | } from './helpers'; 41 | import type { 42 | CodeChallenge, 43 | JwtTransform, 44 | MutableRedirectUri, 45 | MutableResponse, 46 | MutableToken, 47 | OAuth2Endpoints, 48 | OAuth2EndpointsInput, 49 | PKCEAlgorithm, 50 | ScopesOrTransform, 51 | StatusCodeMutableResponse, 52 | } from './types'; 53 | import { Events } from './types'; 54 | import { InternalEvents } from './types-internals'; 55 | 56 | const DEFAULT_ENDPOINTS: OAuth2Endpoints = Object.freeze({ 57 | wellKnownDocument: '/.well-known/openid-configuration', 58 | token: '/token', 59 | jwks: '/jwks', 60 | authorize: '/authorize', 61 | userinfo: '/userinfo', 62 | revoke: '/revoke', 63 | endSession: '/endsession', 64 | introspect: '/introspect', 65 | }); 66 | 67 | /** 68 | * Provides a request handler for an OAuth 2 server. 69 | */ 70 | export class OAuth2Service extends EventEmitter { 71 | /** 72 | * Creates a new instance of OAuth2Server. 73 | * @param {OAuth2Issuer} oauth2Issuer The OAuth2Issuer instance 74 | * that will be offered through the service. 75 | * @param {OAuth2EndpointsInput | undefined} paths Endpoint path name overrides. 76 | */ 77 | 78 | #issuer: OAuth2Issuer; 79 | #requestHandler: RequestListener; 80 | #nonce: Record; 81 | #codeChallenges: Map; 82 | #endpoints: OAuth2Endpoints; 83 | 84 | constructor(oauth2Issuer: OAuth2Issuer, endpoints?: OAuth2EndpointsInput) { 85 | super(); 86 | this.#issuer = oauth2Issuer; 87 | 88 | this.#endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints }; 89 | this.#requestHandler = this.buildRequestHandler(); 90 | this.#nonce = {}; 91 | this.#codeChallenges = new Map(); 92 | } 93 | 94 | /** 95 | * Returns the OAuth2Issuer instance bound to this service. 96 | * @returns The OAuth2Issuer instance. 97 | */ 98 | get issuer(): OAuth2Issuer { 99 | return this.#issuer; 100 | } 101 | 102 | /** 103 | * Builds a JWT with a key in the keystore. The key will be selected in a round-robin fashion. 104 | * @param req The incoming HTTP request. 105 | * @param expiresIn Time in seconds for the JWT to expire. Default: 3600 seconds. 106 | * @param scopesOrTransform A scope, array of scopes, 107 | * or JWT transformation callback. 108 | * @returns The produced JWT. 109 | * @fires OAuth2Service#beforeTokenSigning 110 | */ 111 | async buildToken( 112 | req: IncomingMessage, 113 | expiresIn: number, 114 | scopesOrTransform: ScopesOrTransform | undefined, 115 | ): Promise { 116 | this.issuer.once(InternalEvents.BeforeSigning, (token: MutableToken) => { 117 | /** 118 | * Before token signing event. 119 | * @event OAuth2Service#beforeTokenSigning 120 | * @param {MutableToken} token The unsigned JWT header and payload. 121 | * @param {IncomingMessage} req The incoming HTTP request. 122 | */ 123 | this.emit(Events.BeforeTokenSigning, token, req); 124 | }); 125 | 126 | return await this.issuer.buildToken({ scopesOrTransform, expiresIn }); 127 | } 128 | 129 | /** 130 | * Returns a request handler to be used as a callback for http.createServer(). 131 | * @returns The request handler. 132 | */ 133 | get requestHandler(): RequestListener { 134 | return this.#requestHandler; 135 | } 136 | 137 | private buildRequestHandler = (): RequestListener => { 138 | const app = express(); 139 | app.disable('x-powered-by'); 140 | app.use(json({ strict: true })); 141 | app.use(cors()); 142 | app.get(this.#endpoints.wellKnownDocument, this.openidConfigurationHandler); 143 | app.get(this.#endpoints.jwks, this.jwksHandler); 144 | app.post( 145 | this.#endpoints.token, 146 | urlencoded({ extended: false }), 147 | this.tokenHandler, 148 | ); 149 | app.get(this.#endpoints.authorize, this.authorizeHandler); 150 | app.get(this.#endpoints.userinfo, this.userInfoHandler); 151 | app.post(this.#endpoints.revoke, this.revokeHandler); 152 | app.get(this.#endpoints.endSession, this.endSessionHandler); 153 | app.post(this.#endpoints.introspect, this.introspectHandler); 154 | 155 | return app as RequestListener; 156 | }; 157 | 158 | private openidConfigurationHandler: RequestHandler = (_req, res) => { 159 | assertIsString(this.issuer.url, 'Unknown issuer url.'); 160 | 161 | const normalizedIssuerUrl = trimPotentialTrailingSlash(this.issuer.url); 162 | 163 | const openidConfig = { 164 | issuer: this.issuer.url, 165 | token_endpoint: `${normalizedIssuerUrl}${this.#endpoints.token}`, 166 | authorization_endpoint: `${normalizedIssuerUrl}${this.#endpoints.authorize}`, 167 | userinfo_endpoint: `${normalizedIssuerUrl}${this.#endpoints.userinfo}`, 168 | token_endpoint_auth_methods_supported: ['none'], 169 | jwks_uri: `${normalizedIssuerUrl}${this.#endpoints.jwks}`, 170 | response_types_supported: ['code'], 171 | grant_types_supported: [ 172 | 'client_credentials', 173 | 'authorization_code', 174 | 'password', 175 | ], 176 | token_endpoint_auth_signing_alg_values_supported: ['RS256'], 177 | response_modes_supported: ['query'], 178 | id_token_signing_alg_values_supported: ['RS256'], 179 | revocation_endpoint: `${normalizedIssuerUrl}${this.#endpoints.revoke}`, 180 | subject_types_supported: ['public'], 181 | end_session_endpoint: `${normalizedIssuerUrl}${this.#endpoints.endSession}`, 182 | introspection_endpoint: `${normalizedIssuerUrl}${this.#endpoints.introspect}`, 183 | code_challenge_methods_supported: supportedPkceAlgorithms, 184 | }; 185 | 186 | res.json(openidConfig); 187 | }; 188 | 189 | private jwksHandler: RequestHandler = (_req, res) => { 190 | res.json({ keys: this.issuer.keys.toJSON() }); 191 | }; 192 | 193 | private tokenHandler: RequestHandler = async (req, res, next) => { 194 | try { 195 | const tokenTtl = defaultTokenTtl; 196 | 197 | res.set({ 'Cache-Control': 'no-store', Pragma: 'no-cache' }); 198 | 199 | let xfn: ScopesOrTransform | undefined; 200 | 201 | assertIsValidTokenRequest(req.body); 202 | 203 | if ('code_verifier' in req.body && 'code' in req.body) { 204 | try { 205 | const code = req.body.code; 206 | const verifier = req.body.code_verifier; 207 | const savedCodeChallenge = this.#codeChallenges.get(code); 208 | if (savedCodeChallenge === undefined) { 209 | throw new AssertionError({ message: 'code_challenge required' }); 210 | } 211 | this.#codeChallenges.delete(code); 212 | if (!isValidPkceCodeVerifier(verifier)) { 213 | throw new AssertionError({ 214 | message: 215 | "Invalid 'code_verifier'. The verifier does not conform with the RFC7636 spec. Ref: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1", 216 | }); 217 | } 218 | const doesVerifierMatchCodeChallenge = 219 | await pkceVerifierMatchesChallenge(verifier, savedCodeChallenge); 220 | if (!doesVerifierMatchCodeChallenge) { 221 | throw new AssertionError({ 222 | message: 'code_verifier provided does not match code_challenge', 223 | }); 224 | } 225 | } catch (e) { 226 | res.status(400).json({ 227 | error: 'invalid_request', 228 | error_description: (e as AssertionError).message, 229 | }); 230 | } 231 | } 232 | 233 | const reqBody = req.body; 234 | 235 | let { scope } = reqBody; 236 | const { aud } = reqBody; 237 | 238 | switch (req.body.grant_type) { 239 | case 'client_credentials': 240 | xfn = (_header, payload) => { 241 | Object.assign(payload, { scope, aud }); 242 | }; 243 | break; 244 | case 'password': 245 | xfn = (_header, payload) => { 246 | Object.assign(payload, { 247 | sub: reqBody.username, 248 | amr: ['pwd'], 249 | scope, 250 | }); 251 | }; 252 | break; 253 | case 'authorization_code': 254 | scope = scope ?? 'dummy'; 255 | xfn = (_header, payload) => { 256 | Object.assign(payload, { sub: 'johndoe', amr: ['pwd'], scope }); 257 | }; 258 | break; 259 | case 'refresh_token': 260 | scope = scope ?? 'dummy'; 261 | xfn = (_header, payload) => { 262 | Object.assign(payload, { sub: 'johndoe', amr: ['pwd'], scope }); 263 | }; 264 | break; 265 | default: 266 | res.status(400); 267 | res.json({ error: 'invalid_grant' }); 268 | return; 269 | } 270 | 271 | const token = await this.buildToken(req, tokenTtl, xfn); 272 | const body: Record = { 273 | access_token: token, 274 | token_type: 'Bearer', 275 | expires_in: tokenTtl, 276 | scope, 277 | }; 278 | 279 | if (req.body.grant_type !== 'client_credentials') { 280 | const credentials = basicAuth(req); 281 | const clientId = credentials ? credentials.name : req.body.client_id; 282 | 283 | const xfn: JwtTransform = (_header, payload) => { 284 | Object.assign(payload, { sub: 'johndoe', aud: clientId }); 285 | if (reqBody.code !== undefined && reqBody.code in this.#nonce) { 286 | Object.assign(payload, { nonce: this.#nonce[reqBody.code] }); 287 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 288 | delete this.#nonce[reqBody.code]; 289 | } 290 | }; 291 | 292 | body['id_token'] = await this.buildToken(req, tokenTtl, xfn); 293 | body['refresh_token'] = randomUUID(); 294 | } 295 | 296 | const tokenEndpointResponse: MutableResponse = { body, statusCode: 200 }; 297 | 298 | /** 299 | * Before token response event. 300 | * @event OAuth2Service#beforeResponse 301 | * @param {MutableResponse} response The response body and status code. 302 | * @param {IncomingMessage} req The incoming HTTP request. 303 | */ 304 | this.emit(Events.BeforeResponse, tokenEndpointResponse, req); 305 | 306 | res.status(tokenEndpointResponse.statusCode); 307 | res.json(tokenEndpointResponse.body); 308 | } catch (e) { 309 | next(e); 310 | } 311 | }; 312 | 313 | private authorizeHandler: RequestHandler = (req, res) => { 314 | const code = randomUUID(); 315 | const { 316 | nonce, 317 | scope, 318 | redirect_uri: redirectUri, 319 | response_type: responseType, 320 | state, 321 | code_challenge, 322 | code_challenge_method, 323 | } = req.query; 324 | 325 | assertIsString(redirectUri, 'Invalid redirectUri type'); 326 | assertIsStringOrUndefined(nonce, 'Invalid nonce type'); 327 | assertIsStringOrUndefined(scope, 'Invalid scope type'); 328 | assertIsStringOrUndefined(state, 'Invalid state type'); 329 | assertIsStringOrUndefined(code_challenge, 'Invalid code_challenge type'); 330 | assertIsStringOrUndefined( 331 | code_challenge_method, 332 | 'Invalid code_challenge_method type', 333 | ); 334 | 335 | const url = new URL(redirectUri); 336 | 337 | if (responseType === 'code') { 338 | if (code_challenge) { 339 | const codeChallengeMethod = code_challenge_method ?? 'plain'; 340 | assertIsString( 341 | codeChallengeMethod, 342 | "Invalid 'code_challenge_method' type", 343 | ); 344 | if ( 345 | !supportedPkceAlgorithms.includes( 346 | codeChallengeMethod as PKCEAlgorithm, 347 | ) 348 | ) { 349 | res.status(400); 350 | res.json({ 351 | error: 'invalid_request', 352 | error_description: `Unsupported code_challenge method ${codeChallengeMethod}. The following code_challenge_method are supported: ${supportedPkceAlgorithms.join( 353 | ', ', 354 | )}`, 355 | }); 356 | return; 357 | } 358 | this.#codeChallenges.set(code, { 359 | challenge: code_challenge, 360 | method: codeChallengeMethod as PKCEAlgorithm, 361 | }); 362 | } 363 | if (nonce !== undefined) { 364 | this.#nonce[code] = nonce; 365 | } 366 | url.searchParams.set('code', code); 367 | } else { 368 | url.searchParams.set('error', 'unsupported_response_type'); 369 | url.searchParams.set( 370 | 'error_description', 371 | 'The authorization server does not support obtaining an access token using this response_type.', 372 | ); 373 | } 374 | 375 | if (state) { 376 | url.searchParams.set('state', state); 377 | } 378 | 379 | const authorizeRedirectUri: MutableRedirectUri = { url }; 380 | 381 | /** 382 | * Before authorize redirect event. 383 | * @event OAuth2Service#beforeAuthorizeRedirect 384 | * @param {MutableRedirectUri} authorizeRedirectUri The redirect uri and query params to redirect to. 385 | * @param {IncomingMessage} req The incoming HTTP request. 386 | */ 387 | this.emit(Events.BeforeAuthorizeRedirect, authorizeRedirectUri, req); 388 | 389 | // Note: This is a textbook definition of an "open redirect" vuln 390 | // cf. https://cwe.mitre.org/data/definitions/601.html 391 | // 392 | // However, this whole library is expected to be used as a test helper, 393 | // so there's no real point in making the exposed API more complex (by 394 | // exposing an endpoint to preregister whitelisted urls, for instance) 395 | // for the sake of security. 396 | // 397 | // This is *not* a real oAuth2 server. This is *not* to be run in production. 398 | res.redirect(url.href); 399 | }; 400 | 401 | private userInfoHandler: RequestHandler = (req, res) => { 402 | const userInfoResponse: MutableResponse = { 403 | body: { sub: 'johndoe' }, 404 | statusCode: 200, 405 | }; 406 | 407 | /** 408 | * Before user info event. 409 | * @event OAuth2Service#beforeUserinfo 410 | * @param {MutableResponse} response The response body and status code. 411 | * @param {IncomingMessage} req The incoming HTTP request. 412 | */ 413 | this.emit(Events.BeforeUserinfo, userInfoResponse, req); 414 | 415 | res.status(userInfoResponse.statusCode).json(userInfoResponse.body); 416 | }; 417 | 418 | private revokeHandler: RequestHandler = (req, res) => { 419 | const revokeResponse: StatusCodeMutableResponse = { statusCode: 200 }; 420 | 421 | /** 422 | * Before revoke event. 423 | * @event OAuth2Service#beforeRevoke 424 | * @param {StatusCodeMutableResponse} response The response status code. 425 | * @param {IncomingMessage} req The incoming HTTP request. 426 | */ 427 | this.emit(Events.BeforeRevoke, revokeResponse, req); 428 | 429 | res.status(revokeResponse.statusCode).send(''); 430 | }; 431 | 432 | private endSessionHandler: RequestHandler = (req, res) => { 433 | assertIsString( 434 | req.query['post_logout_redirect_uri'], 435 | 'Invalid post_logout_redirect_uri type', 436 | ); 437 | 438 | const postLogoutRedirectUri: MutableRedirectUri = { 439 | url: new URL(req.query['post_logout_redirect_uri']), 440 | }; 441 | 442 | /** 443 | * Before post logout redirect event. 444 | * @event OAuth2Service#beforePostLogoutRedirect 445 | * @param {MutableRedirectUri} postLogoutRedirectUri 446 | * @param {IncomingMessage} req The incoming HTTP request. 447 | */ 448 | this.emit(Events.BeforePostLogoutRedirect, postLogoutRedirectUri, req); 449 | 450 | res.redirect(postLogoutRedirectUri.url.href); 451 | }; 452 | 453 | private introspectHandler: RequestHandler = (req, res) => { 454 | const introspectResponse: MutableResponse = { 455 | body: { active: true }, 456 | statusCode: 200, 457 | }; 458 | 459 | /** 460 | * Before introspect event. 461 | * @event OAuth2Service#beforeIntrospect 462 | * @param {MutableResponse} response The response body and status code. 463 | * @param {IncomingMessage} req The incoming HTTP request. 464 | */ 465 | this.emit(Events.BeforeIntrospect, introspectResponse, req); 466 | 467 | res.status(introspectResponse.statusCode); 468 | res.json(introspectResponse.body); 469 | }; 470 | } 471 | 472 | const trimPotentialTrailingSlash = (url: string): string => { 473 | return url.endsWith('/') ? url.slice(0, -1) : url; 474 | }; 475 | -------------------------------------------------------------------------------- /src/lib/types-internals.ts: -------------------------------------------------------------------------------- 1 | import type { JWK as JoseJWK } from 'jose'; 2 | 3 | export interface JWKWithKid extends JoseJWK { 4 | kid: string; 5 | alg: string; 6 | [propName: string]: unknown; 7 | } 8 | 9 | export enum InternalEvents { 10 | BeforeSigning = 'beforeSigning', 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ServerOptions } from 'node:https'; 2 | 3 | import type { JWKWithKid } from './types-internals'; 4 | import type { supportedPkceAlgorithms } from './helpers'; 5 | 6 | export interface TokenRequest { 7 | scope?: string; 8 | grant_type: string; 9 | username?: unknown; 10 | client_id?: unknown; 11 | code?: string; 12 | aud?: string[] | string; 13 | code_verifier?: string; 14 | } 15 | 16 | export interface Options { 17 | host?: string; 18 | port: number; 19 | cert?: string; 20 | key?: string; 21 | keys: Record[]; 22 | saveJWK: boolean; 23 | } 24 | 25 | export type HttpServerOptions = Pick & 26 | Pick; 27 | 28 | export interface MutableRedirectUri { 29 | url: URL; 30 | } 31 | 32 | export interface MutableToken { 33 | header: Header; 34 | payload: Payload; 35 | } 36 | 37 | export interface Header { 38 | kid: string; 39 | [key: string]: unknown; 40 | } 41 | 42 | export interface Payload { 43 | iss: string; 44 | iat: number; 45 | exp: number; 46 | nbf: number; 47 | [key: string]: unknown; 48 | } 49 | 50 | export interface StatusCodeMutableResponse { 51 | statusCode: number; 52 | } 53 | 54 | export interface MutableResponse extends StatusCodeMutableResponse { 55 | body: Record | ''; 56 | } 57 | 58 | export type ScopesOrTransform = string | string[] | JwtTransform; 59 | 60 | export type JwtTransform = (header: Header, payload: Payload) => void; 61 | 62 | export enum Events { 63 | BeforeTokenSigning = 'beforeTokenSigning', 64 | BeforeResponse = 'beforeResponse', 65 | BeforeUserinfo = 'beforeUserinfo', 66 | BeforeRevoke = 'beforeRevoke', 67 | BeforeAuthorizeRedirect = 'beforeAuthorizeRedirect', 68 | BeforePostLogoutRedirect = 'beforePostLogoutRedirect', 69 | BeforeIntrospect = 'beforeIntrospect', 70 | } 71 | 72 | export interface TokenBuildOptions { 73 | /** 74 | * The 'kid' of the key that will be used to sign the JWT. 75 | * If omitted, the next key in the round - robin will be used. 76 | */ 77 | kid?: string | undefined; 78 | 79 | /** 80 | * A scope, array of scopes, or JWT transformation callback. 81 | */ 82 | scopesOrTransform?: ScopesOrTransform | undefined; 83 | 84 | /** 85 | * Time in seconds before the JWT to expire. Default: 3600 seconds. 86 | */ 87 | expiresIn?: number | undefined; 88 | } 89 | 90 | export interface JWK extends JWKWithKid { 91 | alg: string; 92 | } 93 | 94 | export interface OAuth2Endpoints { 95 | wellKnownDocument: string; 96 | token: string; 97 | jwks: string; 98 | authorize: string; 99 | userinfo: string; 100 | revoke: string; 101 | endSession: string; 102 | introspect: string; 103 | } 104 | 105 | export type OAuth2EndpointsInput = Partial; 106 | 107 | export interface OAuth2Options { 108 | endpoints?: OAuth2EndpointsInput; 109 | } 110 | 111 | export type PKCEAlgorithm = (typeof supportedPkceAlgorithms)[number]; 112 | 113 | export interface CodeChallenge { 114 | challenge: string; 115 | method: PKCEAlgorithm; 116 | } 117 | -------------------------------------------------------------------------------- /src/oauth2-mock-server.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright (c) AXA Assistance France 5 | * 6 | * Licensed under the AXA Assistance France License (the "License"); you 7 | * may not use this file except in compliance with the License. 8 | * A copy of the License can be found in the LICENSE.md file distributed 9 | * together with this file. 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { writeFile } from 'node:fs/promises'; 19 | import { fileURLToPath } from 'node:url'; 20 | import path from 'node:path'; 21 | 22 | import { assertIsString, readJsonFromFile, shift } from './lib/helpers'; 23 | import type { JWK, Options } from './lib/types'; 24 | 25 | import { OAuth2Server } from './index'; 26 | 27 | const __filename = fileURLToPath(import.meta.url); 28 | 29 | /* eslint no-console: off */ 30 | 31 | const defaultOptions: Options = { 32 | port: 8080, 33 | keys: [], 34 | saveJWK: false, 35 | }; 36 | 37 | async function cli(args: string[]): Promise { 38 | let options; 39 | 40 | try { 41 | options = parseCliArgs(args); 42 | } catch (err) { 43 | console.error(err instanceof Error ? err.message : err); 44 | process.exitCode = 1; 45 | throw err; 46 | } 47 | 48 | if (options === null) { 49 | showHelp(); 50 | return null; 51 | } 52 | 53 | return await startServer(options); 54 | } 55 | 56 | function parseCliArgs(args: string[]): Options | null { 57 | const opts = { ...defaultOptions }; 58 | 59 | while (args.length > 0) { 60 | const arg = shift(args); 61 | 62 | switch (arg) { 63 | case '-h': 64 | case '--help': 65 | return null; 66 | case '-a': 67 | opts.host = shift(args); 68 | break; 69 | case '-p': 70 | opts.port = parsePort(shift(args)); 71 | break; 72 | case '-c': 73 | opts.cert = shift(args); 74 | break; 75 | case '-k': 76 | opts.key = shift(args); 77 | break; 78 | case '--jwk': 79 | opts.keys.push(readJsonFromFile(shift(args))); 80 | break; 81 | case '--save-jwk': 82 | opts.saveJWK = true; 83 | break; 84 | default: 85 | throw new Error(`Unrecognized option '${arg}'.`); 86 | } 87 | } 88 | 89 | return opts; 90 | } 91 | 92 | function showHelp() { 93 | const scriptName = path.basename(__filename).replace(/\.(ts|js)$/, ''); 94 | console.log(`Usage: ${scriptName} [options] 95 | ${scriptName} -a localhost -p 8080 96 | 97 | Options: 98 | -h, --help Shows this help information. 99 | -a
Address on which the server will listen for connections. 100 | If omitted, the server will accept connections on [::] 101 | if IPv6 is available, or 0.0.0.0 otherwise. 102 | -p TCP port on which the server will listen for connections. 103 | If omitted, 8080 will be used. 104 | If 0 is provided, the operating system will assign 105 | an arbitrary unused port. 106 | -c Optional file path to an SSL cert. Both cert and key need 107 | to be supplied to enable SSL. 108 | -k Optional file path to an SSL key. Both key and cert need 109 | to be supplied to enable SSL. 110 | --jwk Adds a JSON-formatted key to the server's keystore. 111 | Can be specified many times. 112 | --save-jwk Saves all the keys in the keystore as "{kid}.json". 113 | 114 | If no keys are added via the --jwk option, a new random RSA key 115 | will be generated. This key can then be saved to disk with the --save-jwk 116 | for later reuse.`); 117 | } 118 | 119 | function parsePort(portStr: string) { 120 | const port = parseInt(portStr, 10); 121 | 122 | if (Number.isNaN(port) || port < 0 || port > 65535) { 123 | throw new Error('Invalid port number.'); 124 | } 125 | 126 | return port; 127 | } 128 | 129 | async function saveJWK(keys: JWK[]) { 130 | for (const key of keys) { 131 | const filename = `${key.kid}.json`; 132 | await writeFile(filename, JSON.stringify(key, null, 2)); 133 | console.log(`JSON web key written to file "${filename}".`); 134 | } 135 | } 136 | 137 | async function startServer(opts: Options) { 138 | const server = new OAuth2Server(opts.key, opts.cert); 139 | 140 | await Promise.all( 141 | opts.keys.map(async (key) => { 142 | const jwk = await server.issuer.keys.add(key); 143 | 144 | console.log(`Added key with kid "${jwk.kid}"`); 145 | }), 146 | ); 147 | 148 | if (opts.keys.length === 0) { 149 | const jwk = await server.issuer.keys.generate('RS256'); 150 | console.log(`Generated new RSA key with kid "${jwk.kid}"`); 151 | } 152 | 153 | if (opts.saveJWK) { 154 | await saveJWK(server.issuer.keys.toJSON(true)); 155 | } 156 | 157 | await server.start(opts.port, opts.host); 158 | 159 | const addr = server.address(); 160 | const hostname = addr.family === 'IPv6' ? `[${addr.address}]` : addr.address; 161 | 162 | console.log( 163 | `OAuth 2 server listening on http://${hostname}:${addr.port.toString()}`, 164 | ); 165 | 166 | assertIsString(server.issuer.url, 'Empty host'); 167 | console.log(`OAuth 2 issuer is ${server.issuer.url}`); 168 | 169 | process.once('SIGINT', () => { 170 | console.log('OAuth 2 server is stopping...'); 171 | 172 | const handler = async () => { 173 | await server.stop(); 174 | }; 175 | 176 | handler().catch((e: unknown) => { 177 | throw e; 178 | }); 179 | 180 | console.log('OAuth 2 server has been stopped.'); 181 | }); 182 | 183 | return server; 184 | } 185 | 186 | export default cli(process.argv.slice(2)); 187 | -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'fs/promises'; 2 | 3 | import { afterEach, describe, it, expect, vi } from 'vitest'; 4 | 5 | import { exec } from './lib/child-script'; 6 | 7 | vi.mock('fs/promises', () => ({ 8 | writeFile: vi.fn().mockImplementation(() => ''), 9 | })); 10 | 11 | const mockWriteFileAsync = vi.mocked(writeFile); 12 | 13 | describe('CLI', () => { 14 | afterEach(() => { 15 | vi.resetModules(); 16 | }); 17 | 18 | it.each([ 19 | ['-h'], 20 | ['--help'], 21 | ])('should be able to print out usage information (%s)', async (arg) => { 22 | const res = await executeCli(arg); 23 | 24 | expect(res.result).toBeNull(); 25 | expect(res.exitCode).toBeUndefined(); 26 | expect(res.stdout).toMatch(/^Usage: oauth2-mock-server \[options\]/); 27 | }); 28 | 29 | it.each([ 30 | ['-unknown'], 31 | ['--unknown'], 32 | ['123'], 33 | [' '], 34 | ])('should not allow unrecognized options', async (arg) => { 35 | const res = await executeCli(arg); 36 | 37 | expect(res).toEqual(errorResponse(`Unrecognized option '${arg}'.`)); 38 | }); 39 | 40 | it.each([ 41 | ['0.0.0.0'], 42 | ['::'], 43 | ['localhost'], 44 | ['127.0.0.1'], 45 | ['::1'], 46 | ])('should accept a binding address (%s)', async (address) => { 47 | const res = await executeCli('-a', address, '-p', '0'); 48 | 49 | expect(res).toEqual({ 50 | result: expect.any(Object), 51 | exitCode: undefined, 52 | stdout: expect.any(String), 53 | stderr: '', 54 | }); 55 | 56 | expect(res.stdout).toMatch(/^OAuth 2 server listening on http:\/\/.+?:\d+$/m); 57 | expect(res.stdout).toMatch(/^OAuth 2 issuer is http:\/\/localhost:\d+$/m); 58 | }); 59 | 60 | it.each([ 61 | ['not-a-number'], 62 | ['-1'], 63 | ['65536'], 64 | ])('should not allow invalid port number \'%s\'', async (port) => { 65 | const res = await executeCli('-p', port); 66 | 67 | expect(res).toEqual(errorResponse('Invalid port number.')); 68 | }); 69 | 70 | it('should allow importing JSON-formatted keys', async () => { 71 | const res = await executeCli('--jwk', 'test/keys/test-rs256-key.json', '--jwk', 'test/keys/test-es256-key.json', '--jwk', 'test/keys/test-eddsa-key.json', '-p', '0'); 72 | 73 | expect(res.stdout).toMatch(/^Added key with kid "test-rs256-key"$/m); 74 | expect(res.stdout).toMatch(/^Added key with kid "test-es256-key"$/m); 75 | expect(res.stdout).toMatch(/^Added key with kid "test-eddsa-key"$/m); 76 | 77 | expect(res.result).not.toBeNull(); 78 | const { keys } = res.result!.issuer; 79 | 80 | expect(keys.get('test-rs256-key')).toBeDefined(); 81 | expect(keys.get('test-es256-key')).toBeDefined(); 82 | expect(keys.get('test-eddsa-key')).toBeDefined(); 83 | }); 84 | 85 | it('should allow exporting JSON-formatted keys', async () => { 86 | 87 | let generatedPath = ''; 88 | 89 | mockWriteFileAsync.mockImplementation((p) => { 90 | if (typeof (p) !== 'string') { 91 | throw new Error("Unepextected path type."); 92 | } 93 | 94 | generatedPath = p; 95 | 96 | return Promise.resolve(); 97 | }); 98 | 99 | const res = await executeCli('--save-jwk', '-p', '0'); 100 | 101 | expect(res.result).not.toBeNull(); 102 | const key = res.result!.issuer.keys.get(); 103 | 104 | expect(key).toBeDefined(); 105 | expect(key).toHaveProperty('kid'); 106 | 107 | expect(generatedPath).toBe(`${key!.kid}.json`); 108 | 109 | expect(res.stdout).toMatch(/^Generated new RSA key with kid "[\w-]+"$/m); 110 | expect(res.stdout).toMatch(/^JSON web key written to file "[\w-]+\.json"\.$/m); 111 | }); 112 | }); 113 | 114 | async function executeCli(...args: string[]) { 115 | const res = await exec(args); 116 | 117 | if (res.result) { 118 | await res.result.stop(); 119 | } 120 | 121 | return res; 122 | } 123 | 124 | function errorResponse(message: string) { 125 | return { 126 | err: expect.any(Error), 127 | result: null, 128 | exitCode: 1, 129 | stdout: '', 130 | stderr: `${message}\n`, 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /test/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import type { AddressInfo } from 'net'; 2 | 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import { 6 | assertIsAddressInfo, 7 | assertIsPlainObject, 8 | assertIsString, 9 | assertIsStringOrUndefined, 10 | assertIsValidTokenRequest, 11 | createPKCECodeChallenge, 12 | createPKCEVerifier, 13 | isValidPkceCodeVerifier, 14 | pkceVerifierMatchesChallenge, 15 | shift, 16 | } from '../src/lib/helpers'; 17 | import type { CodeChallenge, PKCEAlgorithm } from '../src'; 18 | 19 | describe('helpers', () => { 20 | describe('assertIsString', () => { 21 | it.each([ 22 | null, 23 | 1, 24 | true, 25 | {}, 26 | [] 27 | ])('throws on wrong types (%s)', (input) => { 28 | expect(() => { assertIsString(input, "boom"); }).toThrow(); 29 | }); 30 | 31 | it('does not throw on strings', () => { 32 | expect(() => { assertIsString("good", "will not throw"); }).not.toThrow(); 33 | }); 34 | }); 35 | 36 | describe('assertIsStringOrUndefined', () => { 37 | it.each([ 38 | null, 39 | 1, 40 | true, 41 | {}, 42 | [] 43 | ])('throws on wrong types (%s)', (input) => { 44 | expect(() => { assertIsStringOrUndefined(input, "boom"); }).toThrow(); 45 | }); 46 | 47 | it('does not throw on strings', () => { 48 | expect(() => { assertIsStringOrUndefined("good", "will not throw"); }).not.toThrow(); 49 | }); 50 | 51 | it('does not throw on undefined', () => { 52 | expect(() => { assertIsStringOrUndefined(undefined, "will not throw"); }).not.toThrow(); 53 | }); 54 | }); 55 | 56 | describe('assertIsAddressInfo', () => { 57 | it.each([ 58 | "nope", 59 | null, 60 | ])('throws on wrong values (%s)', (input) => { 61 | expect(() => { assertIsAddressInfo(input); }).toThrow(); 62 | }); 63 | 64 | it('does not throw on valid input', () => { 65 | const input: AddressInfo = { 66 | address: "here", 67 | family: "We are family!", 68 | port: 42 69 | }; 70 | expect(() => { assertIsAddressInfo(input); }).not.toThrow(); 71 | }); 72 | }); 73 | 74 | describe('assertIsPlainObject', () => { 75 | it.each([ 76 | "nope", 77 | null, 78 | 1, 79 | false, 80 | [] 81 | ])('throws on wrong values (%s)', (input) => { 82 | expect(() => { assertIsPlainObject(input, "boom"); }).toThrow(); 83 | }); 84 | 85 | it.each([ 86 | {}, 87 | { a: 1 }, 88 | ])('does not throw on valid input (%s)', (input) => { 89 | expect(() => { assertIsPlainObject(input, "boom"); }).not.toThrow(); 90 | }); 91 | }); 92 | 93 | describe('assertIsValidTokenRequest', () => { 94 | it.each([ 95 | "nope", 96 | null, 97 | 1, 98 | false, 99 | [], 100 | { grant_type: 1 }, 101 | { grant_type: "g", code: 1 }, 102 | { grant_type: "g", scope: 1 }, 103 | { grant_type: "g", scope: "s", code: 1 }, 104 | { grant_type: "g", scope: 1, code: "c" }, 105 | { grant_type: "g", scope: "1", code: "c", aud: 1 }, 106 | { grant_type: "g", scope: "1", code: "c", aud: [1] }, 107 | ])('throws on wrong values (%s)', (input) => { 108 | expect(() => { assertIsValidTokenRequest(input); }).toThrow(); 109 | }); 110 | 111 | it.each([ 112 | { grant_type: "g" }, 113 | { grant_type: "g", code: "c" }, 114 | { grant_type: "g", scope: "s" }, 115 | { grant_type: "g", scope: "s", code: "c" }, 116 | { grant_type: "g", scope: "s", code: "c", aud: "a" }, 117 | { grant_type: "g", scope: "s", code: "c", aud: ["a", "b"] }, 118 | ])('does not throw on valid input (%s)', (input) => { 119 | expect(() => { assertIsValidTokenRequest(input); }).not.toThrow(); 120 | }); 121 | }); 122 | 123 | describe('shift', () => { 124 | it('throws on empty array', () => { 125 | expect(() => shift([])).toThrow(); 126 | }); 127 | 128 | it('throws on array containing an undefined entry', () => { 129 | expect(() => shift([undefined])).toThrow(); 130 | }); 131 | 132 | it('does not throw on valid input', () => { 133 | expect(() => shift(["a"])).not.toThrow(); 134 | }); 135 | }); 136 | 137 | describe('pkce', () => { 138 | describe('code_verifier', () => { 139 | it('should accept a valid PKCE code_verifier', () => { 140 | const verifier128 = 141 | 'PXa7p8YHHUAJGrcG2eW0x7FY_EBtRTlaUHnyz1jKWnNp0G-2HZt9KjA0UOp87DmuIqoV4Y_owVsM-QICvrSa5dWxOndVEhSsFMMgy68AYkw4PGHkGaN_aIRIHJ8mQ4EZ'; 142 | const verifier42 = 'xyo94uhy3zKvgB0NJwLms86SwcjtWviEOpkBnGgaLlo'; 143 | expect(isValidPkceCodeVerifier(verifier128)).toBe(true); 144 | expect(isValidPkceCodeVerifier(verifier42)).toBe(true); 145 | 146 | const verifierWith129chars = `${verifier128}a`; 147 | expect(isValidPkceCodeVerifier(verifierWith129chars)).toBe(false); 148 | expect( 149 | isValidPkceCodeVerifier(verifier42.slice(0, verifier42.length - 1)) 150 | ).toBe(false); 151 | }); 152 | 153 | it('should create a valid code_verifier', () => { 154 | expect(isValidPkceCodeVerifier(createPKCEVerifier())).toBe(true); 155 | }); 156 | 157 | it('should create a valid code_challenge', async () => { 158 | const verifier = 'xyo94uhy3zKvgB0NJwLms86SwcjtWviEOpkBnGgaLlo'; 159 | const expectedChallenge = 'b7elB7ZyxIXgFyvBznKvxl7wOB-H17Pz0a3B62NIMFI'; 160 | const generatedCodeChallenge = await createPKCECodeChallenge( 161 | verifier, 162 | 'S256' 163 | ); 164 | expect(generatedCodeChallenge).toBe(expectedChallenge); 165 | const expectedCodeLength = 43; // BASE64-urlencoded sha256 hashes should always be 43 characters in length. 166 | expect( 167 | await createPKCECodeChallenge(createPKCEVerifier(), 'S256') 168 | ).toHaveLength(expectedCodeLength); 169 | }); 170 | 171 | it('should match code_verifier and code_challenge', async () => { 172 | const verifier = createPKCEVerifier(); 173 | const codeChallengeMethod = 'S256'; 174 | const challenge: CodeChallenge = { 175 | challenge: await createPKCECodeChallenge( 176 | verifier, 177 | codeChallengeMethod 178 | ), 179 | method: codeChallengeMethod, 180 | }; 181 | expect(await pkceVerifierMatchesChallenge(verifier, challenge)).toBe(true); 182 | }); 183 | 184 | it('should throw on an unsupported method', async () => { 185 | const verifier = createPKCEVerifier(); 186 | await expect(createPKCECodeChallenge(verifier, 'BAD-METHOD' as PKCEAlgorithm)).rejects.toThrowError('Unsupported PKCE method ("BAD-METHOD")'); 187 | }); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /test/http-server.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import type { RequestListener } from 'http'; 3 | 4 | import { describe, it, expect } from 'vitest'; 5 | import request from 'supertest'; 6 | 7 | import { HttpServer } from '../src'; 8 | 9 | describe('HTTP Server', () => { 10 | it('should be able to start and stop the server', async () => { 11 | const server = new HttpServer(dummyHandler); 12 | 13 | await expect(server.start()).resolves.not.toThrow(); 14 | 15 | const host = `http://127.0.0.1:${server.address().port.toString()}`; 16 | const res = await request(host).get('/').expect(200); 17 | 18 | expect(res.body).toEqual({ 19 | value: 'Dummy response', 20 | }); 21 | 22 | await expect(server.stop()).resolves.not.toThrow(); 23 | }); 24 | 25 | it('should be listening only when the server is started', async () => { 26 | const server = new HttpServer(dummyHandler); 27 | expect(server.listening).toBe(false); 28 | 29 | await server.start(); 30 | expect(server.listening).toBe(true); 31 | 32 | await server.stop(); 33 | expect(server.listening).toBe(false); 34 | }); 35 | 36 | it('should support https if cert + key options are supplied', async () => { 37 | const server = new HttpServer(dummyHandler, { 38 | key: readFileSync('test/keys/localhost-key.pem'), 39 | cert: readFileSync('test/keys/localhost-cert.pem'), 40 | }); 41 | 42 | await expect(server.start()).resolves.not.toThrow(); 43 | 44 | expect(server.listening).toBe(true); 45 | 46 | const host = `https://127.0.0.1:${server.address().port.toString()}`; 47 | const res = await request(host).get('/').trustLocalhost(true).expect(200); 48 | 49 | expect(res.body).toEqual({ 50 | value: 'Dummy response', 51 | }); 52 | 53 | await server.stop(); 54 | expect(server.listening).toBe(false); 55 | }); 56 | 57 | it('should have an address only when the server is started', async () => { 58 | const server = new HttpServer(dummyHandler); 59 | expect(() => server.address()).toThrow('Server is not started.'); 60 | 61 | await server.start(); 62 | expect(server.address()).toMatchObject({ 63 | address: expect.any(String), 64 | family: expect.stringMatching(/IPv4|IPv6/), 65 | port: expect.any(Number), 66 | }); 67 | 68 | await server.stop(); 69 | expect(() => server.address()).toThrow('Server is not started.'); 70 | }); 71 | 72 | it('should not be able to start the server when it\'s already started', async () => { 73 | const server = new HttpServer(dummyHandler); 74 | 75 | await server.start(); 76 | 77 | await expect(server.start()).rejects.toThrow('Server has already been started.'); 78 | 79 | await server.stop(); 80 | }); 81 | 82 | it('should not be able to stop the server when it\'s already stopped', async () => { 83 | const server = new HttpServer(dummyHandler); 84 | 85 | await expect(server.stop()).rejects.toThrow('Server is not started.'); 86 | }); 87 | }); 88 | 89 | const dummyHandler: RequestListener = (_req, res) => { 90 | res.statusCode = 200; 91 | res.setHeader('Content-Type', 'application/json'); 92 | res.end('{ "value": "Dummy response" }'); 93 | }; 94 | -------------------------------------------------------------------------------- /test/jwk-store.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { JWKStore, type JWK } from '../src'; 4 | import { privateToPublicKeyTransformer } from '../src/lib/helpers'; 5 | 6 | import * as testKeys from './keys'; 7 | 8 | describe('JWK Store', () => { 9 | describe('generate()', () => { 10 | it.each([ 11 | ["RSASSA-PKCS1-v1_5", "RS256", "RSA"], 12 | ["RSASSA-PKCS1-v1_5", "RS384", "RSA"], 13 | ["RSASSA-PKCS1-v1_5", "RS512", "RSA"], 14 | ["RSASSA-PSS", "PS256", "RSA"], 15 | ["RSASSA-PSS", "PS384", "RSA"], 16 | ["RSASSA-PSS", "PS512", "RSA"], 17 | ["ECDSA", "ES256", "EC"], 18 | ["ECDSA", "ES384", "EC"], 19 | ["ECDSA", "ES512", "EC"], 20 | ])('should be able to generate a new %s based key (alg = %s)', async (_kind: string, alg: string, expectedKty: string) => { 21 | const store = new JWKStore(); 22 | const key = await store.generate(alg); 23 | expect(key).toMatchObject({ 24 | alg: alg, 25 | kty: expectedKty, 26 | kid: expect.stringMatching(/^[\w-]+$/), 27 | }); 28 | }); 29 | 30 | it.each([ 31 | "Ed25519", 32 | ])('should be able to generate a new EdDSA based key (crv = %s)', async (crv: string) => { 33 | const store = new JWKStore(); 34 | const key = await store.generate('EdDSA', { crv }); 35 | expect(key).toMatchObject({ 36 | alg: 'EdDSA', 37 | kty: 'OKP', 38 | crv, 39 | kid: expect.stringMatching(/^[\w-]+$/), 40 | }); 41 | }); 42 | 43 | it.each([ 44 | "RS123", 45 | "dunno", 46 | ])('throws on unsupported algs (alg = %s)', async (alg: string) => { 47 | const store = new JWKStore(); 48 | 49 | await expect(() => store.generate(alg)).rejects.toThrow("Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value"); 50 | }); 51 | 52 | it.each([ 53 | "Ed007", 54 | "dunno", 55 | ])('throws on unsupported crv for EdDSA alg (crv = %s)', async (crv: string) => { 56 | const store = new JWKStore(); 57 | 58 | await expect(() => store.generate('EdDSA', { crv })).rejects.toThrow("Invalid or unsupported crv option provided, supported values are: Ed25519"); 59 | }); 60 | 61 | it.each([ 62 | ['RS256', ['e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi']] 63 | ])('should return the private key of a key (alg = %s)', async (alg: string, expectedProps: string[]) => { 64 | const store = new JWKStore(); 65 | const jwk = await store.generate(alg); 66 | 67 | for (const prop of expectedProps) { 68 | expect(jwk).toHaveProperty(prop); 69 | } 70 | }); 71 | }); 72 | 73 | describe("add()", () => { 74 | it.each([ 75 | ['RSA', testKeys.getParsed('test-rs256-key.json')], 76 | ['EC', testKeys.getParsed('test-es256-key.json')], 77 | ['OKP', testKeys.getParsed('test-eddsa-key.json')], 78 | ])('should be able to add a JWK key to the store (kty = %s)', async (keyType, testKey) => { 79 | const store = new JWKStore(); 80 | const key = await store.add(testKey); 81 | 82 | expect(key).toMatchObject({ 83 | kty: keyType, 84 | kid: testKey['kid'], 85 | }); 86 | }); 87 | 88 | it.each([ 89 | ['RSA', testKeys.getParsed('test-rs256-key.json')], 90 | ['EC', testKeys.getParsed('test-es256-key.json')], 91 | ['OKP', testKeys.getParsed('test-eddsa-key.json')] 92 | ])('throws when serialized key lacks the "alg" property (kty = %s)', async (_keyType, testKey) => { 93 | const store = new JWKStore(); 94 | 95 | delete testKey['alg']; 96 | 97 | await expect(() => store.add(testKey)).rejects.toThrow('Unspecified JWK "alg" property'); 98 | }); 99 | 100 | it.each([ 101 | ['RSA', testKeys.getParsed('test-rs256-key.json')], 102 | ['EC', testKeys.getParsed('test-es256-key.json')], 103 | ['OKP', testKeys.getParsed('test-eddsa-key.json')] 104 | ])('throws when serialized key contains an unsupported "alg" value (kty = %s)', async (_keyType, testKey) => { 105 | const store = new JWKStore(); 106 | 107 | testKey['alg'] = "DUNNO256"; 108 | 109 | await expect(() => store.add(testKey)).rejects.toThrow('Unsupported JWK "alg" value ("DUNNO256")'); 110 | }); 111 | 112 | it('throws when serialized RSA key is public"', async () => { 113 | const store = new JWKStore(); 114 | 115 | const testKey = testKeys.getParsed('test-rs256-key.json'); 116 | 117 | const publicKey = privateToPublicKeyTransformer(testKey as JWK); 118 | 119 | await expect(() => store.add(publicKey)).rejects.toThrow('Invalid JWK type. No "private" key related data has been found.'); 120 | }); 121 | 122 | it('throws when serialized EC key is public"', async () => { 123 | const store = new JWKStore(); 124 | 125 | const testKey = testKeys.getParsed('test-es256-key.json'); 126 | 127 | delete testKey['d']; 128 | 129 | await expect(() => store.add(testKey)).rejects.toThrow('Invalid JWK type. No "private" key related data has been found.'); 130 | }); 131 | 132 | 133 | it('throws when serialized OKP key is public"', async () => { 134 | const store = new JWKStore(); 135 | 136 | const testKey = testKeys.getParsed('test-eddsa-key.json'); 137 | 138 | delete testKey['d']; 139 | 140 | await expect(() => store.add(testKey)).rejects.toThrow('Invalid JWK type. No "private" key related data has been found.'); 141 | }); 142 | 143 | it('adding a key will overwrite an existing key in the store bearing the same "kid"', async () => { 144 | const store = new JWKStore(); 145 | 146 | const one = testKeys.getParsed('test-rs256-key.json'); 147 | expect(one['kty']).toBe("RSA"); 148 | one['kid'] = "new_id"; 149 | await store.add(one); 150 | 151 | const retrievedOne = store.get("new_id"); 152 | expect(retrievedOne).not.toBeNull(); 153 | expect(retrievedOne!.kty).toEqual(one['kty']); 154 | 155 | const two = testKeys.getParsed('test-es256-key.json'); 156 | expect(two['kty']).toBe("EC"); 157 | two['kid'] = "new_id"; 158 | await store.add(two); 159 | 160 | const retrievedTwo = store.get("new_id"); 161 | expect(retrievedTwo).not.toBeNull(); 162 | expect(retrievedTwo!.kty).toEqual(two['kty']); 163 | }); 164 | }); 165 | 166 | describe("get()", () => { 167 | it('should be able to retrieve a key by its \'kid\'', async () => { 168 | const store = new JWKStore(); 169 | const key1 = await store.generate('RS256', { kid: 'key-one' }); 170 | const key2 = await store.generate('RS256', { kid: 'key-two' }); 171 | 172 | expect(key1.kid).not.toEqual(key2.kid); 173 | 174 | const stored1 = store.get('key-one'); 175 | const stored2a = store.get('key-two'); 176 | const stored2b = store.get('key-two'); 177 | const stored3 = store.get('non-existing-kid'); 178 | 179 | expect(stored1).toBe(key1); 180 | expect(stored2a).toBe(key2); 181 | expect(stored2b).toBe(key2); 182 | expect(stored3).toBeUndefined(); 183 | }); 184 | 185 | it('should be able to retrieve keys in a round-robin manner', async () => { 186 | const store = new JWKStore(); 187 | await store.generate('RS256', { kid: 'key-one' }); 188 | await store.generate('RS256', { kid: 'key-two' }); 189 | await store.generate('RS256', { kid: 'key-three' }); 190 | 191 | const key1 = store.get(); 192 | expect(key1).not.toBeNull(); 193 | 194 | const key2 = store.get(); 195 | expect(key2).not.toBeNull(); 196 | expect(key2!.kid).not.toEqual(key1!.kid); 197 | 198 | const key3 = store.get(); 199 | expect(key3).not.toBeNull(); 200 | expect(key3!.kid).not.toEqual(key1!.kid); 201 | expect(key3!.kid).not.toEqual(key2!.kid); 202 | 203 | const key4 = store.get(); 204 | expect(key4).not.toBeNull(); 205 | expect(key4!.kid).toEqual(key1!.kid); 206 | }); 207 | 208 | it('should return undefined when trying to retrieve a key from an empty store', () => { 209 | const store = new JWKStore(); 210 | 211 | const res1 = store.get(); 212 | const res2 = store.get('non-existing-kid'); 213 | 214 | expect(res1).toBeUndefined(); 215 | expect(res2).toBeUndefined(); 216 | }); 217 | }); 218 | 219 | describe("toJSON()", () => { 220 | it.each([ 221 | undefined, 222 | true, 223 | false 224 | ])('should be able to produce a JSON representation of the public keys in the key store (including private fields: %s)', async (shouldIncludePrivates?: boolean) => { 225 | const store = new JWKStore(); 226 | await store.generate('RS256', { kid: 'key-one' }); 227 | await store.generate('RS256', { kid: 'key-two' }); 228 | await store.generate('RS256', { kid: 'key-three' }); 229 | 230 | const jwks = store.toJSON(shouldIncludePrivates); 231 | expect(jwks).toHaveProperty("keys"); 232 | expect(jwks).toBeInstanceOf(Array); 233 | 234 | expect(jwks).toHaveLength(3); 235 | 236 | for (const key of jwks) { 237 | expect(key).toBeInstanceOf(Object); 238 | expect(key).toHaveProperty("kid"); 239 | expect(typeof (key).kid).toBe("string"); 240 | } 241 | 242 | expect(jwks.map((key) => key.kid).sort()).toEqual(['key-one', 'key-three', 'key-two']); 243 | 244 | jwks.forEach((jwk) => { 245 | expect(store.get(jwk.kid)).not.toBeNull(); 246 | 247 | ['e', 'n'].forEach((prop) => { 248 | expect(jwk).toHaveProperty(prop); 249 | }); 250 | 251 | ['d', 'p', 'q', 'dp', 'dq', 'qi'].forEach((prop) => { 252 | const isExposed = prop in jwk; 253 | const shouldBeExposed = shouldIncludePrivates === true; 254 | expect(isExposed).toEqual(shouldBeExposed); 255 | }); 256 | }); 257 | }); 258 | 259 | it.each([ 260 | ['RSA', testKeys.getParsed('test-rs256-key.json'), ['d', 'p', 'q', 'dp', 'dq', 'qi']], 261 | ['EC', testKeys.getParsed('test-es256-key.json'), ['d']], 262 | ['OKP', testKeys.getParsed('test-eddsa-key.json'), ['d']] 263 | ])('properly removes private fields from key when requesting it (kty = %s)', async (_keyType, testKey, fields) => { 264 | const store = new JWKStore(); 265 | 266 | await store.add(testKey); 267 | const keys = store.toJSON(false); 268 | expect(keys).toHaveLength(1); 269 | 270 | const key = keys[0]; 271 | 272 | fields.forEach(prop => { 273 | expect(key).not.toHaveProperty(prop); 274 | }); 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /test/keys/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { readJsonFromFile } from '../../src/lib/helpers'; 4 | 5 | export function getParsed(filename: string): Record { 6 | const filepath = path.join(__dirname, filename); 7 | return readJsonFromFile(filepath); 8 | } 9 | -------------------------------------------------------------------------------- /test/keys/localhost-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEpDCCAowCCQDP80ws297ATzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls 3 | b2NhbGhvc3QwHhcNMjExMTA5MDAwNjU2WhcNNDkwMzI2MDAwNjU2WjAUMRIwEAYD 4 | VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh 5 | U2CxhbuBmEA7ItatCYG7xt0EU1slquLKc7YgQ24M4BVuHxgCPNLFxcVZeqBA0nS7 6 | ZcZQRBBdg+mIvfwtcZQlbxnG5uqqd5crkL/Nu7502vuxlGxof2VANFl5cH73YBax 7 | ZMSWsbCS3S215/cpXEJ9nftPrdLT+f24HtDDU5kzJXz2WBPd8cIXD80x/D+kpS/h 8 | 165RiRYqZ0/hA01Q+S7oiD1W3DGx1YX9tadTZhW8a9sgm5WRSpP/kJQnTVLTeotK 9 | U3JumlQ4ugsTl1OlEeTruHaAT2VqejHzihCHqbU8oJQWywD6gWAgLkD1tPeuiWcT 10 | 4cPo0R4RzwmJlAwrGRSx14+eKLBqcJqABGb4cY9Ch4MopnDU6NqyBodS98B/LzR5 11 | 2QUUpuZ1U+d14LS7ySLkByMc6mHLUtM0pmE4LT1KetR39O5bpLSMofsESzKzvB85 12 | XfGUDHne0wJBmlPOZUBuOs4qiuikIibPoy+sj3SIl8cT9bM0pB6xsVHY7Bu8r+kB 13 | BRnvj5F0u53xNP/96fpun6LW+W2zmluMVx28yIJdESH9hdQPjgoVE7CB254e3Hkp 14 | Ri/A6svhMVRyu0cRiyCaQLUhF9PEzVKSONk1mk/+9kki0rP2tKFi/kBgMACMFf/9 15 | dteJuOiwQsFKeN/KMR8zBOgvuF4SqB8+8lHv2lnNCQIDAQABMA0GCSqGSIb3DQEB 16 | CwUAA4ICAQCAP/fYd5ahxoBYdIdgJgjXfEsRVCsLrRFxGcMT+e5ZHuRvusKCA58b 17 | 2wlwgCxsGKC19ZB7xZ0bw0zTOqTXPBU3JIzjqR7LAvpUEicpr2D6G+9A2mOtFH9H 18 | I95I0cX34axpTOU1hiT+pIj71rPNtZBAWUigplFxllrwASSD7te6qfvrHcn5T9XF 19 | MrgqMxQ/x4jMB40Su/kqpNnOXr7vssETu7rVYDAlys1AlUB1vwBOra44i48XDHjv 20 | bdSX0tYFGNh4AevTnRh/nYNgQ+5xSfn0bjz4fYv0Z1xzNxY/9jIrTp0Eqq25NRE3 21 | VEj4MEmBA1Bei0xDUxhlHz+Ru/VyaB9qlMyjNQxM5duj57CfetoT2vnERpx9s7R6 22 | eyVeavn5b++7XMyrjiEvh/w7rAMc2ABr+Ua0LRgSEC3TaDPa8XqVj5Hk7MG4LpTj 23 | QvlfZG1fdQxlcBghF0uANX31eDTaxCvfcrK1vc3M/b2O9aToBNWScw41HV4c5tTp 24 | TteYjhOgQVmbY7Fg37/YHfVc9Qn9xCusQOJgQnoUBv9dyJANaQTzsazmSbficCpv 25 | XCR9eidrgI5Q4sjHePc4tT5bPkJfz8Xx+5LCo7D09TGf1/NVjS2nYBkNruLtha5T 26 | 7Qbjmi9AksWtdtFxkwZKm0y7dbh9wB6YR/wTp/lyoQvsD+5p6FbyJA== 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /test/keys/localhost-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDhU2CxhbuBmEA7 3 | ItatCYG7xt0EU1slquLKc7YgQ24M4BVuHxgCPNLFxcVZeqBA0nS7ZcZQRBBdg+mI 4 | vfwtcZQlbxnG5uqqd5crkL/Nu7502vuxlGxof2VANFl5cH73YBaxZMSWsbCS3S21 5 | 5/cpXEJ9nftPrdLT+f24HtDDU5kzJXz2WBPd8cIXD80x/D+kpS/h165RiRYqZ0/h 6 | A01Q+S7oiD1W3DGx1YX9tadTZhW8a9sgm5WRSpP/kJQnTVLTeotKU3JumlQ4ugsT 7 | l1OlEeTruHaAT2VqejHzihCHqbU8oJQWywD6gWAgLkD1tPeuiWcT4cPo0R4RzwmJ 8 | lAwrGRSx14+eKLBqcJqABGb4cY9Ch4MopnDU6NqyBodS98B/LzR52QUUpuZ1U+d1 9 | 4LS7ySLkByMc6mHLUtM0pmE4LT1KetR39O5bpLSMofsESzKzvB85XfGUDHne0wJB 10 | mlPOZUBuOs4qiuikIibPoy+sj3SIl8cT9bM0pB6xsVHY7Bu8r+kBBRnvj5F0u53x 11 | NP/96fpun6LW+W2zmluMVx28yIJdESH9hdQPjgoVE7CB254e3HkpRi/A6svhMVRy 12 | u0cRiyCaQLUhF9PEzVKSONk1mk/+9kki0rP2tKFi/kBgMACMFf/9dteJuOiwQsFK 13 | eN/KMR8zBOgvuF4SqB8+8lHv2lnNCQIDAQABAoICAAzL+W/bknyXsv4oylxlb9LE 14 | RoUaXtDS1bJ9w9gtPDDLASyR44EtByNoSf788eD8ktiZvRuN4ggNl9PGRm4mRy2h 15 | E6uyLZ2k0RcOT3ZVtUlZrnW1YjrFEXEGCBbXhr5x3JgSiv+r88VDfncYR3c34CbW 16 | NBGgY5raVciBJ4YQV2EKPgCXm/k847L/mL2//7VQFFoVL7c9u96Io4a580gn/rwC 17 | EMnAE6pv3/46tNgNsipTTcyaSXJI9ALNqTxaLdwI16RO/O6/IQ1fs4VXUM7NJ10j 18 | yscauz3C1tdn/exWfQ6GaCKrQND5TAZH2jSaJH7rGsmMRt4QEk8cnQQNPmusLvqX 19 | YSZFAGSOwWq9WLY4lGEGZriyckhy4Dz27HCR+qtO/1t4bzAhzqUj9IiIHD8n9E6C 20 | x7CwlrrMplpLKvtY2aM0R5BcMXZ9MOlxckpb4Y98Jb3bjtrqAL3Md9OBk1V6WhOQ 21 | Y647ys9IvFjkl50sNjkav8FD+JgSCD47V+1T7aYmeCmvXBvOazOUKkQpdoGVIdET 22 | /gqEqZjfqglYUv5HoMtzRUm5gaumYoKqO1kX5nZG4Yzyb3+mHhTYt4FnhyumCaTq 23 | irSQt7LhMD4j2NiCRIfyj7oRm1g4iQ6AP9zYTwCUM1vfNw1/++rRuL77VbNa2n59 24 | EM3PwcFu5ZUfDJkV+y4pAoIBAQD4921KMeXgxIO0gLJpPEh/pwIdGVHIdWRqG+Wo 25 | Vptu02DfX5wtqzDe7tfphZOHKMIhYelKEFNup4uFiJDTLldoyyXyKrBeOCBFq2c4 26 | bwHvXXvX5dTk4fw4q6leI4sqc9Gvil9ELmJLY5Je+8WwfjzYRMMQh5wsr0NiV85w 27 | Ayf6db9xEiYRV8pMafsfGZi8bz/GBj6SlyqhZyLgrbdStnpKolNpZb3gDjWpTozk 28 | uhzZ3Z+xPqyE2W+aODWRYvrS7tMqBxTPdmELUjpzTdvsK++22ArIJA4zR2I7eP48 29 | f6CHHcw50xSYU4xXXupLvmoIHWvm7pD9d8b3Yym9ur5Ej9ijAoIBAQDnsPnVpRCo 30 | L5J5wW/mTjBzcx3KM/XFsjXe5v24SXlwr5zFvaRsqPm4ZfGyE0hu29ZNUuomV5Al 31 | BRQu7WPSN/BTL+5L0jQaXs3dxhEGY7iPdUGhO9xCKonLD3pJv82jGcnoK4piTjK9 32 | EG+vvXp8PDk/hMqEeZbpCeSjkMLTYTUYnBnbKdJl8f83FsalVF8mbo5Ug4YyAcLM 33 | dcl0fQxLoQfGT3Mrd3TA9MjxMOTr6pk7+23LqP4AlWL0NurU1s1REp+YkB8H1K6q 34 | 0GFlC/HiETLwOrbU9DOfHUR9ArvEMXyaB73JIL7tCZlIiuhbB1RKuDpsS67sc30H 35 | mmEYaPbOlUJjAoIBABTzZVwIBOs8PkUTS3A1pXgwiqn+NnajAlc3j8TxvNS/rvg9 36 | KHxjWpJ4EO1S005dbLC4d5RG+W5nZQNQsbdDwBFOfxv44aycFoMmYGJMXSJSgJhz 37 | e83jAoGWWINWhGCHLI8UGt429QiZEXkywLkQdnhQmQWsE8Vi3k3JS9L7QQL/o6OV 38 | PmTZOWnixUIhukupJPm6DsvxMqIwdqvnknTAbFwZeuF0sVzIdVWRCaQ50TG4R7b3 39 | qjbnwU9+CWv5o1faxyRTQjoPIS19tvN7CtW+AMcnOQkMl7FTAX41uVdH+i9En6oC 40 | DEqhRT8Y+qhzFqMmLq1ZSBQ8Xx70Vk/kBhzo6fcCggEAc8e+sYLRNhq/ydRehOph 41 | z++g3WDl2qtZvFAWTjd5VlSLtU01k49Fa16WT3TPZ4a4Kt/aXtq3D6jgEytSDMUj 42 | dY6oOH+MxfjGS6Mk8tp2akogR+BQC0iSn0TWyW/t5SmH+lt3xfRp6o0CIwI+6UJ3 43 | IUTMpzLTtRTaitpWYadcDzZCvYDCwWsTLIZXMltdSSlKOvWW4/p5TA84YtvyfwCz 44 | j+g4F2qUj7BDkjPq0fHkLVNR5MpIVCZS0tN2unG1HGSAGI880n734ihL42D6uQv7 45 | No1AvF5kRYkspoPtLPfF2nsv3ghdILsBPH0d7A9XkhdKKsAPY926h0ggaUBaxZVV 46 | HQKCAQEAtGqz6/dYCPwMlE2JVikIP1jFoZ2BQ9eIYVAi1cP4NrT/vIWnzPCrJ7bv 47 | 2rPczYlZX9o38wEfUGtFXZ92nBjQHFyXYIk3fz14r8wGhjLSJWvJJAQcmqzhUESx 48 | MawVKChSrD4EGFx5/mffTvIpaNgr7HRYolWizBhqxIRW6eOzZM/QXdfAiDnkXqdg 49 | RHqUkzSxcbIvfp9y2+WYyWXC2E9uSdUU5/wiLOygqgDhXViyrklhNwYNm+jL2mDi 50 | M6ING/peUXxR0rCtKvJJmhn+kDTJQq04BjqOZ2AnasbHHdVEV498sqafKE2G99cO 51 | sjgssimwaMfJTK+yLyOlX9RHgeDPug== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /test/keys/test-eddsa-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "kid": "test-eddsa-key", 3 | "alg": "EdDSA", 4 | "kty": "OKP", 5 | "crv": "Ed25519", 6 | "x": "6o-SmsCSd_1uS6N2JgFI5V6Ywyq5-rNDHg_Q16Gh8QI", 7 | "d": "367g9b4UoWGkDPaO87i-OPyFccaYiS8akG2XEsAMRUY" 8 | } 9 | -------------------------------------------------------------------------------- /test/keys/test-es256-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "kid": "test-es256-key", 3 | "alg": "ES256", 4 | "kty": "EC", 5 | "crv": "P-256", 6 | "x": "ro6MqVk5gDhXKcNTDRT-j4NNtVz9tAIS_arfM88-YBU", 7 | "y": "onyCf_UvqOcQH7gp98BFwAO_V2JIinvVAXOdzRQc4kU", 8 | "d": "3U897imImdNxelERx5v3fen2Zc_mdxrpGsJ_LfMQyq8" 9 | } 10 | -------------------------------------------------------------------------------- /test/keys/test-rs256-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "kid": "test-rs256-key", 3 | "alg": "RS256", 4 | "kty": "RSA", 5 | "e": "AQAB", 6 | "n": "0uB6RfjVbos20OnEguISQrbWJ5py7wk57maY9lC7QkoxPk54lAFmi_e2aIca8ob6Bc2_DI7O7-d1ZmnbwX8fA_tnL30joBVgxgbVaHOvMxrKnZXOHrTgiLiCtemdTV-nDAzElszI2b6eNdza9FVDYBp0S92HFYbisBg2uNBnqtQe7fj594huhT26W8HKHxS3l7-ID8ixyPdkDZ69GlJpUpuI0uBlsaIx7uEHcP3gONQYGoj5KJ4qE9GwyKbP4TQYMVx2szy48WNUj3WrTpAEopWEAgUcZqjqn8Kf6RfGdFFVDn_yR7hRmhT8kdhHGptiFpDrpRINIptMvGWXE3CjJw", 7 | "d": "zsFJIVHX8YqNNEf_ZFRDkyWw0yObjMJ_Cj-NufNtppEXtv85OeQlpxY4wkO1KBUvDoGrz6Q1QQKvGLqxvAXnYwAbK3SGeW58c6w_kSnbnT-naNp4PaMagvJlNfbXRAWEw0vBdBlU1CkQ9b6U7vqqFI_bdoa8615U5FLZVFsBSXro3H_BD7OF3pAPMx6UecdiHWkS7D40sPVTE0d2CcbFqwP0tT8Kfv22ZMfeH7UGuDLmw2zqoGnLDw0728ikfMHnbT8og_9FcxZX8GH5imGz6JxH0xVzVobz7YklGGIDT7E4e-J4rNscFlOfOqKG2xbfuCTkVvxlTPZbzpd4kOjgAQ", 8 | "p": "8eKA08kSL_6psH-ptds44VOx6-x5BhCII5o8Nx8TWbIWsckV5-khQSbe_ZPrMtNCJbpyg0Dnsjk-l_oebXwFKXLl9YE36N1ch6N-6OZjNL_BC25BSqxUByVadNs6768Jc3PpZWa06N6AJ1Jda8kEIUACaIDzdAuiM-2YCTTpZXE", 9 | "q": "3y6_743eszwJZuowyC8YJ_fFAMUnwz7li-P69t5yQCE-2Vwyx3r0_7AL-J9Knq7GWW0DaJfSmQeb-kz8JOxsv6PKj7d4R_kkltbTJm_4jEZ2cgOVpGvSCsp7ePHJWaHiXdq8VTtz0dexr_bNuCQV_8Ij0S5xezhD5gwMgXgl5hc", 10 | "dp": "GtBSGagjhQCFDRTJ2mgH4SqnAZFARUTV4qR5Yl7mhT3xuBmjfQbh8df-Rjv8ibDTl10YzqYGSUKBRtWqQ9bIt7SN-24spXrMhBjCu8y2WKWdH8hEIqQB2JzybxvSZ7mOoabKy7vb-CiaKkG-K3GoLIts-Oq6kjWr8He5L-QWr3E", 11 | "dq": "1KVrdSkcdnQOlUMwBZxxjKtTxj8TesFz3wzycsNR4m6KvnlNXKl3xCMN1O6ZH4ZxMIVgyQhXPT11zVAKsnedyh6agsKR1Sm6RgoblzT6gVUlx85IhyfAMk3oxTzC1ycaUsh1x1PlL5wvDfS-3-NqHozwqX9x2rCqaQcz5yv-GDM", 12 | "qi": "xXLxrThnevYI9sb4rIDDAiO5OKXNOvBkuKWinb4ZF-luwRx2yP8XPpF3qvGaQTTcgqQTpcqXNu8ftHd7-cnc85ck_ALYc1sptrW9XKPECEeL2tSsIpB7a-tJeSbIGQQNFhtPlRa2pKY4viKCNakFq2Mn6caR1H8fKVsrlliRzY8" 13 | } 14 | -------------------------------------------------------------------------------- /test/lib/child-script.ts: -------------------------------------------------------------------------------- 1 | import util from 'util'; 2 | 3 | import type { OAuth2Server } from '../../src'; 4 | 5 | interface Output { 6 | result: OAuth2Server | null; 7 | err?: unknown; 8 | exitCode: number | undefined; 9 | stdout: string; 10 | stderr: string; 11 | } 12 | 13 | export async function exec(args: string[]): Promise { 14 | process.argv = ['irrelevant', 'irrelevant as well', ...args]; 15 | 16 | const log = ConsoleOutHook('log'); 17 | const error = ConsoleOutHook('error'); 18 | 19 | const res: Output = { 20 | result: null, 21 | err: undefined, 22 | exitCode: 0, 23 | stdout: '', 24 | stderr: '' 25 | }; 26 | 27 | try { 28 | const mod = await import('../../src/oauth2-mock-server'); 29 | res.result = await mod.default; 30 | } catch (err) { 31 | res.err = err; 32 | } finally { 33 | log.mockRestore(); 34 | error.mockRestore(); 35 | res.exitCode = process.exitCode; 36 | process.exitCode = undefined; 37 | } 38 | 39 | res.stdout = log.output(); 40 | res.stderr = error.output(); 41 | 42 | return res; 43 | } 44 | 45 | function ConsoleOutHook(method: 'log' | 'error') { 46 | let entries: string[] = []; 47 | 48 | const old = console[method]; 49 | console[method] = function (msg?: unknown, ...args: unknown[]): void { 50 | entries.push(util.format(msg, ...args)); 51 | entries.push('\n'); 52 | }; 53 | 54 | return { 55 | mockClear: function mockClear() { 56 | entries = []; 57 | }, 58 | 59 | mockRestore: function mockRestore() { 60 | console[method] = old; 61 | }, 62 | 63 | output: function output() { 64 | return entries.join(''); 65 | }, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /test/lib/test_helpers.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from "assert"; 2 | 3 | import type {JWTVerifyResult } from "jose"; 4 | import { importJWK, jwtVerify } from "jose"; 5 | 6 | import type { OAuth2Issuer } from "../../src/lib/oauth2-issuer"; 7 | import { privateToPublicKeyTransformer } from "../../src/lib/helpers"; 8 | 9 | export const verifyTokenWithKey = async (issuer: OAuth2Issuer, token: string, kid: string): Promise => { 10 | const key = issuer.keys.get(kid); 11 | 12 | if (key === undefined) { 13 | throw new AssertionError({ message: 'Key is undefined' }); 14 | } 15 | 16 | const publicKey = await importJWK(privateToPublicKeyTransformer(key)); 17 | 18 | const verified = await jwtVerify(token, publicKey); 19 | return verified; 20 | }; 21 | -------------------------------------------------------------------------------- /test/oauth2-issuer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | 3 | import { OAuth2Issuer } from '../src'; 4 | import type { JwtTransform, MutableToken } from '../src/lib/types'; 5 | 6 | import * as testKeys from './keys'; 7 | import { verifyTokenWithKey } from './lib/test_helpers'; 8 | 9 | describe('OAuth 2 issuer', () => { 10 | let issuer: OAuth2Issuer; 11 | 12 | beforeAll(async () => { 13 | issuer = new OAuth2Issuer(); 14 | issuer.url = 'https://issuer.example.com'; 15 | 16 | await issuer.keys.add(testKeys.getParsed('test-rs256-key.json')); 17 | await issuer.keys.add(testKeys.getParsed('test-es256-key.json')); 18 | await issuer.keys.add(testKeys.getParsed('test-eddsa-key.json')); 19 | }); 20 | 21 | it('should not allow to build tokens for an unknown \'kid\'', async () => { 22 | await expect(() => issuer.buildToken({ kid: 'unknown-kid' })).rejects.toThrow('Cannot build token: Unknown key.'); 23 | }); 24 | 25 | it.each([ 26 | ['test-rs256-key', "RS256"], 27 | ['test-es256-key', "ES256"], 28 | ['test-eddsa-key', "EdDSA"], 29 | ])('should be able to build tokens (%s)', async (kid: string, expectedAlg: string) => { 30 | const now = Math.floor(Date.now() / 1000); 31 | const expiresIn = 1000; 32 | 33 | const token = await issuer.buildToken({ kid, expiresIn }); 34 | 35 | expect(token).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); 36 | 37 | const decoded = await verifyTokenWithKey(issuer, token, kid); 38 | 39 | expect(decoded.protectedHeader).toEqual({ 40 | alg: expectedAlg, 41 | typ: 'JWT', 42 | kid 43 | }); 44 | 45 | const p = decoded.payload; 46 | 47 | expect(p).toMatchObject({ 48 | iss: issuer.url, 49 | iat: expect.any(Number), 50 | exp: expect.any(Number), 51 | nbf: expect.any(Number), 52 | }); 53 | 54 | const parsedP = p as { iss: string; iat: number; exp: number; nbf: number }; 55 | expect(parsedP.iat).toBeGreaterThanOrEqual(now); 56 | expect(parsedP.exp - parsedP.iat).toEqual(expiresIn); 57 | expect(parsedP.nbf).toBeLessThan(now); 58 | }); 59 | 60 | const scopeInjector: JwtTransform = (_header, payload) => { 61 | payload['scope'] = "urn:scope-1 urn:scope-2"; 62 | }; 63 | 64 | it.each([ 65 | ['urn:scope-1 urn:scope-2'], 66 | [['urn:scope-1', 'urn:scope-2']], 67 | [scopeInjector], 68 | ])('should be able to build tokens with a scope', async (scopes) => { 69 | const token = await issuer.buildToken({ kid: 'test-rs256-key', scopesOrTransform: scopes }); 70 | 71 | const decoded = await verifyTokenWithKey(issuer, token, 'test-rs256-key'); 72 | 73 | expect(decoded.payload).toHaveProperty("scope"); 74 | 75 | expect(decoded.payload['scope']).toBe('urn:scope-1 urn:scope-2'); 76 | }); 77 | 78 | it('should be able to build tokens and modify the header or the payload before signing', async () => { 79 | const transform: JwtTransform = (header, payload) => { 80 | header['x5t'] = 'a-new-value'; 81 | payload['sub'] = 'the-subject'; 82 | }; 83 | 84 | const token = await issuer.buildToken({ kid: 'test-rs256-key', scopesOrTransform: transform }); 85 | 86 | const decoded = await verifyTokenWithKey(issuer, token, 'test-rs256-key'); 87 | 88 | expect(decoded).toMatchObject({ 89 | protectedHeader: { x5t: 'a-new-value' }, 90 | payload: { 91 | sub: 'the-subject' 92 | }, 93 | }); 94 | }); 95 | 96 | it('should be able to modify the header and the payload through a beforeSigning event', async () => { 97 | issuer.once('beforeSigning', (token: MutableToken) => { 98 | token.header['x5t'] = 'a-new-value'; 99 | token.payload['sub'] = 'the-subject'; 100 | }); 101 | 102 | const token = await issuer.buildToken({ kid: 'test-rs256-key' }); 103 | const decoded = await verifyTokenWithKey(issuer, token, 'test-rs256-key'); 104 | 105 | expect(decoded).toMatchObject({ 106 | protectedHeader: { x5t: 'a-new-value' }, 107 | payload: { 108 | sub: 'the-subject' 109 | }, 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/oauth2-server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import request from 'supertest'; 3 | 4 | import { OAuth2Server } from '../src'; 5 | 6 | describe('OAuth 2 Server', () => { 7 | it('should be able to start and stop the server', async () => { 8 | const server = new OAuth2Server(); 9 | 10 | await expect(server.start()).resolves.not.toThrow(); 11 | 12 | const host = `http://127.0.0.1:${server.address().port.toString()}`; 13 | await request(host).get('/').expect(404); 14 | 15 | await expect(server.stop()).resolves.not.toThrow(); 16 | }); 17 | 18 | it('should have an issuer URL that matches the server\'s endpoint', async () => { 19 | const server = new OAuth2Server(); 20 | 21 | expect(server.issuer.url).toBeUndefined(); 22 | 23 | await server.start(undefined, 'localhost'); 24 | expect(server.issuer.url).toBe(`http://localhost:${server.address().port.toString()}`); 25 | 26 | await expect(server.stop()).resolves.toBeUndefined(); 27 | }); 28 | 29 | it('should expose the oauth2 service', () => { 30 | const server = new OAuth2Server(); 31 | 32 | expect(server.service).toBeDefined(); 33 | }); 34 | 35 | it("should throw if only one of cert/key is supplied", () => { 36 | expect(() => { 37 | new OAuth2Server("test/keys/localhost-key.pem"); 38 | }).toThrow(); 39 | 40 | expect(() => { 41 | new OAuth2Server(undefined, "test/keys/localhost-cert.pem"); 42 | }).toThrow(); 43 | }); 44 | 45 | it('should not raise an UnhandledPromiseRejectionWarning when wrongly invoking the /token endpoint', async () => { 46 | const server = new OAuth2Server(); 47 | 48 | await expect(server.start()).resolves.not.toThrow(); 49 | 50 | const host = `http://127.0.0.1:${server.address().port.toString()}`; 51 | const res = await request(host) 52 | .post('/token') 53 | .set('Content-Type', 'multipart/form-data;'); 54 | 55 | expect(res.text).toContain("[ERR_ASSERTION]: Invalid token request body"); 56 | 57 | await expect(server.stop()).resolves.not.toThrow(); 58 | }); 59 | 60 | it('should override custom endpoint pathnames', async () => { 61 | const endpoints = { jwks: '/custom-jwks' }; 62 | const server = new OAuth2Server(undefined, undefined, { endpoints }); 63 | 64 | await expect(server.start()).resolves.not.toThrow(); 65 | 66 | const host = `http://127.0.0.1:${server.address().port.toString()}`; 67 | await request(host) 68 | .get('/custom-jwks') 69 | .expect(200); 70 | 71 | await expect(server.stop()).resolves.not.toThrow(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/oauth2-service.test.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, type RequestListener } from 'http'; 2 | import qs from 'querystring'; 3 | 4 | import { describe, it, expect, beforeAll } from 'vitest'; 5 | import request from 'supertest'; 6 | 7 | import { OAuth2Issuer, OAuth2Service } from '../src'; 8 | import type { MutableRedirectUri } from '../src/lib/types'; 9 | import { 10 | createPKCECodeChallenge, 11 | createPKCEVerifier, 12 | } from '../src/lib/helpers'; 13 | 14 | import * as testKeys from './keys'; 15 | import { verifyTokenWithKey } from './lib/test_helpers'; 16 | 17 | describe.each([ 18 | 'https://issuer.example.com', 19 | 'https://issuer.example.com/' 20 | ]) 21 | ('OAuth 2 service with issuer %s', (issuerUrl: string) => { 22 | 23 | let issuer: OAuth2Issuer; 24 | let service: OAuth2Service; 25 | 26 | beforeAll(async () => { 27 | issuer = new OAuth2Issuer(); 28 | issuer.url = issuerUrl; 29 | await issuer.keys.add(testKeys.getParsed('test-rs256-key.json')); 30 | 31 | service = new OAuth2Service(issuer); 32 | }); 33 | 34 | it('should use custom endpoint paths', async () => { 35 | const customService = new OAuth2Service(issuer, { 36 | wellKnownDocument: '/custom-well-known', 37 | jwks: '/custom-jwks', 38 | token: '/custom-token', 39 | authorize: '/custom-authorize', 40 | userinfo: '/custom-userinfo', 41 | // 'revoke', 'endSession' purposefully omitted to test defaults, 42 | introspect: '/custom-introspect', 43 | }); 44 | 45 | // OpenID well known document 46 | const res = await request(customService.requestHandler) 47 | .get('/custom-well-known') 48 | .expect(200); 49 | 50 | const endpointsPrefix = wellKnownEndpointsPrefixFrom(customService.issuer); 51 | 52 | expect(res.body).toMatchObject({ 53 | jwks_uri: `${endpointsPrefix}/custom-jwks`, 54 | token_endpoint: `${endpointsPrefix}/custom-token`, 55 | authorization_endpoint: `${endpointsPrefix}/custom-authorize`, 56 | userinfo_endpoint: `${endpointsPrefix}/custom-userinfo`, 57 | revocation_endpoint: `${endpointsPrefix}/revoke`, 58 | end_session_endpoint: `${endpointsPrefix}/endsession`, 59 | introspection_endpoint: `${endpointsPrefix}/custom-introspect`, 60 | }); 61 | 62 | const getTestCases: [string, number, string?][] = [ 63 | ['/custom-jwks', 200], 64 | ['/jwks', 404], 65 | ['/custom-userinfo', 200], 66 | ['/userinfo', 404], 67 | ['/authorize', 404], 68 | ['/custom-authorize', 302, 'redirect_uri=http://example.com&scope=dummy_scope&state=1'], 69 | ['/endsession', 302, 'post_logout_redirect_uri=http://example.com'] 70 | ]; 71 | 72 | // GET 73 | for (const [path, expectedStatus, query] of getTestCases) { 74 | await request(customService.requestHandler) 75 | .get(path) 76 | .query(query ?? '') 77 | .expect(expectedStatus); 78 | } 79 | 80 | const postTestCases: [string, number][] = [ 81 | ['/custom-token', 500], // 500 implies it was routed successfully 82 | ['/token', 404], 83 | ['/revoke', 200], 84 | ['/custom-introspect', 200], 85 | ]; 86 | 87 | // POST 88 | for (const [path, expectedStatus] of postTestCases) { 89 | await request(customService.requestHandler) 90 | .post(path) 91 | .expect(expectedStatus); 92 | } 93 | }); 94 | 95 | const wellKnownEndpointsPrefixFrom = (issuer: OAuth2Issuer) => { 96 | const { url } = issuer; 97 | expect(url).not.toBeUndefined(); 98 | 99 | return url!.endsWith('/') ? url!.slice(0, -1) : url!; 100 | }; 101 | 102 | it('should expose an OpenID configuration endpoint', async () => { 103 | const res = await request(service.requestHandler) 104 | .get('/.well-known/openid-configuration') 105 | .expect(200); 106 | 107 | const endpointsPrefix = wellKnownEndpointsPrefixFrom(service.issuer); 108 | 109 | expect(res.body).toEqual({ 110 | issuer: service.issuer.url, 111 | token_endpoint: `${endpointsPrefix}/token`, 112 | authorization_endpoint: `${endpointsPrefix}/authorize`, 113 | userinfo_endpoint: `${endpointsPrefix}/userinfo`, 114 | token_endpoint_auth_methods_supported: ['none'], 115 | jwks_uri: `${endpointsPrefix}/jwks`, 116 | response_types_supported: ['code'], 117 | grant_types_supported: ['client_credentials', 'authorization_code', 'password'], 118 | token_endpoint_auth_signing_alg_values_supported: ['RS256'], 119 | response_modes_supported: ['query'], 120 | id_token_signing_alg_values_supported: ['RS256'], 121 | revocation_endpoint: `${endpointsPrefix}/revoke`, 122 | subject_types_supported: ['public'], 123 | introspection_endpoint: `${endpointsPrefix}/introspect`, 124 | code_challenge_methods_supported: ['plain', 'S256'], 125 | end_session_endpoint: `${endpointsPrefix}/endsession`, 126 | }); 127 | 128 | expect(JSON.stringify(res.body)).not.toMatch(/(? { 132 | const res = await request(service.requestHandler) 133 | .get('/jwks') 134 | .expect(200); 135 | 136 | expect(res.body).toMatchObject({ 137 | keys: [ 138 | { 139 | kty: 'RSA', 140 | kid: 'test-rs256-key', 141 | n: expect.any(String), 142 | e: expect.any(String), 143 | }, 144 | ], 145 | }); 146 | 147 | expect(res.body.keys[0]).not.toHaveProperty('d'); 148 | }); 149 | 150 | it('should expose a token endpoint that handles Client Credentials grants', async () => { 151 | const res = await tokenRequest(service.requestHandler) 152 | .send({ 153 | grant_type: 'client_credentials', 154 | scope: 'urn:first-scope urn:second-scope', 155 | }) 156 | .expect(200); 157 | 158 | expect(res.body).toMatchObject({ 159 | access_token: expect.any(String), 160 | token_type: 'Bearer', 161 | expires_in: 3600, 162 | scope: 'urn:first-scope urn:second-scope', 163 | }); 164 | 165 | const key = service.issuer.keys.get('test-rs256-key'); 166 | expect(key).not.toBeNull(); 167 | 168 | const resBody = res.body as { access_token: string; scope: string }; 169 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 170 | 171 | expect(decoded.payload).toMatchObject({ 172 | iss: service.issuer.url, 173 | scope: resBody.scope, 174 | }); 175 | }); 176 | 177 | it.each([ 178 | 'aud', 179 | ['aud1', 'aud2'] 180 | ])('should expose a token endpoint that includes an aud claim on Client Credentials grants', async (aud) => { 181 | const res = await tokenRequest(service.requestHandler) 182 | .send(qs.stringify({ 183 | grant_type: 'client_credentials', 184 | aud, 185 | })) 186 | .expect(200); 187 | 188 | const resBody = res.body as { access_token: string; }; 189 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 190 | 191 | expect(decoded.payload).toMatchObject({ aud }); 192 | }); 193 | 194 | 195 | it('should expose a token endpoint that handles Resource Owner Password Credentials grants', async () => { 196 | const res = await request(service.requestHandler) 197 | .post('/token') 198 | .type('form') 199 | .send({ 200 | grant_type: 'password', 201 | username: 'the-resource-owner@example.com', 202 | scope: 'urn:first-scope urn:second-scope', 203 | }) 204 | .expect(200); 205 | 206 | expect(res.body).toMatchObject({ 207 | access_token: expect.any(String), 208 | token_type: 'Bearer', 209 | expires_in: 3600, 210 | scope: 'urn:first-scope urn:second-scope', 211 | refresh_token: expect.any(String), 212 | }); 213 | 214 | const resBody = res.body as { access_token: string; scope: string }; 215 | 216 | const key = service.issuer.keys.get('test-rs256-key'); 217 | expect(key).not.toBeNull(); 218 | 219 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 220 | 221 | expect(decoded.payload).toMatchObject({ 222 | iss: service.issuer.url, 223 | scope: resBody.scope, 224 | sub: 'the-resource-owner@example.com', 225 | amr: ['pwd'], 226 | }); 227 | }); 228 | 229 | it('should expose a token endpoint that handles authorization_code grants', async () => { 230 | const res = await request(service.requestHandler) 231 | .post('/token') 232 | .type('form') 233 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 234 | .send({ 235 | grant_type: 'authorization_code', 236 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 237 | redirect_uri: 'https://example.com/callback', 238 | }) 239 | .expect(200); 240 | 241 | expect(res.body).toMatchObject({ 242 | access_token: expect.any(String), 243 | token_type: 'Bearer', 244 | expires_in: 3600, 245 | scope: 'dummy', 246 | id_token: expect.any(String), 247 | refresh_token: expect.any(String), 248 | }); 249 | 250 | const key = service.issuer.keys.get('test-rs256-key'); 251 | expect(key).not.toBeNull(); 252 | 253 | const resBody = res.body as { access_token: string }; 254 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 255 | 256 | expect(decoded.payload).toMatchObject({ 257 | iss: service.issuer.url, 258 | scope: 'dummy', 259 | sub: 'johndoe', 260 | amr: ['pwd'], 261 | }); 262 | }); 263 | 264 | it('should expose a token endpoint that copies scope for authorization_code grants', async () => { 265 | const res = await request(service.requestHandler) 266 | .post('/token') 267 | .type('form') 268 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 269 | .send({ 270 | grant_type: 'authorization_code', 271 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 272 | redirect_uri: 'https://example.com/callback', 273 | scope: 'test' 274 | }) 275 | .expect(200); 276 | 277 | expect(res.body).toMatchObject({ 278 | access_token: expect.any(String), 279 | token_type: 'Bearer', 280 | expires_in: 3600, 281 | scope: 'test', 282 | id_token: expect.any(String), 283 | refresh_token: expect.any(String), 284 | }); 285 | 286 | const key = service.issuer.keys.get('test-rs256-key'); 287 | expect(key).not.toBeNull(); 288 | 289 | const resBody = res.body as { access_token: string }; 290 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 291 | 292 | expect(decoded.payload).toMatchObject({ 293 | iss: service.issuer.url, 294 | scope: 'test', 295 | sub: 'johndoe', 296 | amr: ['pwd'], 297 | }); 298 | }); 299 | 300 | it('should expose a token endpoint that handles authorization_code grants without the basic authorization', async () => { 301 | const res = await request(service.requestHandler) 302 | .post('/token') 303 | .type('form') 304 | .send({ 305 | grant_type: 'authorization_code', 306 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 307 | redirect_uri: 'https://example.com/callback', 308 | client_id: 'client_id_sample', 309 | }) 310 | .expect(200); 311 | 312 | expect(res.body).toMatchObject({ 313 | access_token: expect.any(String), 314 | token_type: 'Bearer', 315 | expires_in: 3600, 316 | scope: 'dummy', 317 | id_token: expect.any(String), 318 | refresh_token: expect.any(String), 319 | }); 320 | 321 | const key = service.issuer.keys.get('test-rs256-key'); 322 | expect(key).not.toBeNull(); 323 | 324 | const resBody = res.body as { 325 | access_token: string; 326 | scope: string; 327 | id_token: string; 328 | }; 329 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 330 | 331 | expect(decoded.payload).toMatchObject({ 332 | iss: service.issuer.url, 333 | scope: 'dummy', 334 | sub: 'johndoe', 335 | amr: ['pwd'], 336 | }); 337 | 338 | const decodedIdToken = await verifyTokenWithKey(service.issuer, resBody.id_token, 'test-rs256-key'); 339 | 340 | expect(decodedIdToken.payload).toMatchObject({ 341 | aud: 'client_id_sample', 342 | }); 343 | }); 344 | 345 | it('should expose a token endpoint that handles refresh_token grants', async () => { 346 | const res = await request(service.requestHandler) 347 | .post('/token') 348 | .type('form') 349 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 350 | .send({ 351 | grant_type: 'refresh_token', 352 | refresh_token: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 353 | }) 354 | .expect(200); 355 | 356 | expect(res.body).toMatchObject({ 357 | access_token: expect.any(String), 358 | token_type: 'Bearer', 359 | expires_in: 3600, 360 | scope: 'dummy', 361 | id_token: expect.any(String), 362 | refresh_token: expect.any(String), 363 | }); 364 | 365 | const key = service.issuer.keys.get('test-rs256-key'); 366 | expect(key).not.toBeNull(); 367 | 368 | const resBody = res.body as { access_token: string }; 369 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 370 | 371 | expect(decoded.payload).toMatchObject({ 372 | iss: service.issuer.url, 373 | scope: 'dummy', 374 | sub: 'johndoe', 375 | amr: ['pwd'], 376 | }); 377 | }); 378 | 379 | it('should expose a token endpoint that copies scope for refresh_token grants', async () => { 380 | const res = await request(service.requestHandler) 381 | .post('/token') 382 | .type('form') 383 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 384 | .send({ 385 | grant_type: 'refresh_token', 386 | refresh_token: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 387 | scope: 'test' 388 | }) 389 | .expect(200); 390 | 391 | expect(res.body).toMatchObject({ 392 | access_token: expect.any(String), 393 | token_type: 'Bearer', 394 | expires_in: 3600, 395 | scope: 'test', 396 | id_token: expect.any(String), 397 | refresh_token: expect.any(String), 398 | }); 399 | 400 | const key = service.issuer.keys.get('test-rs256-key'); 401 | expect(key).not.toBeNull(); 402 | 403 | const resBody = res.body as { access_token: string }; 404 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 405 | 406 | expect(decoded.payload).toMatchObject({ 407 | iss: service.issuer.url, 408 | scope: 'test', 409 | sub: 'johndoe', 410 | amr: ['pwd'], 411 | }); 412 | }); 413 | 414 | it('should expose a token endpoint that remembers nonce', async () => { 415 | const resAuth = await request(service.requestHandler) 416 | .get('/authorize') 417 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754'); 418 | 419 | const res = await request(service.requestHandler) 420 | .post('/token') 421 | .type('form') 422 | .send({ 423 | grant_type: 'authorization_code', 424 | code: getCode(resAuth), 425 | redirect_uri: 'https://example.com/callback', 426 | client_id: 'abcecedf', 427 | }) 428 | .expect(200); 429 | 430 | const key = service.issuer.keys.get('test-rs256-key'); 431 | expect(key).not.toBeNull(); 432 | 433 | expect(res.body).toMatchObject({ 434 | id_token: expect.any(String), 435 | }); 436 | const resBody = res.body as { id_token: string }; 437 | const decoded = await verifyTokenWithKey(service.issuer, resBody.id_token, 'test-rs256-key'); 438 | 439 | expect(decoded.payload).toMatchObject({ 440 | sub: 'johndoe', 441 | aud: 'abcecedf', 442 | nonce: '21ba8e4a-26af-4538-b98a-bccf031f6754', 443 | }); 444 | }); 445 | 446 | it('should expose a token endpoint that remembers nonces of multiple clients', async () => { 447 | const resAuth = await request(service.requestHandler) 448 | .get('/authorize') 449 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754'); 450 | 451 | await request(service.requestHandler) 452 | .get('/authorize') 453 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state456&client_id=abcecedf&nonce=7184422e-f260-11ea-adc1-0242ac120002'); 454 | 455 | const res = await request(service.requestHandler) 456 | .post('/token') 457 | .type('form') 458 | .send({ 459 | grant_type: 'authorization_code', 460 | code: getCode(resAuth), 461 | redirect_uri: 'https://example.com/callback', 462 | client_id: 'abcecedf', 463 | }) 464 | .expect(200); 465 | 466 | const key = service.issuer.keys.get('test-rs256-key'); 467 | expect(key).not.toBeNull(); 468 | 469 | expect(res.body).toMatchObject({ 470 | id_token: expect.any(String), 471 | }); 472 | const resBody = res.body as { id_token: string }; 473 | const decoded = await verifyTokenWithKey(service.issuer, resBody.id_token, 'test-rs256-key'); 474 | 475 | expect(decoded.payload).toMatchObject({ 476 | sub: 'johndoe', 477 | aud: 'abcecedf', 478 | nonce: '21ba8e4a-26af-4538-b98a-bccf031f6754', 479 | }); 480 | }); 481 | 482 | it('should expose a token endpoint that forgets nonce used', async () => { 483 | await request(service.requestHandler) 484 | .get('/authorize') 485 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754'); 486 | 487 | await request(service.requestHandler) 488 | .post('/token') 489 | .type('form') 490 | .send({ 491 | grant_type: 'authorization_code', 492 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 493 | redirect_uri: 'https://example.com/callback', 494 | client_id: 'abcecedf', 495 | }); 496 | 497 | const res = await request(service.requestHandler) 498 | .post('/token') 499 | .type('form') 500 | .send({ 501 | grant_type: 'authorization_code', 502 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 503 | redirect_uri: 'https://example.com/callback', 504 | client_id: 'abcecedf', 505 | }) 506 | .expect(200); 507 | 508 | const key = service.issuer.keys.get('test-rs256-key'); 509 | expect(key).not.toBeNull(); 510 | 511 | expect(res.body).toMatchObject({ 512 | id_token: expect.any(String), 513 | }); 514 | const resBody = res.body as { id_token: string }; 515 | const decoded = await verifyTokenWithKey(service.issuer, resBody.id_token, 'test-rs256-key'); 516 | 517 | expect(decoded.payload).toMatchObject({ 518 | sub: 'johndoe', 519 | aud: 'abcecedf', 520 | }); 521 | }); 522 | 523 | it('should expose a token endpoint that accepts a JSON request body', async () => { 524 | const res = await request(service.requestHandler) 525 | .post('/token') 526 | .type('json') 527 | .send({ 528 | grant_type: 'password', 529 | username: 'the-resource-owner@example.com', 530 | scope: 'urn:first-scope urn:second-scope', 531 | }) 532 | .expect(200); 533 | 534 | expect(res.body).toMatchObject({ 535 | access_token: expect.any(String), 536 | token_type: 'Bearer', 537 | expires_in: 3600, 538 | scope: 'urn:first-scope urn:second-scope', 539 | refresh_token: expect.any(String), 540 | }); 541 | }); 542 | 543 | it('should redirect to callback url when calling authorize endpoint with code response type and no state', async () => { 544 | const res = await request(service.requestHandler) 545 | .get('/authorize') 546 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&client_id=abcecedf') 547 | .redirects(0) 548 | .expect(302); 549 | 550 | expect(res).toMatchObject({ 551 | headers: { 552 | location: expect.stringMatching(/http:\/\/example\.com\/callback\?code=[^&]*/) 553 | } 554 | }); 555 | }); 556 | 557 | it('should redirect to callback url keeping state when calling authorize endpoint with code response type', async () => { 558 | const res = await request(service.requestHandler) 559 | .get('/authorize') 560 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf') 561 | .redirects(0) 562 | .expect(302); 563 | 564 | expect(res).toMatchObject({ 565 | headers: { 566 | location: expect.stringMatching(/http:\/\/example\.com\/callback\?code=[^&]*&state=state123/) 567 | } 568 | }); 569 | }); 570 | 571 | it('should be able to manipulate url and query params when redirecting within authorize endpoint', async () => { 572 | service.once('beforeAuthorizeRedirect', (authorizeRedirectUri: MutableRedirectUri, req) => { 573 | expect(req).toBeInstanceOf(IncomingMessage); 574 | 575 | expect(authorizeRedirectUri.url.toString()).toMatch(/http:\/\/example.com\/callback\?code=[^&]+&state=state123/); 576 | 577 | authorizeRedirectUri.url.hostname = 'foo.com'; 578 | authorizeRedirectUri.url.pathname = '/cb'; 579 | authorizeRedirectUri.url.protocol = 'https'; 580 | authorizeRedirectUri.url.searchParams.set('code', 'testcode'); 581 | authorizeRedirectUri.url.searchParams.set('extra_param', 'value'); 582 | authorizeRedirectUri.url.searchParams.delete('state'); 583 | }); 584 | 585 | const res = await request(service.requestHandler) 586 | .get('/authorize') 587 | .query('response_type=code&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf') 588 | .redirects(0) 589 | .expect(302); 590 | 591 | expect(res).toMatchObject({ 592 | headers: { 593 | location: expect.stringMatching(/https:\/\/foo\.com\/cb\?code=testcode&extra_param=value/) 594 | } 595 | }); 596 | }); 597 | 598 | it('should redirect to callback url with an error and keeping state when calling authorize endpoint with an invalid response type', async () => { 599 | const res = await request(service.requestHandler) 600 | .get('/authorize') 601 | .query('response_type=invalid_response_type&redirect_uri=http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf') 602 | .redirects(0) 603 | .expect(302); 604 | 605 | expect(res).toMatchObject({ 606 | headers: { 607 | location: 'http://example.com/callback?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+obtaining+an+access+token+using+this+response_type.&state=state123' 608 | } 609 | }); 610 | }); 611 | 612 | it('should not handle token requests unsupported grant types', async () => { 613 | const res = await tokenRequest(service.requestHandler) 614 | .send({ 615 | grant_type: 'INVALID_GRANT_TYPE', 616 | }) 617 | .expect(400); 618 | 619 | expect(res.body).toMatchObject({ 620 | error: 'invalid_grant', 621 | }); 622 | }); 623 | 624 | it('should be able to transform the token endpoint response', async () => { 625 | service.once('beforeResponse', (tokenEndpointResponse, req) => { 626 | expect(req).toBeInstanceOf(IncomingMessage); 627 | tokenEndpointResponse.body.expires_in = 9000; 628 | tokenEndpointResponse.body.some_stuff = 'whatever'; 629 | tokenEndpointResponse.statusCode = 302; 630 | }); 631 | 632 | const res = await request(service.requestHandler) 633 | .post('/token') 634 | .type('form') 635 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 636 | .send({ 637 | grant_type: 'authorization_code', 638 | code: '6b575dd1-2c3b-4284-81b1-e281138cdbbd', 639 | redirect_uri: 'https://example.com/callback', 640 | }) 641 | .expect(302); 642 | 643 | expect(res.body).toMatchObject({ 644 | access_token: expect.any(String), 645 | token_type: 'Bearer', 646 | expires_in: 9000, 647 | scope: 'dummy', 648 | id_token: expect.any(String), 649 | refresh_token: expect.any(String), 650 | some_stuff: 'whatever', 651 | }); 652 | }); 653 | 654 | it('should allow customizing the token response through a beforeTokenSigning event', async () => { 655 | service.once('beforeTokenSigning', (token, req) => { 656 | expect(req).toBeInstanceOf(IncomingMessage); 657 | token.payload.custom_header = req.headers['custom-header']; 658 | token.payload.iss = "https://tada.com"; 659 | }); 660 | 661 | const res = await tokenRequest(service.requestHandler) 662 | .set('Custom-Header', 'custom-token-value') 663 | .send({ 664 | grant_type: 'client_credentials', 665 | scope: 'a-test-scope', 666 | }) 667 | .expect(200); 668 | 669 | const key = service.issuer.keys.get('test-rs256-key'); 670 | expect(key).not.toBeNull(); 671 | 672 | expect(res.body).toMatchObject({ 673 | access_token: expect.any(String), 674 | }); 675 | const resBody = res.body as { access_token: string }; 676 | 677 | const decoded = await verifyTokenWithKey(service.issuer, resBody.access_token, 'test-rs256-key'); 678 | 679 | expect(decoded.payload).toMatchObject({ 680 | iss: "https://tada.com", 681 | scope: 'a-test-scope', 682 | custom_header: 'custom-token-value', 683 | }); 684 | }); 685 | 686 | it('should expose the userinfo endpoint', async () => { 687 | const res = await request(service.requestHandler) 688 | .get('/userinfo') 689 | .expect(200); 690 | 691 | expect(res.body).toMatchObject({ 692 | sub: 'johndoe', 693 | }); 694 | }); 695 | 696 | it('should allow customizing the userinfo response through a beforeUserinfo event', async () => { 697 | service.once('beforeUserinfo', (userInfoResponse, req) => { 698 | expect(req).toBeInstanceOf(IncomingMessage); 699 | userInfoResponse.body = { 700 | error: 'invalid_token', 701 | error_message: 'token is expired', 702 | }; 703 | userInfoResponse.statusCode = 401; 704 | }); 705 | const res = await request(service.requestHandler) 706 | .get('/userinfo') 707 | .expect(401); 708 | 709 | expect(res.body).toMatchObject({ 710 | error: 'invalid_token', 711 | error_message: 'token is expired', 712 | }); 713 | }); 714 | 715 | it('should expose the revoke endpoint', async () => { 716 | const res = await request(service.requestHandler) 717 | .post('/revoke') 718 | .type('form') 719 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 720 | .send({ 721 | token: 'authorization_code', 722 | token_type_hint: 'refresh_token', 723 | }) 724 | .expect(200); 725 | 726 | expect(res.text).toBe(''); 727 | }); 728 | 729 | it('should allow customizing the revoke response through a beforeRevoke event', async () => { 730 | service.once('beforeRevoke', (revokeResponse, req) => { 731 | expect(req).toBeInstanceOf(IncomingMessage); 732 | revokeResponse.body = ''; 733 | revokeResponse.statusCode = 204; 734 | }); 735 | const res = await request(service.requestHandler) 736 | .post('/revoke') 737 | .type('form') 738 | .set('authorization', `Basic ${Buffer.from('dummy_client_id:dummy_client_secret').toString('base64')}`) 739 | .send({ 740 | token: 'authorization_code', 741 | token_type_hint: 'refresh_token', 742 | }) 743 | .expect(204); 744 | 745 | expect(res.text).toBeFalsy(); 746 | }); 747 | 748 | it('should expose CORS headers in a GET request', async () => { 749 | const res = await request(service.requestHandler) 750 | .get('/.well-known/openid-configuration') 751 | .expect(200); 752 | 753 | expect(res).toMatchObject({ 754 | headers: { 'access-control-allow-origin': '*' }, 755 | }); 756 | }); 757 | 758 | it('should expose CORS headers in an OPTIONS request', async () => { 759 | const res = await request(service.requestHandler) 760 | .options('/token') 761 | .expect(204); 762 | 763 | expect(res).toMatchObject({ 764 | headers: { 'access-control-allow-origin': '*' }, 765 | }); 766 | }); 767 | 768 | it('should redirect to post_logout_redirect_uri when calling end_session_endpoint', async () => { 769 | const postLogoutRedirectUri = 'http://example.com/signin?param=test'; 770 | 771 | const res = await request(service.requestHandler) 772 | .get('/endsession') 773 | .query(`post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`) 774 | .redirects(0) 775 | .expect(302); 776 | 777 | expect(res.headers['location']).toBe(postLogoutRedirectUri); 778 | }); 779 | 780 | it('should be able to manipulate url and query params when redirecting within post_logout_redirect_uri', async () => { 781 | const postLogoutRedirectUri = 'http://example.com/signin?param=test'; 782 | 783 | service.once('beforePostLogoutRedirect', (postLogoutRedirectURL: MutableRedirectUri, req) => { 784 | expect(req).toBeInstanceOf(IncomingMessage); 785 | 786 | expect(postLogoutRedirectURL.url.toString()).toBe(postLogoutRedirectUri); 787 | 788 | postLogoutRedirectURL.url.hostname = 'post-logout.com'; 789 | }); 790 | 791 | const res = await request(service.requestHandler) 792 | .get('/endsession') 793 | .query(`post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`) 794 | .redirects(0) 795 | .expect(302); 796 | 797 | expect(res.headers['location']).toBe('http://post-logout.com/signin?param=test'); 798 | }); 799 | 800 | it('should expose a token introspection endpoint that returns information about a token', async () => { 801 | const res = await request(service.requestHandler) 802 | .post('/introspect') 803 | .type('form') 804 | .expect(200); 805 | 806 | expect(res.body).toMatchObject({ 807 | active: true, 808 | }); 809 | }); 810 | 811 | it('should allow customizing the introspect response through a beforeIntrospect event', async () => { 812 | service.once('beforeIntrospect', (introspectResponse, req) => { 813 | expect(req).toBeInstanceOf(IncomingMessage); 814 | introspectResponse.body = { 815 | active: true, 816 | scope: 'dummy', 817 | username: 'johndoe', 818 | }; 819 | introspectResponse.statusCode = 200; 820 | }); 821 | const res = await request(service.requestHandler) 822 | .post('/introspect') 823 | .expect(200); 824 | 825 | expect(res.body).toMatchObject({ 826 | active: true, 827 | scope: 'dummy', 828 | username: 'johndoe', 829 | }); 830 | }); 831 | 832 | describe('PKCE', () => { 833 | it('should grant access in normal PKCE flow with SHA-256 code_verifier', async () => { 834 | const verifier = createPKCEVerifier(); 835 | 836 | const searchParams = new URLSearchParams({ 837 | response_type: 'code', 838 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 839 | code_challenge: await createPKCECodeChallenge(verifier, 'S256'), 840 | code_challenge_method: 'S256', 841 | }); 842 | 843 | const resAuth = await request(service.requestHandler) 844 | .get('/authorize') 845 | .query(searchParams.toString()); 846 | 847 | const res = await tokenRequest(service.requestHandler).send({ 848 | grant_type: 'authorization_code', 849 | code: getCode(resAuth), 850 | redirect_uri: 'https://example.com/callback', 851 | client_id: 'abcecedf', 852 | code_verifier: verifier, 853 | }); 854 | expect(res.statusCode).toBe(200); 855 | }); 856 | 857 | it('should grant access in normal PKCE flow with plain code_verifier', async () => { 858 | const verifier = createPKCEVerifier(); 859 | 860 | const searchParams = new URLSearchParams({ 861 | response_type: 'code', 862 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 863 | code_challenge: await createPKCECodeChallenge(verifier), 864 | code_challenge_method: 'plain', 865 | }); 866 | 867 | const resAuth = await request(service.requestHandler) 868 | .get('/authorize') 869 | .query(searchParams.toString()); 870 | 871 | const res = await tokenRequest(service.requestHandler).send({ 872 | grant_type: 'authorization_code', 873 | code: getCode(resAuth), 874 | redirect_uri: 'https://example.com/callback', 875 | client_id: 'abcecedf', 876 | code_verifier: verifier, 877 | }); 878 | expect(res.statusCode).toBe(200); 879 | }); 880 | 881 | it('should revoke on mismatching code_challenge_method', async () => { 882 | const verifier = createPKCEVerifier(); 883 | 884 | const searchParams = new URLSearchParams({ 885 | response_type: 'code', 886 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 887 | code_challenge: await createPKCECodeChallenge(verifier, 'plain'), 888 | code_challenge_method: 'S256', 889 | }); 890 | 891 | const resAuth = await request(service.requestHandler) 892 | .get('/authorize') 893 | .query(searchParams.toString()); 894 | 895 | const res = await tokenRequest(service.requestHandler).send({ 896 | grant_type: 'authorization_code', 897 | code: getCode(resAuth), 898 | redirect_uri: 'https://example.com/callback', 899 | client_id: 'abcecedf', 900 | code_verifier: verifier, 901 | }); 902 | expect(res.statusCode).toBe(400); 903 | expect(res.body).toMatchInlineSnapshot(` 904 | { 905 | "error": "invalid_request", 906 | "error_description": "code_verifier provided does not match code_challenge", 907 | } 908 | `); 909 | }); 910 | 911 | it('should revoke on invalid code_verifier', async () => { 912 | const verifier = createPKCEVerifier(); 913 | 914 | const searchParams = new URLSearchParams({ 915 | response_type: 'code', 916 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 917 | code_challenge: await createPKCECodeChallenge(verifier), 918 | code_challenge_method: 'S256', 919 | }); 920 | 921 | const resAuth = await request(service.requestHandler) 922 | .get('/authorize') 923 | .query(searchParams.toString()); 924 | 925 | const res = await tokenRequest(service.requestHandler).send({ 926 | grant_type: 'authorization_code', 927 | code: getCode(resAuth), 928 | redirect_uri: 'https://example.com/callback', 929 | client_id: 'abcecedf', 930 | code_verifier: 'invalid', 931 | }); 932 | expect(res.statusCode).toBe(400); 933 | expect(res.body).toMatchInlineSnapshot(` 934 | { 935 | "error": "invalid_request", 936 | "error_description": "Invalid 'code_verifier'. The verifier does not conform with the RFC7636 spec. Ref: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1", 937 | } 938 | `); 939 | }); 940 | 941 | it('should revoke on non-matching challenge', async () => { 942 | const searchParams = new URLSearchParams({ 943 | response_type: 'code', 944 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 945 | }); 946 | 947 | const resAuth = await request(service.requestHandler) 948 | .get('/authorize') 949 | .query(searchParams.toString()); 950 | 951 | const res = await tokenRequest(service.requestHandler).send({ 952 | grant_type: 'authorization_code', 953 | code: getCode(resAuth), 954 | redirect_uri: 'https://example.com/callback', 955 | client_id: 'abcecedf', 956 | code_verifier: createPKCEVerifier(), 957 | }); 958 | expect(res.statusCode).toBe(400); 959 | expect(res.body).toMatchInlineSnapshot(` 960 | { 961 | "error": "invalid_request", 962 | "error_description": "code_challenge required", 963 | } 964 | `); 965 | }); 966 | 967 | it('should revoke on unsupported code_challende_method', async () => { 968 | const searchParams = new URLSearchParams({ 969 | response_type: 'code', 970 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 971 | code_challenge: await createPKCECodeChallenge(), 972 | code_challenge_method: 'invalid' 973 | }); 974 | 975 | const resAuth = await request(service.requestHandler) 976 | .get('/authorize') 977 | .query(searchParams.toString()); 978 | 979 | expect(resAuth.statusCode).toBe(400); 980 | expect(resAuth.body).toMatchInlineSnapshot(` 981 | { 982 | "error": "invalid_request", 983 | "error_description": "Unsupported code_challenge method invalid. The following code_challenge_method are supported: plain, S256", 984 | } 985 | `); 986 | 987 | 988 | }); 989 | 990 | it('should default to plain code_challenge_method if not provided', async () => { 991 | const verifier = createPKCEVerifier(); 992 | 993 | const searchParams = new URLSearchParams({ 994 | response_type: 'code', 995 | redirect_uri: 'http://example.com/callback&scope=dummy_scope&state=state123&client_id=abcecedf&nonce=21ba8e4a-26af-4538-b98a-bccf031f6754', 996 | code_challenge: await createPKCECodeChallenge(verifier, 'plain'), 997 | }); 998 | 999 | const resAuth = await request(service.requestHandler) 1000 | .get('/authorize') 1001 | .query(searchParams.toString()); 1002 | 1003 | const res = await tokenRequest(service.requestHandler).send({ 1004 | grant_type: 'authorization_code', 1005 | code: getCode(resAuth), 1006 | redirect_uri: 'https://example.com/callback', 1007 | client_id: 'abcecedf', 1008 | code_verifier: verifier, 1009 | }); 1010 | expect(res.statusCode).toBe(200); 1011 | }); 1012 | }); 1013 | }); 1014 | 1015 | function getCode(response: request.Response) { 1016 | expect(response).toMatchObject({ 1017 | header: { location: expect.any(String) }, 1018 | }); 1019 | const parsed = response as unknown as { header: { location: string } }; 1020 | const url = new URL(parsed.header.location); 1021 | return url.searchParams.get('code'); 1022 | } 1023 | 1024 | function tokenRequest(app: RequestListener) { 1025 | return request(app) 1026 | .post('/token') 1027 | .type('form') 1028 | .expect('Cache-Control', 'no-store') 1029 | .expect('Pragma', 'no-cache'); 1030 | } 1031 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/", 5 | "test/", 6 | "vitest.config.ts", 7 | "eslint.config.js", 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/", 4 | "rollup.config.ts" 5 | ], 6 | "compilerOptions": { 7 | /* https://www.npmjs.com/package/@tsconfig/node20 */ 8 | "target": "ES2022", 9 | "module": "preserve", 10 | "moduleResolution": "bundler", 11 | "lib": [ 12 | "ES2023" 13 | ], 14 | "strict": true /* Enable all strict type-checking options. */, 15 | "noUnusedLocals": true /* Report errors on unused locals. */, 16 | "noUnusedParameters": true /* Report errors on unused parameters. */, 17 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 18 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 19 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 20 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 21 | "allowUnusedLabels": false, 22 | "allowUnreachableCode": false, 23 | "exactOptionalPropertyTypes": true, 24 | "noImplicitOverride": true, 25 | "noPropertyAccessFromIndexSignature": true, 26 | "noUncheckedIndexedAccess": true, 27 | "removeComments": true, 28 | "skipLibCheck": true 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: {}, 5 | }); 6 | --------------------------------------------------------------------------------