├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .gitignore ├── .idea ├── .gitignore ├── jsLibraryMappings.xml ├── modules.xml ├── prettier.xml ├── ts-oauth2-server.iml └── vcs.xml ├── .node-version ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── SECURITY.md ├── docs ├── .gitignore ├── .idea │ ├── .gitignore │ ├── codeStyles │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── dbnavigator.xml │ ├── docs.iml │ ├── modules.xml │ └── vcs.xml ├── .prettierignore ├── .prettierrc ├── README.md ├── babel.config.js ├── docs │ ├── Extras │ │ ├── access_tokens.md │ │ ├── glossary.md │ │ └── references.md │ ├── adapters │ │ ├── express.md │ │ ├── fastify.md │ │ ├── index.md │ │ ├── nuxt.md │ │ └── vanilla.md │ ├── authorization_server │ │ ├── configuration.mdx │ │ └── index.mdx │ ├── endpoints │ │ ├── authorize.mdx │ │ ├── index.mdx │ │ ├── introspect.mdx │ │ ├── revoke.mdx │ │ └── token.mdx │ ├── faqs.md │ ├── getting_started │ │ ├── entities.md │ │ ├── index.mdx │ │ └── repositories.mdx │ ├── grants │ │ ├── authorization_code.mdx │ │ ├── client_credentials.mdx │ │ ├── custom.mdx │ │ ├── implicit.mdx │ │ ├── index.mdx │ │ ├── password.mdx │ │ ├── refresh_token.mdx │ │ └── token_exchange.mdx │ └── upgrade_guide.md ├── docusaurus.config.ts ├── package.json ├── pnpm-lock.yaml ├── sidebars.ts ├── src │ ├── components │ │ ├── Card │ │ │ ├── index.tsx │ │ │ └── styles.modules.css │ │ ├── CodeExamples.mdx │ │ ├── MarkdownWrapper.tsx │ │ ├── dividers │ │ │ ├── Spikes │ │ │ │ ├── index.module.css │ │ │ │ └── index.tsx │ │ │ └── Triangle │ │ │ │ ├── index.module.css │ │ │ │ └── index.tsx │ │ ├── grants │ │ │ └── RequiredForGrants.tsx │ │ ├── icons.tsx │ │ └── icons │ │ │ ├── nodejs.tsx │ │ │ └── typescript.tsx │ ├── css │ │ └── custom.css │ ├── index.d.ts │ ├── pages │ │ ├── _example_authorization_server.mdx │ │ ├── _example_entities.mdx │ │ ├── _example_repositories.mdx │ │ ├── _index_install.mdx │ │ ├── _logos.tsx │ │ ├── _which_grant.mdx │ │ └── index.tsx │ └── theme │ │ └── MDXComponents.tsx ├── static │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── img │ │ ├── logo.svg │ │ ├── oauth2-server-social-card.jpg │ │ ├── oauth2-server-social-card.png │ │ └── oauth2-server-social-card.svg │ └── robots.txt ├── tailwind-config.cjs ├── tailwind.config.js └── tsconfig.json ├── example ├── .env.example ├── .gitignore ├── README.md ├── docker-compose.yml ├── package.json ├── pnpm-lock.yaml ├── prisma │ └── schema.prisma ├── src │ ├── entities │ │ ├── auth_code.ts │ │ ├── client.ts │ │ ├── scope.ts │ │ ├── token.ts │ │ └── user.ts │ ├── main.ts │ ├── repositories │ │ ├── auth_code_repository.ts │ │ ├── client_repository.ts │ │ ├── scope_repository.ts │ │ ├── token_repository.ts │ │ └── user_repository.ts │ └── utils │ │ └── custom_jwt_service.ts └── tsconfig.json ├── jsr.json ├── package.json ├── pnpm-lock.yaml ├── src ├── adapters │ ├── express.ts │ ├── fastify.ts │ └── vanilla.ts ├── authorization_server.ts ├── code_verifiers │ ├── S256.verifier.ts │ ├── plain.verifier.ts │ └── verifier.ts ├── entities │ ├── auth_code.entity.ts │ ├── client.entity.ts │ ├── scope.entity.ts │ ├── token.entity.ts │ └── user.entity.ts ├── exceptions │ └── oauth.exception.ts ├── grants │ ├── abstract │ │ ├── abstract.grant.ts │ │ ├── abstract_authorized.grant.ts │ │ ├── custom.grant.ts │ │ └── grant.interface.ts │ ├── auth_code.grant.ts │ ├── client_credentials.grant.ts │ ├── implicit.grant.ts │ ├── password.grant.ts │ ├── refresh_token.grant.ts │ └── token_exchange.grant.ts ├── index.ts ├── options.ts ├── repositories │ ├── access_token.repository.ts │ ├── auth_code.repository.ts │ ├── client.repository.ts │ ├── scope.repository.ts │ └── user.repository.ts ├── requests │ ├── authorization.request.ts │ └── request.ts ├── responses │ ├── bearer_token.response.ts │ ├── redirect.response.ts │ └── response.ts └── utils │ ├── array.ts │ ├── base64.ts │ ├── date_interval.ts │ ├── errors.ts │ ├── jwt.ts │ ├── scopes.ts │ ├── time.ts │ ├── token.ts │ └── urls.ts ├── test ├── e2e │ ├── _helpers │ │ └── in_memory │ │ │ ├── database.ts │ │ │ ├── oauth_authorization_server.ts │ │ │ └── repository.ts │ ├── adapters │ │ ├── express.spec.ts │ │ ├── fastify.spec.ts │ │ └── vanilla.spec.ts │ ├── authorization_server.spec.ts │ └── grants │ │ ├── auth_code.grant.spec.ts │ │ ├── client_credentials.grant.spec.ts │ │ ├── implicit.grant.spec.ts │ │ ├── password.grant.spec.ts │ │ ├── refresh_token.grant.spec.ts │ │ └── token_exchange.grant.spec.ts ├── setup.ts └── unit │ ├── index.spec.ts │ └── utils │ ├── time.spec.ts │ └── token.spec.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jasonraimondi 2 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: build and test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | check_versions: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: | 15 | jsr_version=$(jq -r '.version' jsr.json) 16 | pkg_version=$(jq -r '.version' package.json) 17 | if [[ "$jsr_version" != "$pkg_version" ]]; then 18 | echo "Version Mismatch" 19 | echo "JSR: $jsr_version" 20 | echo "Node: $pkg_version" 21 | exit 1 22 | fi 23 | 24 | build: 25 | runs-on: ubuntu-latest 26 | needs: [check_versions] 27 | strategy: 28 | matrix: 29 | node-version: [ 20.x, 22.x ] 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: pnpm/action-setup@v4 33 | with: 34 | version: 10 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: pnpm 39 | cache-dependency-path: pnpm-lock.yaml 40 | - run: pnpm install --frozen-lockfile --production false 41 | - run: pnpm test:cov 42 | env: 43 | CI: true 44 | DOMAIN: localhost 45 | JWT_SECRET: testing-access-token-secret 46 | - name: report coverage to code climate 47 | # code coverage report will only run on the main branch and not on pull request 48 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 49 | run: | 50 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 51 | chmod +x ./cc-test-reporter 52 | ./cc-test-reporter format-coverage -t lcov coverage/lcov.info 53 | ./cc-test-reporter upload-coverage 54 | env: 55 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 56 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | 7 | jobs: 8 | publish_npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: pnpm/action-setup@v4 13 | with: 14 | version: 10 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version-file: '.node-version' 18 | cache: pnpm 19 | cache-dependency-path: pnpm-lock.yaml 20 | registry-url: "https://registry.npmjs.org" 21 | - run: pnpm i --frozen-lockfile --production false 22 | - name: Check Release type and Publish 23 | run: | 24 | if ${{ github.event.release.prerelease }}; then 25 | echo "Publishing pre-release..." 26 | pnpm publish --verbose --access=public --no-git-checks --tag next 27 | else 28 | echo "Publishing release..." 29 | pnpm publish --verbose --access=public --no-git-checks 30 | fi 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 33 | 34 | publish_jsr: 35 | runs-on: ubuntu-latest 36 | permissions: 37 | contents: read 38 | id-token: write 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: pnpm/action-setup@v4 42 | with: 43 | version: 10 44 | - uses: actions/setup-node@v4 45 | with: 46 | node-version-file: '.node-version' 47 | cache: pnpm 48 | cache-dependency-path: pnpm-lock.yaml 49 | - run: pnpm install --frozen-lockfile --production false 50 | - run: pnpm dlx jsr publish 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/coverage/ 2 | **/node_modules/ 3 | **/dist/ 4 | **/cache/ 5 | 6 | ### Node ### 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS X 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | # GitHub Copilot persisted chat sessions 10 | /copilot/chatSessions 11 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/ts-oauth2-server.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.12.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | *.yaml 4 | *.md 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 120 4 | trailingComma: all 5 | tabWidth: 2 6 | semi: true 7 | singleQuote: false 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jason Raimondi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript OAuth2.0 Server 2 | 3 | [![JSR](https://jsr.io/badges/@jmondi/oauth2-server?style=flat-square)](https://jsr.io/@jmondi/oauth2-server) 4 | [![NPM Version](https://img.shields.io/npm/v/%40jmondi%2Foauth2-server?style=flat-square)](https://www.npmjs.com/package/@jmondi/oauth2-server) 5 | [![GitHub Workflow Status]( https://img.shields.io/github/actions/workflow/status/jasonraimondi/ts-oauth2-server/build-and-test.yml?branch=main&style=flat-square)](https://github.com/jasonraimondi/ts-oauth2-server) 6 | [![Test Coverage](https://img.shields.io/codeclimate/coverage/jasonraimondi/typescript-oauth2-server?style=flat-square)](https://codeclimate.com/github/jasonraimondi/typescript-oauth2-server/test_coverage) 7 | [![NPM Downloads](https://img.shields.io/npm/dt/@jmondi/oauth2-server?label=npm%20downloads&style=flat-square)](https://www.npmjs.com/package/@jmondi/oauth2-server) 8 | 9 | `@jmondi/oauth2-server` is a standards compliant implementation of an OAuth 2.0 authorization server written in TypeScript. 10 | 11 | Requires `node >= 18`. [Read the docs](https://tsoauth2server.com/) 12 | 13 | The following RFCs are implemented: 14 | 15 | - [RFC6749 "OAuth 2.0"](https://tools.ietf.org/html/rfc6749) 16 | - [RFC6750 "The OAuth 2.0 Authorization Framework: Bearer Token Usage"](https://tools.ietf.org/html/rfc6750) 17 | - [RFC7009 "OAuth 2.0 Token Revocation"](https://tools.ietf.org/html/rfc7009) 18 | - [RFC7519 "JSON Web Token (JWT)"](https://tools.ietf.org/html/rfc7519) 19 | - [RFC7636 "Proof Key for Code Exchange by OAuth Public Clients"](https://tools.ietf.org/html/rfc7636) 20 | - [RFC7662 "OAuth 2.0 Token Introspection"](https://tools.ietf.org/html/rfc7662) 21 | - [RFC8693 "OAuth 2.0 Token Exchange"](https://datatracker.ietf.org/doc/html/rfc8693) 22 | 23 | Out of the box it supports the following grants: 24 | 25 | - [Authorization code grant](https://tsoauth2server.com/docs/grants/authorization_code) 26 | - [Client credentials grant](https://tsoauth2server.com/docs/grants/client_credentials) 27 | - [Refresh grant](https://tsoauth2server.com/docs/grants/refresh_token) 28 | - [Implicit grant](https://tsoauth2server.com/docs/grants/implicit) // not recommended 29 | - [Resource owner password credentials grant](https://tsoauth2server.com/docs/grants/password) // not recommended 30 | 31 | Framework support: 32 | 33 | The included adapters are just helper functions, any framework should be supported. Take a look at the adapter implementations to learn how you can create custom adapters for your favorite tool! 34 | 35 | - [VanillaJS](https://tsoauth2server.com/docs/adapters/vanilla) 36 | - [Express](https://tsoauth2server.com/docs/adapters/express) 37 | - [Fastify](https://tsoauth2server.com/docs/adapters/fastify). 38 | 39 | ### Usage 40 | 41 | A example using client credentials grant 42 | 43 | ```ts 44 | const authorizationServer = new AuthorizationServer( 45 | clientRepository, 46 | accessTokenRepository, 47 | scopeRepository, 48 | "secret-key", 49 | ); 50 | authorizationServer.enableGrantType("client_credentials"); 51 | 52 | app.post("/token", async (req: Express.Request, res: Express.Response) => { 53 | try { 54 | const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 55 | return handleExpressResponse(res, oauthResponse); 56 | } catch (e) { 57 | handleExpressError(e, res); 58 | } 59 | }); 60 | 61 | app.post("/token/revoke", async (req: Express.Request, res: Express.Response) => { 62 | try { 63 | const oauthResponse = await authorizationServer.revoke(req); 64 | return handleExpressResponse(res, oauthResponse); 65 | } catch (e) { 66 | handleExpressError(e, res); 67 | } 68 | }); 69 | ``` 70 | 71 | Example implementations: 72 | 73 | - [Simple](./example) 74 | - [Advanced](https://github.com/jasonraimondi/ts-oauth2-server-example) 75 | 76 | ### Security 77 | 78 | | Version | Latest Version | Security Updates | 79 | |-----------------|----------------|------------------| 80 | | [4.x][version4] | :tada: | :tada: | 81 | | [3.x][version3] | :tada: | :tada: | 82 | | [2.x][version2] | | :tada: | 83 | 84 | [version4]: https://github.com/jasonraimondi/ts-oauth2-server/tree/main 85 | [version3]: https://github.com/jasonraimondi/ts-oauth2-server/tree/3.x 86 | [version2]: https://github.com/jasonraimondi/ts-oauth2-server/tree/2.x 87 | 88 | ## Migration Guide 89 | 90 | - [v1 to v2](https://github.com/jasonraimondi/ts-oauth2-server/releases/tag/v2.0.0) 91 | - [v2 to v3](https://tsoauth2server.com/docs/upgrade_guide#to-v3) 92 | - [v3 to v4](https://tsoauth2server.com/docs/upgrade_guide#to-v4) 93 | 94 | ## Thanks 95 | 96 | This project is inspired by the [PHP League's OAuth2 Server](https://oauth2.thephpleague.com/). Check out the [PHP League's other packages](https://thephpleague.com/#packages) for some other great PHP projects. 97 | 98 | ## Star History 99 | 100 | [![Star History Chart](https://api.star-history.com/svg?repos=jasonraimondi/ts-oauth2-server&type=Timeline)](https://star-history.com/#jasonraimondi/ts-oauth2-server&Timeline) 101 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Latest Version | Security Updates | 6 | |---------|--------------------|--------------------| 7 | | 4.x | :white_check_mark: | :white_check_mark: | 8 | | 3.x | :white_check_mark: | :white_check_mark: | 9 | | 2.x | | :white_check_mark: | 10 | | 1.x | | | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you discover any security related issues, please email jason@raimondi.us instead of using the issue tracker. 15 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /docs/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 89 | -------------------------------------------------------------------------------- /docs/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /docs/.idea/docs.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | .docusaurus/ 2 | 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 100 4 | trailingComma: all 5 | tabWidth: 2 6 | semi: true 7 | singleQuote: false 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ pnpm i 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ pnpm start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ pnpm build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/Extras/access_tokens.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # JWT / Access Tokens 6 | 7 | ## Issuer (**iss** [rfc](https://tools.ietf.org/html/rfc7519#section-4.1.1)) 8 | 9 | You can customize the `iss` property by setting the `issuer` property in [the AuthorizationServer configuration](../authorization_server/configuration.mdx). 10 | 11 | ## Audience (**aud** [rfc](https://tools.ietf.org/html/rfc7519#section-4.1.3)) 12 | 13 | You can customize the `aud` field by passing `aud`. 14 | 15 | | Endpoint | Query | Body | 16 | | ------------ | ------------------- | ------------------- | 17 | | `/token` | `aud` \| `audience` | `aud` \| `audience` | 18 | | `/authorize` | `aud` \| `audience` | | 19 | 20 | ## Extra Token Fields 21 | 22 | You can add additional properties to the encoded access token by implementing the `extraTokenFields` method in your `JwtService` class. 23 | 24 | ```ts 25 | import { JwtService } from "@jmondi/oauth2-server"; 26 | 27 | export class MyCustomJwtService extends JwtService { 28 | extraTokenFields(params: ExtraAccessTokenFieldArgs) { 29 | const { user = undefined, client } = params; 30 | return { 31 | email: user?.email, 32 | myCustomProps: "this will be in the decoded token!", 33 | }; 34 | } 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/docs/Extras/glossary.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Glossary 3 | sidebar_position: 7 4 | --- 5 | 6 | # Glossary 7 | 8 | ### Resource Server 9 | 10 | The resource server is the OAuth 2.0 term for your API server. The resource server handles authenticated requests after the client has obtained an access token. 11 | 12 | ### Client 13 | 14 | The application attempting to gain access to the resource server. The client must have an [OAuthClient](../getting_started/entities.md#client-entity) 15 | 16 | [access_token_response]: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ "Access Token Response" 17 | [client_credentials]: https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/ "Client Credentials Grant" 18 | -------------------------------------------------------------------------------- /docs/docs/Extras/references.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 9 3 | --- 4 | 5 | # References 6 | 7 | - https://github.com/thephpleague/oauth2-server - This project was influenced by the [PHP League OAuth2 Server](https://oauth2.thephpleague.com/) and shares a lot of the same ideas. 8 | - https://tools.ietf.org/html/rfc6749#section-4.4 9 | - https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/ 10 | - https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ 11 | - https://tools.ietf.org/html/rfc6749#section-4.1 12 | - https://tools.ietf.org/html/rfc7009 13 | - https://tools.ietf.org/html/rfc7636 14 | - https://tools.ietf.org/html/rfc7662 15 | - https://www.oauth.com/oauth2-servers/pkce/ 16 | - https://www.oauth.com/oauth2-servers/pkce/authorization-request/ 17 | 18 | [access_token_response]: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ "Access Token Response" 19 | [client_credentials]: https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/ "Client Credentials Grant" 20 | -------------------------------------------------------------------------------- /docs/docs/adapters/express.md: -------------------------------------------------------------------------------- 1 | # Express 2 | 3 | :::info 4 | 5 | Available in >2.0.0 6 | 7 | ::: 8 | 9 | This adapter provides utility functions to convert between Express [Request](https://expressjs.com/en/api.html#req) and [Response](https://expressjs.com/en/api.html#res) objects and the `OAuthRequest`/`OAuthResponse` objects used by this package. 10 | 11 | ## Functions 12 | 13 | ```ts 14 | requestFromExpress(req: Express.Request): OAuthRequest 15 | ``` 16 | 17 | ```ts 18 | handleExpressResponse(expressResponse: Express.Response, oauthResponse: OAuthResponse): void 19 | ``` 20 | 21 | ```ts 22 | handleExpressError(e: unknown | OAuthException, res: Express.Response): void 23 | ``` 24 | 25 | ## Example 26 | 27 | ```ts 28 | import { requestFromExpress, handleExpressResponse, handleExpressError } from "@jmondi/oauth2-server/express"; 29 | import express from 'express'; 30 | 31 | const app = express(); 32 | 33 | // ... 34 | 35 | app.post('/oauth2/token', async (req: express.Request, res: express.Response) => { 36 | const authorizationServer = req.app.get('authorization_server'); 37 | 38 | try { 39 | const oauthResponse = await authorizationServer 40 | .respondToAccessTokenRequest(requestFromExpress(req)); 41 | 42 | handleExpressResponse(res, oauthResponse); 43 | } catch (e) { 44 | handleExpressError(res, e); 45 | } 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/docs/adapters/fastify.md: -------------------------------------------------------------------------------- 1 | # Fastify 2 | 3 | :::info 4 | 5 | Available in >2.0.0 6 | 7 | ::: 8 | 9 | 10 | This adapter provides utility functions to convert between Fastify [Request](https://fastify.dev/docs/latest/Reference/Request/) and [Reply](https://fastify.dev/docs/latest/Reference/Reply/) objects and the `OAuthRequest`/`OAuthResponse` objects used by this package. 11 | 12 | ## Functions 13 | 14 | ```ts 15 | requestFromFastify(req: FastifyRequest): OAuthRequest 16 | ``` 17 | 18 | ```ts 19 | handleFastifyReply(fastifyReply: FastifyReply, oauthResponse: OAuthResponse): void 20 | ``` 21 | 22 | ```ts 23 | handleFastifyError(e: unknown | OAuthException, reply: FastifyReply): void 24 | ``` 25 | 26 | ## Example 27 | 28 | ```ts 29 | import { requestFromFastify, handleFastifyReply, handleFastifyError } from "@jmondi/oauth2-server/fastify"; 30 | import fastify from 'fastify' 31 | 32 | const app = fastify() 33 | 34 | // ... 35 | 36 | app.post('/oauth2/token', async (request: fastify.Request, reply: fastify.Reply) => { 37 | const authorizationServer = request.server.authorizationServer; 38 | 39 | try { 40 | const oauthResponse = await authorizationServer 41 | .respondToAccessTokenRequest(requestFromFastify(request)); 42 | 43 | handleFastifyReply(reply, oauthResponse); 44 | } catch (e) { 45 | handleFastifyError(reply, e); 46 | } 47 | }); 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/docs/adapters/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Adapters 3 | sidebar_position: 5 4 | --- 5 | 6 | # Adapters 7 | 8 | Adapters are a set of helper functions to provide framework specific integration into `@jmondi/oauth2-server`. We provide adapters for some common tools: 9 | 10 | - [Express](./express.md) - If you're using Express, you can use the `@jmondi/oauth2-server/express` adapter. 11 | - [Fastify](./fastify.md) - If you're using Fastify, you can use the `@jmondi/oauth2-server/fastify` adapter. 12 | - [VanillaJS](./vanilla.md) - Adapts the Fetch [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) so you can use Honojs, Sveltekit, Nextjs or whatever tool your using that uses the native [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) `@jmondi/oauth2-server/vanilla` adapter. 13 | - [Nuxt](./nuxt.md) - If you are using Nuxt, you can directly copy this code and use it as is. 14 | -------------------------------------------------------------------------------- /docs/docs/adapters/nuxt.md: -------------------------------------------------------------------------------- 1 | # Nuxt 2 | 3 | This code is used to transform a Nuxt response or to construct a new Nuxt HTTP response. 4 | 5 | ## Source Code 6 | 7 | ```ts 8 | import type { OAuthResponse } from '@jmondi/oauth2-server' 9 | import { ErrorType, OAuthException, OAuthRequest } from '@jmondi/oauth2-server' 10 | import { getHeaders, getQuery, readBody, sendError, type H3Event } from 'h3' 11 | 12 | export function responseWithH3(event: H3Event, oauthResponse: OAuthResponse, wrapResp?: string): void { 13 | if (oauthResponse.status === 302) { 14 | if (typeof oauthResponse.headers.location !== 'string' || oauthResponse.headers.location === '') { 15 | throw new OAuthException(`missing redirect location`, ErrorType.InvalidRequest) 16 | } 17 | event.respondWith( 18 | new Response(null, { 19 | status: 302, 20 | headers: { 21 | Location: oauthResponse.headers.location 22 | } 23 | }) 24 | ) 25 | } 26 | 27 | let body = oauthResponse.body 28 | if (wrapResp) { 29 | body = { [wrapResp]: body } 30 | } 31 | 32 | event.respondWith( 33 | new Response(JSON.stringify(body), { 34 | status: oauthResponse.status, 35 | headers: oauthResponse.headers 36 | }) 37 | ) 38 | } 39 | 40 | export async function requestFromH3(event: H3Event, updatedBody?: Record): Promise { 41 | let query: Record = {} 42 | let body: Record = {} 43 | if (['GET', 'DELETE', 'HEAD', 'OPTIONS'].includes(event.method.toUpperCase())) { 44 | query = getQuery(event) as Record 45 | } 46 | if (['POST', 'PUT', 'PATCH'].includes(event.method.toUpperCase())) { 47 | if (updatedBody) { 48 | body = updatedBody 49 | } else { 50 | body = await readBody(event) 51 | } 52 | } 53 | return new OAuthRequest({ 54 | query: query, 55 | body: body, 56 | headers: getHeaders(event) ?? {} 57 | }) 58 | } 59 | 60 | export function handleErrorWithH3(event: H3Event, e: unknown | OAuthException): void { 61 | if (isOAuthError(e)) { 62 | sendError(event, e) 63 | return 64 | } 65 | throw e 66 | } 67 | 68 | export function isOAuthError(error: unknown): error is OAuthException { 69 | if (!error) return false 70 | if (typeof error !== 'object') return false 71 | return 'oauth' in error 72 | } 73 | ``` 74 | 75 | ## Functions 76 | 77 | ```ts 78 | function responseWithH3(event: H3Event, oauthResponse: OAuthResponse, wrapResp?: string): void 79 | ``` 80 | 81 | ```ts 82 | export async function requestFromH3(event: H3Event, updatedBody?: Record): Promise 83 | ``` 84 | 85 | ```ts 86 | export function handleErrorWithH3(event: H3Event, e: unknown | OAuthException) 87 | ``` 88 | 89 | 90 | ## Example 91 | 92 | 93 | ```ts 94 | import { requestFromH3, handleErrorWithH3, responseWithH3 } from '../../tools/converts' 95 | 96 | export default defineEventHandler(async (event) => { 97 | const body = await readBody(event) 98 | body.grant_type = 'password' 99 | body.client_id = useRuntimeConfig().oauth.client.clientId 100 | body.client_secret = useRuntimeConfig().oauth.client.secret 101 | try { 102 | // Here is an instance of new AuthorizationServer. 103 | const oauthServer = useOAuthServer() 104 | // A transformation is applied here. 105 | const oauthResponse = await oauthServer.respondToAccessTokenRequest(await requestFromH3(event, body)) 106 | // The response is directly forwarded here. 107 | responseWithH3(event, oauthResponse, 'data') 108 | } catch (e) { 109 | console.error(e) 110 | handleErrorWithH3(event, e) 111 | } 112 | }) 113 | ``` -------------------------------------------------------------------------------- /docs/docs/adapters/vanilla.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Vanilla 6 | 7 | :::info 8 | 9 | Available in >3.4.0 10 | 11 | ::: 12 | 13 | This adapter provides utility functions to convert between vanilla JavaScript [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects and the `OAuthRequest`/`OAuthResponse` objects used by the this package. 14 | 15 | ## Functions 16 | 17 | ```ts 18 | responseFromVanilla(res: Response): OAuthResponse 19 | ``` 20 | 21 | ```ts 22 | requestFromVanilla(req: Request): Promise 23 | ``` 24 | 25 | ```ts 26 | responseToVanilla(oauthResponse: OAuthResponse): Response 27 | ``` 28 | 29 | ## Example 30 | 31 | ```ts 32 | import { requestFromVanilla, responseToVanilla } from "@jmondi/oauth2-server/vanilla"; 33 | 34 | import { Hono } from 'hono' 35 | const app = new Hono() 36 | 37 | // ... 38 | 39 | app.post('/oauth2/token', async (c) => { 40 | const authorizationServer = c.get("authorization_server"); 41 | 42 | const oauthResponse = await authorizationServer 43 | .respondToAccessTokenRequest(await requestFromVanilla(request)) 44 | .catch(e => { 45 | error(400, e.message); 46 | }); 47 | 48 | return responseToVanilla(oauthResponse); 49 | }); 50 | 51 | export default app 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/docs/authorization_server/configuration.mdx: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | :::info 4 | 5 | The default configuration is great for most users. You might not need to tweak anything here. 6 | 7 | ::: 8 | 9 | The authorization server has a few optional settings with the following default values; 10 | 11 | | Option | Type | Default | Details | 12 | | ------------------------ | ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `requiresPKCE` | boolean | true | PKCE is enabled by default and recommended for all users. To support a legacy client without PKCE, disable this option. [[Learn more]][requires-pkce] | 14 | | `requiresS256` | boolean | true | Disabled by default. If you want to require all clients to use S256, you can enable that here. [[Learn more]][requires-s256] | 15 | | `notBeforeLeeway` | number | 0 | Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value MUST be a number containing a NumericDate value. | 16 | | `tokenCID` | "id" or "name" | "id" | Sets the JWT `accessToken.cid` to either the `client.id` or `client.name`.

