├── .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 |
5 |
6 |
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 | [](https://jsr.io/@jmondi/oauth2-server)
4 | [](https://www.npmjs.com/package/@jmondi/oauth2-server)
5 | [](https://github.com/jasonraimondi/ts-oauth2-server)
6 | [](https://codeclimate.com/github/jasonraimondi/typescript-oauth2-server/test_coverage)
7 | [](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 | [](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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/docs/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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 |
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 |
--------------------------------------------------------------------------------