├── .node-version ├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .prettierignore ├── docs ├── static │ ├── robots.txt │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── img │ │ ├── oauth2-server-social-card.jpg │ │ └── oauth2-server-social-card.png ├── .prettierignore ├── src │ ├── components │ │ ├── Card │ │ │ ├── styles.modules.css │ │ │ └── index.tsx │ │ ├── dividers │ │ │ ├── Spikes │ │ │ │ ├── index.tsx │ │ │ │ └── index.module.css │ │ │ └── Triangle │ │ │ │ ├── index.tsx │ │ │ │ └── index.module.css │ │ ├── MarkdownWrapper.tsx │ │ ├── CodeExamples.mdx │ │ ├── grants │ │ │ └── RequiredForGrants.tsx │ │ └── icons │ │ │ ├── typescript.tsx │ │ │ └── nodejs.tsx │ ├── index.d.ts │ ├── theme │ │ └── MDXComponents.tsx │ ├── pages │ │ ├── _example_authorization_server.mdx │ │ ├── _example_entities.mdx │ │ ├── _index_install.mdx │ │ ├── _example_repositories.mdx │ │ └── _which_grant.mdx │ └── css │ │ └── custom.css ├── babel.config.js ├── .prettierrc ├── .idea │ ├── codeStyles │ │ ├── codeStyleConfig.xml │ │ └── Project.xml │ ├── vcs.xml │ ├── .gitignore │ ├── modules.xml │ └── docs.iml ├── tsconfig.json ├── .gitignore ├── tailwind-config.cjs ├── README.md ├── docs │ ├── endpoints │ │ ├── index.mdx │ │ ├── token.mdx │ │ ├── revoke.mdx │ │ └── introspect.mdx │ ├── Extras │ │ ├── glossary.md │ │ ├── references.md │ │ └── access_tokens.md │ ├── grants │ │ ├── custom.mdx │ │ ├── implicit.mdx │ │ ├── index.mdx │ │ ├── client_credentials.mdx │ │ ├── password.mdx │ │ ├── refresh_token.mdx │ │ └── token_exchange.mdx │ ├── adapters │ │ ├── index.md │ │ ├── vanilla.md │ │ ├── express.md │ │ ├── fastify.md │ │ └── nuxt.md │ ├── faqs.md │ ├── getting_started │ │ └── entities.md │ ├── authorization_server │ │ └── index.mdx │ └── upgrade_guide.md ├── tailwind.config.js ├── sidebars.ts ├── package.json └── docusaurus.config.ts ├── example ├── .gitignore ├── .env.example ├── src │ ├── utils │ │ └── custom_jwt_service.ts │ ├── entities │ │ ├── scope.ts │ │ ├── user.ts │ │ ├── client.ts │ │ ├── auth_code.ts │ │ └── token.ts │ ├── repositories │ │ ├── user_repository.ts │ │ ├── scope_repository.ts │ │ ├── client_repository.ts │ │ ├── auth_code_repository.ts │ │ └── token_repository.ts │ └── main.ts ├── docker-compose.yml ├── README.md ├── package.json ├── tsconfig.json └── prisma │ └── schema.prisma ├── src ├── entities │ ├── scope.entity.ts │ ├── user.entity.ts │ ├── token.entity.ts │ ├── client.entity.ts │ └── auth_code.entity.ts ├── utils │ ├── array.ts │ ├── token.ts │ ├── logger.ts │ ├── errors.ts │ ├── time.ts │ ├── urls.ts │ ├── scopes.ts │ ├── date_interval.ts │ ├── base64.ts │ └── jwt.ts ├── grants │ ├── abstract │ │ ├── custom.grant.ts │ │ ├── grant.interface.ts │ │ └── abstract_authorized.grant.ts │ ├── password.grant.ts │ ├── refresh_token.grant.ts │ ├── token_exchange.grant.ts │ └── implicit.grant.ts ├── code_verifiers │ ├── verifier.ts │ ├── plain.verifier.ts │ └── S256.verifier.ts ├── responses │ ├── redirect.response.ts │ ├── bearer_token.response.ts │ └── response.ts ├── options.ts ├── requests │ ├── request.ts │ └── authorization.request.ts ├── repositories │ ├── client.repository.ts │ ├── user.repository.ts │ ├── scope.repository.ts │ ├── auth_code.repository.ts │ └── access_token.repository.ts ├── index.ts └── adapters │ ├── express.ts │ ├── fastify.ts │ └── vanilla.ts ├── .prettierrc ├── .claude ├── settings.json └── agents │ └── oauth2-server-expert.md ├── .idea ├── jsLibraryMappings.xml ├── vcs.xml ├── prettier.xml ├── .gitignore ├── modules.xml └── ts-oauth2-server.iml ├── .gitignore ├── tsconfig.build.json ├── test ├── unit │ ├── utils │ │ ├── token.spec.ts │ │ └── time.spec.ts │ └── index.spec.ts ├── setup.ts └── e2e │ ├── _helpers │ └── in_memory │ │ ├── database.ts │ │ └── oauth_authorization_server.ts │ ├── adapters │ └── express.spec.ts │ └── grants │ └── password.grant.spec.ts ├── jsr.json ├── vitest.config.ts ├── SECURITY.md ├── tsconfig.json ├── LICENSE ├── package.json ├── CLAUDE.md └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 20.12.2 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jasonraimondi 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | *.yaml 4 | *.md 5 | -------------------------------------------------------------------------------- /docs/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | .docusaurus/ 2 | 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea 3 | .env 4 | 5 | -------------------------------------------------------------------------------- /docs/src/components/Card/styles.modules.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | padding: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/HEAD/docs/static/favicon.ico -------------------------------------------------------------------------------- /src/entities/scope.entity.ts: -------------------------------------------------------------------------------- 1 | export interface OAuthScope { 2 | name: string; 3 | [key: string]: any; 4 | } 5 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/HEAD/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/HEAD/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const arrayDiff = (arr1: T[], arr2: T[]): T[] => arr1.filter(x => !arr2.includes(x)); 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 120 4 | trailingComma: all 5 | tabWidth: 2 6 | semi: true 7 | singleQuote: false 8 | -------------------------------------------------------------------------------- /docs/static/img/oauth2-server-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/HEAD/docs/static/img/oauth2-server-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/oauth2-server-social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/ts-oauth2-server/HEAD/docs/static/img/oauth2-server-social-card.png -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 100 4 | trailingComma: all 5 | tabWidth: 2 6 | semi: true 7 | singleQuote: false 8 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /docs/src/components/dividers/Spikes/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.css"; 2 | 3 | export function SpikesDivider() { 4 | return
; 5 | } 6 | -------------------------------------------------------------------------------- /docs/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /docs/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export interface LoggerService { 2 | log(message?: any, ...optionalParams: any[]): void; 3 | } 4 | 5 | export class ConsoleLoggerService implements LoggerService { 6 | log = console.log; 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "WebFetch(domain:github.com)", 5 | "WebFetch(domain:datatracker.ietf.org)" 6 | ] 7 | }, 8 | "enableAllProjectMcpServers": false 9 | } 10 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /docs/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/src/components/MarkdownWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | interface MDXWrapperProps { 4 | children: ReactNode; 5 | } 6 | 7 | function MDXWrapper({ children }) { 8 | return ( 9 |
10 |
{children}
11 |
12 | ); 13 | } 14 | 15 | export default MDXWrapper; 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/oauth2-server", 3 | "version": "4.2.2", 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/.idea/docs.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/ts-oauth2-server.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | /** 6 | * Fetches a client entity from storage by client ID. 7 | * @param clientId The client identifier string 8 | * @returns Promise resolving to an OAuthClient 9 | */ 10 | getByIdentifier(clientId: string): Promise; 11 | 12 | /** 13 | * Validates the client using the grant type and optional client secret. 14 | * @param grantType The grant type identifier 15 | * @param client The OAuth client entity 16 | * @param clientSecret Optional client secret string 17 | * @returns Promise resolving to a boolean indicating validity 18 | */ 19 | isClientValid(grantType: GrantIdentifier, client: OAuthClient, clientSecret?: string): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | /** 7 | * Fetches a user entity from storage by their identifier and optional password. 8 | * The grant type and client may also be provided for additional validation. 9 | * @param identifier The user identifier 10 | * @param password Optional password for credential validation 11 | * @param grantType Optional grant type identifier 12 | * @param client Optional OAuth client entity 13 | * @returns Promise resolving to an OAuthUser or undefined if not found 14 | */ 15 | getUserByCredentials( 16 | identifier: OAuthUserIdentifier, 17 | password?: string, 18 | grantType?: GrantIdentifier, 19 | client?: OAuthClient, 20 | ): Promise; 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/_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/_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Encodes a string or Buffer to base64. 3 | * 4 | * @param str - String or Buffer to encode 5 | * @returns Base64 encoded string 6 | * 7 | * @example 8 | * ```ts 9 | * import { base64encode } from "@jmondi/oauth2-server"; 10 | * 11 | * const basicAuth = "Basic " + base64encode(`${clientId}:${clientSecret}`); 12 | * ``` 13 | */ 14 | export function base64encode(str: string | Buffer): string { 15 | if (typeof str === "string") str = Buffer.from(str); 16 | return str.toString("base64"); 17 | } 18 | 19 | /** 20 | * Decodes a base64 string or Buffer to a binary string. 21 | * 22 | * @param str - Base64 string or Buffer to decode 23 | * @returns Decoded binary string 24 | */ 25 | export function base64decode(str: string | Buffer): string { 26 | if (typeof str === "string") str = Buffer.from(str, "base64"); 27 | return str.toString("binary"); 28 | } 29 | 30 | /** 31 | * Encodes a string or Buffer to base64url format (URL-safe base64). 32 | * Replaces + with -, / with _, and removes padding =. 33 | * 34 | * @param str - String or Buffer to encode 35 | * @returns Base64url encoded string 36 | */ 37 | export function base64urlencode(str: string | Buffer): string { 38 | return base64encode(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 39 | } 40 | -------------------------------------------------------------------------------- /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 | /** 8 | * Fetches all scope entities from storage by their names. 9 | * @param scopeNames Array of scope name strings 10 | * @returns Promise resolving to an array of OAuthScope entities 11 | */ 12 | getAllByIdentifiers(scopeNames: string[]): Promise; 13 | 14 | /** 15 | * Finalizes the set of scopes for a client and user before token or authorization code issuance. 16 | * This method validates the requested scopes and optionally modifies the set of scopes. 17 | * @param scopes Array of OAuthScope entities 18 | * @param identifier The grant type identifier 19 | * @param client The OAuth client entity 20 | * @param user_id Optional user identifier 21 | * @returns Promise resolving to an array of finalized OAuthScope entities 22 | */ 23 | finalize( 24 | scopes: OAuthScope[], 25 | identifier: GrantIdentifier, 26 | client: OAuthClient, 27 | user_id?: OAuthUserIdentifier, 28 | ): Promise; 29 | } 30 | -------------------------------------------------------------------------------- /docs/src/components/grants/RequiredForGrants.tsx: -------------------------------------------------------------------------------- 1 | export default function RequiredForGrants(props) { 2 | const enabledGrants = props.grants; 3 | const allGrants = [ 4 | { 5 | label: "Authorization Code", 6 | href: "authorization_code", 7 | }, 8 | { 9 | label: "Client Credentials", 10 | href: "client_credentials", 11 | }, 12 | { 13 | label: "Refresh Token", 14 | href: "refresh_token", 15 | }, 16 | { 17 | label: "Password", 18 | href: "password", 19 | }, 20 | { 21 | label: "Implicit", 22 | href: "implicit", 23 | }, 24 | { 25 | label: "Custom", 26 | href: "custom", 27 | }, 28 | ] as const; 29 | 30 | const grants = allGrants.filter(g => enabledGrants.includes(g.href)); 31 | 32 | return ( 33 |
34 | Used in Grants: 35 | 36 | {grants.map(s => ( 37 | 42 | {s.label} 43 | 44 | ))} 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /docs/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/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/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, originatingAuthCodeId } = params; 30 | return { 31 | email: user?.email, 32 | originatingAuthCodeId, 33 | myCustomProps: "this will be in the decoded token!", 34 | }; 35 | } 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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, 24.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: Upload coverage reports to Codecov 47 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.node-version == '20.x' }} 48 | uses: codecov/codecov-action@v5 49 | with: 50 | token: ${{ secrets.CODECOV_TOKEN }} 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /docs/docs/faqs.md: -------------------------------------------------------------------------------- 1 | # FAQ's 2 | 3 | ## Why isn't there a built-in `verifyToken()` method? 4 | 5 | The OAuth 2.0 specification intentionally leaves token validation up to individual implementations because different applications have different requirements for: 6 | 7 | - Scope checking 8 | - Audience validation 9 | - Custom business logic 10 | - Performance considerations 11 | 12 | ## How do I validate OAuth access tokens in my application? 13 | 14 | There are two main methods for validating OAuth access tokens: 15 | 16 | ### Option 1: JWT + Repository Check 17 | 18 | This approach combines JWT signature verification with a database check for token revocation: 19 | 20 | ```typescript 21 | async function validateToken(accessToken: string) { 22 | // Verify JWT signature and decode 23 | const decoded = await jwtService.verify(accessToken); 24 | 25 | // Check if token exists and isn't revoked using the jti claim 26 | const storedToken = await accessTokenRepository.getByAccessToken(decoded.jti); 27 | 28 | return storedToken && !isExpired(storedToken) ? { valid: true, decoded, storedToken } : { valid: false }; 29 | } 30 | ``` 31 | 32 | ### Option 2: Use the Introspection Endpoint 33 | 34 | The OAuth library provides an `/introspect` endpoint (RFC 7662) that handles validation for you. You can either: 35 | 36 | - Expose it and call it from your middleware 37 | - Use the same logic internally by calling `authorizationServer.introspect()` directly 38 | 39 | This method handles both JWT verification and token status checking automatically. 40 | 41 | ## Common Errors 42 | 43 | ### `Unsupported grant_type` 44 | 45 | Check if you're enabling the desired grant type on the AuthorizationServer. See https://tsoauth2server.com/docs/authorization_server/#enabling-grant-types for more. 46 | 47 | ```typescript 48 | import {AuthorizationServer} from "@jmondi/oauth2-server"; 49 | 50 | const authorizationServer = new AuthorizationServer(...); 51 | authorizationServer.enableGrantType({ grant: "password" ... }); 52 | ``` 53 | 54 | ### `Client has been revoked or is invalid` 55 | 56 | Check the `OAuthClientRepository#isClientValid` method, it is returning **false**. 57 | -------------------------------------------------------------------------------- /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 | /** 8 | * Fetches an authorization code entity from storage by its identifier. 9 | * @param authCodeCode The authorization code string 10 | * @returns Promise resolving to an OAuthAuthCode 11 | */ 12 | getByIdentifier(authCodeCode: string): Promise; 13 | 14 | /** 15 | * Asynchronously issues a new OAuthAuthCode for the given client, user, and scopes. 16 | * The returned auth code should not be persisted yet. 17 | * Note: The `expiresAt` value set here may be replaced by the authorization server 18 | * using the TTL configured in `enableGrantType`. 19 | * @param client OAuth client entity 20 | * @param user OAuth user entity or undefined 21 | * @param scopes Array of OAuth scopes 22 | * @returns OAuthAuthCode or Promise resolving to OAuthAuthCode 23 | */ 24 | issueAuthCode( 25 | client: OAuthClient, 26 | user: OAuthUser | undefined, 27 | scopes: OAuthScope[], 28 | ): OAuthAuthCode | Promise; 29 | 30 | /** 31 | * Persists an OAuthAuthCode into your storage. 32 | * @param authCode The authorization code to persist 33 | * @returns Promise resolving when persistence is complete 34 | */ 35 | persist(authCode: OAuthAuthCode): Promise; 36 | 37 | /** 38 | * Checks if an authorization code has been revoked. 39 | * Return `true` if the code has been manually revoked, otherwise `false`. 40 | * @param authCodeCode The authorization code string 41 | * @returns Promise resolving to a boolean indicating revocation status 42 | */ 43 | isRevoked(authCodeCode: string): Promise; 44 | 45 | /** 46 | * Revokes an authorization code. 47 | * @param authCodeCode The authorization code string 48 | * @returns Promise resolving when revocation is complete 49 | */ 50 | revoke(authCodeCode: string): Promise; 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | originatingAuthCodeId?: string; 10 | }; 11 | export interface JwtInterface { 12 | verify(token: string, options?: VerifyOptions): Promise>; 13 | decode(encryptedData: string): null | Record | string; 14 | sign(payload: string | Buffer | Record, options?: SignOptions): Promise; 15 | extraTokenFields?(params: ExtraAccessTokenFieldArgs): ExtraAccessTokenFields | Promise; 16 | } 17 | 18 | /** 19 | * JWT service implementation for handling JSON Web Tokens. 20 | * Provides methods to sign, verify, and decode JWT tokens. 21 | */ 22 | export class JwtService implements JwtInterface { 23 | /** 24 | * Creates a new JWT service instance. 25 | * 26 | * @param secretOrPrivateKey - Secret key or private key for signing/verifying tokens 27 | */ 28 | constructor(private readonly secretOrPrivateKey: Secret) {} 29 | 30 | /** 31 | * Asynchronously verify given token using a secret or a public key to get a decoded token 32 | */ 33 | verify(token: string, options: VerifyOptions = {}): Promise> { 34 | return new Promise((resolve, reject) => { 35 | jwt.verify(token, this.secretOrPrivateKey, options, (err, decoded: any) => { 36 | if (decoded) resolve(decoded); 37 | else reject(err); 38 | }); 39 | }); 40 | } 41 | 42 | /** 43 | * Returns the decoded payload without verifying if the signature is valid. 44 | */ 45 | decode(encryptedData: string): null | { [key: string]: any } | string { 46 | return jwt.decode(encryptedData); 47 | } 48 | 49 | /** 50 | * Sign the given payload into a JSON Web Token string 51 | */ 52 | sign(payload: string | Buffer | Record): Promise { 53 | return new Promise((resolve, reject) => { 54 | jwt.sign(payload, this.secretOrPrivateKey, (err, encoded) => { 55 | if (encoded) resolve(encoded); 56 | else reject(err); 57 | }); 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | /** 8 | * Asynchronously issues a new OAuthToken for the given client, scopes, and optional user. 9 | * The returned token should not be persisted yet. 10 | * Note: The `accessTokenExpiresAt` and `refreshTokenExpiresAt` value set here will be replaced 11 | * by the authorization server using the TTL configured in `enableGrantType`. 12 | * @param client OAuth client entity 13 | * @param scopes Array of OAuth scopes 14 | * @param user Optional OAuth user entity 15 | * @returns Promise resolving to an OAuthToken 16 | */ 17 | issueToken(client: OAuthClient, scopes: OAuthScope[], user?: OAuthUser | null): Promise; 18 | 19 | /** 20 | * Enhances an already-persisted OAuthToken with refresh token fields. 21 | * @param accessToken The persisted access token 22 | * @param client OAuth client entity 23 | * @returns Promise resolving to an OAuthToken with refresh token fields 24 | */ 25 | issueRefreshToken(accessToken: OAuthToken, client: OAuthClient): Promise; 26 | 27 | /** 28 | * Persists an OAuthToken into your storage. 29 | * @param accessToken The access token to persist 30 | * @returns Promise resolving when persistence is complete 31 | */ 32 | persist(accessToken: OAuthToken): Promise; 33 | 34 | /** 35 | * Revokes an access token. Called when a refresh token is used to reissue an access token. 36 | * The original access token is revoked, and a new access token is issued. 37 | * @param accessToken The access token to revoke 38 | * @returns Promise resolving when revocation is complete 39 | */ 40 | revoke(accessToken: OAuthToken): Promise; 41 | 42 | /** 43 | * (Optional) Called by the authorization code grant if the original authorization code is reused. 44 | * See RFC6749 section 4.1.2 for details. 45 | * @param authCodeId The authorization code identifier 46 | * @returns Promise resolving when descendant tokens are revoked 47 | */ 48 | revokeDescendantsOf?(authCodeId: string): Promise; 49 | 50 | /** 51 | * Called when an access token is validated by the authorization server. 52 | * Return `true` if the refresh token has been manually revoked, otherwise `false`. 53 | * @param refreshToken The refresh token to check 54 | * @returns Promise resolving to a boolean indicating revocation status 55 | */ 56 | isRefreshTokenRevoked(refreshToken: OAuthToken): Promise; 57 | 58 | /** 59 | * Fetches a refresh token entity from storage by refresh token string. 60 | * @param refreshTokenToken The refresh token string 61 | * @returns Promise resolving to an OAuthToken 62 | */ 63 | getByRefreshToken(refreshTokenToken: string): Promise; 64 | 65 | /** 66 | * (Optional) Required if using /introspect RFC7662 "OAuth 2.0 Token Introspection" 67 | * @see https://tsoauth2server.com/docs/getting_started/endpoints#the-introspect-endpoint 68 | * @param accessTokenToken The access token string 69 | * @returns Promise resolving to an OAuthToken 70 | */ 71 | getByAccessToken?(accessTokenToken: string): Promise; 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/oauth2-server", 3 | "version": "4.2.2", 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.21", 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 | "splitting": false, 108 | "sourcemap": true, 109 | "minify": false, 110 | "keepNames": true, 111 | "dts": { 112 | "compilerOptions": { 113 | "removeComments": false 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | /** 9 | * Converts an Express Response object to an OAuthResponse. 10 | * 11 | * @param res - Express Response object 12 | * @returns OAuthResponse instance 13 | */ 14 | export function responseFromExpress({ status, ...res }: Response): OAuthResponse { 15 | return new OAuthResponse({ status: res.statusCode ?? 200, ...res }); 16 | } 17 | 18 | /** 19 | * Converts an Express Request object to an OAuthRequest. 20 | * 21 | * @param req - Express Request object 22 | * @returns OAuthRequest instance 23 | * 24 | * @example 25 | * ```ts 26 | * import { requestFromExpress } from "@jmondi/oauth2-server/express"; 27 | * 28 | * const authRequest = await authorizationServer.validateAuthorizationRequest( 29 | * requestFromExpress(req) 30 | * ); 31 | * ``` 32 | */ 33 | export function requestFromExpress(req: Request): OAuthRequest { 34 | return new OAuthRequest(req); 35 | } 36 | 37 | /** 38 | * Handles sending an OAuthResponse back through Express. 39 | * Properly handles both regular responses and redirects. 40 | * 41 | * @param expressResponse - Express Response object 42 | * @param oauthResponse - OAuth response to send 43 | * 44 | * @example 45 | * ```ts 46 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 47 | * return handleExpressResponse(res, oauthResponse); 48 | * ``` 49 | */ 50 | export function handleExpressResponse(expressResponse: Response, oauthResponse: OAuthResponse): void { 51 | if (oauthResponse.status === 302) { 52 | if (!oauthResponse.headers.location) throw new Error("missing redirect location"); 53 | expressResponse.set(oauthResponse.headers); 54 | expressResponse.redirect(oauthResponse.headers.location); 55 | } else { 56 | expressResponse.set(oauthResponse.headers); 57 | expressResponse.status(oauthResponse.status).send(oauthResponse.body); 58 | } 59 | } 60 | 61 | /** 62 | * Handles OAuth errors in Express applications. 63 | * Converts OAuthExceptions to appropriate HTTP responses. 64 | * Generic errors are automatically converted to 500 Internal Server Error. 65 | * 66 | * @param e - Error object, typically an OAuthException 67 | * @param res - Express Response object 68 | * 69 | * @example 70 | * ```ts 71 | * try { 72 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 73 | * return handleExpressResponse(res, oauthResponse); 74 | * } catch (e) { 75 | * handleExpressError(e, res); 76 | * } 77 | * ``` 78 | */ 79 | // @todo v4.0 flip these to always be Express as first arg, OAuth as second. Then update Docs 80 | export function handleExpressError(e: unknown | OAuthException, res: Response): void { 81 | if (isOAuthError(e)) { 82 | res.status(e.status); 83 | res.send({ 84 | status: e.status, 85 | message: e.message, 86 | error: e.errorType, 87 | error_description: e.errorDescription ?? e.error, 88 | }); 89 | return; 90 | } 91 | 92 | // Convert generic errors to OAuthException 93 | const errorMessage = e instanceof Error ? e.message : 'An unexpected error occurred'; 94 | const oauthError = OAuthException.internalServerError(errorMessage); 95 | 96 | res.status(oauthError.status); 97 | res.send({ 98 | status: oauthError.status, 99 | message: oauthError.message, 100 | error: oauthError.errorType, 101 | error_description: oauthError.errorDescription ?? oauthError.error, 102 | }); 103 | } 104 | -------------------------------------------------------------------------------- /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 | /** 9 | * Converts a Fastify Reply object to an OAuthResponse. 10 | * 11 | * @param res - Fastify Reply object 12 | * @returns OAuthResponse instance 13 | */ 14 | export function responseFromFastify(res: FastifyReply): OAuthResponse { 15 | return new OAuthResponse({ 16 | headers: >(res.headers) ?? {}, 17 | }); 18 | } 19 | 20 | /** 21 | * Converts a Fastify Request object to an OAuthRequest. 22 | * 23 | * @param req - Fastify Request object 24 | * @returns OAuthRequest instance 25 | * 26 | * @example 27 | * ```ts 28 | * import { requestFromFastify } from "@jmondi/oauth2-server/fastify"; 29 | * 30 | * const authRequest = await authorizationServer.validateAuthorizationRequest( 31 | * requestFromFastify(req) 32 | * ); 33 | * ``` 34 | */ 35 | export function requestFromFastify(req: FastifyRequest): OAuthRequest { 36 | return new OAuthRequest({ 37 | query: >req.query ?? {}, 38 | body: >req.body ?? {}, 39 | headers: >req.headers ?? {}, 40 | }); 41 | } 42 | 43 | /** 44 | * Handles OAuth errors in Fastify applications. 45 | * Converts OAuthExceptions to appropriate HTTP responses. 46 | * Generic errors are automatically converted to 500 Internal Server Error. 47 | * 48 | * @param e - Error object, typically an OAuthException 49 | * @param res - Fastify Reply object 50 | * 51 | * @example 52 | * ```ts 53 | * try { 54 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 55 | * return handleFastifyReply(res, oauthResponse); 56 | * } catch (e) { 57 | * handleFastifyError(e, res); 58 | * } 59 | * ``` 60 | */ 61 | // @todo v4.0 flip these to always be Fastify as first arg, OAuth as second. Then update Docs 62 | export function handleFastifyError(e: unknown | OAuthException, res: FastifyReply): void { 63 | if (isOAuthError(e)) { 64 | res.status(e.status).send({ 65 | status: e.status, 66 | message: e.message, 67 | error: e.errorType, 68 | error_description: e.errorDescription ?? e.error, 69 | }); 70 | return; 71 | } 72 | 73 | // Convert generic errors to OAuthException 74 | const errorMessage = e instanceof Error ? e.message : 'An unexpected error occurred'; 75 | const oauthError = OAuthException.internalServerError(errorMessage); 76 | 77 | res.status(oauthError.status).send({ 78 | status: oauthError.status, 79 | message: oauthError.message, 80 | error: oauthError.errorType, 81 | error_description: oauthError.errorDescription ?? oauthError.error, 82 | }); 83 | } 84 | 85 | /** 86 | * Handles sending an OAuthResponse back through Fastify. 87 | * Properly handles both regular responses and redirects. 88 | * 89 | * @param res - Fastify Reply object 90 | * @param response - OAuth response to send 91 | * 92 | * @example 93 | * ```ts 94 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 95 | * return handleFastifyReply(res, oauthResponse); 96 | * ``` 97 | */ 98 | export function handleFastifyReply(res: FastifyReply, response: OAuthResponse): void { 99 | if (response.status === 302) { 100 | if (!response.headers.location) throw new Error("missing redirect location"); 101 | res.headers(response.headers); 102 | res.redirect(response.headers.location, 302); 103 | } else { 104 | res.headers(response.headers); 105 | res.status(response.status).send(response.body); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /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, oldToken.originatingAuthCodeId); 40 | 41 | newToken = await this.issueRefreshToken(newToken, client); 42 | 43 | const extraJwtFields = await this.extraJwtFields(req, client, user, newToken.originatingAuthCodeId); 44 | 45 | return await this.makeBearerTokenResponse(client, newToken, scopes, extraJwtFields); 46 | } 47 | 48 | private async validateOldRefreshToken(request: RequestInterface, clientId: string): Promise { 49 | const providedRefreshToken = this.getRequestParameter("refresh_token", request); 50 | 51 | if (!providedRefreshToken) { 52 | throw OAuthException.invalidParameter("refresh_token"); 53 | } 54 | 55 | let refreshTokenData: any; 56 | let refreshToken: OAuthToken | null = null; 57 | 58 | if (this.options.useOpaqueRefreshTokens) { 59 | refreshToken = await this.tokenRepository.getByRefreshToken(providedRefreshToken); 60 | refreshTokenData = { 61 | refresh_token_id: refreshToken.refreshToken, 62 | client_id: refreshToken.client.id, 63 | expire_time: refreshToken.refreshTokenExpiresAt, 64 | }; 65 | } else { 66 | try { 67 | refreshTokenData = await this.decrypt(providedRefreshToken); 68 | } catch (e) { 69 | if (e instanceof Error && e.message === "invalid signature") { 70 | throw OAuthException.invalidParameter("refresh_token", "Cannot verify the refresh token"); 71 | } 72 | throw OAuthException.invalidParameter("refresh_token", "Cannot decrypt the refresh token"); 73 | } 74 | } 75 | 76 | if (!refreshTokenData?.refresh_token_id) { 77 | throw OAuthException.invalidParameter("refresh_token", "Token missing"); 78 | } 79 | 80 | if (refreshTokenData?.client_id !== clientId) { 81 | throw OAuthException.invalidParameter("refresh_token", "Token is not linked to client"); 82 | } 83 | 84 | if (Date.now() / 1000 > refreshTokenData?.expire_time) { 85 | throw OAuthException.invalidParameter("refresh_token", "Token has expired"); 86 | } 87 | 88 | if (!refreshToken) { 89 | refreshToken = await this.tokenRepository.getByRefreshToken(refreshTokenData.refresh_token_id); 90 | } 91 | 92 | if (await this.tokenRepository.isRefreshTokenRevoked(refreshToken)) { 93 | throw OAuthException.invalidParameter("refresh_token", "Token has been revoked"); 94 | } 95 | 96 | return refreshToken; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Commands and Guidelines for ts-oauth2-server 6 | 7 | ### Build/Test/Format Commands 8 | - `pnpm build` - Clean and build the project 9 | - `pnpm test` - Run all tests with Vitest 10 | - `pnpm test:watch` - Run tests in watch mode 11 | - `pnpm test:cov` - Run tests with coverage 12 | - `pnpm vitest run path/to/test/file.spec.ts` - Run a single test file 13 | - `pnpm format` - Format code with Prettier 14 | 15 | ### Code Style Guidelines 16 | - **TypeScript**: Use strict mode with no implicit any, strict null checks 17 | - **Imports**: Use `.js` extension in import paths 18 | - **Formatting**: Follow Prettier defaults (package uses Prettier v3.x) 19 | - **Naming**: 20 | - Files: snake_case.ts 21 | - Classes: PascalCase 22 | - Functions/variables: camelCase 23 | - **Error Handling**: Use custom OAuthException with specific error types 24 | - **Types**: 25 | - Always use explicit types for function parameters and returns 26 | - Avoid `any` - use proper typing or generics 27 | - **Tests**: Write tests using Vitest with describe/it structure 28 | - **Architecture**: Follow repository pattern for data access 29 | 30 | ### Breaking Changes Policy 31 | - **CRITICAL**: Avoid breaking changes at all costs - this is a published library used by many consumers 32 | - Never change existing public APIs, method signatures, or exported interfaces 33 | - Always add new functionality as optional parameters with sensible defaults 34 | - Use deprecation warnings for any planned removals (but prefer keeping deprecated code) 35 | - Consider backwards-compatible alternatives before making any interface changes 36 | - When in doubt, extend rather than modify existing functionality 37 | 38 | ### Documentation Requirements 39 | - **Always update documentation** when adding new functionality or changing behavior 40 | - Update the main documentation site at https://tsoauth2server.com/ for user-facing changes 41 | - Update this CLAUDE.md file if architectural patterns or development practices change 42 | - Ensure code examples in documentation remain current and functional 43 | - Document new configuration options, interfaces, or breaking changes thoroughly 44 | 45 | ### Architecture Overview 46 | 47 | This is a TypeScript OAuth 2.0 authorization server library. The core architecture consists of: 48 | 49 | - **AuthorizationServer**: Main entry point that orchestrates OAuth flows 50 | - **Grants**: Implement specific OAuth grant types (auth_code, client_credentials, etc.) 51 | - **Repositories**: Abstract data access layer (client, token, user, scope, auth_code) 52 | - **Entities**: Core domain objects (OAuthClient, OAuthToken, OAuthUser, etc.) 53 | - **Adapters**: Framework-specific helpers (Express, Fastify, Vanilla) 54 | - **Code Verifiers**: PKCE implementation (Plain, S256) 55 | 56 | #### Key Components: 57 | - **Grant System**: Each grant type (authorization_code, client_credentials, password, implicit, refresh_token, token_exchange) is implemented as a separate class inheriting from AbstractGrant 58 | - **Repository Pattern**: All data persistence is abstracted through repository interfaces that must be implemented by consumers 59 | - **Framework Agnostic**: Core library is framework-independent with optional adapters for popular frameworks 60 | - **Standards Compliance**: Implements multiple OAuth 2.0 RFCs (6749, 6750, 7009, 7519, 7636, 7662, 8693) 61 | - **Logging Support**: Optional logger service for debugging token operations, revocations, and grant processing errors 62 | 63 | #### Module Exports: 64 | The library supports multiple entry points via package.json exports: 65 | - Main: `./src/index.ts` (all exports) 66 | - Framework adapters: `./vanilla`, `./express`, `./fastify` 67 | 68 | #### Testing Structure: 69 | - Unit tests: `test/unit/` 70 | - E2E tests: `test/e2e/` (organized by grants and adapters) 71 | - Test setup: `test/setup.ts` 72 | - Coverage excludes: `.github`, `.idea`, `docs`, `example` 73 | -------------------------------------------------------------------------------- /docs/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 89 | -------------------------------------------------------------------------------- /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 | // Finalize scopes with user_id to validate client authorization 81 | // and allow user-specific scope restrictions 82 | const finalizedScopes = await this.scopeRepository.finalize( 83 | validScopes, 84 | this.identifier, 85 | client, 86 | user?.id, 87 | ); 88 | 89 | const accessToken = await this.issueAccessToken(accessTokenTTL, client, user, finalizedScopes); 90 | 91 | const extraJwtFields = await this.extraJwtFields(req, client, user); 92 | 93 | return await this.makeBearerTokenResponse(client, accessToken, finalizedScopes, extraJwtFields); 94 | } 95 | 96 | private isSubjectTokenType(value: string): value is `urn:${string}:oauth:token-type:${string}` { 97 | return this.SUBJECT_TOKEN_TYPE_REGEX.test(value); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.claude/agents/oauth2-server-expert.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: oauth2-server-expert 3 | description: Use this agent when you need expert guidance on the @jmondi/oauth2-server TypeScript library, including OAuth 2.0 implementation questions, RFC compliance issues, grant type configurations, repository pattern implementations, or troubleshooting authorization server setup. Also audits for documentation file updates. 4 | model: sonnet 5 | color: cyan 6 | --- 7 | You are an OAuth 2.0 authorization server expert specializing in the @jmondi/oauth2-server TypeScript library (https://github.com/jasonraimondi/ts-oauth2-server). You have deep expertise in OAuth 2.0 standards and this specific implementation. 8 | 9 | **Your Core Expertise:** 10 | - Complete mastery of the ts-oauth2-server library architecture, including AuthorizationServer, Grants, Repositories, Entities, Adapters, and Code Verifiers 11 | - Strict adherence to implemented RFCs: 6749 (OAuth 2.0), 6750 (Bearer Token), 7009 (Token Revocation), 7519 (JWT), 7636 (PKCE), 7662 (Token Introspection), and 8693 (Token Exchange) 12 | - TypeScript best practices with explicit typing and strict mode compliance 13 | - Repository pattern implementation for various databases and storage systems 14 | - Framework integration patterns (Express, Fastify, Vanilla) 15 | - Grant type implementations and flow configurations 16 | - Documentation maintenance and accuracy auditing 17 | 18 | **Your Responsibilities:** 19 | 20 | 1. **Standards Compliance**: Always ensure recommendations align with the relevant RFC specifications. Reference specific RFC sections when applicable. 21 | 22 | 2. **TypeScript Excellence**: Provide code examples using strict TypeScript with explicit types, following the project's coding standards (snake_case files, PascalCase classes, camelCase functions/variables). 23 | 24 | 3. **Architecture Guidance**: Help users implement the repository pattern correctly, configure grants properly, and integrate with their chosen frameworks. 25 | 26 | 4. **Troubleshooting**: Diagnose OAuth flow issues, token validation problems, and configuration errors with systematic debugging approaches. 27 | 28 | 5. **Security Best Practices**: Emphasize security considerations for OAuth implementations, including proper token handling, client authentication, and PKCE usage. 29 | 30 | 6. **Documentation Auditing**: When code changes or updates are made, proactively check if documentation needs updating: 31 | - Identify affected documentation files (README.md, API docs, guides, examples) 32 | - Flag outdated code examples, API signatures, or configuration patterns 33 | - Suggest specific documentation updates with clear diffs 34 | - Ensure documentation reflects current RFC compliance and best practices 35 | - Verify example code matches actual implementation patterns 36 | - Check for version-specific documentation that needs updating 37 | 38 | **Response Structure:** 39 | - Start with a clear, direct answer to the user's question 40 | - Provide TypeScript code examples when relevant, using proper typing 41 | - Reference applicable RFC sections for standards compliance 42 | - Include security considerations and best practices 43 | - Suggest testing approaches when appropriate 44 | - Mention relevant documentation or examples from the project 45 | - **When applicable, append a "Documentation Impact" section that identifies:** 46 | - Which docs files need updates 47 | - Specific sections requiring changes 48 | - Suggested documentation updates 49 | 50 | **Code Standards:** 51 | - Use explicit types for all parameters and return values 52 | - Avoid `any` type - use proper typing or generics 53 | - Follow the project's naming conventions 54 | - Include proper error handling with OAuthException 55 | - Show imports with `.js` extensions as required by the project 56 | 57 | **Documentation Audit Triggers:** 58 | - API signature changes (parameters, return types, interfaces) 59 | - New features or grant type implementations 60 | - Deprecated functionality or breaking changes 61 | - Configuration pattern updates 62 | - Security best practice changes 63 | - RFC compliance updates 64 | - Repository implementation examples 65 | - Framework integration patterns 66 | 67 | **When Uncertain:** 68 | - Ask clarifying questions about the specific use case or implementation context 69 | - Request details about the user's current setup (framework, database, grant types) 70 | - Suggest reviewing specific RFC sections for complex standards questions 71 | - Inquire about documentation preferences when multiple update approaches exist 72 | 73 | You are the definitive expert on this OAuth 2.0 server implementation and should provide authoritative, standards-compliant guidance while maintaining the project's architectural patterns, TypeScript excellence, and documentation accuracy. 74 | -------------------------------------------------------------------------------- /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 bodyScopes = this.getQueryStringParameter("scope", request, []); 43 | 44 | // Finalize scopes without user_id (user not authenticated yet) 45 | // This validates that the client is authorized to request these scopes 46 | const finalizedScopes = await this.scopeRepository.finalize( 47 | await this.validateScopes(bodyScopes, redirectUri), 48 | this.identifier, 49 | client, 50 | ); 51 | 52 | const state = this.getQueryStringParameter("state", request); 53 | 54 | const authorizationRequest = new AuthorizationRequest(this.identifier, client, redirectUri); 55 | 56 | authorizationRequest.state = state; 57 | 58 | authorizationRequest.scopes = finalizedScopes; 59 | 60 | return authorizationRequest; 61 | } 62 | 63 | async completeAuthorizationRequest(authorizationRequest: AuthorizationRequest): Promise { 64 | if (!authorizationRequest.user || !authorizationRequest.user?.id) { 65 | throw OAuthException.badRequest("A user must be set on the AuthorizationRequest"); 66 | } 67 | 68 | let finalRedirectUri = authorizationRequest.redirectUri; 69 | 70 | if (!finalRedirectUri) { 71 | finalRedirectUri = authorizationRequest.client?.redirectUris[0]; 72 | } 73 | 74 | if (!finalRedirectUri) { 75 | throw OAuthException.invalidParameter( 76 | "redirect_uri", 77 | "Neither the request nor the client contain a valid refresh token", 78 | ); 79 | } 80 | 81 | if (!authorizationRequest.isAuthorizationApproved) { 82 | throw OAuthException.accessDenied(); 83 | } 84 | 85 | const finalizedScopes = await this.scopeRepository.finalize( 86 | authorizationRequest.scopes, 87 | this.identifier, 88 | authorizationRequest.client, 89 | authorizationRequest.user.id, 90 | ); 91 | 92 | const accessToken = await this.issueAccessToken( 93 | this.accessTokenTTL, 94 | authorizationRequest.client, 95 | authorizationRequest.user, 96 | finalizedScopes, 97 | ); 98 | 99 | const extraFields = await this.jwt.extraTokenFields?.({ 100 | user: authorizationRequest.user, 101 | client: authorizationRequest.client, 102 | }); 103 | 104 | const encryptedAccessToken = await this.encryptAccessToken( 105 | authorizationRequest.client, 106 | accessToken, 107 | authorizationRequest.scopes, 108 | extraFields ?? {}, 109 | ); 110 | 111 | const params: Record = { 112 | access_token: encryptedAccessToken, 113 | token_type: "Bearer", 114 | expires_in: getSecondsUntil(accessToken.accessTokenExpiresAt).toString(), 115 | }; 116 | 117 | if (authorizationRequest.state) params.state = authorizationRequest.state.toString(); 118 | 119 | return new RedirectResponse(this.makeRedirectUrl(finalRedirectUri, params)); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 convert non-OAuthException errors to internal server error", () => { 98 | const mockExpressRes = { 99 | status: vi.fn().mockReturnThis(), 100 | send: vi.fn(), 101 | } as unknown as Response; 102 | const error = new Error("Database connection failed"); 103 | 104 | handleExpressError(error, mockExpressRes); 105 | 106 | expect(mockExpressRes.status).toHaveBeenCalledWith(500); 107 | expect(mockExpressRes.send).toHaveBeenCalledWith({ 108 | status: 500, 109 | message: "Internal server error: Database connection failed", 110 | error: "server_error", 111 | error_description: "Database connection failed", 112 | }); 113 | }); 114 | 115 | it("should handle unknown error types gracefully", () => { 116 | const mockExpressRes = { 117 | status: vi.fn().mockReturnThis(), 118 | send: vi.fn(), 119 | } as unknown as Response; 120 | 121 | handleExpressError("string error", mockExpressRes); 122 | 123 | expect(mockExpressRes.status).toHaveBeenCalledWith(500); 124 | expect(mockExpressRes.send).toHaveBeenCalledWith({ 125 | status: 500, 126 | message: "Internal server error: An unexpected error occurred", 127 | error: "server_error", 128 | error_description: "An unexpected error occurred", 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript OAuth2.0 Server 2 | 3 | [![JSR](https://jsr.io/badges/@jmondi/oauth2-server?style=flat-square)](https://jsr.io/@jmondi/oauth2-server) 4 | [![NPM Version](https://img.shields.io/npm/v/%40jmondi%2Foauth2-server?style=flat-square)](https://www.npmjs.com/package/@jmondi/oauth2-server) 5 | [![GitHub Workflow Status]( https://img.shields.io/github/actions/workflow/status/jasonraimondi/ts-oauth2-server/build-and-test.yml?branch=main&style=flat-square)](https://github.com/jasonraimondi/ts-oauth2-server) 6 | [![Test Coverage](https://codecov.io/gh/jasonraimondi/ts-oauth2-server/branch/main/graph/badge.svg?token=F7VTS15XOJ)](https://codecov.io/gh/jasonraimondi/ts-oauth2-server) 7 | [![NPM Downloads](https://img.shields.io/npm/dt/@jmondi/oauth2-server?label=npm%20downloads&style=flat-square)](https://www.npmjs.com/package/@jmondi/oauth2-server) 8 | 9 | `@jmondi/oauth2-server` is a standards compliant implementation of an OAuth 2.0 authorization server written in TypeScript. 10 | 11 | Requires `node >= 18`. [Read the docs](https://tsoauth2server.com/) 12 | 13 | The following RFCs are implemented: 14 | 15 | - [RFC6749 "OAuth 2.0"](https://tools.ietf.org/html/rfc6749) 16 | - [RFC6750 "The OAuth 2.0 Authorization Framework: Bearer Token Usage"](https://tools.ietf.org/html/rfc6750) 17 | - [RFC7009 "OAuth 2.0 Token Revocation"](https://tools.ietf.org/html/rfc7009) 18 | - [RFC7519 "JSON Web Token (JWT)"](https://tools.ietf.org/html/rfc7519) 19 | - [RFC7636 "Proof Key for Code Exchange by OAuth Public Clients"](https://tools.ietf.org/html/rfc7636) 20 | - [RFC7662 "OAuth 2.0 Token Introspection"](https://tools.ietf.org/html/rfc7662) 21 | - [RFC8693 "OAuth 2.0 Token Exchange"](https://datatracker.ietf.org/doc/html/rfc8693) 22 | 23 | Out of the box it supports the following grants: 24 | 25 | - [Authorization code grant](https://tsoauth2server.com/docs/grants/authorization_code) 26 | - [Client credentials grant](https://tsoauth2server.com/docs/grants/client_credentials) 27 | - [Refresh grant](https://tsoauth2server.com/docs/grants/refresh_token) 28 | - [Implicit grant](https://tsoauth2server.com/docs/grants/implicit) // not recommended 29 | - [Resource owner password credentials grant](https://tsoauth2server.com/docs/grants/password) // not recommended 30 | 31 | Framework support: 32 | 33 | The included adapters are just helper functions, any framework should be supported. Take a look at the adapter implementations to learn how you can create custom adapters for your favorite tool! 34 | 35 | - [VanillaJS](https://tsoauth2server.com/docs/adapters/vanilla) 36 | - [Express](https://tsoauth2server.com/docs/adapters/express) 37 | - [Fastify](https://tsoauth2server.com/docs/adapters/fastify). 38 | 39 | ### Usage 40 | 41 | A example using client credentials grant 42 | 43 | ```ts 44 | const authorizationServer = new AuthorizationServer( 45 | clientRepository, 46 | accessTokenRepository, 47 | scopeRepository, 48 | "secret-key", 49 | ); 50 | authorizationServer.enableGrantType("client_credentials"); 51 | 52 | app.post("/token", async (req: Express.Request, res: Express.Response) => { 53 | try { 54 | const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 55 | return handleExpressResponse(res, oauthResponse); 56 | } catch (e) { 57 | handleExpressError(e, res); 58 | } 59 | }); 60 | 61 | app.post("/token/revoke", async (req: Express.Request, res: Express.Response) => { 62 | try { 63 | const oauthResponse = await authorizationServer.revoke(req); 64 | return handleExpressResponse(res, oauthResponse); 65 | } catch (e) { 66 | handleExpressError(e, res); 67 | } 68 | }); 69 | ``` 70 | 71 | Example implementations: 72 | 73 | - [Simple](./example) 74 | - [Advanced](https://github.com/jasonraimondi/ts-oauth2-server-example) 75 | 76 | ### Security 77 | 78 | | Version | Latest Version | Security Updates | 79 | |-----------------|----------------|------------------| 80 | | [4.x][version4] | :tada: | :tada: | 81 | | [3.x][version3] | :tada: | :tada: | 82 | | [2.x][version2] | | :tada: | 83 | 84 | [version4]: https://github.com/jasonraimondi/ts-oauth2-server/tree/main 85 | [version3]: https://github.com/jasonraimondi/ts-oauth2-server/tree/3.x 86 | [version2]: https://github.com/jasonraimondi/ts-oauth2-server/tree/2.x 87 | 88 | ## Migration Guide 89 | 90 | - [v1 to v2](https://github.com/jasonraimondi/ts-oauth2-server/releases/tag/v2.0.0) 91 | - [v2 to v3](https://tsoauth2server.com/docs/upgrade_guide#to-v3) 92 | - [v3 to v4](https://tsoauth2server.com/docs/upgrade_guide#to-v4) 93 | 94 | ## Thanks 95 | 96 | This project is inspired by the [PHP League's OAuth2 Server](https://oauth2.thephpleague.com/). Check out the [PHP League's other packages](https://thephpleague.com/#packages) for some other great PHP projects. 97 | 98 | ## Star History 99 | 100 | [![Star History Chart](https://api.star-history.com/svg?repos=jasonraimondi/ts-oauth2-server&type=Timeline)](https://star-history.com/#jasonraimondi/ts-oauth2-server&Timeline) 101 | -------------------------------------------------------------------------------- /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 { isOAuthError } from "../utils/errors.js"; 5 | 6 | /** 7 | * Converts a standard Fetch API Response object to an OAuthResponse. 8 | * 9 | * @param res - Fetch API Response object 10 | * @returns OAuthResponse instance 11 | */ 12 | export function responseFromVanilla(res: Response): OAuthResponse { 13 | const headers: Record = {}; 14 | res.headers.forEach((value, key) => { 15 | if (key === "cookie") return; 16 | headers[key] = value; 17 | }); 18 | 19 | return new OAuthResponse({ 20 | headers: headers, 21 | }); 22 | } 23 | 24 | /** 25 | * Converts an OAuthResponse to a standard Fetch API Response. 26 | * Properly handles both regular responses and redirects. 27 | * 28 | * @param oauthResponse - OAuth response to convert 29 | * @returns Fetch API Response object 30 | * @throws {OAuthException} When redirect location is missing for 302 responses 31 | * 32 | * @example 33 | * ```ts 34 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 35 | * return responseToVanilla(oauthResponse); 36 | * ``` 37 | */ 38 | export function responseToVanilla(oauthResponse: OAuthResponse): Response { 39 | if (oauthResponse.status === 302) { 40 | if (typeof oauthResponse.headers.location !== "string" || oauthResponse.headers.location === "") { 41 | throw new OAuthException(`missing redirect location`, ErrorType.InvalidRequest); 42 | } 43 | return new Response(null, { 44 | status: 302, 45 | headers: { 46 | Location: oauthResponse.headers.location, 47 | }, 48 | }); 49 | } 50 | 51 | return new Response(JSON.stringify(oauthResponse.body), { 52 | status: oauthResponse.status, 53 | headers: oauthResponse.headers, 54 | }); 55 | } 56 | 57 | /** 58 | * Converts a standard Fetch API Request object to an OAuthRequest. 59 | * Handles both URL-encoded and JSON request bodies. 60 | * 61 | * @param req - Fetch API Request object 62 | * @returns Promise that resolves to an OAuthRequest instance 63 | * 64 | * @example 65 | * ```ts 66 | * import { requestFromVanilla } from "@jmondi/oauth2-server/vanilla"; 67 | * 68 | * const authRequest = await authorizationServer.validateAuthorizationRequest( 69 | * await requestFromVanilla(req) 70 | * ); 71 | * ``` 72 | */ 73 | export async function requestFromVanilla(req: Request): Promise { 74 | const url = new URL(req.url); 75 | const query: Record = Object.fromEntries(url.searchParams); 76 | const headers: Record = Object.fromEntries(req.headers); 77 | 78 | let body: Record = {}; 79 | const rawContentType = req.headers.get("content-type") || ""; 80 | const contentType = rawContentType.split(";")[0].trim(); 81 | 82 | if (req.body) { 83 | if (contentType === "application/x-www-form-urlencoded") { 84 | body = Object.fromEntries(new URLSearchParams(await req.text())); 85 | } else if (contentType === "application/json") { 86 | body = (await req.json()) as Record; 87 | } 88 | } 89 | 90 | return new OAuthRequest({ 91 | query: query, 92 | body: body, 93 | headers: headers, 94 | }); 95 | } 96 | 97 | /** 98 | * Converts any error to a proper OAuth response. 99 | * Use this to handle errors in Vanilla/Fetch API implementations. 100 | * 101 | * @param e - Error object, typically an OAuthException 102 | * @returns OAuthResponse with appropriate error details 103 | * 104 | * @example 105 | * ```ts 106 | * try { 107 | * const oauthResponse = await authorizationServer.respondToAccessTokenRequest(req); 108 | * return responseToVanilla(oauthResponse); 109 | * } catch (e) { 110 | * return responseToVanilla(handleVanillaError(e)); 111 | * } 112 | * ``` 113 | */ 114 | export function handleVanillaError(e: unknown | OAuthException): OAuthResponse { 115 | if (isOAuthError(e)) { 116 | return new OAuthResponse({ 117 | status: e.status, 118 | headers: { "content-type": "application/json" }, 119 | body: { 120 | status: e.status, 121 | message: e.message, 122 | error: e.errorType, 123 | error_description: e.errorDescription ?? e.error, 124 | }, 125 | }); 126 | } 127 | 128 | // Convert generic errors to OAuthException 129 | const errorMessage = e instanceof Error ? e.message : "An unexpected error occurred"; 130 | const oauthError = OAuthException.internalServerError(errorMessage); 131 | 132 | return new OAuthResponse({ 133 | status: oauthError.status, 134 | headers: { "content-type": "application/json" }, 135 | body: { 136 | status: oauthError.status, 137 | message: oauthError.message, 138 | error: oauthError.errorType, 139 | error_description: oauthError.errorDescription ?? oauthError.error, 140 | }, 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------