In 3.x the default is **"id"**, in v2.x the default was **"name"**. [[Learn more]][token-cid] | 17 | | `issuer` | string \| undefined | undefined | Sets the JWT `accessToken.iss` to this value. | 18 | | `authenticateIntrospect` | boolean | true | Authorize the [/introspect](../endpoints/introspect.mdx) endpoint using `client_credentials`, this requires users to pass in a valid client_id and client_secret (or Authorization header)

In 4.x the default is **true**, in v3.x the default was **false**. | 19 | | `authenticateRevoke` | boolean | true | Authorize the [/revoke](../endpoints/revoke.mdx) endpoint using `client_credentials`, this requires users to pass in a valid client_id and client_secret (or Authorization header)

In 4.x the default is **true**, in v3.x the default was **false**. | 20 | 21 | ```ts 22 | type AuthorizationServerOptions = { 23 | requiresPKCE: true; 24 | requiresS256: false; 25 | notBeforeLeeway: 0; 26 | tokenCID: "id" | "name"; 27 | issuer: undefined; 28 | authenticateIntrospect: boolean; 29 | authenticateRevoke: boolean; 30 | }; 31 | ``` 32 | 33 | To configure these options, pass the value in as the last argument: 34 | 35 | ```ts 36 | const authorizationServer = new AuthorizationServer( 37 | clientRepository, 38 | accessTokenRepository, 39 | scopeRepository, 40 | new JwtService("secret-key"), 41 | { 42 | issuer: "auth.example.com", 43 | }, 44 | ); 45 | ``` 46 | 47 | [requires-pkce]: https://datatracker.ietf.org/doc/html/rfc7636 48 | [requires-s256]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 49 | [token-cid]: https://github.com/jasonraimondi/ts-oauth2-server/blob/e7a31b207701f90552abc82d82c72b143bc15130/src/grants/abstract/abstract.grant.ts#L123 50 | -------------------------------------------------------------------------------- /docs/docs/authorization_server/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # The Authorization Server 6 | 7 | The `AuthorizationServer` is a core component of the OAuth 2.0 framework, responsible for authenticating resource owners and issuing access tokens to clients. This class provides a flexible and customizable implementation of an authorization server. 8 | 9 | ## Initialization 10 | 11 | To create an instance of the `AuthorizationServer`, use the following constructor: 12 | 13 | ```ts 14 | const authorizationServer = new AuthorizationServer( 15 | clientRepository, 16 | accessTokenRepository, 17 | scopeRepository, 18 | "secret-key", 19 | configuration, 20 | ); 21 | ``` 22 | 23 | Parameters: 24 | 25 | - `clientRepository`: An instance of the client repository 26 | - `accessTokenRepository`: An instance of the access token repository 27 | - `scopeRepository`: An instance of the scope repository 28 | - `"secret-key"`: A string used for signing tokens (ensure this is kept secure) 29 | - `configuration`: An optional object for additional server configuration 30 | 31 | ## Enabling Grant Types 32 | 33 | By default, no grant types are enabled when creating an `AuthorizationServer`. Each grant type must be explicitly enabled using the `enableGrantType` method. This approach allows for fine-grained control over which OAuth 2.0 flows your server supports. 34 | 35 | ```ts 36 | authorizationServer.enableGrantType("client_credentials"); 37 | authorizationServer.enableGrantType("refresh_token"); 38 | authorizationServer.enableGrantType({ 39 | grant: "authorization_code", 40 | userRepository, 41 | authCodeRepository, 42 | }); 43 | // any other grant types you want to enable 44 | ``` 45 | 46 | Note that the Authorization Code grant requires additional repositories: `userRepository` and `authCodeRepository`. 47 | 48 | ### Example: Enabling Multiple Grant Types 49 | 50 | You can enable multiple grant types on the same server: 51 | 52 | ```ts 53 | const authorizationServer = new AuthorizationServer( 54 | clientRepository, 55 | accessTokenRepository, 56 | scopeRepository, 57 | "secret-key", 58 | configuration, 59 | ); 60 | 61 | authorizationServer.enableGrantType("client_credentials"); 62 | authorizationServer.enableGrantType("refresh_token"); 63 | authorizationServer.enableGrantType({ 64 | grant: "authorization_code", 65 | userRepository, 66 | authCodeRepository, 67 | }); 68 | ``` 69 | 70 | ## Best Practices 71 | 72 | 1. **Security**: Keep the `secret-key` confidential and use a strong, unique value in production. 73 | 2. **Grant Types**: Only enable the grant types necessary for your application to minimize potential attack vectors. 74 | 75 | ## Additional Considerations 76 | 77 | - **PKCE Support**: If implementing the Authorization Code grant, consider adding support for Proof Key for Code Exchange (PKCE) to enhance security for public clients. 78 | - **Scope Validation**: Implement proper scope validation in your `scopeRepository` to ensure clients only receive access to permitted resources. 79 | - **Token Management**: Implement token revocation and introspection endpoints for better token lifecycle management. 80 | - **Error Handling**: Implement comprehensive error handling to provide clear and secure responses for various error scenarios. 81 | -------------------------------------------------------------------------------- /docs/docs/endpoints/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # OAuth 2.0 Endpoints 6 | 7 | This server implements the following OAuth 2.0 endpoints, each supporting specific functionalities as defined in various RFC specifications: 8 | 9 | ## Core Endpoints 10 | 11 | - [The `/token` Endpoint](./token.mdx) 12 | - [The `/authorize` Endpoint](./authorize.mdx) 13 | 14 | ## Token Management Endpoints 15 | 16 | - [The `/token/introspect` Endpoint](./introspect.mdx) 17 | - [The `/token/revoke` Endpoint](./revoke.mdx) 18 | 19 | :::note 20 | All endpoints should be accessed over HTTPS to ensure secure communication. 21 | ::: 22 | -------------------------------------------------------------------------------- /docs/docs/endpoints/introspect.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | title: /token/introspect 4 | --- 5 | 6 | # The Introspect Endpoint 7 | 8 | The `/token/introspect` endpoint is a back channel endpoint that revokes an existing token. The introspect endpoint requires the `TokenRepository#getByAccessToken` method to introspect `token_type_hint=access_token`. 9 | 10 | :::info 11 | - Implementing this endpoint is optional 12 | - This endpoint requires `TokenRepository#getByAccessToken` to be defined if using `token_type_hint=access_token` 13 | ::: 14 | 15 | ```ts 16 | app.post("/token/introspect", async (req: Express.Request, res: Express.Response) => { 17 | try { 18 | const oauthResponse = await authorizationServer.introspect(req); 19 | return handleExpressResponse(res, oauthResponse); 20 | } catch (e) { 21 | handleExpressError(e, res); 22 | return; 23 | } 24 | }); 25 | ``` 26 | 27 | ### Configure 28 | 29 | Client credentials authentication is enabled by default. To disable, set `authenticateIntrospect` to `false`. 30 | 31 | ```ts 32 | const authoriztionServer = new AuthorizationServer( 33 | ..., 34 | { 35 | authenticateIntrospect: false, 36 | } 37 | ); 38 | ``` 39 | 40 | ### Request 41 | 42 | A complete token introspection request will include the following parameters: 43 | 44 | - **token** (required): The string value of the token to be introspected 45 | - **token_type_hint** (optional, default: access_token): A hint about the type of the token submitted for introspection. Valid values are: `access_token` and `refresh_token` 46 | 47 | The request must be authenticated using the client credentials method. 48 | 49 |
50 | View sample introspect request 51 | 52 | You can authenticate by passing the `client_id` and `client_secret` as a query string, or through basic auth. 53 | 54 | 55 | ```http request 56 | POST /token/introspect HTTP/1.1 57 | Host: example.com 58 | Content-Type: application/x-www-form-urlencoded 59 | 60 | token=xxxxxxxxxx 61 | &token_type_hint=refresh_token 62 | &client_id=xxxxxxxxxx 63 | &client_secret=xxxxxxxxxx 64 | ``` 65 | 66 | 67 | ```http request [] 68 | POST /token/introspect HTTP/1.1 69 | Host: example.com 70 | Content-Type: application/x-www-form-urlencoded 71 | Authorization: Basic MTpzdXBlci1zZWNyZXQtc2VjcmV0 72 | 73 | token=xxxxxxxxxx 74 | &token_type_hint=refresh_token 75 | ``` 76 | 77 | 78 | 79 | ```ts 80 | new AuthorizationServer(..., { 81 | authenticateIntrospect: false, 82 | }) 83 | ``` 84 | 85 | ```http request [] 86 | POST /token/introspect HTTP/1.1 87 | Host: example.com 88 | Content-Type: application/x-www-form-urlencoded 89 | 90 | token=xxxxxxxxxx 91 | &token_type_hint=refresh_token 92 | ``` 93 | 94 | 95 |
96 | 97 | ### Response 98 | 99 | The authorization server will respond with a JSON object containing the following fields: 100 | 101 | - **active** (required): A boolean value indicating whether the token is currently active 102 | - **scope** (optional): A space-separated list of scopes associated with the token 103 | - **client_id** (optional): The client identifier for the OAuth 2.0 client that requested this token 104 | - **username** (optional): A human-readable identifier for the resource owner who authorized this token 105 | - **token_type** (optional): The type of the token (e.g., `Bearer`) 106 | - **exp** (optional): The timestamp indicating when the token will expire 107 | - **iat** (optional): The timestamp indicating when the token was issued 108 | - **nbf** (optional): The timestamp indicating when the token is not to be used before 109 | - **sub** (optional): The subject of the token 110 | - **aud** (optional): The intended audience of the token 111 | - **iss** (optional): The issuer of the token 112 | - **jti** (optional): The unique identifier for the token 113 | 114 | Additional fields may be included in the response. 115 | 116 | A client credentials grant can be used to authenticate the client. 117 | 118 | 119 | 120 | ```ts 121 | import { base64encode } from "@jmondi/oauth2-server"; 122 | 123 | const basicAuth = "Basic " + base64encode(`${clientId}:${clientSecret}`); 124 | const response = await fetch("/token/introspect", { 125 | method: "POST", 126 | headers: { 127 | Authorization: basicAuth, 128 | }, 129 | body: JSON.stringify({ 130 | token: token, 131 | }), 132 | }); 133 | await response.json() 134 | ``` 135 | 136 | 137 | ```ts 138 | const response = await fetch("/token/introspect", { 139 | method: "POST", 140 | body: JSON.stringify({ 141 | token: token, 142 | client_id: clientId, 143 | client_secret: clientSecret, 144 | }), 145 | }); 146 | await response.json() 147 | ``` 148 | 149 | 150 | 151 | :::note Supports the following RFC\'S 152 | [RFC7662 (OAuth 2.0 Token Introspection)](https://datatracker.ietf.org/doc/html/rfc7662) 153 | ::: 154 | -------------------------------------------------------------------------------- /docs/docs/endpoints/revoke.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: /token/revoke 4 | --- 5 | 6 | # The Revoke Endpoint 7 | 8 | The `/token/revoke` endpoint is a back channel endpoint that revokes an existing token. 9 | 10 | :::info 11 | - Implementing this endpoint is optional 12 | - This endpoint requires `TokenRepository#getByAccessToken` to be defined if using `token_type_hint=access_token` 13 | ::: 14 | 15 | ```ts 16 | app.post("/token/revoke", async (req: Express.Request, res: Express.Response) => { 17 | try { 18 | const oauthResponse = await authorizationServer.revoke(req); 19 | return handleExpressResponse(res, oauthResponse); 20 | } catch (e) { 21 | handleExpressError(e, res); 22 | return; 23 | } 24 | }); 25 | ``` 26 | 27 | ### Configure 28 | 29 | Client credentials authentication is enabled by default. To disable, set `authenticateRevoke` to `false`. 30 | 31 | ```ts 32 | const authoriztionServer = new AuthorizationServer( 33 | ..., 34 | { 35 | authenticateRevoke: false, 36 | } 37 | ); 38 | ``` 39 | 40 | ### Request 41 | 42 | A complete token revocation request will include the following parameters: 43 | 44 | - **token** (required): The token to be revoked 45 | - **token_type_hint** (optional): A hint about the type of the token submitted for revocation. Valid values are: `access_token`, `refresh_token`, `auth_code` 46 | 47 | The request must be authenticated using client_credentials. 48 | 49 |
50 | View sample introspect request 51 | 52 | You can authenticate by passing the `client_id` and `client_secret` as a query string, or through basic auth. 53 | 54 | 55 | ```http request 56 | POST /token/revoke HTTP/1.1 57 | Host: example.com 58 | Content-Type: application/x-www-form-urlencoded 59 | 60 | token=xxxxxxxxxx 61 | &token_type_hint=refresh_token 62 | &client_id=xxxxxxxxxx 63 | &client_secret=xxxxxxxxxx 64 | ``` 65 | 66 | 67 | ```http request [] 68 | POST /token/revoke HTTP/1.1 69 | Host: example.com 70 | Content-Type: application/x-www-form-urlencoded 71 | Authorization: Basic MTpzdXBlci1zZWNyZXQtc2VjcmV0 72 | 73 | token=xxxxxxxxxx 74 | &token_type_hint=refresh_token 75 | ``` 76 | 77 | 78 | 79 | ```ts 80 | new AuthorizationServer(..., { 81 | authenticateRevoke: false, 82 | }) 83 | ``` 84 | 85 | ```http request [] 86 | POST /token/revoke HTTP/1.1 87 | Host: example.com 88 | Content-Type: application/x-www-form-urlencoded 89 | 90 | token=xxxxxxxxxx 91 | &token_type_hint=refresh_token 92 | ``` 93 | 94 | 95 | 96 |
97 | 98 | 99 | ### Response 100 | 101 | The authorization server will respond with: 102 | 103 | - An HTTP 200 (OK) status code if the token was successfully revoked or if the client submitted an invalid token 104 | - An HTTP 400 (Bad Request) status code if the request is invalid or malformed 105 | - An HTTP 401 (Unauthorized) status code if the client is not authorized to revoke the token 106 | 107 | The response body will be empty for successful revocations. For error responses, the server may include additional error information as specified in the OAuth 2.0 specification 108 | 109 | :::note Supports the following RFC\'S 110 | [RFC7009 (OAuth 2.0 Token Revocation)](https://datatracker.ietf.org/doc/html/rfc7009) 111 | ::: 112 | -------------------------------------------------------------------------------- /docs/docs/endpoints/token.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: /token 4 | --- 5 | 6 | # The Token Endpoint 7 | 8 | The `/token` endpoint is a back channel endpoint that issues a usable access token. It supports multiple grant types as defined in OAuth 2.0 specifications. 9 | 10 | :::info 11 | - All requests to the `/token` endpoint should use the HTTP POST method and include appropriate authentication (e.g., client credentials in the Authorization header or in the request body). 12 | 13 | - The url `/token` can be anything, some other common urls are: `/oauth/token`, `/v1/token`, etc. 14 | ::: 15 | 16 | ```ts 17 | app.post("/token", async (req: Express.Request, res: Express.Response) => { 18 | try { 19 | const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 20 | return handleExpressResponse(res, oauthResponse); 21 | } catch (e) { 22 | handleExpressError(e, res); 23 | return; 24 | } 25 | }); 26 | ``` 27 | 28 | ## Flow 29 | 30 | The `/token` endpoint supports the following grant types: 31 | 32 | ### [Authorization Code Grant](/docs/grants/authorization_code) (RFC6749 Section 4.1) 33 | 34 | - Used to exchange an authorization code for an access token 35 | - Request parameters: 36 | - `grant_type=authorization_code` 37 | - `code`: The authorization code received from the authorization server 38 | - `redirect_uri`: Must match the original redirect URI used in the authorization request 39 | - `client_id`: The client identifier 40 | 41 | ### [Refresh Token Grant](/docs/grants/refresh_token) (RFC6749 Section 6) 42 | 43 | - Used to obtain a new access token using a refresh token 44 | - Request parameters: 45 | - `grant_type=refresh_token` 46 | - `refresh_token`: The refresh token issued to the client 47 | - `scope` (optional): The scope of the access request 48 | 49 | ### [Client Credentials Grant](/docs/grants/client_credentials) (RFC6749 Section 4.4) 50 | 51 | - Used for machine-to-machine authentication where no user is involved 52 | - Request parameters: 53 | - `grant_type=client_credentials` 54 | - `scope` (optional): The scope of the access request 55 | 56 | ### [Resource Owner Password Credentials Grant](/docs/grants/authorization_code) (RFC6749 Section 4.3) 57 | - Used to exchange the resource owner's credentials for an access token 58 | - Request parameters: 59 | - `grant_type=password` 60 | - `username`: The resource owner's username 61 | - `password`: The resource owner's password 62 | - `scope` (optional): The scope of the access request 63 | 64 | ### [Token Exchange](/docs/grants/token_exchange) (RFC8693) 65 | - Used to exchange one security token for another 66 | - Request parameters: 67 | - `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` 68 | - `subject_token`: The security token that is the subject of the exchange 69 | - `subject_token_type`: An identifier for the type of the `subject_token` 70 | - `requested_token_type` (optional): An identifier for the type of the requested security token 71 | - `audience` (optional): The logical name of the target service where the client intends to use the requested security token 72 | 73 | :::note Supports the following RFC\'S 74 | [RFC6749 (OAuth 2.0)](https://datatracker.ietf.org/doc/html/rfc6749), [RFC6750 (Bearer Token Usage)](https://datatracker.ietf.org/doc/html/rfc6750), [RFC8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693) 75 | ::: 76 | -------------------------------------------------------------------------------- /docs/docs/faqs.md: -------------------------------------------------------------------------------- 1 | # FAQ's 2 | 3 | ## Error: `Unsupported grant_type` 4 | 5 | Check if you're enabling the desired grant type on the AuthorizationServer. See https://tsoauth2server.com/docs/authorization_server/#enabling-grant-types for more. 6 | 7 | ```typescript 8 | import {AuthorizationServer} from "@jmondi/oauth2-server"; 9 | 10 | const authorizationServer = new AuthorizationServer(...); 11 | authorizationServer.enableGrantType({ grant: "password" ... }); 12 | ``` 13 | 14 | ## Error: `Client has been revoked or is invalid` 15 | 16 | Check the `OAuthClientRepository#isClientValid` method, it is returning **false**. 17 | -------------------------------------------------------------------------------- /docs/docs/getting_started/entities.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Entity Interfaces 6 | 7 | ## Client Entity 8 | 9 | The Client Entity represents an application that requests access to protected resources on behalf of the resource owner (user). 10 | 11 | :::info redirect_uris: 12 | 13 | - URIs must be absolute. 14 | - URIs may include query parameters in application/x-www-form-urlencoded format 15 | - URIs must not include fragment components. 16 | 17 | ::: 18 | 19 | ```ts 20 | interface OAuthClient { 21 | id: string; 22 | name: string; 23 | secret?: string; 24 | redirectUris: string[]; 25 | allowedGrants: GrantIdentifier[]; 26 | scopes: OAuthScope[]; 27 | } 28 | ``` 29 | 30 | ## Auth Code Entity 31 | 32 | The Auth Code Entity represents a short-lived authorization code used in the Authorization Code grant type. It's an intermediary step between user authorization and token issuance. 33 | 34 | ```ts 35 | interface OAuthAuthCode { 36 | code: string; 37 | redirectUri?: string; 38 | codeChallenge?: string; 39 | codeChallengeMethod?: CodeChallengeMethod; 40 | expiresAt: Date; 41 | user?: OAuthUser; 42 | client: OAuthClient; 43 | scopes: OAuthScope[]; 44 | } 45 | 46 | type CodeChallengeMethod = "S256" | "plain"; 47 | ``` 48 | 49 | ## Token Entity 50 | 51 | The Token Entity represents access and refresh tokens issued to clients. 52 | 53 | ```ts 54 | interface OAuthToken { 55 | accessToken: string; 56 | accessTokenExpiresAt: Date; 57 | refreshToken?: string | null; 58 | refreshTokenExpiresAt?: Date | null; 59 | client: OAuthClient; 60 | user?: OAuthUser | null; 61 | scopes: OAuthScope[]; 62 | originatingAuthCodeId?: string; 63 | } 64 | ``` 65 | 66 | ## User Entity 67 | 68 | The User Entity represents the resource owner - typically the end-user who authorizes an application to access their account. 69 | 70 | ```ts 71 | interface OAuthUser { 72 | id: string; 73 | [key: string]: any; 74 | } 75 | ``` 76 | 77 | ## Scope Entity 78 | 79 | Scopes are used to define and limit the extent of access granted to a client application. They provide granular control over the permissions given to third-party applications. 80 | 81 | For more information on OAuth 2.0 scopes, visit: https://www.oauth.com/oauth2-servers/scope/ 82 | 83 | ```ts 84 | interface OAuthScope { 85 | name: string; 86 | [key: string]: any; 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/docs/grants/client_credentials.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from "@theme/Tabs"; 6 | import TabItem from "@theme/TabItem"; 7 | 8 | # Client Credentials Grant 9 | 10 | When applications request an access token to access their own resources, not on behalf of a user. 11 | 12 | :::tip 13 | The `refresh_token` grant is enabled by default 14 | ::: 15 | 16 | :::warning 17 | The client_credentials grant should only be used by clients that can hold a secret. No Browser or Native Mobile Apps should be using this grant. 18 | ::: 19 | 20 | ### Flow 21 | 22 | The client sends a **POST** to the `/token` endpoint with the following body: 23 | 24 | - **grant_type** must be set to `client_credentials` 25 | - **client_id** is the client identifier you received when you first created the application 26 | - **client_secret** is the client secret 27 | - **scope** is a string with a space delimited list of requested scopes. The requested scopes must be valid for the client. 28 | 29 |
30 | View sample client_credentials request 31 | 32 | _Did you know?_ You can authenticate by passing the `client_id` and `client_secret` as a query string, or through basic auth. 33 | 34 | 35 | 36 | ```http request 37 | POST /token HTTP/1.1 38 | Host: example.com 39 | Content-Type: application/x-www-form-urlencoded 40 | 41 | grant_type=client_credentials 42 | &client_id=xxxxxxxxxx 43 | &client_secret=xxxxxxxxxx 44 | &scope="contacts.read contacts.write" 45 | ``` 46 | 47 | 48 | ```http request [] 49 | POST /token HTTP/1.1 50 | Host: example.com 51 | Authorization: Basic MTpzdXBlci1zZWNyZXQtc2VjcmV0 52 | 53 | grant_type=client_credentials 54 | &scope="contacts.read contacts.write" 55 | ``` 56 | 57 | 58 | 59 |
60 | 61 | The authorization server will respond with the following response. 62 | 63 | - **token_type** will always be `Bearer` 64 | - **expires_in** is the time the token will live in seconds 65 | - **access_token** is a JWT signed token and can be used to authenticate into the resource server 66 | - **scope** is a space delimited list of scopes the token has access to 67 | 68 |
69 | View sample client_credentials response 70 | ```http request 71 | HTTP/1.1 200 OK 72 | Content-Type: application/json; charset=UTF-8 73 | Cache-Control: no-store 74 | Pragma: no-cache 75 | 76 | { 77 | token_type: 'Bearer', 78 | expires_in: 3600, 79 | access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDE3MDY0NjYsIm5iZiI6MTYwMTcwMjg2NiwiaWF0IjoxNjAxNzAyODY2LCJqdGkiOiJuZXcgdG9rZW4iLCJjaWQiOiJ0ZXN0IGNsaWVudCIsInNjb3BlIjoiIn0.KcXoCP6u9uhvtOoistLBskESA0tyT2I1SDe5Yn9iM4I', 80 | scope: 'contacts.create contacts.read' 81 | } 82 | ``` 83 |
84 | -------------------------------------------------------------------------------- /docs/docs/grants/custom.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # Custom Grant ⚠️ 6 | 7 | To implement a custom grant, you may extend the `AbstractGrant` class. 8 | 9 | :::warning 10 | 11 | This is advanced usage. Make sure you understand the OAuth2.0 specification before implementing a custom grant. 12 | 13 | ::: 14 | 15 | :::note Enable this grant 16 | 17 | ```ts 18 | const customGrant = new MyCustomGrant(...); 19 | 20 | authorizationServer.enableGrantTypes( 21 | [customGrant, new DateInterval("1d")], 22 | ); 23 | ``` 24 | 25 | ::: 26 | 27 | ## Extending the CustomGrant class 28 | 29 | Once you've implemented your custom grant you need to enable it in your `AuthorizationServer`. 30 | 31 | ```ts 32 | export class MyCustomGrant extends CustomGrant { 33 | readonly identifier = "custom:my_custom_grant"; 34 | 35 | ... // Implement required methods 36 | } 37 | ``` 38 | 39 | -------------------------------------------------------------------------------- /docs/docs/grants/implicit.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | # Implicit Grant ⚠️ ⚠️ 6 | 7 | :::warning Not Recommended 8 | 9 | This server supports the Implicit Grant, but its use is strongly discouraged due to security concerns. The OAuth 2.0 Security Best Current Practice (RFC 8252) recommends against using the Implicit Grant flow. 10 | 11 | For native and single-page applications, the recommended approach is to use the Authorization Code Grant with PKCE (Proof Key for Code Exchange) extension. This method provides better security without requiring a client secret. 12 | 13 | If you're developing a web application with a backend, consider using the standard Authorization Code Grant with a client secret stored securely on your server. 14 | 15 | ::: 16 | 17 | 18 | 19 | Please look at these great resources: 20 | 21 | - [OAuth 2.0 Implicit Grant](https://oauth.net/2/grant-types/implicit/) 22 | - VIDEO: [What's Going On with the Implicit Flow?](https://www.youtube.com/watch?v=CHzERullHe8) by Aaron Parecki 23 | - [Is the OAuth 2.0 Implicit Flow Dead?](https://developer.okta.com/blog/2019/05/01/is-the-oauth-implicit-flow-dead) by Aaron Parecki (developer.okta.com) 24 | -------------------------------------------------------------------------------- /docs/docs/grants/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: OAuth 2.0 Grants 3 | sidebar_position: 3 4 | --- 5 | 6 | import WhichGrant from "../../src/pages/_which_grant.mdx"; 7 | 8 | # Grants 9 | 10 | ### Client Credentials Grant 11 | 12 | If the access token owner is a machine, such as a server or an application acting on its own behalf, rather than an individual user, the client can use the Client Credentials Grant. This grant is designed for scenarios where the client needs to access resources autonomously without the context of a specific user. 13 | 14 | ### Auth Code Grant with PKCE 15 | 16 | If the access token owner is a user, the recommended grant is the Authorization Code Grant with Proof Key for Code Exchange (PKCE). This grant involves a series of steps where the client redirects the user to the authorization server, the user grants access, and the server provides an authorization code that the client exchanges for an access token. PKCE adds an extra layer of security to protect against authorization code interception attacks. 17 | 18 | ### Refresh Token Grant 19 | 20 | If the client already has a refresh token, it can use the Refresh Token Grant to obtain a new access token without requiring the user's interaction. This grant is useful for long-lived sessions and background processes. 21 | 22 | ## Which Grant? 23 | 24 | Grants are different ways a [client](../Extras/glossary.md#client) can obtain an `access_token` that will authorize 25 | it to use the [resource server](../Extras/glossary.md#resource-server). 26 | 27 | Deciding which grant to use depends on the type of client the end user will be using. 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/docs/grants/password.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | import Tabs from "@theme/Tabs"; 6 | import TabItem from "@theme/TabItem"; 7 | 8 | # Password Grant 9 | 10 | The Password Grant is for first party clients that are able to hold secrets (ie not Browser or Native Mobile Apps) 11 | 12 | :::note Enable this grant 13 | 14 | ```ts 15 | authorizationServer.enableGrantType({ 16 | grant: "password", 17 | userRepository, 18 | }); 19 | ``` 20 | 21 | ::: 22 | 23 | ### Flow 24 | 25 | A complete refresh token request will include the following parameters: 26 | 27 | - **grant_type** must be set to `password` 28 | - **client_id** is the client identifier you received when you first created the application 29 | - **client_secret** if the client is confidential (has a secret), this must be provided 30 | - **username** 31 | - **password** 32 | - **scope** (optional) 33 | 34 |
35 | View sample password grant request 36 | 37 | 38 | 39 | ```http request 40 | POST /token HTTP/1.1 41 | Host: example.com 42 | Content-Type: application/x-www-form-urlencoded 43 | 44 | grant_type=password 45 | &client_id=xxxxxxxxx 46 | &client_secret=xxxxxxxxx 47 | &username=xxxxxxxxx 48 | &password=xxxxxxxxx 49 | &scope="contacts.read contacts.write" 50 | ``` 51 | 52 | 53 | ```http request 54 | POST /token HTTP/1.1 55 | Host: example.com 56 | Authorization: Basic Y4NmE4MzFhZGFkNzU2YWRhN 57 | 58 | grant_type=password 59 | &username=xxxxxxxxx 60 | &password=xxxxxxxxx 61 | &scope="contacts.read contacts.write" 62 | ``` 63 | 64 | 65 | 66 |
67 | 68 | The authorization server will respond with the following response 69 | 70 | - **token_type** will always be `Bearer` 71 | - **expires_in** is the time the token will live in seconds 72 | - **access_token** is a JWT signed token and is used to authenticate into the resource server 73 | - **refresh_token** is a JWT signed token and can be used in with the [refresh grant](./refresh_token.mdx) 74 | - **scope** is a space delimited list of scopes the token has access to 75 | 76 |
77 | View sample password grant response 78 | ```http request 79 | HTTP/1.1 200 OK 80 | Content-Type: application/json; charset=UTF-8 81 | Cache-Control: no-store 82 | Pragma: no-cache 83 | 84 | { 85 | token_type: 'Bearer', 86 | expires_in: 3600, 87 | access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MTJhYjlhNC1jNzg2LTQ4YTYtOGFkNi05NGM1M2E4ZGM2NTEiLCJleHAiOjE2MDE3NjcyOTksIm5iZiI6MTYwMTc2MzY5OSwiaWF0IjoxNjAxNzYzNjk5LCJqdGkiOiJuZXcgdG9rZW4iLCJjaWQiOiJ0ZXN0IGNsaWVudCIsInNjb3BlIjoiIn0.sX6SWc2Af8jn-izFnrLgNIcNuZz_tRLl2p7M3CzQwKg', 88 | refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiIzNTYxNWYyZi0xM2ZhLTQ3MzEtODNhMS05ZTM0NTU2YWIzOTAiLCJhY2Nlc3NfdG9rZW5faWQiOiJuZXcgdG9rZW4iLCJyZWZyZXNoX3Rva2VuX2lkIjoidGhpcy1pcy1teS1zdXBlci1zZWNyZXQtcmVmcmVzaC10b2tlbiIsInNjb3BlIjoiIiwidXNlcl9pZCI6IjUxMmFiOWE0LWM3ODYtNDhhNi04YWQ2LTk0YzUzYThkYzY1MSIsImV4cGlyZV90aW1lIjoxNjAxNzY3Mjk5LCJpYXQiOjE2MDE3NjM2OTh9.SSa7miIdk3bxyzg0f3M9jKBXWjPgD4QEw-AU3SYvBk0', 89 | scope: 'contacts.read contacts.write' 90 | } 91 | 92 | ``` 93 |
94 | -------------------------------------------------------------------------------- /docs/docs/grants/refresh_token.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import Tabs from "@theme/Tabs"; 6 | import TabItem from "@theme/TabItem"; 7 | 8 | # Refresh Token Grant 9 | 10 | Access tokens eventually expire. The refresh token grant enables the client to obtain a new access_token from an existing refresh_token. 11 | 12 | :::tip 13 | 14 | The `refresh_token` grant is enabled by default 15 | 16 | ::: 17 | 18 | ### Flow 19 | 20 | A complete refresh token request will include the following parameters: 21 | 22 | - **grant_type** must be set to `refresh_token` 23 | - **client_id** is the client identifier you received when you first created the application 24 | - **client_secret** if the client is confidential (has a secret), this must be provided 25 | - **refresh_token** must be the signed token previously issued to the client 26 | - **scope** (optional) the requested scope must not include any additional scopes that were not previously issued to the original token 27 | 28 |
29 | View sample refresh_token request 30 | 31 | 32 | 33 | ```http request 34 | POST /token HTTP/1.1 35 | Host: example.com 36 | Content-Type: application/x-www-form-urlencoded 37 | 38 | grant_type=refresh_token 39 | &refresh_token=xxxxxxxxx 40 | &client_id=xxxxxxxxx 41 | &client_secret=xxxxxxxxx 42 | &scope="contacts.read contacts.write" 43 | ``` 44 | 45 | 46 | ```http request 47 | POST /token HTTP/1.1 48 | Host: example.com 49 | Authorization: Basic Y4NmE4MzFhZGFkNzU2YWRhN 50 | Content-Type: application/x-www-form-urlencoded 51 | 52 | grant_type=refresh_token 53 | &refresh_token=xxxxxxxxx 54 | &scope="contacts.read contacts.write" 55 | ``` 56 | 57 | 58 | 59 |
60 | 61 | The authorization server will respond with the following response 62 | 63 | - **token_type** will always be `Bearer` 64 | - **expires_in** is the time the token will live in seconds 65 | - **access_token** is a JWT signed token and is used to authenticate into the resource server 66 | - **refresh_token** is a JWT signed token and can be used in with the refresh grant (this one) 67 | - **scope** is a space delimited list of scopes the token has access to 68 | 69 |
70 | View sample refresh_token response 71 | ```http request 72 | HTTP/1.1 200 OK 73 | Content-Type: application/json; charset=UTF-8 74 | Cache-Control: no-store 75 | Pragma: no-cache 76 | 77 | { 78 | token_type: 'Bearer', 79 | expires_in: 3600, 80 | access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MTJhYjlhNC1jNzg2LTQ4YTYtOGFkNi05NGM1M2E4ZGM2NTEiLCJleHAiOjE2MDE3NjcyMTIsIm5iZiI6MTYwMTc2MzYxMiwiaWF0IjoxNjAxNzYzNjEyLCJqdGkiOiJuZXcgdG9rZW4iLCJjaWQiOiJ0ZXN0IGNsaWVudCIsInNjb3BlIjoiIn0.PO4eKSDVsFuKvebEXndWbZsprgzjkzEfHI7cl4N0YpM', 81 | refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiIzNTYxNWYyZi0xM2ZhLTQ3MzEtODNhMS05ZTM0NTU2YWIzOTAiLCJhY2Nlc3NfdG9rZW5faWQiOiJuZXcgdG9rZW4iLCJyZWZyZXNoX3Rva2VuX2lkIjoidGhpcy1pcy1teS1zdXBlci1zZWNyZXQtcmVmcmVzaC10b2tlbiIsInNjb3BlIjoiIiwidXNlcl9pZCI6IjUxMmFiOWE0LWM3ODYtNDhhNi04YWQ2LTk0YzUzYThkYzY1MSIsImV4cGlyZV90aW1lIjoxNjAxNzY3MjEyLCJpYXQiOjE2MDE3NjM2MTF9.du4KfAzelSA8hzBaqGlrSvPtH-BxOcoUBXW4HS3pJkM', 82 | scope: 'contacts.read contacts.write' 83 | } 84 | 85 | ```` 86 |
87 | 88 | ### Revocation 89 | 90 | Refresh tokens are only valid for a single use. In addition, they can be explicitly revoked on a server that supports 91 | [RFC7009 “OAuth 2.0 Token Revocation”](https://tools.ietf.org/html/rfc7009). 92 | 93 | A refresh token revocation request will include the following parameters: 94 | 95 | - **token** is the signed token previously issued to the client 96 | - **token_type_hint** MUST be set to `refresh_token` 97 | 98 |
99 | View sample revoke refresh_token request 100 | ```http request 101 | POST /token HTTP/1.1 102 | Host: example.com 103 | Content-Type: application/x-www-form-urlencoded 104 | 105 | token_type_hint=refresh_token 106 | &refresh_token=xxxxxxxxx 107 | ```` 108 | 109 |
110 | 111 | The authorization server will respond with the following response 112 | 113 |
114 | View sample revoke refresh_token response 115 | ```http request HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache ``` 116 |
117 | -------------------------------------------------------------------------------- /docs/docs/grants/token_exchange.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # Token Exchange Grant 6 | 7 | The [RFC 8693 - OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) facilitates the secure exchange of tokens for accessing different resources or services. This documentation guides you through enabling this grant type on your authorization server, detailing request and response handling to ensure robust and secure token management. 8 | 9 | :::note Enable this grant 10 | 11 | To enable the token exchange grant, you'll need to provide your own implementation of `processTokenExchangeFn`. This function should orchestrate the exchange with the required third-party services based on your specific needs. 12 | 13 | ```ts 14 | authorizationServer.enableGrant({ 15 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", 16 | processTokenExchangeFn: async ( 17 | args: ProcessTokenExchangeArgs, 18 | ): Promise => { 19 | const { 20 | resource, 21 | audience, 22 | scopes, 23 | requestedTokenType, 24 | subjectToken, 25 | subjectTokenType, 26 | actorToken, 27 | actorTokenType, 28 | } = args; 29 | 30 | // Implement the logic to handle the token exchange. 31 | // This could involve validating the subject token, interacting with third-party services, 32 | // and generating or retrieving an appropriate access token for the user. 33 | // Example: 34 | const user = await exchangeTokenForUser(subjectToken, subjectTokenType); 35 | 36 | // Return the user object associated with the exchanged token, or undefined if exchange fails 37 | return user; 38 | }, 39 | }); 40 | ``` 41 | 42 | ::: 43 | 44 | ### Flow 45 | 46 | The client sends a **POST** to the `/token` endpoint with the following body: 47 | 48 | - **grant_type** must be set to `urn:ietf:params:oauth:grant-type:token-exchange` 49 | - **client_id** is the client identifier you received when you first created the application 50 | - **subject_token** a security token that represents the identity of the party on behalf of whom the request is being made 51 | - **subject_token_type** an identifier, as described in Section 3, that indicates the type of the security token in the subject_token parameter [See more info](https://datatracker.ietf.org/doc/html/rfc8693#TokenTypeIdentifiers) 52 | - **actor_token** (_optional_) a security token that represents the identity of the acting party 53 | - **actor_token_type** (_optional but required when actor_token is present_) an identifier that indicates the type of the security token in the actor_token parameter [See more info](https://datatracker.ietf.org/doc/html/rfc8693#TokenTypeIdentifiers) 54 | - **resource** (_optional_) a URI that indicates the target service or resource where the client intends to use the requested security token. 55 | - **audience** (_optional_) is the logical name of the target service where the client intends to use the requested security token. 56 | - **requested_token_type** (_optional_) is an identifier for the type of the requested security token [See more info](https://datatracker.ietf.org/doc/html/rfc8693#TokenTypeIdentifiers) 57 | - **scope** (_optional_) is a string with a space delimited list of requested scopes. The requested scopes must be valid for the client. 58 | 59 |
60 | View sample request 61 | _Did you know?_ You can authenticate by passing the `client_id` and `client_secret` as a query string, or through basic auth. 62 | 63 | ```http request [Query String] 64 | POST /token HTTP/1.1 65 | Host: example.com 66 | Content-Type: application/x-www-form-urlencoded 67 | 68 | grant_type=urn:ietf:params:oauth:grant-type:token-exchange 69 | &client_id=ec6875c5-407a-4242-947a-1ab5e6ad632f 70 | &requested_token_type=urn:ietf:params:oauth:token-type:access_token 71 | &subject_token={steam_session_id} 72 | &subject_token_type=urn:ietf:oauth:token-type:steam_session_ticket 73 | &scope="contacts.read contacts.write" 74 | ``` 75 | 76 |
77 | 78 | The authorization server will respond with the following response. 79 | 80 | - **token_type** will always be `Bearer` 81 | - **expires_in** is the time the token will live in seconds 82 | - **access_token** is a JWT signed token and can be used to authenticate into the resource server 83 | - **scope** is a space delimited list of scopes the token has access to 84 | 85 |
86 | View sample response 87 | ```http request 88 | HTTP/1.1 200 OK 89 | Content-Type: application/json; charset=UTF-8 90 | Cache-Control: no-store 91 | Pragma: no-cache 92 | 93 | { 94 | token_type: 'Bearer', 95 | expires_in: 3600, 96 | access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDE3MDY0NjYsIm5iZiI6MTYwMTcwMjg2NiwiaWF0IjoxNjAxNzAyODY2LCJqdGkiOiJuZXcgdG9rZW4iLCJjaWQiOiJ0ZXN0IGNsaWVudCIsInNjb3BlIjoiIn0.KcXoCP6u9uhvtOoistLBskESA0tyT2I1SDe5Yn9iM4I', 97 | scope: 'contacts.create contacts.read' 98 | } 99 | 100 | ``` 101 |
102 | ``` 103 | -------------------------------------------------------------------------------- /docs/docs/upgrade_guide.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## To v4 4 | 5 | ### Breaking Change 6 | 7 | Only affects users implementing the `/revoke` and `/introspect` endpoints 8 | 9 | - [`/introspect`](https://tsoauth2server.com/docs/endpoints/introspect) will now authenticate via client_credentials by default 10 | - [`/revoke`](https://tsoauth2server.com/docs/endpoints/revoke) will now authenticate via client_credentials by default 11 | 12 | Before (v3.x): 13 | 14 | ```ts 15 | new AuthorizationServer(..., { 16 | authenticateIntrospect: false, 17 | authenticateRevoke: false, 18 | }) 19 | ``` 20 | 21 | Before (v4.x): 22 | 23 | 24 | ```ts 25 | new AuthorizationServer(..., { 26 | authenticateIntrospect: true, // set to false to match 3.x 27 | authenticateRevoke: true, // set to false to match 3.x 28 | }) 29 | ``` 30 | 31 | ## To v3 32 | 33 | ### This package is now pure ESM 34 | 35 | The package is now entirely ESM (ECMAScript Modules). More details about this change can be found in [Sindre Sorhus's writeup](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c). 36 | 37 | ### `AuthorizationServer` Updates {#authorization-server} 38 | 39 | In v2.x, `AuthorizationServer` constructor required all repositories. In v3.x, it has been simplified. 40 | 41 | **Before (v2.x):** 42 | 43 | ```ts 44 | const authorizationServer = new AuthorizationServer( 45 | authCodeRepository, 46 | clientRepository, 47 | accessTokenRepository, 48 | scopeRepository, 49 | userRepository, 50 | jwtService, 51 | { 52 | requiresS256: false, 53 | tokenCID: "name", 54 | }, 55 | ); 56 | ``` 57 | 58 | **After (v3.x):** 59 | 60 | ```ts 61 | const authorizationServer = new AuthorizationServer( 62 | clientRepository, 63 | accessTokenRepository, 64 | scopeRepository, 65 | new JwtService("secret-key"), 66 | { 67 | requiresS256: true, 68 | tokenCID: "id", 69 | }, 70 | ); 71 | ``` 72 | 73 | ### Enabling Grants 74 | 75 | In v3, `enableGrantType` has been updated for the **"authorization_code"** and **"password"** grants. 76 | 77 | #### Authorization Code Grant 78 | 79 | `AuthCodeGrant` now requires a [`authCodeRepository`](./getting_started/repositories.mdx#auth-code-repository) and a [`userRepository`](./getting_started/repositories.mdx#user-repository). 80 | 81 | **Before (v2.x):** 82 | 83 | ```ts 84 | authorizationServer.enableGrantType("authorization_code"); 85 | ``` 86 | 87 | **After (v3.x):** 88 | 89 | ```ts 90 | authorizationServer.enableGrantType({ 91 | grant: "authorization_code", 92 | userRepository, 93 | authCodeRepository, 94 | }); 95 | ``` 96 | 97 | #### Password Grant 98 | 99 | `PasswordGrant` now requires a [`userRepository`](./getting_started/repositories.mdx#user-repository). 100 | 101 | **Before (v2.x):** 102 | 103 | ```ts 104 | authorizationServer.enableGrantType("password"); 105 | ``` 106 | 107 | **After (v3.x):** 108 | 109 | ```ts 110 | authorizationServer.enableGrantType({ 111 | grant: "password", 112 | userRepository, 113 | }); 114 | ``` 115 | 116 | ### `AuthorizationServerOptions` Default Configuration Updates 117 | 118 | The default options for `AuthorizationServer` have been modified to better align with the OAuth 2.0 specification: 119 | 120 | | Option | v2.x Value | v3.x Value | 121 | | ------------ | ---------- | ---------- | 122 | | requiresS256 | false | true | 123 | | tokenCID | "name" | "id" | 124 | 125 | ### Removed `setOptions` Method 126 | 127 | The undocumented, public method `setOptions` has been removed in v3. Options can be set during `AuthorizationServer` initialization. 128 | 129 | ### `generateRandomToken` Function Fix 130 | 131 | A bug in the `generateRandomToken` function has been fixed in v3.x. 132 | -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from "prism-react-renderer"; 2 | import type { Config } from "@docusaurus/types"; 3 | import type * as Preset from "@docusaurus/preset-classic"; 4 | import tailwindPlugin from "./tailwind-config.cjs"; 5 | 6 | const config: Config = { 7 | title: "@jmondi/oauth2-server", 8 | plugins: [tailwindPlugin], 9 | tagline: 10 | "A Node.js OAuth 2.0 Server in TypeScript that is standards compliant, utilizes JWT and Proof Key for Code Exchange (PKCE)", 11 | favicon: "favicon.ico", 12 | url: "https://tsoauth2server.com", 13 | baseUrl: "/", 14 | onBrokenLinks: "throw", 15 | onBrokenMarkdownLinks: "warn", 16 | i18n: { 17 | defaultLocale: "en", 18 | locales: ["en"], 19 | }, 20 | scripts: [ 21 | { src: "https://plausible.io/js/script.js", defer: true, "data-domain": "tsoauth2server.com" }, 22 | ], 23 | presets: [ 24 | [ 25 | "classic", 26 | { 27 | docs: { 28 | sidebarPath: "./sidebars.ts", 29 | // Please change this to your repo. 30 | // Remove this to remove the "edit this page" links. 31 | editUrl: "https://github.com/jasonraimondi/ts-oauth2-server/tree/main/docs/", 32 | }, 33 | blog: { 34 | showReadingTime: true, 35 | // Please change this to your repo. 36 | // Remove this to remove the "edit this page" links. 37 | editUrl: "https://github.com/jasonraimondi/ts-oauth2-server/tree/main/docs/", 38 | }, 39 | theme: { 40 | customCss: "./src/css/custom.css", 41 | }, 42 | } satisfies Preset.Options, 43 | ], 44 | ], 45 | 46 | themeConfig: { 47 | // Replace with your project's social card 48 | image: "img/oauth2-server-social-card.jpg", 49 | navbar: { 50 | title: "ts-oauth2-server", 51 | logo: { 52 | alt: "ts-oauth2-server Logo", 53 | src: "img/logo.svg", 54 | }, 55 | items: [ 56 | { 57 | sidebarId: "mainSidebar", 58 | type: "docSidebar", 59 | label: "Docs", 60 | position: "left", 61 | }, 62 | { 63 | href: "/docs/authorization_server/configuration/", 64 | label: "Config", 65 | position: "left", 66 | }, 67 | { 68 | href: "https://github.com/sponsors/jasonraimondi", 69 | label: "❤️ Sponsor", 70 | position: "right", 71 | }, 72 | { 73 | href: "https://github.com/jasonraimondi/ts-oauth2-server", 74 | label: "GitHub", 75 | position: "right", 76 | }, 77 | // { 78 | // href: "https://www.npmjs.com/package/@jmondi/oauth2-server", 79 | // label: "NPM", 80 | // position: "right", 81 | // }, 82 | // { 83 | // href: "https://jsr.io/@jmondi/oauth2-server", 84 | // label: "JSR", 85 | // position: "right", 86 | // }, 87 | ], 88 | }, 89 | footer: { 90 | style: "dark", 91 | copyright: `© ${new Date().getFullYear()} Jason Raimondi`, 92 | }, 93 | prism: { 94 | theme: prismThemes.github, 95 | darkTheme: prismThemes.dracula, 96 | }, 97 | algolia: { 98 | appId: "JP2YS2S0EQ", 99 | apiKey: "bf2bc45ac2821dba462ee887527c1816", 100 | indexName: "tsoauth2server", 101 | }, 102 | } satisfies Preset.ThemeConfig, 103 | }; 104 | 105 | export default config; 106 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start --port 8000", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.5.1", 19 | "@docusaurus/preset-classic": "3.5.1", 20 | "@jmondi/github-ui": "^1.0.1", 21 | "@mdx-js/react": "^3.0.1", 22 | "clsx": "^2.1.1", 23 | "lucide-react": "^0.427.0", 24 | "prism-react-renderer": "^2.3.1", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1" 27 | }, 28 | "devDependencies": { 29 | "@docusaurus/module-type-aliases": "3.5.1", 30 | "@docusaurus/tsconfig": "3.5.1", 31 | "@docusaurus/types": "3.5.1", 32 | "autoprefixer": "^10.4.20", 33 | "postcss": "^8.4.41", 34 | "prettier": "^3.3.3", 35 | "tailwindcss": "^3.4.9", 36 | "typescript": "~5.5.4" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.5%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 3 chrome version", 46 | "last 3 firefox version", 47 | "last 5 safari version" 48 | ] 49 | }, 50 | "engines": { 51 | "node": ">=18.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | mainSidebar: [{ type: "autogenerated", dirName: "." }], 16 | 17 | // But you can create a sidebar manually 18 | /* 19 | mainSidebar: [ 20 | 'intro', 21 | 'hello', 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['tutorial-basics/create-a-document'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | export default sidebars; 32 | -------------------------------------------------------------------------------- /docs/src/components/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./styles.modules.css"; 2 | import clsx from "clsx"; 3 | 4 | export function Card({ children, className = "" }) { 5 | return
{children}
; 6 | } 7 | -------------------------------------------------------------------------------- /docs/src/components/Card/styles.modules.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | padding: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/components/CodeExamples.mdx: -------------------------------------------------------------------------------- 1 | import Tabs from "@theme/Tabs"; 2 | import TabItem from "@theme/TabItem"; 3 | 4 | 5 | 6 | pnpm add @jmondi/oauth2-server 7 | 8 | 9 | npm install --save-dev @jmondi/oauth2-server 10 | 11 | 12 | yarn add @jmondi/oauth2-server 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/src/components/MarkdownWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | interface MDXWrapperProps { 4 | children: ReactNode; 5 | } 6 | 7 | function MDXWrapper({ children }) { 8 | return ( 9 |
10 |
{children}
11 |
12 | ); 13 | } 14 | 15 | export default MDXWrapper; 16 | -------------------------------------------------------------------------------- /docs/src/components/dividers/Spikes/index.module.css: -------------------------------------------------------------------------------- 1 | .spikes { 2 | position: relative; 3 | background: var(--ifm-color-primary); 4 | } 5 | 6 | .spikes::after { 7 | content: ""; 8 | position: absolute; 9 | right: 0; 10 | left: -0%; 11 | top: 100%; 12 | z-index: 10; 13 | display: block; 14 | height: 50px; 15 | background-size: 50px 100%; 16 | background-image: linear-gradient(135deg, var(--ifm-color-primary) 25%, transparent 25%), 17 | linear-gradient(225deg, var(--ifm-color-primary) 25%, transparent 25%); 18 | background-position: 0 0; 19 | } 20 | -------------------------------------------------------------------------------- /docs/src/components/dividers/Spikes/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.css"; 2 | 3 | export function SpikesDivider() { 4 | return
; 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/components/dividers/Triangle/index.module.css: -------------------------------------------------------------------------------- 1 | .triangle { 2 | position: relative; 3 | background: var(--ifm-color-primary); 4 | } 5 | 6 | .triangle::before { 7 | content: ""; 8 | position: absolute; 9 | bottom: 0; 10 | width: 0; 11 | height: 0; 12 | border-style: solid; 13 | border-width: 40px 40px 0 40px; 14 | border-color: var(--ifm-color-primary) transparent transparent transparent; 15 | left: var(--before-left, 0); 16 | transform: translateX(-50%) translateY(100%); 17 | } 18 | -------------------------------------------------------------------------------- /docs/src/components/dividers/Triangle/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.css"; 2 | 3 | type Props = { 4 | left?: React.CSSProperties["left"]; 5 | style?: React.CSSProperties; 6 | }; 7 | 8 | export function TriangleDivider({ left = "25px", style }: Props) { 9 | return
; 10 | } 11 | -------------------------------------------------------------------------------- /docs/src/components/grants/RequiredForGrants.tsx: -------------------------------------------------------------------------------- 1 | export default function RequiredForGrants(props) { 2 | const enabledGrants = props.grants; 3 | const allGrants = [ 4 | { 5 | label: "Authorization Code", 6 | href: "authorization_code", 7 | }, 8 | { 9 | label: "Client Credentials", 10 | href: "client_credentials", 11 | }, 12 | { 13 | label: "Refresh Token", 14 | href: "refresh_token", 15 | }, 16 | { 17 | label: "Password", 18 | href: "password", 19 | }, 20 | { 21 | label: "Implicit", 22 | href: "implicit", 23 | }, 24 | { 25 | label: "Custom", 26 | href: "custom", 27 | }, 28 | ] as const; 29 | 30 | const grants = allGrants.filter(g => enabledGrants.includes(g.href)); 31 | 32 | return ( 33 |
34 | Used in Grants: 35 | 36 | {grants.map(s => ( 37 | 42 | {s.label} 43 | 44 | ))} 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /docs/src/components/icons/nodejs.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function Nodejs(props: SVGProps) { 4 | return ( 5 | 12 | 21 | 22 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/components/icons/typescript.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function Typescript(props: SVGProps) { 4 | return ( 5 | 12 | 23 | 24 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* You can override the default Infima variables here. */ 6 | :root { 7 | --ifm-color-primary: #2e8555; 8 | --ifm-color-primary-dark: #29784c; 9 | --ifm-color-primary-darker: #277148; 10 | --ifm-color-primary-darkest: #205d3b; 11 | --ifm-color-primary-light: #33925d; 12 | --ifm-color-primary-lighter: #359962; 13 | --ifm-color-primary-lightest: #3cad6e; 14 | --ifm-code-font-size: 95%; 15 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 16 | } 17 | 18 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 19 | [data-theme="dark"] { 20 | --ifm-color-primary: #966cf7; 21 | --ifm-color-primary-dark: #8a5bf6; 22 | --ifm-color-primary-darker: #7e4af5; 23 | --ifm-color-primary-darkest: #6629f3; 24 | --ifm-color-primary-light: #ae8ef9; 25 | --ifm-color-primary-lighter: #ba9ffa; 26 | --ifm-color-primary-lightest: #ded1fc; 27 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 28 | } 29 | -------------------------------------------------------------------------------- /docs/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.mdx" { 2 | import type { ComponentProps, ComponentType } from "react"; 3 | const MDXComponent: ComponentType>; 4 | export default MDXComponent; 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/pages/_example_authorization_server.mdx: -------------------------------------------------------------------------------- 1 | import CodeBlock from "@theme/CodeBlock"; 2 | 3 | 4 | {` 5 | // services/authorization_server.ts 6 | const authorizationServer = new AuthorizationServer( 7 | clientRepository, 8 | accessTokenRepository, 9 | scopeRepository, 10 | "secret-key", 11 | ); 12 | 13 | authorizationServer.enableGrantType("client_credentials"); 14 | authorizationServer.enableGrantType({ 15 | grant: "authorization_code", 16 | userRepository, 17 | authorizationCodeRepository, 18 | }); 19 | // other grant types you want to enable 20 | `.trim()} 21 | 22 | -------------------------------------------------------------------------------- /docs/src/pages/_example_entities.mdx: -------------------------------------------------------------------------------- 1 | import CodeBlock from "@theme/CodeBlock"; 2 | 3 | {` 4 | // entities/client_entity.ts 5 | import { OAuthClient, GrantIdentifier } from "@jmondi/oauth2-server"; 6 | import { ScopeEntity } from "./scope_entity"; 7 | 8 | class ClientEntity implements OAuthClient { 9 | readonly id: string; 10 | name: string; 11 | secret: string | null; 12 | redirectUris: string[]; 13 | allowedGrants: GrantIdentifier[]; 14 | scopes: ScopeEntity[]; 15 | createdAt: Date; 16 | updatedAt: Date | null; 17 | } 18 | `.trim()} 19 | 20 | {` 21 | // entities/user_entity.ts 22 | import { OAuthUser } from "@jmondi/oauth2-server"; 23 | 24 | export class User implements OAuthUser { 25 | readonly id: string; 26 | email: string; 27 | passwordHash: string | null; 28 | tokenVersion = 0; 29 | lastLoginAt: Date | null; 30 | createdAt: Date; 31 | updatedAt: Date | null; 32 | } 33 | `.trim()} 34 | -------------------------------------------------------------------------------- /docs/src/pages/_example_repositories.mdx: -------------------------------------------------------------------------------- 1 | import CodeBlock from "@theme/CodeBlock"; 2 | 3 | {` 4 | // repositories/client_repository.ts 5 | import { PrismaClient } from "@prisma/client"; 6 | import { GrantIdentifier, OAuthClient, OAuthClientRepository } from "@jmondi/oauth2-server"; 7 | 8 | import { Client } from "../entities/client.js"; 9 | 10 | export class ClientRepository implements OAuthClientRepository { 11 | constructor(private readonly prisma: PrismaClient) { 12 | } 13 | 14 | async getByIdentifier(clientId: string): Promise { 15 | return await this.prisma.oAuthClient.findUniqueOrThrow({ 16 | where: { 17 | id: clientId, 18 | }, 19 | include: { 20 | scopes: true, 21 | }, 22 | }); 23 | } 24 | 25 | async isClientValid( 26 | grantType: GrantIdentifier, 27 | client: OAuthClient, 28 | clientSecret?: string, 29 | ): Promise { 30 | // implement me (see examples) 31 | } 32 | } 33 | `.trim()} 34 | -------------------------------------------------------------------------------- /docs/src/pages/_index_install.mdx: -------------------------------------------------------------------------------- 1 | import Tabs from "@theme/Tabs"; 2 | import TabItem from "@theme/TabItem"; 3 | import CodeBlock from "@theme/CodeBlock"; 4 | 5 | 6 | 7 | pnpm add @jmondi/oauth2-server 8 | 9 | 10 | npm install --save @jmondi/oauth2-server 11 | 12 | 13 | yarn add @jmondi/oauth2-server 14 | 15 | 16 | npx jsr add @jmondi/oauth2-server 17 | 18 | 19 | deno add @jmondi/oauth2-server 20 | 21 | 22 | bunx jsr add @jmondi/oauth2-server 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/src/pages/_which_grant.mdx: -------------------------------------------------------------------------------- 1 | import CodeBlock from "@theme/CodeBlock"; 2 | 3 | 4 | {` 5 | +-------+ 6 | | Start | 7 | +-------+ 8 | V 9 | | 10 | +------------------------+ +-----------------------+ 11 | | Have a refresh token? |>----Yes----->| Refresh Token Grant | 12 | +------------------------+ +-----------------------+ 13 | V 14 | | 15 | No 16 | | 17 | +---------------------+ 18 | | Who is the | +--------------------------+ 19 | | Access token owner? |>---A Machine---->| Client Credentials Grant | 20 | +---------------------+ +--------------------------+ 21 | V 22 | | 23 | | 24 | A User 25 | | 26 | | 27 | +----------------------+ 28 | | What type of client? | 29 | +----------------------+ 30 | | 31 | | +---------------------------+ 32 | |>-----------Server App---------->| Auth Code Grant with PKCE | 33 | | +---------------------------+ 34 | | 35 | | +---------------------------+ 36 | |>-------Browser Based App------->| Auth Code Grant with PKCE | 37 | | +---------------------------+ 38 | | 39 | | +---------------------------+ 40 | |>-------Native Mobile App------->| Auth Code Grant with PKCE | 41 | +---------------------------+ 42 | `.trim()} 43 | 44 | -------------------------------------------------------------------------------- /docs/src/theme/MDXComponents.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // Import the original mapper 3 | import MDXComponents from "@theme-original/MDXComponents"; 4 | import Tabs from "@theme/Tabs"; 5 | import TabItem from "@theme/TabItem"; 6 | 7 | export default { 8 | // Re-use the default mapping 9 | ...MDXComponents, 10 | // Map the "" tag to our Highlight component 11 | // `Highlight` will receive all props that were passed to `` in MDX 12 | Tabs, 13 | TabItem, 14 | }; 15 | -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/2ba3b8453971aadca52baf376c00fe87f7b94582/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/2ba3b8453971aadca52baf376c00fe87f7b94582/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/2ba3b8453971aadca52baf376c00fe87f7b94582/docs/static/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/oauth2-server-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/2ba3b8453971aadca52baf376c00fe87f7b94582/docs/static/img/oauth2-server-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/oauth2-server-social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/2ba3b8453971aadca52baf376c00fe87f7b94582/docs/static/img/oauth2-server-social-card.png -------------------------------------------------------------------------------- /docs/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /docs/tailwind-config.cjs: -------------------------------------------------------------------------------- 1 | function tailwindPlugin(context, options) { 2 | return { 3 | name: "tailwind-plugin", 4 | configurePostCss(postcssOptions) { 5 | postcssOptions.plugins = [ 6 | require("postcss-import"), 7 | require("tailwindcss"), 8 | require("autoprefixer"), 9 | ]; 10 | return postcssOptions; 11 | }, 12 | }; 13 | } 14 | 15 | module.exports = tailwindPlugin; 16 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | corePlugins: { 6 | preflight: false, 7 | container: false, 8 | }, 9 | darkMode: ["class", '[data-theme="dark"]'], 10 | content: ["./src/**/*.{jsx,tsx,html}", "./docs/**/*.mdx}"], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: ['"Inter"', ...fontFamily.sans], 15 | jakarta: ['"Plus Jakarta Sans"', ...fontFamily.sans], 16 | mono: ['"Fira Code"', ...fontFamily.mono], 17 | }, 18 | borderRadius: { 19 | sm: "4px", 20 | }, 21 | screens: { 22 | sm: "0px", 23 | lg: "997px", 24 | }, 25 | colors: {}, 26 | }, 27 | }, 28 | plugins: [], 29 | }; 30 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | # String, buffer, or object containing either the secret for HMAC algorithms or the PEM encoded private key for RSA and ECDSA 2 | # https://github.com/auth0/node-jsonwebtoken#usage 3 | OAUTH_CODES_SECRET=changeme 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea 3 | .env 4 | 5 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Typescript OAuth2.0 Server Example 2 | 3 | ## Want a better example? 4 | 5 | See the [ts-oauth2-server-example](https://github.com/jasonraimondi/ts-oauth2-server-example) repository for a more full example. 6 | 7 | ## Getting Started 8 | 9 | ```bash 10 | cp .env.example .env 11 | ``` 12 | 13 | ```dotenv 14 | # String, buffer, or object containing either the secret for HMAC algorithms or the PEM encoded private key for RSA and ECDSA 15 | # https://github.com/auth0/node-jsonwebtoken#usage 16 | OAUTH_CODES_SECRET=changeme 17 | ``` 18 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | environment: 7 | POSTGRES_DB: prismadb 8 | POSTGRES_USER: prisma 9 | POSTGRES_PASSWORD: secret 10 | TZ: UTC 11 | PGTZ: UTC 12 | ports: 13 | - 8888:5432 14 | volumes: 15 | - pgdata:/var/lib/postgresql/data 16 | 17 | volumes: 18 | pgdata: 19 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/oauth2-server-example", 3 | "license": "MIT", 4 | "scripts": { 5 | "gen": "prisma generate", 6 | "dev": "tsx watch src/main.ts" 7 | }, 8 | "type": "module", 9 | "engines": { 10 | "node": ">=16" 11 | }, 12 | "devDependencies": { 13 | "@types/bcryptjs": "^2.4.6", 14 | "@types/body-parser": "^1.19.5", 15 | "@types/express": "^4.17.21", 16 | "@types/node": "^20.14.9", 17 | "prisma": "^5.16.1", 18 | "tsx": "^4.16.0", 19 | "typescript": "^5.5.3" 20 | }, 21 | "dependencies": { 22 | "@jmondi/oauth2-server": "3.3.0", 23 | "@prisma/client": "^5.16.1", 24 | "bcryptjs": "^2.4.3", 25 | "body-parser": "^1.20.2", 26 | "dotenv": "^16.4.5", 27 | "express": "^4.19.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgres" 6 | url = "postgresql://prisma:secret@localhost:8888/prismadb?schema=public" 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model User { 14 | id String @id @db.Uuid 15 | email String @unique 16 | passwordHash String @db.VarChar(255) 17 | OAuthAuthCodes OAuthAuthCode[] 18 | OAuthTokens OAuthToken[] 19 | 20 | @@index([email], name: "idx_user_email") 21 | } 22 | 23 | enum GrantTypes { 24 | client_credentials 25 | authorization_code 26 | refresh_token 27 | implicit 28 | password 29 | } 30 | 31 | model OAuthClient { 32 | id String @id @default(uuid()) @db.Uuid 33 | name String @db.VarChar(255) 34 | secret String? @db.VarChar(255) 35 | redirectUris String[] 36 | allowedGrants GrantTypes[] 37 | scopes OAuthScope[] 38 | authCodes OAuthAuthCode[] 39 | tokens OAuthToken[] 40 | OAuthClientScope OAuthClientScope[] 41 | } 42 | 43 | model OAuthClientScope { 44 | clientId String @db.Uuid 45 | client OAuthClient @relation(fields: [clientId], references: [id]) 46 | scopeId String @db.Uuid 47 | scope OAuthScope @relation(fields: [scopeId], references: [id]) 48 | 49 | @@id([clientId, scopeId]) 50 | @@index([clientId], name: "idx_oauthclient_oauthscope_clientid") 51 | @@index([scopeId], name: "idx_oauthclient_oauthscope_scopeid") 52 | @@map("oauthClient_oauthScope") 53 | } 54 | 55 | enum CodeChallengeMethod { 56 | S256 57 | plain 58 | } 59 | 60 | model OAuthAuthCode { 61 | code String @id 62 | redirectUri String? 63 | codeChallenge String? 64 | codeChallengeMethod CodeChallengeMethod @default(plain) 65 | expiresAt DateTime 66 | user User? @relation(fields: [userId], references: [id]) 67 | userId String? @db.Uuid 68 | client OAuthClient @relation(fields: [clientId], references: [id]) 69 | clientId String @db.Uuid 70 | scopes OAuthScope[] 71 | } 72 | 73 | model OAuthToken { 74 | accessToken String @id 75 | accessTokenExpiresAt DateTime 76 | refreshToken String? @unique 77 | refreshTokenExpiresAt DateTime? 78 | client OAuthClient @relation(fields: [clientId], references: [id]) 79 | clientId String @db.Uuid 80 | user User? @relation(fields: [userId], references: [id]) 81 | userId String? @db.Uuid 82 | scopes OAuthScope[] 83 | 84 | @@index([accessToken], name: "idx_oauthtoken_accesstoken") 85 | @@index([refreshToken], name: "idx_oauthtoken_refreshtoken") 86 | } 87 | 88 | model OAuthScope { 89 | id String @id @db.Uuid 90 | name String 91 | OAuthClients OAuthClient[] 92 | OAuthAuthCode OAuthAuthCode[] 93 | OAuthToken OAuthToken[] 94 | 95 | 96 | OAuthClientScope OAuthClientScope[] 97 | @@index([name], name: "idx_oauthscope_name") 98 | } 99 | -------------------------------------------------------------------------------- /example/src/entities/auth_code.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OAuthClient as ClientModel, 3 | OAuthAuthCode as AuthCodeModel, 4 | OAuthScope as ScopeModel, 5 | User as UserModel, 6 | } from "@prisma/client"; 7 | import { OAuthAuthCode, CodeChallengeMethod } from "@jmondi/oauth2-server"; 8 | 9 | import { Client } from "./client.js"; 10 | import { Scope } from "./scope.js"; 11 | import { User } from "./user.js"; 12 | 13 | type Optional = Partial<{ 14 | user: UserModel; 15 | scopes: ScopeModel[]; 16 | }>; 17 | 18 | type Required = { 19 | client: ClientModel; 20 | }; 21 | 22 | export class AuthCode implements AuthCodeModel, OAuthAuthCode { 23 | readonly code: string; 24 | codeChallenge: string | null; 25 | codeChallengeMethod: CodeChallengeMethod; 26 | redirectUri: string | null; 27 | user: User | null; 28 | userId: string | null; 29 | client: Client; 30 | clientId: string; 31 | expiresAt: Date; 32 | createdAt: Date; 33 | scopes: Scope[]; 34 | 35 | constructor({ user, client, scopes, ...entity }: AuthCodeModel & Required & Optional) { 36 | this.code = entity.code; 37 | this.codeChallenge = entity.codeChallenge; 38 | this.codeChallengeMethod = entity.codeChallengeMethod; 39 | this.redirectUri = entity.redirectUri; 40 | this.user = user ? new User(user) : null; 41 | this.userId = entity.userId; 42 | this.client = new Client(client); 43 | this.clientId = entity.clientId; 44 | this.scopes = scopes?.map(s => new Scope(s)) ?? []; 45 | this.expiresAt = new Date(); 46 | this.createdAt = new Date(); 47 | } 48 | 49 | get isExpired(): boolean { 50 | return new Date() > this.expiresAt; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /example/src/entities/client.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient as ClientModel, OAuthScope as ScopeModel } from "@prisma/client"; 2 | import { GrantIdentifier, OAuthClient } from "@jmondi/oauth2-server"; 3 | 4 | import { Scope } from "./scope.js"; 5 | 6 | type Relations = { 7 | scopes: ScopeModel[]; 8 | }; 9 | 10 | export class Client implements ClientModel, OAuthClient { 11 | readonly id: string; 12 | name: string; 13 | secret: string | null; 14 | redirectUris: string[]; 15 | allowedGrants: GrantIdentifier[]; 16 | scopes: Scope[]; 17 | createdAt: Date; 18 | 19 | constructor({ scopes, ...entity }: ClientModel & Partial) { 20 | this.id = entity.id; 21 | this.name = entity.name; 22 | this.secret = entity.secret ?? null; 23 | this.redirectUris = entity.redirectUris; 24 | this.allowedGrants = entity.allowedGrants; 25 | this.scopes = scopes?.map(s => new Scope(s)) ?? []; 26 | this.createdAt = new Date(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /example/src/entities/scope.ts: -------------------------------------------------------------------------------- 1 | import { OAuthScope as ScopeModel } from "@prisma/client"; 2 | import { OAuthScope } from "@jmondi/oauth2-server"; 3 | 4 | export class Scope implements ScopeModel, OAuthScope { 5 | readonly id: string; 6 | name: string; 7 | 8 | constructor(entity: ScopeModel) { 9 | this.id = entity.id; 10 | this.name = entity.name; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /example/src/entities/token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OAuthToken as TokenModel, 3 | OAuthScope as ScopeModel, 4 | OAuthClient as ClientModel, 5 | User as UserModel, 6 | } from "@prisma/client"; 7 | import { OAuthToken } from "@jmondi/oauth2-server"; 8 | 9 | import { Client } from "./client.js"; 10 | import { Scope } from "./scope.js"; 11 | import { User } from "./user.js"; 12 | 13 | type Relations = Partial<{ 14 | user: UserModel | null; 15 | scopes: ScopeModel[] | null; 16 | }>; 17 | 18 | type Required = { 19 | client: ClientModel; 20 | }; 21 | 22 | export class Token implements TokenModel, OAuthToken { 23 | accessToken: string; 24 | accessTokenExpiresAt: Date; 25 | refreshToken: string | null; 26 | refreshTokenExpiresAt: Date | null; 27 | client: Client; 28 | clientId: string; 29 | user: User | null; 30 | userId: string | null; 31 | scopes: Scope[]; 32 | createdAt: Date; 33 | 34 | constructor({ client, user, scopes, ...entity }: TokenModel & Required & Relations) { 35 | this.accessToken = entity.accessToken; 36 | this.accessTokenExpiresAt = entity.accessTokenExpiresAt; 37 | this.refreshToken = entity.refreshToken; 38 | this.refreshTokenExpiresAt = entity.refreshTokenExpiresAt; 39 | this.user = user ? new User(user) : null; 40 | this.userId = entity.userId; 41 | this.client = new Client(client); 42 | this.clientId = entity.clientId; 43 | this.scopes = scopes?.map(s => new Scope(s)) ?? []; 44 | this.createdAt = new Date(); 45 | } 46 | 47 | get isRevoked() { 48 | return Date.now() > this.accessTokenExpiresAt.getTime(); 49 | } 50 | 51 | revoke() { 52 | this.accessTokenExpiresAt = new Date(0); 53 | this.refreshTokenExpiresAt = new Date(0); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /example/src/entities/user.ts: -------------------------------------------------------------------------------- 1 | import { User as UserModel } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | import { OAuthUser } from "@jmondi/oauth2-server"; 4 | 5 | export class User implements UserModel, OAuthUser { 6 | readonly id: string; 7 | email: string; 8 | passwordHash: string; 9 | 10 | constructor(entity: UserModel) { 11 | this.id = entity.id; 12 | this.email = entity.email; 13 | this.passwordHash = entity.passwordHash; 14 | } 15 | 16 | async setPassword(password: string) { 17 | this.passwordHash = await bcrypt.hash(password, 12); 18 | } 19 | 20 | async verify(password: string) { 21 | if (!(await bcrypt.compare(password, this.passwordHash))) { 22 | throw new Error("invalid password"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /example/src/main.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { PrismaClient } from "@prisma/client"; 4 | import bodyParser from "body-parser"; 5 | import Express from "express"; 6 | import { AuthorizationServer, DateInterval } from "@jmondi/oauth2-server"; 7 | import { handleExpressError, handleExpressResponse } from "@jmondi/oauth2-server/express"; 8 | 9 | import { AuthCodeRepository } from "./repositories/auth_code_repository.js"; 10 | import { ClientRepository } from "./repositories/client_repository.js"; 11 | import { ScopeRepository } from "./repositories/scope_repository.js"; 12 | import { TokenRepository } from "./repositories/token_repository.js"; 13 | import { UserRepository } from "./repositories/user_repository.js"; 14 | import { MyCustomJwtService } from "./utils/custom_jwt_service.js"; 15 | 16 | async function bootstrap() { 17 | const prisma = new PrismaClient(); 18 | const authCodeRepository = new AuthCodeRepository(prisma); 19 | const userRepository = new UserRepository(prisma); 20 | 21 | const authorizationServer = new AuthorizationServer( 22 | new ClientRepository(prisma), 23 | new TokenRepository(prisma), 24 | new ScopeRepository(prisma), 25 | new MyCustomJwtService(process.env.OAUTH_CODES_SECRET!), 26 | ); 27 | authorizationServer.enableGrantTypes( 28 | ["client_credentials", new DateInterval("1d")], 29 | ["refresh_token", new DateInterval("30d")], 30 | { grant: "authorization_code", authCodeRepository, userRepository }, 31 | ); 32 | 33 | const app = Express(); 34 | 35 | app.use(bodyParser.json()); 36 | app.use(bodyParser.urlencoded({ extended: false })); 37 | 38 | app.get("/authorize", async (req: Express.Request, res: Express.Response) => { 39 | try { 40 | // Validate the HTTP request and return an AuthorizationRequest object. 41 | const authRequest = await authorizationServer.validateAuthorizationRequest(req); 42 | 43 | // The auth request object can be serialized and saved into a user's session. 44 | // You will probably want to redirect the user at this point to a login endpoint. 45 | 46 | // Once the user has logged in set the user on the AuthorizationRequest 47 | console.log("Once the user has logged in set the user on the AuthorizationRequest"); 48 | authRequest.user = { id: "abc", email: "user@example.com" }; 49 | 50 | // At this point you should redirect the user to an authorization page. 51 | // This form will ask the user to approve the client and the scopes requested. 52 | 53 | // Once the user has approved or denied the client update the status 54 | // (true = approved, false = denied) 55 | authRequest.isAuthorizationApproved = true; 56 | 57 | // Return the HTTP redirect response 58 | const oauthResponse = await authorizationServer.completeAuthorizationRequest(authRequest); 59 | return handleExpressResponse(res, oauthResponse); 60 | } catch (e) { 61 | handleExpressError(e, res); 62 | } 63 | }); 64 | 65 | app.post("/token", async (req: Express.Request, res: Express.Response) => { 66 | try { 67 | const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 68 | return handleExpressResponse(res, oauthResponse); 69 | } catch (e) { 70 | handleExpressError(e, res); 71 | return; 72 | } 73 | }); 74 | 75 | app.get("/", (_: Express.Request, res: Express.Response) => { 76 | res.json({ 77 | success: true, 78 | GET: ["/authorize"], 79 | POST: ["/token"], 80 | }); 81 | }); 82 | 83 | app.listen(3000); 84 | console.log("app is listening on http://localhost:3000"); 85 | } 86 | 87 | bootstrap().catch(console.log); 88 | -------------------------------------------------------------------------------- /example/src/repositories/auth_code_repository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { DateInterval, generateRandomToken, OAuthAuthCode, OAuthAuthCodeRepository } from "@jmondi/oauth2-server"; 3 | 4 | import { AuthCode } from "../entities/auth_code.js"; 5 | import { Client } from "../entities/client.js"; 6 | import { Scope } from "../entities/scope.js"; 7 | import { User } from "../entities/user.js"; 8 | 9 | export class AuthCodeRepository implements OAuthAuthCodeRepository { 10 | constructor(private readonly prisma: PrismaClient) {} 11 | 12 | async getByIdentifier(authCodeCode: string): Promise { 13 | const entity = await this.prisma.oAuthAuthCode.findUnique({ 14 | rejectOnNotFound: true, 15 | where: { 16 | code: authCodeCode, 17 | }, 18 | include: { 19 | client: true, 20 | }, 21 | }); 22 | return new AuthCode(entity); 23 | } 24 | 25 | async isRevoked(authCodeCode: string): Promise { 26 | const authCode = await this.getByIdentifier(authCodeCode); 27 | return authCode.isExpired; 28 | } 29 | 30 | issueAuthCode(client: Client, user: User | undefined, scopes: Scope[]): OAuthAuthCode { 31 | return new AuthCode({ 32 | redirectUri: null, 33 | code: generateRandomToken(), 34 | codeChallenge: null, 35 | codeChallengeMethod: "S256", 36 | expiresAt: new DateInterval("15m").getEndDate(), 37 | client, 38 | clientId: client.id, 39 | user, 40 | userId: user?.id ?? null, 41 | scopes, 42 | }); 43 | } 44 | 45 | async persist({ user, client, scopes, ...authCode }: AuthCode): Promise { 46 | await this.prisma.oAuthAuthCode.create({ data: authCode }); 47 | } 48 | 49 | async revoke(authCodeCode: string): Promise { 50 | await this.prisma.oAuthAuthCode.update({ 51 | where: { code: authCodeCode }, 52 | data: { 53 | expiresAt: new Date(0), 54 | }, 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /example/src/repositories/client_repository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { GrantIdentifier, OAuthClient, OAuthClientRepository } from "@jmondi/oauth2-server"; 3 | 4 | import { Client } from "../entities/client.js"; 5 | 6 | export class ClientRepository implements OAuthClientRepository { 7 | constructor(private readonly prisma: PrismaClient) {} 8 | 9 | async getByIdentifier(clientId: string): Promise { 10 | return new Client( 11 | await this.prisma.oAuthClient.findUnique({ 12 | rejectOnNotFound: true, 13 | where: { 14 | id: clientId, 15 | }, 16 | include: { 17 | scopes: true, 18 | }, 19 | }), 20 | ); 21 | } 22 | 23 | async isClientValid(grantType: GrantIdentifier, client: OAuthClient, clientSecret?: string): Promise { 24 | if (client.secret && client.secret !== clientSecret) { 25 | return false; 26 | } 27 | return client.allowedGrants.includes(grantType); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/src/repositories/scope_repository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { GrantIdentifier, OAuthScope, OAuthScopeRepository } from "@jmondi/oauth2-server"; 3 | 4 | import { Client } from "../entities/client.js"; 5 | import { Scope } from "../entities/scope.js"; 6 | 7 | export class ScopeRepository implements OAuthScopeRepository { 8 | constructor(private readonly prisma: PrismaClient) {} 9 | 10 | async getAllByIdentifiers(scopeNames: string[]): Promise { 11 | const scopes = await this.prisma.oAuthScope.findMany({ 12 | where: { 13 | name: { 14 | in: scopeNames, 15 | }, 16 | }, 17 | }); 18 | return scopes.map(s => new Scope(s)); 19 | } 20 | 21 | async finalize( 22 | scopes: OAuthScope[], 23 | _identifier: GrantIdentifier, 24 | _client: Client, 25 | _user_id?: string, 26 | ): Promise { 27 | return scopes; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/src/repositories/token_repository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { DateInterval, generateRandomToken, OAuthClient, OAuthTokenRepository } from "../../../src"; 3 | 4 | import { Client } from "../entities/client.js"; 5 | import { Scope } from "../entities/scope.js"; 6 | import { Token } from "../entities/token.js"; 7 | import { User } from "../entities/user.js"; 8 | 9 | export class TokenRepository implements OAuthTokenRepository { 10 | constructor(private readonly prisma: PrismaClient) {} 11 | 12 | async findById(accessToken: string): Promise { 13 | const token = await this.prisma.oAuthToken.findUnique({ 14 | rejectOnNotFound: true, 15 | where: { 16 | accessToken, 17 | }, 18 | include: { 19 | user: true, 20 | client: true, 21 | scopes: true, 22 | }, 23 | }); 24 | return new Token(token); 25 | } 26 | 27 | async issueToken(client: Client, scopes: Scope[], user?: User): Promise { 28 | return new Token({ 29 | accessToken: generateRandomToken(), 30 | accessTokenExpiresAt: new DateInterval("2h").getEndDate(), 31 | refreshToken: null, 32 | refreshTokenExpiresAt: null, 33 | client, 34 | clientId: client.id, 35 | user: user, 36 | userId: user?.id ?? null, 37 | scopes, 38 | }); 39 | } 40 | 41 | async getByRefreshToken(refreshToken: string): Promise { 42 | const token = await this.prisma.oAuthToken.findUnique({ 43 | rejectOnNotFound: true, 44 | where: { refreshToken }, 45 | include: { 46 | client: true, 47 | scopes: true, 48 | user: true, 49 | }, 50 | }); 51 | return new Token(token); 52 | } 53 | 54 | async isRefreshTokenRevoked(token: Token): Promise { 55 | return Date.now() > (token.refreshTokenExpiresAt?.getTime() ?? 0); 56 | } 57 | 58 | async issueRefreshToken(token: Token, _: OAuthClient): Promise { 59 | token.refreshToken = generateRandomToken(); 60 | token.refreshTokenExpiresAt = new DateInterval("2h").getEndDate(); 61 | await this.prisma.oAuthToken.update({ 62 | where: { 63 | accessToken: token.accessToken, 64 | }, 65 | data: { 66 | refreshToken: token.refreshToken, 67 | refreshTokenExpiresAt: token.refreshTokenExpiresAt, 68 | }, 69 | }); 70 | return token; 71 | } 72 | 73 | async persist({ user, client, scopes, ...token }: Token): Promise { 74 | await this.prisma.oAuthToken.upsert({ 75 | where: { 76 | accessToken: token.accessToken, 77 | }, 78 | update: {}, 79 | create: token, 80 | }); 81 | } 82 | 83 | async revoke(accessToken: Token): Promise { 84 | accessToken.revoke(); 85 | await this.update(accessToken); 86 | } 87 | 88 | private async update({ user, client, scopes, ...token }: Token): Promise { 89 | await this.prisma.oAuthToken.update({ 90 | where: { 91 | accessToken: token.accessToken, 92 | }, 93 | data: token, 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /example/src/repositories/user_repository.ts: -------------------------------------------------------------------------------- 1 | import { GrantIdentifier, OAuthUserRepository } from "@jmondi/oauth2-server"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | import { Client } from "../entities/client.js"; 5 | import { User } from "../entities/user.js"; 6 | 7 | export class UserRepository implements OAuthUserRepository { 8 | constructor(private readonly prisma: PrismaClient) {} 9 | 10 | async getUserByCredentials( 11 | identifier: string, 12 | password?: string, 13 | _grantType?: GrantIdentifier, 14 | _client?: Client, 15 | ): Promise { 16 | const user = new User( 17 | await this.prisma.user.findUnique({ 18 | rejectOnNotFound: true, 19 | where: { id: identifier }, 20 | }), 21 | ); 22 | 23 | // verity password and if user is allowed to use grant, etc... 24 | if (password) await user.verify(password); 25 | 26 | return user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /example/src/utils/custom_jwt_service.ts: -------------------------------------------------------------------------------- 1 | import { ExtraAccessTokenFieldArgs, JwtService } from "@jmondi/oauth2-server"; 2 | 3 | export class MyCustomJwtService extends JwtService { 4 | extraTokenFields({ user, client }: ExtraAccessTokenFieldArgs) { 5 | return { 6 | email: user?.email, 7 | client: client.name, 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", 5 | "module": "node16", 6 | "moduleResolution": "node16", 7 | "lib": ["esnext", "esnext.asynciterable"], 8 | "outDir": "dist", 9 | 10 | /* Strict Type-Checking Options */ 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictBindCallApply": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | 19 | /* Additional Checks */ 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noImplicitReturns": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | /* Module Resolution Options */ 26 | "esModuleInterop": true, 27 | "resolveJsonModule": true, 28 | 29 | /* Experimental Options */ 30 | "experimentalDecorators": true, 31 | "emitDecoratorMetadata": true, 32 | 33 | /* Advanced Options */ 34 | "forceConsistentCasingInFileNames": true, 35 | "typeRoots": [ 36 | "@types", 37 | "node_modules/@types", 38 | ] 39 | }, 40 | "include": ["src", "prisma"], 41 | "exclude": ["node_modules/**"] 42 | } -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/oauth2-server", 3 | "version": "4.0.5", 4 | "exports": { 5 | ".": "./src/index.ts", 6 | "./express": "./src/adapters/express.ts", 7 | "./fastify": "./src/adapters/fastify.ts", 8 | "./vanilla": "./src/adapters/vanilla.ts" 9 | }, 10 | "publish": { 11 | "exclude": [ 12 | "example", 13 | "docs", 14 | "test" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/oauth2-server", 3 | "version": "4.0.5", 4 | "type": "module", 5 | "author": "Jason Raimondi ", 6 | "funding": "https://github.com/sponsors/jasonraimondi", 7 | "license": "MIT", 8 | "scripts": { 9 | "clean": "rimraf dist", 10 | "prebuild": "run-s clean", 11 | "bundle": "tsup", 12 | "build": "run-s clean bundle", 13 | "start": "tsc -p tsconfig.build.json --watch", 14 | "test": "vitest run", 15 | "test:watch": "vitest", 16 | "test:cov": "vitest run --coverage", 17 | "format": "prettier --write \"**/*.{ts,js,tsx,md,mdx}\"", 18 | "prepublishOnly": "run-s build test" 19 | }, 20 | "exports": { 21 | ".": "./src/index.ts", 22 | "./vanilla": "./src/adapters/vanilla.ts", 23 | "./express": "./src/adapters/express.ts", 24 | "./fastify": "./src/adapters/fastify.ts" 25 | }, 26 | "publishConfig": { 27 | "main": "./dist/index.js", 28 | "module": "./dist/index.js", 29 | "types": "./dist/index.d.ts", 30 | "exports": { 31 | ".": { 32 | "import": "./dist/index.js", 33 | "require": "./dist/index.cjs", 34 | "types": "./dist/index.d.ts" 35 | }, 36 | "./vanilla": { 37 | "import": "./dist/vanilla.js", 38 | "require": "./dist/vanilla.cjs", 39 | "types": "./dist/vanilla.d.ts" 40 | }, 41 | "./express": { 42 | "import": "./dist/express.js", 43 | "require": "./dist/express.cjs", 44 | "types": "./dist/express.d.ts" 45 | }, 46 | "./fastify": { 47 | "import": "./dist/fastify.js", 48 | "require": "./dist/fastify.cjs", 49 | "types": "./dist/fastify.d.ts" 50 | } 51 | }, 52 | "typesVersions": { 53 | "*": { 54 | "*": [ 55 | "./dist/*", 56 | "./dist/index.d.ts" 57 | ] 58 | } 59 | } 60 | }, 61 | "files": [ 62 | "dist", 63 | "src" 64 | ], 65 | "engines": { 66 | "node": ">=16" 67 | }, 68 | "devDependencies": { 69 | "@types/body-parser": "^1.19.5", 70 | "@types/express": "^4.17.21", 71 | "@types/jsonwebtoken": "^9.0.6", 72 | "@types/ms": "^0.7.34", 73 | "@types/node": "^20.14.9", 74 | "@types/supertest": "^6.0.2", 75 | "@vitest/coverage-istanbul": "^1.6.0", 76 | "body-parser": "^1.20.2", 77 | "express": "^4.19.2", 78 | "fastify": "^4.28.1", 79 | "npm-run-all": "^4.1.5", 80 | "prettier": "^3.3.2", 81 | "rimraf": "^5.0.7", 82 | "supertest": "^7.0.0", 83 | "tslib": "^2.6.3", 84 | "tsup": "^8.1.0", 85 | "typescript": "^5.5.3", 86 | "vite": "^5.4.15", 87 | "vitest": "^1.6.1" 88 | }, 89 | "dependencies": { 90 | "jsonwebtoken": "^9.0.2", 91 | "ms": "^2.1.3", 92 | "uri-js": "^4.4.1" 93 | }, 94 | "tsup": { 95 | "entry": { 96 | "index": "./src/index.ts", 97 | "vanilla": "./src/adapters/vanilla.ts", 98 | "express": "./src/adapters/express.ts", 99 | "fastify": "./src/adapters/fastify.ts" 100 | }, 101 | "format": [ 102 | "cjs", 103 | "esm" 104 | ], 105 | "target": "node16", 106 | "clean": true, 107 | "dts": true, 108 | "splitting": false, 109 | "sourcemap": true 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/adapters/express.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { OAuthException } from "../exceptions/oauth.exception.js"; 3 | 4 | import { OAuthRequest } from "../requests/request.js"; 5 | import { OAuthResponse } from "../responses/response.js"; 6 | import { isOAuthError } from "../utils/errors.js"; 7 | 8 | export function responseFromExpress({ status, ...res }: Response): OAuthResponse { 9 | return new OAuthResponse({ status: res.statusCode ?? 200, ...res }); 10 | } 11 | 12 | export function requestFromExpress(req: Request): OAuthRequest { 13 | return new OAuthRequest(req); 14 | } 15 | 16 | export function handleExpressResponse(expressResponse: Response, oauthResponse: OAuthResponse): void { 17 | if (oauthResponse.status === 302) { 18 | if (!oauthResponse.headers.location) throw new Error("missing redirect location"); 19 | expressResponse.set(oauthResponse.headers); 20 | expressResponse.redirect(oauthResponse.headers.location); 21 | } else { 22 | expressResponse.set(oauthResponse.headers); 23 | expressResponse.status(oauthResponse.status).send(oauthResponse.body); 24 | } 25 | } 26 | 27 | // @todo v4.0 flip these to always be Express as first arg, OAuth as second. Then update Docs 28 | export function handleExpressError(e: unknown | OAuthException, res: Response): void { 29 | if (isOAuthError(e)) { 30 | res.status(e.status); 31 | res.send({ 32 | status: e.status, 33 | message: e.message, 34 | error: e.errorType, 35 | error_description: e.errorDescription ?? e.error, 36 | }); 37 | return; 38 | } 39 | throw e; 40 | } 41 | -------------------------------------------------------------------------------- /src/adapters/fastify.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyReply, FastifyRequest } from "fastify"; 2 | import { OAuthException } from "../exceptions/oauth.exception.js"; 3 | 4 | import { OAuthRequest } from "../requests/request.js"; 5 | import { OAuthResponse } from "../responses/response.js"; 6 | import { isOAuthError } from "../utils/errors.js"; 7 | 8 | export function responseFromFastify(res: FastifyReply): OAuthResponse { 9 | return new OAuthResponse({ 10 | headers: >(res.headers) ?? {}, 11 | }); 12 | } 13 | 14 | export function requestFromFastify(req: FastifyRequest): OAuthRequest { 15 | return new OAuthRequest({ 16 | query: >req.query ?? {}, 17 | body: >req.body ?? {}, 18 | headers: >req.headers ?? {}, 19 | }); 20 | } 21 | 22 | // @todo v4.0 flip these to always be Fastify as first arg, OAuth as second. Then update Docs 23 | export function handleFastifyError(e: unknown | OAuthException, res: FastifyReply): void { 24 | if (isOAuthError(e)) { 25 | res.status(e.status).send({ 26 | status: e.status, 27 | message: e.message, 28 | error: e.errorType, 29 | error_description: e.errorDescription ?? e.error, 30 | }); 31 | return; 32 | } 33 | throw e; 34 | } 35 | 36 | export function handleFastifyReply(res: FastifyReply, response: OAuthResponse): void { 37 | if (response.status === 302) { 38 | if (!response.headers.location) throw new Error("missing redirect location"); 39 | res.headers(response.headers); 40 | res.redirect(response.headers.location, 302); 41 | } else { 42 | res.headers(response.headers); 43 | res.status(response.status).send(response.body); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/adapters/vanilla.ts: -------------------------------------------------------------------------------- 1 | import { OAuthRequest } from "../requests/request.js"; 2 | import { OAuthResponse } from "../responses/response.js"; 3 | import { ErrorType, OAuthException } from "../exceptions/oauth.exception.js"; 4 | import type { ReadableStream } from "stream/web"; 5 | 6 | export function responseFromVanilla(res: Response): OAuthResponse { 7 | const headers: Record = {}; 8 | res.headers.forEach((value, key) => { 9 | if (key === "cookie") return; 10 | headers[key] = value; 11 | }); 12 | 13 | return new OAuthResponse({ 14 | headers: headers, 15 | }); 16 | } 17 | 18 | export function responseToVanilla(oauthResponse: OAuthResponse): Response { 19 | if (oauthResponse.status === 302) { 20 | if (typeof oauthResponse.headers.location !== "string" || oauthResponse.headers.location === "") { 21 | throw new OAuthException(`missing redirect location`, ErrorType.InvalidRequest); 22 | } 23 | return new Response(null, { 24 | status: 302, 25 | headers: { 26 | Location: oauthResponse.headers.location, 27 | }, 28 | }); 29 | } 30 | 31 | return new Response(JSON.stringify(oauthResponse.body), { 32 | status: oauthResponse.status, 33 | headers: oauthResponse.headers, 34 | }); 35 | } 36 | 37 | export async function requestFromVanilla(req: Request): Promise { 38 | const url = new URL(req.url); 39 | const query: Record = {}; 40 | url.searchParams.forEach((value, key) => { 41 | query[key] = value; 42 | }); 43 | 44 | let body: Record = {}; 45 | 46 | if (isReadableStream(req.body)) { 47 | body = JSON.parse(await streamToString(req.body)); 48 | } else if (req.body != null) { 49 | body = JSON.parse(req.body); 50 | } 51 | 52 | const headers: Record = {}; 53 | req.headers.forEach((value, key) => { 54 | if (key === "cookie") return; 55 | headers[key] = value; 56 | }); 57 | 58 | return new OAuthRequest({ 59 | query: query, 60 | body: body, 61 | headers: headers, 62 | }); 63 | } 64 | 65 | async function streamToString(stream: ReadableStream): Promise { 66 | const reader = stream.getReader(); 67 | let result = ""; 68 | while (true) { 69 | const { done, value } = await reader.read(); 70 | if (done) break; 71 | result += new TextDecoder().decode(value); 72 | } 73 | return result; 74 | } 75 | 76 | function isReadableStream(value: unknown): value is ReadableStream { 77 | return ( 78 | value !== null && 79 | typeof value === "object" && 80 | typeof (value as ReadableStream).getReader === "function" && 81 | typeof (value as ReadableStream).tee === "function" && 82 | typeof (value as ReadableStream).locked !== "undefined" 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/code_verifiers/S256.verifier.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | import { base64urlencode } from "../utils/base64.js"; 4 | import { ICodeChallenge } from "./verifier.js"; 5 | 6 | export class S256Verifier implements ICodeChallenge { 7 | public readonly method = "S256"; 8 | 9 | verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean { 10 | const codeHash = createHash("sha256").update(codeVerifier).digest(); 11 | return codeChallenge === base64urlencode(codeHash); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/code_verifiers/plain.verifier.ts: -------------------------------------------------------------------------------- 1 | import { ICodeChallenge } from "./verifier.js"; 2 | 3 | export class PlainVerifier implements ICodeChallenge { 4 | public readonly method = "plain"; 5 | 6 | verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean { 7 | return codeChallenge === codeVerifier; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/code_verifiers/verifier.ts: -------------------------------------------------------------------------------- 1 | export type CodeChallengeMethod = "S256" | "plain"; 2 | 3 | export interface ICodeChallenge { 4 | method: CodeChallengeMethod; 5 | 6 | verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/entities/auth_code.entity.ts: -------------------------------------------------------------------------------- 1 | import { CodeChallengeMethod } from "../code_verifiers/verifier.js"; 2 | import { OAuthClient } from "./client.entity.js"; 3 | import { OAuthScope } from "./scope.entity.js"; 4 | import { OAuthUser } from "./user.entity.js"; 5 | 6 | export interface OAuthAuthCode { 7 | code: string; 8 | redirectUri?: string | null; 9 | codeChallenge?: string | null; 10 | codeChallengeMethod?: CodeChallengeMethod | null; 11 | expiresAt: Date; 12 | user?: OAuthUser | null; 13 | client: OAuthClient; 14 | scopes: OAuthScope[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/entities/client.entity.ts: -------------------------------------------------------------------------------- 1 | import { GrantIdentifier } from "../grants/abstract/grant.interface.js"; 2 | import { OAuthScope } from "./scope.entity.js"; 3 | 4 | export interface OAuthClient { 5 | id: string; 6 | name: string; 7 | secret?: string | null; 8 | redirectUris: string[]; 9 | allowedGrants: GrantIdentifier[]; 10 | scopes: OAuthScope[]; 11 | [key: string]: any; 12 | } 13 | 14 | export function isClientConfidential(client: OAuthClient): boolean { 15 | return !!client.secret; 16 | } 17 | -------------------------------------------------------------------------------- /src/entities/scope.entity.ts: -------------------------------------------------------------------------------- 1 | export interface OAuthScope { 2 | name: string; 3 | [key: string]: any; 4 | } 5 | -------------------------------------------------------------------------------- /src/entities/token.entity.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "./client.entity.js"; 2 | import { OAuthScope } from "./scope.entity.js"; 3 | import { OAuthUser } from "./user.entity.js"; 4 | 5 | export interface OAuthToken { 6 | accessToken: string; 7 | accessTokenExpiresAt: Date; 8 | refreshToken?: string | null; 9 | refreshTokenExpiresAt?: Date | null; 10 | client: OAuthClient; 11 | user?: OAuthUser | null; 12 | scopes: OAuthScope[]; 13 | originatingAuthCodeId?: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | export type OAuthUserIdentifier = string | number; 2 | 3 | export interface OAuthUser { 4 | id: OAuthUserIdentifier; 5 | [key: string]: any; 6 | } 7 | -------------------------------------------------------------------------------- /src/grants/abstract/abstract_authorized.grant.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "uri-js"; 2 | 3 | import { OAuthClient } from "../../entities/client.entity.js"; 4 | import { OAuthException } from "../../exceptions/oauth.exception.js"; 5 | import { RequestInterface } from "../../requests/request.js"; 6 | import { AbstractGrant } from "./abstract.grant.js"; 7 | import { urlsAreSameIgnoringPort } from "../../utils/urls.js"; 8 | 9 | export abstract class AbstractAuthorizedGrant extends AbstractGrant { 10 | protected makeRedirectUrl( 11 | uri: string, 12 | params: 13 | | URLSearchParams 14 | | string 15 | | Record> 16 | | Iterable<[string, string]> 17 | | ReadonlyArray<[string, string]>, 18 | queryDelimiter = "?", 19 | ): string { 20 | params = new URLSearchParams(params); 21 | const split = uri.includes(queryDelimiter) ? "&" : queryDelimiter; 22 | return uri + split + params.toString(); 23 | } 24 | 25 | protected getRedirectUri(request: RequestInterface, client: OAuthClient): string | undefined { 26 | let redirectUri = this.getQueryStringParameter("redirect_uri", request); 27 | 28 | if (!redirectUri) { 29 | return; 30 | } 31 | 32 | if (Array.isArray(redirectUri) && redirectUri.length === 1) { 33 | redirectUri = redirectUri[0]; 34 | } 35 | 36 | this.validateRedirectUri(redirectUri, client); 37 | 38 | return redirectUri; 39 | } 40 | 41 | private validateRedirectUri(redirectUri: any, client: OAuthClient) { 42 | if (typeof redirectUri !== "string" || redirectUri === "") { 43 | throw OAuthException.invalidParameter("redirect_uri"); 44 | } 45 | 46 | const parsed = parse(redirectUri); 47 | 48 | if (!parsed.scheme) { 49 | throw OAuthException.invalidParameter("redirect_uri"); 50 | } 51 | 52 | if (!!parsed.fragment) { 53 | throw OAuthException.invalidParameter( 54 | "redirect_uri", 55 | "Redirection endpoint must not contain url fragment based on RFC6749, section 3.1.2", 56 | ); 57 | } 58 | 59 | if (!client.redirectUris.some(uri => urlsAreSameIgnoringPort(redirectUri, uri))) { 60 | throw OAuthException.invalidClient("Invalid redirect_uri"); 61 | } 62 | 63 | return redirectUri; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/grants/abstract/custom.grant.ts: -------------------------------------------------------------------------------- 1 | import { AbstractGrant } from "./abstract.grant.js"; 2 | 3 | export abstract class CustomGrant extends AbstractGrant { 4 | abstract readonly identifier: `custom:${string}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/grants/abstract/grant.interface.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationServerOptions } from "../../authorization_server.js"; 2 | import { AuthorizationRequest } from "../../requests/authorization.request.js"; 3 | import { RequestInterface } from "../../requests/request.js"; 4 | import { ResponseInterface } from "../../responses/response.js"; 5 | import { DateInterval } from "../../utils/date_interval.js"; 6 | 7 | export type GrantIdentifier = 8 | | "authorization_code" 9 | | "client_credentials" 10 | | "refresh_token" 11 | | "password" 12 | | "implicit" 13 | | "urn:ietf:params:oauth:grant-type:token-exchange" 14 | | `custom:${string}`; 15 | 16 | export interface GrantInterface { 17 | readonly options: AuthorizationServerOptions; 18 | 19 | readonly identifier: GrantIdentifier; 20 | 21 | canRespondToAccessTokenRequest(request: RequestInterface): boolean; 22 | 23 | respondToAccessTokenRequest(request: RequestInterface, accessTokenTTL: DateInterval): Promise; 24 | 25 | canRespondToAuthorizationRequest(request: RequestInterface): boolean; 26 | 27 | validateAuthorizationRequest(request: RequestInterface): Promise; 28 | 29 | completeAuthorizationRequest(authorizationRequest: AuthorizationRequest): Promise; 30 | 31 | canRespondToRevokeRequest(request: RequestInterface): boolean; 32 | 33 | respondToRevokeRequest(request: RequestInterface): Promise; 34 | 35 | canRespondToIntrospectRequest(request: RequestInterface): boolean; 36 | 37 | respondToIntrospectRequest(request: RequestInterface): Promise; 38 | } 39 | -------------------------------------------------------------------------------- /src/grants/client_credentials.grant.ts: -------------------------------------------------------------------------------- 1 | import { RequestInterface } from "../requests/request.js"; 2 | import { OAuthResponse, ResponseInterface } from "../responses/response.js"; 3 | import { DateInterval } from "../utils/date_interval.js"; 4 | import { AbstractGrant, ParsedAccessToken, ParsedRefreshToken } from "./abstract/abstract.grant.js"; 5 | import { OAuthException } from "../exceptions/oauth.exception.js"; 6 | import { OAuthToken } from "../entities/token.entity.js"; 7 | import { OAuthTokenIntrospectionResponse } from "../authorization_server.js"; 8 | 9 | export class ClientCredentialsGrant extends AbstractGrant { 10 | readonly identifier = "client_credentials"; 11 | 12 | async respondToAccessTokenRequest(req: RequestInterface, accessTokenTTL: DateInterval): Promise { 13 | const client = await this.validateClient(req); 14 | 15 | const bodyScopes = this.getRequestParameter("scope", req, []); 16 | 17 | const finalizedScopes = await this.scopeRepository.finalize( 18 | await this.validateScopes(bodyScopes), 19 | this.identifier, 20 | client, 21 | ); 22 | 23 | const user = undefined; 24 | 25 | const accessToken = await this.issueAccessToken(accessTokenTTL, client, user, finalizedScopes); 26 | 27 | const jwtExtras = await this.extraJwtFields(req, client, user); 28 | 29 | return await this.makeBearerTokenResponse(client, accessToken, finalizedScopes, jwtExtras); 30 | } 31 | 32 | canRespondToIntrospectRequest(_request: RequestInterface): boolean { 33 | return true; 34 | } 35 | 36 | async respondToIntrospectRequest(req: RequestInterface): Promise { 37 | req.body["grant_type"] = this.identifier; 38 | 39 | if (this.options.authenticateIntrospect) await this.validateClient(req); 40 | 41 | const { parsedToken, oauthToken, expiresAt, tokenType } = await this.tokenFromRequest(req); 42 | 43 | const active = expiresAt > new Date(); 44 | 45 | let body: OAuthTokenIntrospectionResponse = { active: false }; 46 | 47 | if (active && oauthToken) { 48 | body = { 49 | active: true, 50 | scope: oauthToken.scopes.map(s => s.name).join(this.options.scopeDelimiter), 51 | client_id: oauthToken.client.id, 52 | token_type: tokenType, 53 | ...(typeof parsedToken === "object" ? parsedToken : {}), 54 | }; 55 | } 56 | 57 | return new OAuthResponse({ body }); 58 | } 59 | 60 | canRespondToRevokeRequest(request: RequestInterface): boolean { 61 | return this.getRequestParameter("token_type_hint", request) !== "auth_code"; 62 | } 63 | 64 | async respondToRevokeRequest(req: RequestInterface): Promise { 65 | req.body["grant_type"] = this.identifier; 66 | 67 | if (this.options.authenticateRevoke) await this.validateClient(req); 68 | 69 | let { oauthToken } = await this.tokenFromRequest(req); 70 | 71 | // Invalid tokens do not cause an error response since the client cannot handle such an error. 72 | // @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 73 | if (oauthToken) await this.tokenRepository.revoke(oauthToken).catch(); 74 | 75 | return new OAuthResponse(); 76 | } 77 | 78 | private readonly revokeTokenTypeHintRegExp = /^(access_token|refresh_token|auth_code)$/; 79 | 80 | private async tokenFromRequest(req: RequestInterface) { 81 | const token = this.getRequestParameter("token", req); 82 | 83 | if (!token) { 84 | throw OAuthException.invalidParameter("token", "Missing `token` parameter in request body"); 85 | } 86 | 87 | const tokenTypeHint = this.getRequestParameter("token_type_hint", req); 88 | 89 | if (typeof tokenTypeHint === "string" && !this.revokeTokenTypeHintRegExp.test(tokenTypeHint)) { 90 | throw OAuthException.unsupportedTokenType(); 91 | } 92 | 93 | const parsedToken: unknown = this.jwt.decode(token); 94 | 95 | let oauthToken: undefined | OAuthToken = undefined; 96 | let expiresAt = new Date(0); 97 | let tokenType: "access_token" | "refresh_token" = "access_token"; 98 | 99 | if (tokenTypeHint === "refresh_token" && this.isRefreshTokenPayload(parsedToken)) { 100 | oauthToken = await this.tokenRepository.getByRefreshToken(parsedToken.refresh_token_id).catch(); 101 | expiresAt = oauthToken?.refreshTokenExpiresAt ?? expiresAt; 102 | tokenType = "refresh_token"; 103 | } else if (this.isAccessTokenPayload(parsedToken)) { 104 | if (typeof this.tokenRepository.getByAccessToken !== "function") { 105 | throw OAuthException.internalServerError("TokenRepository#getByAccessToken is not implemented"); 106 | } 107 | 108 | // if token not found, ignore and return undefined oauthToken 109 | oauthToken = await this.tokenRepository.getByAccessToken(parsedToken.jti).catch(); 110 | expiresAt = oauthToken?.accessTokenExpiresAt ?? expiresAt; 111 | } 112 | 113 | return { parsedToken, oauthToken, expiresAt, tokenType }; 114 | } 115 | 116 | private isAccessTokenPayload(token: unknown): token is ParsedAccessToken { 117 | return typeof token === "object" && token !== null && "jti" in token; 118 | } 119 | 120 | private isRefreshTokenPayload(token: unknown): token is ParsedRefreshToken { 121 | return typeof token === "object" && token !== null && "refresh_token_id" in token; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/grants/implicit.grant.ts: -------------------------------------------------------------------------------- 1 | import { OAuthException } from "../exceptions/oauth.exception.js"; 2 | import { AuthorizationRequest } from "../requests/authorization.request.js"; 3 | import { RequestInterface } from "../requests/request.js"; 4 | import { RedirectResponse } from "../responses/redirect.response.js"; 5 | import { ResponseInterface } from "../responses/response.js"; 6 | import { DateInterval } from "../utils/date_interval.js"; 7 | import { getSecondsUntil } from "../utils/time.js"; 8 | import { AbstractAuthorizedGrant } from "./abstract/abstract_authorized.grant.js"; 9 | 10 | export class ImplicitGrant extends AbstractAuthorizedGrant { 11 | readonly identifier = "implicit"; 12 | 13 | private accessTokenTTL: DateInterval = new DateInterval("1h"); 14 | 15 | respondToAccessTokenRequest(_req: RequestInterface, _tokenTTL?: DateInterval): Promise { 16 | throw OAuthException.badRequest("The implicit grant can't respond to access token requests"); 17 | } 18 | 19 | canRespondToAuthorizationRequest(request: RequestInterface): boolean { 20 | return this.getQueryStringParameter("response_type", request) === "token"; 21 | } 22 | 23 | canRespondToAccessTokenRequest(_request: RequestInterface): boolean { 24 | return false; 25 | } 26 | 27 | async validateAuthorizationRequest(request: RequestInterface): Promise { 28 | const clientId = this.getQueryStringParameter("client_id", request); 29 | 30 | if (typeof clientId !== "string") { 31 | throw OAuthException.invalidParameter("client_id"); 32 | } 33 | 34 | const client = await this.clientRepository.getByIdentifier(clientId); 35 | 36 | if (!client) { 37 | throw OAuthException.invalidClient(); 38 | } 39 | 40 | const redirectUri = this.getRedirectUri(request, client); 41 | 42 | const scopes = await this.validateScopes( 43 | this.getQueryStringParameter("scope", request, []), // @see about this.defaultSCopes as third param 44 | redirectUri, 45 | ); 46 | 47 | const state = this.getQueryStringParameter("state", request); 48 | 49 | const authorizationRequest = new AuthorizationRequest(this.identifier, client, redirectUri); 50 | 51 | authorizationRequest.state = state; 52 | 53 | authorizationRequest.scopes = scopes; 54 | 55 | return authorizationRequest; 56 | } 57 | 58 | async completeAuthorizationRequest(authorizationRequest: AuthorizationRequest): Promise { 59 | if (!authorizationRequest.user || !authorizationRequest.user?.id) { 60 | throw OAuthException.badRequest("A user must be set on the AuthorizationRequest"); 61 | } 62 | 63 | let finalRedirectUri = authorizationRequest.redirectUri; 64 | 65 | if (!finalRedirectUri) { 66 | finalRedirectUri = authorizationRequest.client?.redirectUris[0]; 67 | } 68 | 69 | if (!finalRedirectUri) { 70 | throw OAuthException.invalidParameter( 71 | "redirect_uri", 72 | "Neither the request nor the client contain a valid refresh token", 73 | ); 74 | } 75 | 76 | if (!authorizationRequest.isAuthorizationApproved) { 77 | throw OAuthException.accessDenied(); 78 | } 79 | 80 | const finalizedScopes = await this.scopeRepository.finalize( 81 | authorizationRequest.scopes, 82 | this.identifier, 83 | authorizationRequest.client, 84 | authorizationRequest.user.id, 85 | ); 86 | 87 | const accessToken = await this.issueAccessToken( 88 | this.accessTokenTTL, 89 | authorizationRequest.client, 90 | authorizationRequest.user, 91 | finalizedScopes, 92 | ); 93 | 94 | const extraFields = await this.jwt.extraTokenFields?.({ 95 | user: authorizationRequest.user, 96 | client: authorizationRequest.client, 97 | }); 98 | 99 | const encryptedAccessToken = await this.encryptAccessToken( 100 | authorizationRequest.client, 101 | accessToken, 102 | authorizationRequest.scopes, 103 | extraFields ?? {}, 104 | ); 105 | 106 | const params: Record = { 107 | access_token: encryptedAccessToken, 108 | token_type: "Bearer", 109 | expires_in: getSecondsUntil(accessToken.accessTokenExpiresAt).toString(), 110 | }; 111 | 112 | if (authorizationRequest.state) params.state = authorizationRequest.state.toString(); 113 | 114 | return new RedirectResponse(this.makeRedirectUrl(finalRedirectUri, params)); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/grants/password.grant.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "../entities/client.entity.js"; 2 | import { OAuthUser } from "../entities/user.entity.js"; 3 | import { OAuthException } from "../exceptions/oauth.exception.js"; 4 | import { OAuthTokenRepository } from "../repositories/access_token.repository.js"; 5 | import { OAuthClientRepository } from "../repositories/client.repository.js"; 6 | import { OAuthScopeRepository } from "../repositories/scope.repository.js"; 7 | import { OAuthUserRepository } from "../repositories/user.repository.js"; 8 | import { RequestInterface } from "../requests/request.js"; 9 | import { ResponseInterface } from "../responses/response.js"; 10 | import { DateInterval } from "../utils/date_interval.js"; 11 | import { JwtInterface } from "../utils/jwt.js"; 12 | import { AbstractGrant } from "./abstract/abstract.grant.js"; 13 | import { AuthorizationServerOptions } from "../authorization_server.js"; 14 | 15 | export class PasswordGrant extends AbstractGrant { 16 | readonly identifier = "password"; 17 | 18 | constructor( 19 | protected readonly userRepository: OAuthUserRepository, 20 | clientRepository: OAuthClientRepository, 21 | tokenRepository: OAuthTokenRepository, 22 | scopeRepository: OAuthScopeRepository, 23 | jwt: JwtInterface, 24 | options: AuthorizationServerOptions, 25 | ) { 26 | super(clientRepository, tokenRepository, scopeRepository, jwt, options); 27 | } 28 | 29 | async respondToAccessTokenRequest(req: RequestInterface, accessTokenTTL: DateInterval): Promise { 30 | const client = await this.validateClient(req); 31 | 32 | const bodyScopes = this.getRequestParameter("scope", req, []); 33 | 34 | const user = await this.validateUser(req, client); 35 | 36 | const finalizedScopes = await this.scopeRepository.finalize( 37 | await this.validateScopes(bodyScopes), 38 | this.identifier, 39 | client, 40 | user.id, 41 | ); 42 | 43 | let accessToken = await this.issueAccessToken(accessTokenTTL, client, user, finalizedScopes); 44 | 45 | accessToken = await this.issueRefreshToken(accessToken, client); 46 | 47 | const extraJwtFields = await this.extraJwtFields(req, client, user); 48 | 49 | return await this.makeBearerTokenResponse(client, accessToken, finalizedScopes, extraJwtFields); 50 | } 51 | 52 | private async validateUser(request: RequestInterface, client: OAuthClient): Promise { 53 | const username = this.getRequestParameter("username", request); 54 | 55 | if (!username) { 56 | throw OAuthException.invalidParameter("username"); 57 | } 58 | 59 | const password = this.getRequestParameter("password", request); 60 | 61 | if (!password) { 62 | throw OAuthException.invalidParameter("password"); 63 | } 64 | 65 | const user = await this.userRepository.getUserByCredentials(username, password, this.identifier, client); 66 | 67 | if (!user) { 68 | throw OAuthException.invalidGrant(); 69 | } 70 | 71 | return user; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/grants/refresh_token.grant.ts: -------------------------------------------------------------------------------- 1 | import { OAuthToken } from "../entities/token.entity.js"; 2 | import { OAuthException } from "../exceptions/oauth.exception.js"; 3 | import { RequestInterface } from "../requests/request.js"; 4 | import { ResponseInterface } from "../responses/response.js"; 5 | import { DateInterval } from "../utils/date_interval.js"; 6 | import { AbstractGrant } from "./abstract/abstract.grant.js"; 7 | 8 | export class RefreshTokenGrant extends AbstractGrant { 9 | readonly identifier = "refresh_token"; 10 | 11 | async respondToAccessTokenRequest(req: RequestInterface, accessTokenTTL: DateInterval): Promise { 12 | const client = await this.validateClient(req); 13 | 14 | const oldToken = await this.validateOldRefreshToken(req, client.id); 15 | 16 | const user = oldToken.user; 17 | 18 | const scopes = await this.scopeRepository.finalize( 19 | await this.validateScopes( 20 | this.getRequestParameter( 21 | "scope", 22 | req, 23 | oldToken.scopes.map(s => s.name), 24 | ), 25 | ), 26 | this.identifier, 27 | client, 28 | user?.id, 29 | ); 30 | 31 | scopes.forEach(scope => { 32 | if (!oldToken.scopes.map(scope => scope.name).includes(scope.name)) { 33 | throw OAuthException.invalidScope(scope.name); 34 | } 35 | }); 36 | 37 | await this.tokenRepository.revoke(oldToken); 38 | 39 | let newToken = await this.issueAccessToken(accessTokenTTL, client, user, scopes); 40 | newToken.originatingAuthCodeId = oldToken.originatingAuthCodeId; 41 | 42 | newToken = await this.issueRefreshToken(newToken, client); 43 | 44 | const extraJwtFields = await this.extraJwtFields(req, client); 45 | 46 | return await this.makeBearerTokenResponse(client, newToken, scopes, extraJwtFields); 47 | } 48 | 49 | private async validateOldRefreshToken(request: RequestInterface, clientId: string): Promise { 50 | const encryptedRefreshToken = this.getRequestParameter("refresh_token", request); 51 | 52 | if (!encryptedRefreshToken) { 53 | throw OAuthException.invalidParameter("refresh_token"); 54 | } 55 | 56 | let refreshTokenData: any; 57 | 58 | try { 59 | refreshTokenData = await this.decrypt(encryptedRefreshToken); 60 | } catch (e) { 61 | if (e instanceof Error && e.message === "invalid signature") { 62 | throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token"); 63 | } 64 | throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token"); 65 | } 66 | 67 | if (!refreshTokenData?.refresh_token_id) { 68 | throw OAuthException.invalidParameter("refresh_token", "Token missing"); 69 | } 70 | 71 | if (refreshTokenData?.client_id !== clientId) { 72 | throw OAuthException.invalidParameter("refresh_token", "Token is not linked to client"); 73 | } 74 | 75 | if (Date.now() / 1000 > refreshTokenData?.expire_time) { 76 | throw OAuthException.invalidParameter("refresh_token", "Token has expired"); 77 | } 78 | 79 | const refreshToken = await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id); 80 | 81 | if (await this.tokenRepository.isRefreshTokenRevoked(refreshToken)) { 82 | throw OAuthException.invalidParameter("refresh_token", "Token has been revoked"); 83 | } 84 | 85 | return refreshToken; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/grants/token_exchange.grant.ts: -------------------------------------------------------------------------------- 1 | import { RequestInterface } from "../requests/request.js"; 2 | import { ResponseInterface } from "../responses/response.js"; 3 | import { DateInterval } from "../utils/date_interval.js"; 4 | import { AbstractGrant } from "./abstract/abstract.grant.js"; 5 | import { OAuthClientRepository } from "../repositories/client.repository.js"; 6 | import { OAuthTokenRepository } from "../repositories/access_token.repository.js"; 7 | import { OAuthScopeRepository } from "../repositories/scope.repository.js"; 8 | import { JwtInterface } from "../utils/jwt.js"; 9 | import { AuthorizationServerOptions } from "../authorization_server.js"; 10 | import { OAuthUser } from "../entities/user.entity.js"; 11 | import { OAuthException } from "../exceptions/oauth.exception.js"; 12 | import { OAuthScope } from "../entities/scope.entity.js"; 13 | 14 | export type ProcessTokenExchangeArgs = { 15 | resource?: string; 16 | audience?: string; 17 | scopes: OAuthScope[]; 18 | requestedTokenType?: string; 19 | subjectToken: string; 20 | subjectTokenType: `urn:${string}:oauth:token-type:${string}`; 21 | } & ({ actorToken: string; actorTokenType: string } | { actorToken?: never; actorTokenType?: never }); 22 | 23 | export type ProcessTokenExchangeFn = (args: ProcessTokenExchangeArgs) => Promise; 24 | 25 | export class TokenExchangeGrant extends AbstractGrant { 26 | readonly identifier = "urn:ietf:params:oauth:grant-type:token-exchange"; 27 | 28 | readonly SUBJECT_TOKEN_TYPE_REGEX = /^urn:.+:oauth:token-type:.+$/; 29 | 30 | constructor( 31 | private readonly processTokenExchangeFn: ProcessTokenExchangeFn, 32 | clientRepository: OAuthClientRepository, 33 | tokenRepository: OAuthTokenRepository, 34 | scopeRepository: OAuthScopeRepository, 35 | jwt: JwtInterface, 36 | options: AuthorizationServerOptions, 37 | ) { 38 | super(clientRepository, tokenRepository, scopeRepository, jwt, options); 39 | } 40 | 41 | async respondToAccessTokenRequest(req: RequestInterface, accessTokenTTL: DateInterval): Promise { 42 | const client = await this.validateClient(req); 43 | 44 | const subjectToken = this.getRequestParameter("subject_token", req); 45 | 46 | if (typeof subjectToken !== "string") { 47 | throw OAuthException.badRequest("subject_token is required"); 48 | } 49 | 50 | const subjectTokenType = this.getRequestParameter("subject_token_type", req); 51 | 52 | if (!this.isSubjectTokenType(subjectTokenType)) { 53 | // https://datatracker.ietf.org/doc/html/rfc8693#section-3 54 | throw OAuthException.badRequest(`subject_token_type is required in format ${this.SUBJECT_TOKEN_TYPE_REGEX}`); 55 | } 56 | 57 | const actorToken = this.getRequestParameter("actor_token", req); 58 | 59 | const actorTokenType = this.getRequestParameter("actor_token_type", req); 60 | 61 | if (actorToken && !actorTokenType) { 62 | throw OAuthException.badRequest("actor_token_type is required when the actor_token parameter is present"); 63 | } 64 | 65 | const bodyScopes = this.getRequestParameter("scope", req, []); 66 | 67 | const validScopes = await this.validateScopes(bodyScopes); 68 | 69 | const user = await this.processTokenExchangeFn({ 70 | resource: this.getRequestParameter("resource", req), 71 | audience: this.getRequestParameter("audience", req), 72 | scopes: validScopes, 73 | requestedTokenType: this.getRequestParameter("requested_token_type", req), 74 | subjectToken, 75 | subjectTokenType, 76 | actorToken, 77 | actorTokenType, 78 | }); 79 | 80 | const accessToken = await this.issueAccessToken(accessTokenTTL, client, user, validScopes); 81 | 82 | const extraJwtFields = await this.extraJwtFields(req, client, user); 83 | 84 | return await this.makeBearerTokenResponse(client, accessToken, validScopes, extraJwtFields); 85 | } 86 | 87 | private isSubjectTokenType(value: string): value is `urn:${string}:oauth:token-type:${string}` { 88 | return this.SUBJECT_TOKEN_TYPE_REGEX.test(value); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./authorization_server.js"; 2 | export * from "./entities/auth_code.entity.js"; 3 | export * from "./entities/client.entity.js"; 4 | export * from "./entities/scope.entity.js"; 5 | export * from "./entities/token.entity.js"; 6 | export * from "./entities/user.entity.js"; 7 | export * from "./exceptions/oauth.exception.js"; 8 | export * from "./repositories/access_token.repository.js"; 9 | export * from "./repositories/auth_code.repository.js"; 10 | export * from "./repositories/client.repository.js"; 11 | export * from "./repositories/scope.repository.js"; 12 | export * from "./repositories/user.repository.js"; 13 | export * from "./requests/authorization.request.js"; 14 | export * from "./requests/request.js"; 15 | export * from "./responses/response.js"; 16 | export * from "./code_verifiers/verifier.js"; 17 | export * from "./utils/base64.js"; 18 | export * from "./utils/date_interval.js"; 19 | export * from "./utils/errors.js"; 20 | export * from "./utils/jwt.js"; 21 | export * from "./utils/scopes.js"; 22 | export * from "./utils/time.js"; 23 | export * from "./utils/token.js"; 24 | 25 | /** 26 | * These should probably not be exported... 27 | */ 28 | export * from "./grants/auth_code.grant.js"; 29 | export * from "./grants/client_credentials.grant.js"; 30 | export * from "./grants/implicit.grant.js"; 31 | export * from "./grants/password.grant.js"; 32 | export * from "./grants/refresh_token.grant.js"; 33 | export * from "./grants/token_exchange.grant.js"; 34 | export * from "./grants/abstract/abstract.grant.js"; 35 | export * from "./grants/abstract/abstract_authorized.grant.js"; 36 | export * from "./grants/abstract/grant.interface.js"; 37 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationServerOptions } from "./authorization_server.js"; 2 | 3 | export const DEFAULT_AUTHORIZATION_SERVER_OPTIONS: AuthorizationServerOptions = { 4 | requiresPKCE: true, 5 | requiresS256: true, 6 | notBeforeLeeway: 0, 7 | tokenCID: "id", 8 | issuer: undefined, 9 | scopeDelimiter: " ", 10 | authenticateIntrospect: true, 11 | authenticateRevoke: true, 12 | }; 13 | -------------------------------------------------------------------------------- /src/repositories/access_token.repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "../entities/client.entity.js"; 2 | import { OAuthScope } from "../entities/scope.entity.js"; 3 | import { OAuthToken } from "../entities/token.entity.js"; 4 | import { OAuthUser } from "../entities/user.entity.js"; 5 | 6 | export interface OAuthTokenRepository { 7 | issueToken(client: OAuthClient, scopes: OAuthScope[], user?: OAuthUser | null): Promise; 8 | 9 | issueRefreshToken(accessToken: OAuthToken, client: OAuthClient): Promise; 10 | 11 | persist(accessToken: OAuthToken): Promise; 12 | 13 | revoke(accessToken: OAuthToken): Promise; 14 | 15 | revokeDescendantsOf?(authCodeId: string): Promise; 16 | 17 | isRefreshTokenRevoked(refreshToken: OAuthToken): Promise; 18 | 19 | getByRefreshToken(refreshTokenToken: string): Promise; 20 | 21 | /** 22 | * (Optional) Required if using /introspect RFC7662 "OAuth 2.0 Token Introspection" 23 | * @see https://tsoauth2server.com/docs/getting_started/endpoints#the-introspect-endpoint 24 | * @param accessTokenToken 25 | */ 26 | getByAccessToken?(accessTokenToken: string): Promise; 27 | } 28 | -------------------------------------------------------------------------------- /src/repositories/auth_code.repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthAuthCode } from "../entities/auth_code.entity.js"; 2 | import { OAuthClient } from "../entities/client.entity.js"; 3 | import { OAuthScope } from "../entities/scope.entity.js"; 4 | import { OAuthUser } from "../entities/user.entity.js"; 5 | 6 | export interface OAuthAuthCodeRepository { 7 | getByIdentifier(authCodeCode: string): Promise; 8 | 9 | issueAuthCode( 10 | client: OAuthClient, 11 | user: OAuthUser | undefined, 12 | scopes: OAuthScope[], 13 | ): OAuthAuthCode | Promise; 14 | 15 | persist(authCode: OAuthAuthCode): Promise; 16 | 17 | isRevoked(authCodeCode: string): Promise; 18 | revoke(authCodeCode: string): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /src/repositories/client.repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "../entities/client.entity.js"; 2 | import { GrantIdentifier } from "../grants/abstract/grant.interface.js"; 3 | 4 | export interface OAuthClientRepository { 5 | getByIdentifier(clientId: string): Promise; 6 | 7 | isClientValid(grantType: GrantIdentifier, client: OAuthClient, clientSecret?: string): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/repositories/scope.repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "../entities/client.entity.js"; 2 | import { OAuthScope } from "../entities/scope.entity.js"; 3 | import { OAuthUserIdentifier } from "../entities/user.entity.js"; 4 | import { GrantIdentifier } from "../grants/abstract/grant.interface.js"; 5 | 6 | export interface OAuthScopeRepository { 7 | getAllByIdentifiers(scopeNames: string[]): Promise; 8 | 9 | finalize( 10 | scopes: OAuthScope[], 11 | identifier: GrantIdentifier, 12 | client: OAuthClient, 13 | user_id?: OAuthUserIdentifier, 14 | ): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /src/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from "../entities/client.entity.js"; 2 | import { OAuthUser, OAuthUserIdentifier } from "../entities/user.entity.js"; 3 | import { GrantIdentifier } from "../grants/abstract/grant.interface.js"; 4 | 5 | export interface OAuthUserRepository { 6 | getUserByCredentials( 7 | identifier: OAuthUserIdentifier, 8 | password?: string, 9 | grantType?: GrantIdentifier, 10 | client?: OAuthClient, 11 | ): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/requests/authorization.request.ts: -------------------------------------------------------------------------------- 1 | import { CodeChallengeMethod } from "../code_verifiers/verifier.js"; 2 | import { OAuthClient } from "../entities/client.entity.js"; 3 | import { OAuthScope } from "../entities/scope.entity.js"; 4 | import { OAuthUser } from "../entities/user.entity.js"; 5 | import { OAuthException } from "../exceptions/oauth.exception.js"; 6 | import { GrantIdentifier } from "../grants/abstract/grant.interface.js"; 7 | 8 | export class AuthorizationRequest { 9 | scopes: OAuthScope[] = []; 10 | isAuthorizationApproved: boolean; 11 | redirectUri: string | undefined; 12 | state?: string; 13 | codeChallenge?: string; 14 | codeChallengeMethod?: CodeChallengeMethod; 15 | 16 | constructor( 17 | public readonly grantTypeId: GrantIdentifier, 18 | public readonly client: OAuthClient, 19 | redirectUri?: string, 20 | public user?: OAuthUser, 21 | public audience?: string[] | string | null, 22 | ) { 23 | this.scopes = []; 24 | this.isAuthorizationApproved = false; 25 | this.redirectUri = redirectUri ?? client.redirectUris[0]; 26 | if (!this.redirectUri) throw OAuthException.badRequest("Unknown redirect_uri"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/requests/request.ts: -------------------------------------------------------------------------------- 1 | import { Headers, Options } from "../responses/response.js"; 2 | 3 | export interface RequestInterface { 4 | headers: { [key: string]: any }; 5 | query: { [key: string]: any }; 6 | body: { [key: string]: any }; 7 | } 8 | 9 | export class OAuthRequest implements RequestInterface { 10 | body: { [key: string]: any }; 11 | headers: Headers = {}; 12 | query: { [key: string]: any }; 13 | 14 | constructor(options: Options = {}) { 15 | this.headers = { 16 | ...options.headers, 17 | }; 18 | this.query = { 19 | ...options.query, 20 | }; 21 | this.body = { 22 | ...options.body, 23 | }; 24 | } 25 | 26 | set(fieldOrHeaders: string, value: any): void { 27 | this.headers[fieldOrHeaders.toLowerCase()] = value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/responses/bearer_token.response.ts: -------------------------------------------------------------------------------- 1 | import { OAuthToken } from "../entities/token.entity.js"; 2 | import { HttpStatus } from "../exceptions/oauth.exception.js"; 3 | import { OAuthResponse, Options } from "./response.js"; 4 | 5 | export class BearerTokenResponse extends OAuthResponse { 6 | readonly status = HttpStatus.OK; 7 | 8 | constructor( 9 | public readonly accessToken: OAuthToken, 10 | options?: Options, 11 | ) { 12 | super(options); 13 | 14 | this.set("pragma", "no-cache"); 15 | this.set("cache-control", "no-store"); 16 | this.set("content-type", "application/json; charset=UTF-8"); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/responses/redirect.response.ts: -------------------------------------------------------------------------------- 1 | import { OAuthResponse, Options } from "./response.js"; 2 | 3 | export class RedirectResponse extends OAuthResponse { 4 | constructor(redirectUri: string, options?: Options) { 5 | super(options); 6 | this.set("Location", redirectUri); 7 | this.status = 302; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/responses/response.ts: -------------------------------------------------------------------------------- 1 | export interface Headers { 2 | location?: string; 3 | [key: string]: any; 4 | } 5 | 6 | export interface Options { 7 | headers?: Headers; 8 | body?: { [key: string]: any }; 9 | query?: { [key: string]: any }; 10 | status?: number; 11 | [key: string]: any; 12 | } 13 | 14 | export interface ResponseInterface { 15 | status: number; 16 | headers: { [key: string]: any }; 17 | body: { [key: string]: any }; 18 | 19 | get(field: string): string; 20 | 21 | set(field: string, value: string): void; 22 | } 23 | 24 | export class OAuthResponse implements ResponseInterface { 25 | status: number; 26 | body: Record; 27 | headers: Headers; 28 | 29 | constructor(responseOptions: Options = { headers: {} }) { 30 | this.headers = responseOptions.headers ?? {}; 31 | this.body = responseOptions.body ?? {}; 32 | this.status = responseOptions.status ?? 200; 33 | } 34 | 35 | get(field: string): any { 36 | return this.headers[field.toLowerCase()]; 37 | } 38 | 39 | set(fieldOrHeaders: string, value: any): void { 40 | this.headers[fieldOrHeaders.toLowerCase()] = value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const arrayDiff = (arr1: T[], arr2: T[]): T[] => arr1.filter(x => !arr2.includes(x)); 2 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | export function base64encode(str: string | Buffer): string { 2 | if (typeof str === "string") str = Buffer.from(str); 3 | return str.toString("base64"); 4 | } 5 | 6 | export function base64decode(str: string | Buffer): string { 7 | if (typeof str === "string") str = Buffer.from(str, "base64"); 8 | return str.toString("binary"); 9 | } 10 | 11 | export function base64urlencode(str: string | Buffer): string { 12 | return base64encode(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/date_interval.ts: -------------------------------------------------------------------------------- 1 | import ms from "ms"; 2 | 3 | export type DateIntervalType = string; 4 | 5 | export class DateInterval { 6 | public readonly ms: number; 7 | 8 | constructor(interval: DateIntervalType) { 9 | this.ms = ms(interval); 10 | } 11 | 12 | getEndDate(): Date { 13 | return new Date(this.getEndTimeMs()); 14 | } 15 | 16 | getEndTimeMs(): number { 17 | return Date.now() + this.ms; 18 | } 19 | 20 | getEndTimeSeconds(): number { 21 | return Math.ceil(this.getEndTimeMs() / 1000); 22 | } 23 | 24 | getSeconds(): number { 25 | return Math.ceil(this.ms / 1000); 26 | } 27 | 28 | static getDateEnd(ms: string): Date { 29 | return new DateInterval(ms).getEndDate(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { OAuthException } from "../exceptions/oauth.exception.js"; 2 | 3 | export function isOAuthError(error: unknown): error is OAuthException { 4 | if (!error) return false; 5 | if (typeof error !== "object") return false; 6 | return "oauth" in error; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt, { Secret, SignOptions, VerifyOptions } from "jsonwebtoken"; 2 | import { OAuthClient } from "../entities/client.entity.js"; 3 | import { OAuthUser } from "../entities/user.entity.js"; 4 | 5 | export type ExtraAccessTokenFields = Record; 6 | export type ExtraAccessTokenFieldArgs = { 7 | user?: OAuthUser | null; 8 | client: OAuthClient; 9 | }; 10 | export interface JwtInterface { 11 | verify(token: string, options?: VerifyOptions): Promise>; 12 | decode(encryptedData: string): null | Record | string; 13 | sign(payload: string | Buffer | Record, options?: SignOptions): Promise; 14 | extraTokenFields?(params: ExtraAccessTokenFieldArgs): ExtraAccessTokenFields | Promise; 15 | } 16 | 17 | export class JwtService implements JwtInterface { 18 | constructor(private readonly secretOrPrivateKey: Secret) {} 19 | 20 | /** 21 | * Asynchronously verify given token using a secret or a public key to get a decoded token 22 | */ 23 | verify(token: string, options: VerifyOptions = {}): Promise> { 24 | return new Promise((resolve, reject) => { 25 | jwt.verify(token, this.secretOrPrivateKey, options, (err, decoded: any) => { 26 | if (decoded) resolve(decoded); 27 | else reject(err); 28 | }); 29 | }); 30 | } 31 | 32 | /** 33 | * Returns the decoded payload without verifying if the signature is valid. 34 | */ 35 | decode(encryptedData: string): null | { [key: string]: any } | string { 36 | return jwt.decode(encryptedData); 37 | } 38 | 39 | /** 40 | * Sign the given payload into a JSON Web Token string 41 | */ 42 | sign(payload: string | Buffer | Record): Promise { 43 | return new Promise((resolve, reject) => { 44 | jwt.sign(payload, this.secretOrPrivateKey, (err, encoded) => { 45 | if (encoded) resolve(encoded); 46 | else reject(err); 47 | }); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/scopes.ts: -------------------------------------------------------------------------------- 1 | import { OAuthScope } from "../entities/scope.entity.js"; 2 | import { OAuthClient } from "../entities/client.entity.js"; 3 | import { OAuthException } from "../exceptions/oauth.exception.js"; 4 | 5 | export function guardAgainstInvalidClientScopes(client: OAuthClient, scopes: OAuthScope[]): void { 6 | const requestedScopes = scopes.map(scope => scope.name); 7 | const allowedClientScopes = client.scopes.map(scope => scope.name); 8 | const invalidScopes = requestedScopes.filter(x => !allowedClientScopes.includes(x)); 9 | 10 | if (invalidScopes.length > 0) { 11 | throw OAuthException.unauthorizedScope(invalidScopes.join(", ")); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function getSecondsUntil(end: Date, start: Date = new Date()): number { 2 | const time = end.getTime() - start.getTime(); 3 | return Math.floor(time / 1000); 4 | } 5 | 6 | export function roundToSeconds(ms: Date | number): number { 7 | if (ms instanceof Date) ms = ms.getTime(); 8 | return Math.floor(ms / 1000); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/token.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "crypto"; 2 | 3 | export function generateRandomToken(len = 80): string { 4 | return randomBytes(len / 2).toString("hex"); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/urls.ts: -------------------------------------------------------------------------------- 1 | export function urlsAreSameIgnoringPort(url1: string, url2: string): boolean { 2 | try { 3 | const parsedUrl1 = new URL(url1); 4 | const parsedUrl2 = new URL(url2); 5 | 6 | // Compare protocol, hostname, and pathname to ensure URLs are the same, ignoring port 7 | return ( 8 | parsedUrl1.protocol === parsedUrl2.protocol && 9 | parsedUrl1.hostname === parsedUrl2.hostname && 10 | parsedUrl1.pathname === parsedUrl2.pathname 11 | ); 12 | } catch (error) { 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/e2e/_helpers/in_memory/database.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach } from "vitest"; 2 | 3 | import { OAuthAuthCode } from "../../../../src/entities/auth_code.entity.js"; 4 | import { OAuthClient } from "../../../../src/entities/client.entity.js"; 5 | import { OAuthScope } from "../../../../src/entities/scope.entity.js"; 6 | import { OAuthToken } from "../../../../src/entities/token.entity.js"; 7 | import { OAuthUser } from "../../../../src/entities/user.entity.js"; 8 | 9 | export interface InMemory { 10 | users: { [id: string]: OAuthUser }; 11 | clients: { [id: string]: OAuthClient }; 12 | authCodes: { [id: string]: OAuthAuthCode }; 13 | tokens: { [id: string]: OAuthToken }; 14 | scopes: { [id: string]: OAuthScope }; 15 | 16 | flush(): void; 17 | } 18 | 19 | export const inMemoryDatabase: InMemory = { 20 | clients: {}, 21 | authCodes: {}, 22 | tokens: {}, 23 | scopes: {}, 24 | users: {}, 25 | flush() { 26 | this.clients = {}; 27 | this.authCodes = {}; 28 | this.tokens = {}; 29 | this.scopes = {}; 30 | this.users = {}; 31 | }, 32 | }; 33 | 34 | beforeEach(() => { 35 | inMemoryDatabase.flush(); 36 | }); 37 | -------------------------------------------------------------------------------- /test/e2e/_helpers/in_memory/oauth_authorization_server.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationServer } from "../../../../src/authorization_server.js"; 2 | import { DateInterval } from "../../../../src/utils/date_interval.js"; 3 | import { JwtService } from "../../../../src/utils/jwt.js"; 4 | import { 5 | inMemoryAccessTokenRepository, 6 | inMemoryAuthCodeRepository, 7 | inMemoryClientRepository, 8 | inMemoryScopeRepository, 9 | inMemoryUserRepository, 10 | } from "./repository.js"; 11 | 12 | const clientRepository = inMemoryClientRepository; 13 | const authCodeRepository = inMemoryAuthCodeRepository; 14 | const tokenRepository = inMemoryAccessTokenRepository; 15 | const scopeRepository = inMemoryScopeRepository; 16 | const userRepository = inMemoryUserRepository; 17 | 18 | export const testingJwtService = new JwtService("secret secret secret"); 19 | 20 | const authorizationServer = new AuthorizationServer( 21 | clientRepository, 22 | tokenRepository, 23 | scopeRepository, 24 | testingJwtService, 25 | ); 26 | 27 | authorizationServer.enableGrantType( 28 | { grant: "authorization_code", authCodeRepository, userRepository }, 29 | new DateInterval("1m"), 30 | ); 31 | authorizationServer.enableGrantType("client_credentials", new DateInterval("1m")); 32 | authorizationServer.enableGrantType("refresh_token", new DateInterval("1m")); 33 | authorizationServer.enableGrantType("implicit", new DateInterval("1m")); 34 | authorizationServer.enableGrantType({ grant: "password", userRepository }, new DateInterval("1m")); 35 | 36 | export { authorizationServer as inMemoryAuthorizationServer }; 37 | -------------------------------------------------------------------------------- /test/e2e/_helpers/in_memory/repository.ts: -------------------------------------------------------------------------------- 1 | import { OAuthAuthCode } from "../../../../src/entities/auth_code.entity.js"; 2 | import { OAuthClient } from "../../../../src/entities/client.entity.js"; 3 | import { OAuthScope } from "../../../../src/entities/scope.entity.js"; 4 | import { OAuthToken } from "../../../../src/entities/token.entity.js"; 5 | import { OAuthUser } from "../../../../src/entities/user.entity.js"; 6 | import { GrantIdentifier } from "../../../../src/grants/abstract/grant.interface.js"; 7 | import { OAuthTokenRepository } from "../../../../src/repositories/access_token.repository.js"; 8 | import { OAuthAuthCodeRepository } from "../../../../src/repositories/auth_code.repository.js"; 9 | import { OAuthClientRepository } from "../../../../src/repositories/client.repository.js"; 10 | import { OAuthScopeRepository } from "../../../../src/repositories/scope.repository.js"; 11 | import { OAuthUserRepository } from "../../../../src/repositories/user.repository.js"; 12 | import { guardAgainstInvalidClientScopes } from "../../../../src/utils/scopes.js"; 13 | import { DateInterval } from "../../../../src/utils/date_interval.js"; 14 | import { inMemoryDatabase } from "./database.js"; 15 | 16 | const oneHourInFuture = new DateInterval("1h").getEndDate(); 17 | 18 | export const inMemoryClientRepository: OAuthClientRepository = { 19 | async getByIdentifier(clientId: string): Promise { 20 | return inMemoryDatabase.clients[clientId]; 21 | }, 22 | 23 | async isClientValid(grantType: GrantIdentifier, client: OAuthClient, clientSecret?: string): Promise { 24 | if (client.secret !== clientSecret) { 25 | return false; 26 | } 27 | 28 | if (!client.allowedGrants.includes(grantType)) { 29 | return false; 30 | } 31 | 32 | return true; 33 | }, 34 | }; 35 | 36 | export const inMemoryScopeRepository: OAuthScopeRepository = { 37 | async getAllByIdentifiers(scopeNames: string[]): Promise { 38 | return Object.values(inMemoryDatabase.scopes).filter(scope => scopeNames.includes(scope.name)); 39 | }, 40 | async finalize( 41 | scopes: OAuthScope[], 42 | _identifier: GrantIdentifier, 43 | client: OAuthClient, 44 | _user_id?: string, 45 | ): Promise { 46 | guardAgainstInvalidClientScopes(client, scopes); 47 | 48 | return scopes; 49 | }, 50 | }; 51 | 52 | export const inMemoryAccessTokenRepository: OAuthTokenRepository = { 53 | async revoke(accessToken: OAuthToken): Promise { 54 | const token = inMemoryDatabase.tokens[accessToken.accessToken]; 55 | token.accessTokenExpiresAt = new Date(0); 56 | token.refreshTokenExpiresAt = new Date(0); 57 | inMemoryDatabase.tokens[accessToken.accessToken] = token; 58 | }, 59 | async issueToken(client: OAuthClient, _scopes: OAuthScope[], user: OAuthUser): Promise { 60 | return { 61 | accessToken: "new token", 62 | accessTokenExpiresAt: oneHourInFuture, 63 | client, 64 | user, 65 | scopes: [], 66 | }; 67 | }, 68 | async persist(accessToken: OAuthToken): Promise { 69 | inMemoryDatabase.tokens[accessToken.accessToken] = accessToken; 70 | }, 71 | async getByRefreshToken(refreshTokenToken: string): Promise { 72 | const token = Object.values(inMemoryDatabase.tokens).find(token => token.refreshToken === refreshTokenToken); 73 | if (!token) throw new Error("token not found"); 74 | return token; 75 | }, 76 | async getByAccessToken(accessToken: string): Promise { 77 | return inMemoryDatabase.tokens[accessToken]; 78 | }, 79 | async isRefreshTokenRevoked(token: OAuthToken): Promise { 80 | return Date.now() > (token.refreshTokenExpiresAt?.getTime() ?? 0); 81 | }, 82 | async issueRefreshToken(token: OAuthToken, _: OAuthClient): Promise { 83 | token.refreshToken = "refreshtokentoken"; 84 | token.refreshTokenExpiresAt = new DateInterval("1h").getEndDate(); 85 | inMemoryDatabase.tokens[token.accessToken] = token; 86 | return token; 87 | }, 88 | }; 89 | 90 | export const inMemoryAuthCodeRepository: OAuthAuthCodeRepository = { 91 | issueAuthCode(client: OAuthClient, user: OAuthUser | undefined, _scopes: OAuthScope[]): OAuthAuthCode { 92 | return { 93 | code: "my-super-secret-auth-code", 94 | user, 95 | client, 96 | redirectUri: "", 97 | codeChallenge: undefined, 98 | codeChallengeMethod: undefined, 99 | expiresAt: oneHourInFuture, 100 | scopes: [], 101 | }; 102 | }, 103 | async persist(authCode: OAuthAuthCode): Promise { 104 | inMemoryDatabase.authCodes[authCode.code] = authCode; 105 | }, 106 | async isRevoked(authCodeCode: string): Promise { 107 | const authCode = await this.getByIdentifier(authCodeCode); 108 | return Date.now() > authCode.expiresAt.getTime(); 109 | }, 110 | async getByIdentifier(authCodeCode: string): Promise { 111 | return inMemoryDatabase.authCodes[authCodeCode]; 112 | }, 113 | async revoke(authCodeCode: string): Promise { 114 | inMemoryDatabase.authCodes[authCodeCode].expiresAt = new Date(0); 115 | }, 116 | }; 117 | 118 | export const inMemoryUserRepository: OAuthUserRepository = { 119 | async getUserByCredentials( 120 | identifier: string, 121 | password?: string, 122 | _grantType?: GrantIdentifier, 123 | _client?: OAuthClient, 124 | ): Promise { 125 | const user = inMemoryDatabase.users[identifier]; 126 | if (user?.password !== password) return; 127 | return user; 128 | }, 129 | }; 130 | -------------------------------------------------------------------------------- /test/e2e/adapters/express.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { Request, Response } from "express"; 3 | import { 4 | requestFromExpress, 5 | responseFromExpress, 6 | handleExpressResponse, 7 | handleExpressError, 8 | } from "../../../src/adapters/express.js"; 9 | import { ErrorType, OAuthException, OAuthRequest, OAuthResponse } from "../../../src/index.js"; 10 | 11 | describe("adapters/express.js", () => { 12 | describe("responseFromExpress", () => { 13 | it("should create an OAuthResponse from an Express Response", () => { 14 | const mockExpressRes = {} as Response; 15 | const result = responseFromExpress(mockExpressRes); 16 | expect(result).toBeInstanceOf(OAuthResponse); 17 | }); 18 | }); 19 | 20 | describe("requestFromExpress", () => { 21 | it("should create an OAuthRequest from an Express Request", () => { 22 | const mockExpressReq = {} as Request; 23 | const result = requestFromExpress(mockExpressReq); 24 | expect(result).toBeInstanceOf(OAuthRequest); 25 | }); 26 | }); 27 | 28 | describe("handleExpressResponse", () => { 29 | it("should handle redirect responses", () => { 30 | const mockExpressRes = { 31 | set: vi.fn(), 32 | redirect: vi.fn(), 33 | } as unknown as Response; 34 | 35 | const mockOAuthRes = { 36 | status: 302, 37 | headers: { location: "https://example.com" }, 38 | } as OAuthResponse; 39 | 40 | handleExpressResponse(mockExpressRes, mockOAuthRes); 41 | 42 | expect(mockExpressRes.set).toHaveBeenCalledWith(mockOAuthRes.headers); 43 | expect(mockExpressRes.redirect).toHaveBeenCalledWith("https://example.com"); 44 | }); 45 | 46 | it("should handle non-redirect responses", () => { 47 | const mockExpressRes = { 48 | set: vi.fn(), 49 | status: vi.fn().mockReturnThis(), 50 | send: vi.fn(), 51 | } as unknown as Response; 52 | 53 | const mockOAuthRes = { 54 | status: 200, 55 | headers: { "content-type": "application/json" }, 56 | body: { message: "Success" }, 57 | } as unknown as OAuthResponse; 58 | 59 | handleExpressResponse(mockExpressRes, mockOAuthRes); 60 | 61 | expect(mockExpressRes.set).toHaveBeenCalledWith(mockOAuthRes.headers); 62 | expect(mockExpressRes.status).toHaveBeenCalledWith(200); 63 | expect(mockExpressRes.send).toHaveBeenCalledWith({ message: "Success" }); 64 | }); 65 | 66 | it("should throw an error for redirect without location", () => { 67 | const mockExpressRes = {} as Response; 68 | const mockOAuthRes = { 69 | status: 302, 70 | headers: {}, 71 | } as OAuthResponse; 72 | 73 | expect(() => handleExpressResponse(mockExpressRes, mockOAuthRes)).toThrow("missing redirect location"); 74 | }); 75 | }); 76 | 77 | describe("handleExpressError", () => { 78 | it("should handle OAuthException", () => { 79 | const mockExpressRes = { 80 | status: vi.fn().mockReturnThis(), 81 | send: vi.fn(), 82 | } as unknown as Response; 83 | 84 | const oauthError = new OAuthException("Test error", ErrorType.InternalServerError, undefined, undefined, 400); 85 | 86 | handleExpressError(oauthError, mockExpressRes); 87 | 88 | expect(mockExpressRes.status).toHaveBeenCalledWith(400); 89 | expect(mockExpressRes.send).toHaveBeenCalledWith({ 90 | status: 400, 91 | message: "Test error", 92 | error: "server_error", 93 | error_description: "Test error", 94 | }); 95 | }); 96 | 97 | it("should rethrow non-OAuthException errors", () => { 98 | const mockExpressRes = {} as Response; 99 | const error = new Error("Regular error"); 100 | 101 | expect(() => handleExpressError(error, mockExpressRes)).toThrow("Regular error"); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/e2e/adapters/fastify.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { FastifyReply, FastifyRequest } from "fastify"; 3 | import { 4 | handleFastifyError, 5 | handleFastifyReply, 6 | requestFromFastify, 7 | responseFromFastify, 8 | } from "../../../src/adapters/fastify.js"; 9 | import { ErrorType, OAuthException, OAuthRequest, OAuthResponse } from "../../../src/index.js"; 10 | 11 | describe("adapters/fastify.js", () => { 12 | describe("responseFromFastify", () => { 13 | it("should create an OAuthResponse from a FastifyReply", () => { 14 | const mockFastifyReply = { 15 | headers: { "content-type": "application/json" }, 16 | } as unknown as FastifyReply; 17 | const result = responseFromFastify(mockFastifyReply); 18 | expect(result).toBeInstanceOf(OAuthResponse); 19 | expect(result.headers).toEqual({ "content-type": "application/json" }); 20 | }); 21 | 22 | it("should handle undefined headers", () => { 23 | const mockFastifyReply = {} as FastifyReply; 24 | const result = responseFromFastify(mockFastifyReply); 25 | expect(result).toBeInstanceOf(OAuthResponse); 26 | expect(result.headers).toEqual({}); 27 | }); 28 | }); 29 | 30 | describe("requestFromFastify", () => { 31 | it("should create an OAuthRequest from a FastifyRequest", () => { 32 | const mockFastifyRequest = { 33 | query: { foo: "bar" }, 34 | body: { baz: "qux" }, 35 | headers: { "content-type": "application/json" }, 36 | } as FastifyRequest; 37 | const result = requestFromFastify(mockFastifyRequest); 38 | expect(result).toBeInstanceOf(OAuthRequest); 39 | expect(result.query).toEqual({ foo: "bar" }); 40 | expect(result.body).toEqual({ baz: "qux" }); 41 | expect(result.headers).toEqual({ "content-type": "application/json" }); 42 | }); 43 | 44 | it("should handle undefined properties", () => { 45 | const mockFastifyRequest = {} as FastifyRequest; 46 | const result = requestFromFastify(mockFastifyRequest); 47 | expect(result).toBeInstanceOf(OAuthRequest); 48 | expect(result.query).toEqual({}); 49 | expect(result.body).toEqual({}); 50 | expect(result.headers).toEqual({}); 51 | }); 52 | }); 53 | 54 | describe("handleFastifyError", () => { 55 | it("should handle OAuthException", () => { 56 | const mockFastifyReply = { 57 | status: vi.fn().mockReturnThis(), 58 | send: vi.fn(), 59 | } as unknown as FastifyReply; 60 | 61 | const oauthError = new OAuthException("Test error", ErrorType.InternalServerError, undefined, undefined, 400); 62 | 63 | handleFastifyError(oauthError, mockFastifyReply); 64 | 65 | expect(mockFastifyReply.status).toHaveBeenCalledWith(400); 66 | expect(mockFastifyReply.send).toHaveBeenCalledWith({ 67 | status: 400, 68 | message: "Test error", 69 | error: "server_error", 70 | error_description: "Test error", 71 | }); 72 | }); 73 | 74 | it("should rethrow non-OAuthException errors", () => { 75 | const mockFastifyReply = {} as FastifyReply; 76 | const error = new Error("Regular error"); 77 | 78 | expect(() => handleFastifyError(error, mockFastifyReply)).toThrow("Regular error"); 79 | }); 80 | }); 81 | 82 | describe("handleFastifyReply", () => { 83 | it("should handle redirect responses", () => { 84 | const mockFastifyReply = { 85 | headers: vi.fn(), 86 | redirect: vi.fn(), 87 | } as unknown as FastifyReply; 88 | 89 | const mockOAuthResponse = { 90 | status: 302, 91 | headers: { location: "https://example.com" }, 92 | } as OAuthResponse; 93 | 94 | handleFastifyReply(mockFastifyReply, mockOAuthResponse); 95 | 96 | expect(mockFastifyReply.headers).toHaveBeenCalledWith(mockOAuthResponse.headers); 97 | expect(mockFastifyReply.redirect).toHaveBeenCalledWith("https://example.com", 302); 98 | }); 99 | 100 | it("should handle non-redirect responses", () => { 101 | const mockFastifyReply = { 102 | headers: vi.fn(), 103 | status: vi.fn().mockReturnThis(), 104 | send: vi.fn(), 105 | } as unknown as FastifyReply; 106 | 107 | const mockOAuthResponse = { 108 | status: 200, 109 | headers: { "content-type": "application/json" }, 110 | body: { message: "Success" }, 111 | } as unknown as OAuthResponse; 112 | 113 | handleFastifyReply(mockFastifyReply, mockOAuthResponse); 114 | 115 | expect(mockFastifyReply.headers).toHaveBeenCalledWith(mockOAuthResponse.headers); 116 | expect(mockFastifyReply.status).toHaveBeenCalledWith(200); 117 | expect(mockFastifyReply.send).toHaveBeenCalledWith({ message: "Success" }); 118 | }); 119 | 120 | it("should throw an error for redirect without location", () => { 121 | const mockFastifyReply = {} as FastifyReply; 122 | const mockOAuthResponse = { 123 | status: 302, 124 | headers: {}, 125 | } as OAuthResponse; 126 | 127 | expect(() => handleFastifyReply(mockFastifyReply, mockOAuthResponse)).toThrow("missing redirect location"); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/e2e/adapters/vanilla.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest"; 2 | import { requestFromVanilla, responseFromVanilla, responseToVanilla } from "../../../src/adapters/vanilla.js"; 3 | import { OAuthException, OAuthRequest, OAuthResponse } from "../../../src/index.js"; 4 | import { RedirectResponse } from "../../../src/responses/redirect.response.js"; 5 | 6 | describe("adapters/vanilla.js", () => { 7 | describe("responseFromVanilla", () => { 8 | it("should create an OAuthResponse from a vanilla Response", () => { 9 | const mockHeaders = new Headers({ 10 | "content-type": "application/json", 11 | "x-custom-header": "custom-value", 12 | }); 13 | const mockResponse = { headers: mockHeaders } as Response; 14 | 15 | const result = responseFromVanilla(mockResponse); 16 | 17 | expect(result).toBeInstanceOf(OAuthResponse); 18 | expect(result.headers).toEqual({ 19 | "content-type": "application/json", 20 | "x-custom-header": "custom-value", 21 | }); 22 | }); 23 | }); 24 | 25 | describe("responseToVanilla", () => { 26 | it("should create a vanilla Response from an OAuthResponse for non-redirect", () => { 27 | const oauthResponse = new OAuthResponse({ 28 | status: 200, 29 | headers: { "content-type": "application/json" }, 30 | body: { message: "Success" }, 31 | }); 32 | 33 | const result = responseToVanilla(oauthResponse); 34 | 35 | expect(result).toBeInstanceOf(Response); 36 | expect(result.status).toBe(200); 37 | expect(result.headers.get("content-type")).toBe("application/json"); 38 | expect(result.json()).resolves.toEqual({ message: "Success" }); 39 | }); 40 | 41 | it("should create a redirect Response for status 302", () => { 42 | const oauthResponse = new RedirectResponse("https://example.com"); 43 | 44 | const result = responseToVanilla(oauthResponse); 45 | 46 | expect(result).toBeInstanceOf(Response); 47 | expect(result.status).toBe(302); 48 | expect(result.headers.get("Location")).toBe("https://example.com"); 49 | }); 50 | 51 | it("should throw an OAuthException for redirect without location", () => { 52 | // ts-ignore 53 | const oauthResponse = new RedirectResponse(); 54 | 55 | expect(() => responseToVanilla(oauthResponse)).toThrow(OAuthException); 56 | }); 57 | }); 58 | 59 | describe("requestFromVanilla", () => { 60 | let mockRequest: Request; 61 | 62 | beforeEach(() => { 63 | mockRequest = { 64 | url: "https://example.com/path?param1=value1¶m2=value2", 65 | headers: new Headers({ 66 | "content-type": "application/json", 67 | "x-custom-header": "custom-value", 68 | }), 69 | body: JSON.stringify({ key: "value" }), 70 | } as unknown as Request; 71 | }); 72 | 73 | it("should create an OAuthRequest from a vanilla Request", async () => { 74 | const result = await requestFromVanilla(mockRequest); 75 | 76 | expect(result).toBeInstanceOf(OAuthRequest); 77 | expect(result.query).toEqual({ param1: "value1", param2: "value2" }); 78 | expect(result.body).toEqual({ key: "value" }); 79 | expect(result.headers).toEqual({ 80 | "content-type": "application/json", 81 | "x-custom-header": "custom-value", 82 | }); 83 | }); 84 | 85 | it("should handle requests without body", async () => { 86 | mockRequest = { 87 | url: "https://example.com/path?param1=value1¶m2=value2", 88 | headers: new Headers({ 89 | "content-type": "application/json", 90 | "x-custom-header": "custom-value", 91 | }), 92 | body: null, 93 | } as unknown as Request; 94 | 95 | const result = await requestFromVanilla(mockRequest); 96 | 97 | expect(result.body).toEqual({}); 98 | }); 99 | 100 | it("should handle requests with ReadableStream body", async () => { 101 | const mockStream = new ReadableStream({ 102 | start(controller) { 103 | controller.enqueue(new TextEncoder().encode(JSON.stringify({ streamKey: "streamValue" }))); 104 | controller.close(); 105 | }, 106 | }); 107 | mockRequest = { 108 | url: "https://example.com/path?param1=value1¶m2=value2", 109 | headers: new Headers(), 110 | body: mockStream, 111 | } as unknown as Request; 112 | 113 | const result = await requestFromVanilla(mockRequest); 114 | 115 | expect(result.body).toEqual({ streamKey: "streamValue" }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/e2e/grants/password.grant.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from "vitest"; 2 | import { inMemoryDatabase } from "../_helpers/in_memory/database.js"; 3 | import { 4 | DateInterval, 5 | OAuthClient, 6 | OAuthRequest, 7 | OAuthUser, 8 | PasswordGrant, 9 | REGEX_ACCESS_TOKEN, 10 | } from "../../../src/index.js"; 11 | import { expectTokenResponse } from "./client_credentials.grant.spec.js"; 12 | import { JwtService } from "../../../src/utils/jwt.js"; 13 | import { 14 | inMemoryAccessTokenRepository, 15 | inMemoryClientRepository, 16 | inMemoryScopeRepository, 17 | inMemoryUserRepository, 18 | } from "../_helpers/in_memory/repository.js"; 19 | import { DEFAULT_AUTHORIZATION_SERVER_OPTIONS } from "../../../src/options.js"; 20 | 21 | function createGrant() { 22 | return new PasswordGrant( 23 | inMemoryUserRepository, 24 | inMemoryClientRepository, 25 | inMemoryAccessTokenRepository, 26 | inMemoryScopeRepository, 27 | new JwtService("secret-key"), 28 | DEFAULT_AUTHORIZATION_SERVER_OPTIONS, 29 | ); 30 | } 31 | describe("password grant", () => { 32 | let user: OAuthUser; 33 | let client: OAuthClient; 34 | 35 | let grant: PasswordGrant; 36 | 37 | let request: OAuthRequest; 38 | 39 | beforeEach(() => { 40 | request = new OAuthRequest(); 41 | 42 | user = { 43 | id: "512ab9a4-c786-48a6-8ad6-94c53a8dc651", 44 | password: "password123", 45 | }; 46 | client = { 47 | id: "35615f2f-13fa-4731-83a1-9e34556ab390", 48 | name: "test client", 49 | secret: "super-secret-secret", 50 | redirectUris: ["http://localhost"], 51 | allowedGrants: ["password"], 52 | scopes: [], 53 | }; 54 | 55 | grant = createGrant(); 56 | 57 | inMemoryDatabase.clients[client.id] = client; 58 | inMemoryDatabase.users[user.id] = user; 59 | }); 60 | 61 | it("succeeds when valid request", async () => { 62 | // arrange 63 | request = new OAuthRequest({ 64 | body: { 65 | grant_type: "password", 66 | client_id: client.id, 67 | client_secret: client.secret, 68 | username: user.id, 69 | password: user.password, 70 | }, 71 | }); 72 | const accessTokenTTL = new DateInterval("1h"); 73 | 74 | // act 75 | const tokenResponse = await grant.respondToAccessTokenRequest(request, accessTokenTTL); 76 | 77 | // assert 78 | expectTokenResponse(tokenResponse); 79 | expect(tokenResponse.body.refresh_token).toMatch(REGEX_ACCESS_TOKEN); 80 | }); 81 | 82 | it("throws when missing grant_type", async () => { 83 | // arrange 84 | request = new OAuthRequest({ 85 | body: { 86 | grant_type: undefined, 87 | client_id: client.id, 88 | }, 89 | }); 90 | const accessTokenTTL = new DateInterval("1h"); 91 | 92 | // act 93 | const tokenResponse = grant.respondToAccessTokenRequest(request, accessTokenTTL); 94 | 95 | // assert 96 | await expect(tokenResponse).rejects.toThrowError(/Check the `grant_type` parameter/); 97 | }); 98 | 99 | it("throws when missing username", async () => { 100 | // arrange 101 | request = new OAuthRequest({ 102 | body: { 103 | grant_type: "password", 104 | client_id: client.id, 105 | client_secret: client.secret, 106 | }, 107 | }); 108 | const accessTokenTTL = new DateInterval("1h"); 109 | 110 | // act 111 | const tokenResponse = grant.respondToAccessTokenRequest(request, accessTokenTTL); 112 | 113 | // assert 114 | await expect(tokenResponse).rejects.toThrowError(/Check the `username` parameter/); 115 | }); 116 | 117 | it("throws when missing password", async () => { 118 | // arrange 119 | request = new OAuthRequest({ 120 | body: { 121 | grant_type: "password", 122 | client_id: client.id, 123 | client_secret: client.secret, 124 | username: user.id, 125 | }, 126 | }); 127 | const accessTokenTTL = new DateInterval("1h"); 128 | 129 | // act 130 | const tokenResponse = grant.respondToAccessTokenRequest(request, accessTokenTTL); 131 | 132 | // assert 133 | await expect(tokenResponse).rejects.toThrowError(/Check the `password` parameter/); 134 | }); 135 | 136 | it("throws if no user is returned", async () => { 137 | // arrange 138 | request = new OAuthRequest({ 139 | body: { 140 | grant_type: "password", 141 | client_id: client.id, 142 | client_secret: client.secret, 143 | username: "this user does not exist", 144 | password: "password123", 145 | }, 146 | }); 147 | const accessTokenTTL = new DateInterval("1h"); 148 | 149 | // act 150 | const tokenResponse = grant.respondToAccessTokenRequest(request, accessTokenTTL); 151 | 152 | // assert 153 | await expect(tokenResponse).rejects.toThrowError( 154 | /The provided authorization grant \(e\.g\., authorization_code, client_credentials\) or refresh token is invalid/, 155 | ); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, afterEach, vi } from "vitest"; 2 | 3 | beforeEach(() => { 4 | // tell vitest we use mocked time 5 | vi.useFakeTimers(); 6 | const date = new Date(2021, 11, 11, 0, 0, 0); 7 | vi.setSystemTime(date); 8 | }); 9 | 10 | afterEach(() => { 11 | // restoring date after each test run 12 | vi.useRealTimers(); 13 | }); 14 | -------------------------------------------------------------------------------- /test/unit/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | import { JwtService } from "../../src/index.js"; 3 | 4 | it("can use the index.ts file", async () => { 5 | const module = await import("../../src/index.js"); 6 | 7 | expect(module).toBeDefined(); 8 | expect(module.AuthorizationServer).toBeInstanceOf(Function); 9 | expect(new module.JwtService("test")).toBeInstanceOf(JwtService); 10 | }); 11 | -------------------------------------------------------------------------------- /test/unit/utils/time.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { getSecondsUntil } from "../../../src/index.js"; 3 | 4 | describe("utils/time", () => { 5 | it("can calculate seconds until a future date", () => { 6 | const expiresAt = () => new Date(Date.now() + 60 * 60 * 1000); 7 | // flaky test, randomly fails with 3589 8 | expect(getSecondsUntil(expiresAt(), new Date(Date.now() + 10 * 1000))).toBe(3590); 9 | expect(getSecondsUntil(expiresAt(), new Date())).toBe(3600); 10 | expect(getSecondsUntil(expiresAt())).toBe(3600); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/unit/utils/token.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | import { generateRandomToken } from "../../../src/index.js"; 3 | 4 | it("generates a token of length", () => { 5 | expect(generateRandomToken().length).toBe(80); 6 | expect(generateRandomToken(32).length).toBe(32); 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "include": ["src/**/*.ts"], 7 | "exclude": ["node_modules", "test", "dist", "test/**/*.spec.ts", "example", "version-check.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "node16", 5 | "moduleResolution": "node16", 6 | "lib": ["esnext", "esnext.asynciterable"], 7 | "outDir": "dist/esm", 8 | "declaration": true, 9 | 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "baseUrl": ".", 24 | "esModuleInterop": true, 25 | "resolveJsonModule": true, 26 | 27 | "experimentalDecorators": true, 28 | "emitDecoratorMetadata": true, 29 | 30 | "allowSyntheticDefaultImports": true, 31 | "forceConsistentCasingInFileNames": true, 32 | "removeComments": true, 33 | "sourceMap": true 34 | }, 35 | "include": ["src/**/*"], 36 | "exclude": ["example", "coverage", "dist", "node_modules", "version-check.ts"] 37 | } 38 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: "istanbul", 7 | reporter: ["text", "lcovonly"], 8 | exclude: [".github/**", ".idea/**", "docs/**", "example/**"], 9 | }, 10 | setupFiles: ["test/setup.ts"], 11 | exclude: ["docs/**", "example/**", "node_modules/**", "version-check.ts"], 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------