├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ ├── dependabot.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lock ├── package.json ├── scripts └── exports.ts ├── src ├── index.test.ts ├── index.ts └── lib │ ├── redirect.ts │ └── store.ts ├── tsconfig.json └── typedoc.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | reviewers: 10 | - "sergiodxa" 11 | assignees: 12 | - "sergiodxa" 13 | 14 | - package-ecosystem: bun 15 | directory: / 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "sergiodxa" 20 | assignees: 21 | - "sergiodxa" 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Features 4 | labels: 5 | - enhancement 6 | - title: Documentation Changes 7 | labels: 8 | - documentation 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Example 13 | labels: 14 | - example 15 | - title: Deprecations 16 | labels: 17 | - deprecated 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Type of version to bump" 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | 15 | jobs: 16 | bump-version: 17 | name: Bump version 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ssh-key: ${{ secrets.DEPLOY_KEY }} 23 | 24 | - uses: oven-sh/setup-bun@v2 25 | - run: bun install --frozen-lockfile 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: "lts/*" 30 | 31 | - run: | 32 | git config user.name 'Sergio Xalambrí' 33 | git config user.email 'hello@sergiodxa.com' 34 | 35 | - run: npm version ${{ github.event.inputs.version }} 36 | - run: bun run quality:fix 37 | - run: git push origin main --follow-tags 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | - run: bun install --frozen-lockfile 13 | - run: bun run build 14 | 15 | typecheck: 16 | name: Typechecker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | - run: bun install --frozen-lockfile 22 | - run: bun run typecheck 23 | 24 | quality: 25 | name: Code Quality 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - run: bun install --frozen-lockfile 31 | - run: bun run quality 32 | 33 | test: 34 | name: Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: oven-sh/setup-bun@v2 39 | - run: bun install --frozen-lockfile 40 | - run: bun test 41 | 42 | exports: 43 | name: Verify Exports 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: oven-sh/setup-bun@v2 48 | - run: bun install --frozen-lockfile 49 | - run: bun run exports 50 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Enable auto-merge for Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | types: opened 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "docs" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: oven-sh/setup-bun@v2 26 | - run: bun install --frozen-lockfile 27 | - run: bunx typedoc 28 | - uses: actions/configure-pages@v5 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: "./docs" 32 | - uses: actions/deploy-pages@v4 33 | id: deployment 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: "Publish to npm" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v2 14 | - run: bun install --frozen-lockfile 15 | - run: bun run build 16 | - run: bun run exports 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "lts/*" 21 | registry-url: https://registry.npmjs.org/ 22 | 23 | - run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /coverage 4 | /docs 5 | 6 | *.log 7 | .DS_Store -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports.biome": "explicit", 22 | "quickfix.biome": "explicit" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Setup 4 | 5 | Run `bun install` to install the dependencies. 6 | 7 | Run the tests with `bun test`. 8 | 9 | Run the code quality checker with `bun run quality`. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sergio Xalambrí 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth2Strategy 2 | 3 | A strategy to use and implement OAuth2 framework for authentication with federated services like Google, Facebook, GitHub, etc. 4 | 5 | > [!WARNING] 6 | > This strategy expects the identity provider to strictly follow the OAuth2 specification. If the provider does not follow the specification and diverges from it, this strategy may not work as expected. 7 | 8 | ## Supported runtimes 9 | 10 | | Runtime | Has Support | 11 | | ---------- | ----------- | 12 | | Node.js | ✅ | 13 | | Cloudflare | ✅ | 14 | 15 | ## How to use 16 | 17 | ### Installation 18 | 19 | ```bash 20 | npm add remix-auth-oauth2 21 | ``` 22 | 23 | ### Directly 24 | 25 | You can use this strategy by adding it to your authenticator instance and configuring the correct endpoints. 26 | 27 | ```ts 28 | import { OAuth2Strategy, CodeChallengeMethod } from "remix-auth-oauth2"; 29 | 30 | export const authenticator = new Authenticator(); 31 | 32 | authenticator.use( 33 | new OAuth2Strategy( 34 | { 35 | cookie: "oauth2", // Optional, can also be an object with more options 36 | 37 | clientId: CLIENT_ID, 38 | clientSecret: CLIENT_SECRET, 39 | 40 | authorizationEndpoint: "https://provider.com/oauth2/authorize", 41 | tokenEndpoint: "https://provider.com/oauth2/token", 42 | redirectURI: "https://example.app/auth/callback", 43 | 44 | tokenRevocationEndpoint: "https://provider.com/oauth2/revoke", // optional 45 | 46 | scopes: ["openid", "email", "profile"], // optional 47 | codeChallengeMethod: CodeChallengeMethod.S256, // optional 48 | }, 49 | async ({ tokens, request }) => { 50 | // here you can use the params above to get the user and return it 51 | // what you do inside this and how you find the user is up to you 52 | return await getUser(tokens, request); 53 | } 54 | ), 55 | // this is optional, but if you setup more than one OAuth2 instance you will 56 | // need to set a custom name to each one 57 | "provider-name" 58 | ); 59 | ``` 60 | 61 | Then you will need to setup your routes, for the OAuth2 flows you will need to call the `authenticate` method twice. 62 | 63 | First, you will call the `authenticate` method with the provider name you set in the authenticator. 64 | 65 | ```ts 66 | export async function action({ request }: Route.ActionArgs) { 67 | await authenticator.authenticate("provider-name", request); 68 | } 69 | ``` 70 | 71 | > [!NOTE] 72 | > This route can be an `action` or a `loader`, it depends if you trigger the flow doing a POST or GET request. 73 | 74 | This will start the OAuth2 flow and redirect the user to the provider's login page. Once the user logs in and authorizes your application, the provider will redirect the user back to your application redirect URI. 75 | 76 | You will now need a route on that URI to handle the callback from the provider. 77 | 78 | ```ts 79 | export async function loader({ request }: Route.LoaderArgs) { 80 | let user = await authenticator.authenticate("provider-name", request); 81 | // now you have the user object with the data you returned in the verify function 82 | } 83 | ``` 84 | 85 | > [!NOTE] 86 | > This route must be a `loader` as the redirect will trigger a `GET` request. 87 | 88 | Once you have the `user` object returned by your strategy verify function, you can do whatever you want with that information. This can be storing the user in a session, creating a new user in your database, link the account to an existing user in your database, etc. 89 | 90 | ### Using the Refresh Token 91 | 92 | The strategy exposes a public `refreshToken` method that you can use to refresh the access token. 93 | 94 | ```ts 95 | let strategy = new OAuth2Strategy(options, verify); 96 | let tokens = await strategy.refreshToken(refreshToken); 97 | ``` 98 | 99 | The refresh token is part of the `tokens` object the verify function receives. How you store it to call `strategy.refreshToken` and what you do with the `tokens` object after it is up to you. 100 | 101 | The most common approach would be to store the refresh token in the user data and then update the session after refreshing the token. 102 | 103 | ```ts 104 | authenticator.use( 105 | new OAuth2Strategy( 106 | options, 107 | async ({ tokens, request }) => { 108 | let user = await getUser(tokens, request); 109 | return { 110 | ...user, 111 | accessToken: tokens.accessToken() 112 | refreshToken: tokens.hasRefreshToken() ? tokens.refreshToken() : null, 113 | } 114 | } 115 | ) 116 | ); 117 | 118 | // later in your code you can use it to get new tokens object 119 | let tokens = await strategy.refreshToken(user.refreshToken); 120 | ``` 121 | 122 | ### Revoking Tokens 123 | 124 | You can revoke the access token the user has with the provider. 125 | 126 | ```ts 127 | await strategy.revokeToken(user.accessToken); 128 | ``` 129 | 130 | ### Discovering the Provider 131 | 132 | If you want to discover the provider's endpoints, you can use the `discover` static method. 133 | 134 | ```ts 135 | export let authenticator = new Authenticator(); 136 | 137 | authenticator.use( 138 | await OAuth2Strategy.discover( 139 | "https://provider.com", 140 | { 141 | clientId: CLIENT_ID, 142 | clientSecret: CLIENT_SECRET, 143 | redirectURI: "https://example.app/auth/callback", 144 | scopes: ["openid", "email", "profile"], // optional 145 | }, 146 | async ({ tokens, request }) => { 147 | // here you can use the params above to get the user and return it 148 | // what you do inside this and how you find the user is up to you 149 | return await getUser(tokens, request); 150 | } 151 | ) 152 | ); 153 | ``` 154 | 155 | This will fetch the provider's configuration endpoint (`/.well-known/openid-configuration`) and grab the authorization, token and revocation endpoints from it, it will also grab the code challenge method supported and try to use S256 if it is supported. 156 | 157 | Remember this will do a fetch when then strategy is created, this will add a latency to the startup of your application. 158 | 159 | It's recommended to use this method only once and then copy the endpoints to your configuration. 160 | 161 | ### Customizing the Cookie 162 | 163 | You can customize the cookie options by passing an object to the `cookie` option. 164 | 165 | ```ts 166 | authenticator.use( 167 | new OAuth2Strategy( 168 | { 169 | cookie: { 170 | name: "oauth2", 171 | maxAge: 60 * 60 * 24 * 7, // 1 week 172 | path: "/auth", 173 | httpOnly: true, 174 | sameSite: "lax", 175 | secure: process.env.NODE_ENV === "production", 176 | }, 177 | clientId: CLIENT_ID, 178 | clientSecret: CLIENT_SECRET, 179 | authorizationEndpoint: "https://provider.com/oauth2/authorize", 180 | tokenEndpoint: "https://provider.com/oauth2/token", 181 | redirectURI: "https://example.app/auth/callback", 182 | }, 183 | async ({ tokens, request }) => { 184 | return await getUser(tokens, request); 185 | } 186 | ) 187 | ); 188 | ``` 189 | 190 | This will set the cookie with the name `oauth2`, with a max age of 1 week, only accessible on the `/auth` path, http only, same site lax and secure if the application is running in production. 191 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useHookAtTopLevel": "error" 12 | }, 13 | "performance": { 14 | "noBarrelFile": "error", 15 | "noReExportAll": "error" 16 | }, 17 | "style": { 18 | "noDefaultExport": "error", 19 | "noNegationElse": "error", 20 | "useConst": "off", 21 | "useExportType": "off", 22 | "useImportType": "off" 23 | }, 24 | "suspicious": { 25 | "noConsoleLog": "warn", 26 | "noEmptyBlockStatements": "warn", 27 | "noSkippedTests": "error" 28 | } 29 | } 30 | }, 31 | "formatter": { "enabled": true }, 32 | "vcs": { 33 | "enabled": true, 34 | "clientKind": "git", 35 | "defaultBranch": "main", 36 | "useIgnoreFile": true 37 | }, 38 | "overrides": [ 39 | { 40 | "include": ["**/*.md"], 41 | "formatter": { "indentStyle": "tab" } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "remix-auth-oauth2", 6 | "dependencies": { 7 | "@edgefirst-dev/data": "^0.0.4", 8 | "@mjackson/headers": "^0.11.1", 9 | "arctic": "^3.0.0", 10 | }, 11 | "devDependencies": { 12 | "@arethetypeswrong/cli": "^0.18.1", 13 | "@biomejs/biome": "^1.9.4", 14 | "@total-typescript/tsconfig": "^1.0.4", 15 | "@types/bun": "^1.1.14", 16 | "msw": "^2.6.6", 17 | "remix-auth": "^4.0.0", 18 | "typedoc": "^0.28.0", 19 | "typedoc-plugin-mdn-links": "^5.0.1", 20 | "typescript": "^5.7.2", 21 | }, 22 | "peerDependencies": { 23 | "remix-auth": "^4.0.0", 24 | }, 25 | }, 26 | }, 27 | "packages": { 28 | "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], 29 | 30 | "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.1", "", { "dependencies": { "@arethetypeswrong/core": "0.18.1", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "dist/index.js" } }, "sha512-SS1Z5gRSvbP4tl98KlNygSUp3Yfenktt782MQKEbYm6GFPowztnnvdEUhQGm2uVDIH4YkU6av+n8Lm6OEOigqA=="], 31 | 32 | "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.1", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.2", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-uUw47cLgB6zYOpAxFp94NG/J9ev0wcOC+UOmTCFEWtbDEn4vpR0ScoPxD7LCGcPczOd7bDJSJL/gMSz3BknYcw=="], 33 | 34 | "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], 35 | 36 | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], 37 | 38 | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], 39 | 40 | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], 41 | 42 | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], 43 | 44 | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], 45 | 46 | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], 47 | 48 | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], 49 | 50 | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], 51 | 52 | "@braidai/lang": ["@braidai/lang@1.1.0", "", {}, "sha512-xyJYkiyNQtTyCLeHxZmOs7rnB94D+N1IjKNArQIh8+8lTBOY7TFgwEV+Ow5a1uaBi5j2w9fLbWcJFTWLDItl5g=="], 53 | 54 | "@bundled-es-modules/cookie": ["@bundled-es-modules/cookie@2.0.1", "", { "dependencies": { "cookie": "^0.7.2" } }, "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw=="], 55 | 56 | "@bundled-es-modules/statuses": ["@bundled-es-modules/statuses@1.0.1", "", { "dependencies": { "statuses": "^2.0.1" } }, "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg=="], 57 | 58 | "@bundled-es-modules/tough-cookie": ["@bundled-es-modules/tough-cookie@0.1.6", "", { "dependencies": { "@types/tough-cookie": "^4.0.5", "tough-cookie": "^4.1.4" } }, "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw=="], 59 | 60 | "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], 61 | 62 | "@edgefirst-dev/data": ["@edgefirst-dev/data@0.0.4", "", {}, "sha512-VLhlvEPDJ0Sd0pE6sAYTQkIqZCXVonaWlgRJIQQHzfjTXCadF77qqHj5NxaPSc4wCul0DJO/0MnejVqJAXUiRg=="], 63 | 64 | "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.2.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.2.1", "@shikijs/langs": "^3.2.1", "@shikijs/themes": "^3.2.1", "@shikijs/types": "^3.2.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-vaZNGhGLKMY14HbF53xxHNgFO9Wz+t5lTlGNpl2N9xFiKQ0I5oIe0vKjU9dh7Nb3Dw6lZ7wqUE0ri+zcdpnK+Q=="], 65 | 66 | "@inquirer/confirm": ["@inquirer/confirm@5.1.6", "", { "dependencies": { "@inquirer/core": "^10.1.7", "@inquirer/type": "^3.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw=="], 67 | 68 | "@inquirer/core": ["@inquirer/core@10.1.7", "", { "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA=="], 69 | 70 | "@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], 71 | 72 | "@inquirer/type": ["@inquirer/type@3.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA=="], 73 | 74 | "@loaderkit/resolve": ["@loaderkit/resolve@1.0.3", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-oo51csrgEfeHO593bqoPOGwrX093QzDWrc/7y876b/ObDqp2Hbw+rl+3s26WRXIbnhty40T403nwU4UFX3KQCg=="], 75 | 76 | "@mjackson/headers": ["@mjackson/headers@0.11.1", "", {}, "sha512-uXXhd4rtDdDwkqAuGef1nuafkCa1NlTmEc1Jzc0NL4YiA1yON1NFXuqJ3hOuKvNKQwkiDwdD+JJlKVyz4dunFA=="], 77 | 78 | "@mswjs/interceptors": ["@mswjs/interceptors@0.39.2", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg=="], 79 | 80 | "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], 81 | 82 | "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], 83 | 84 | "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], 85 | 86 | "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], 87 | 88 | "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], 89 | 90 | "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], 91 | 92 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], 93 | 94 | "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], 95 | 96 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-wZZAkayEn6qu2+YjenEoFqj0OyQI64EWsNR6/71d1EkG4sxEOFooowKivsWPpaWNBu3sxAG+zPz5kzBL/SsreQ=="], 97 | 98 | "@shikijs/langs": ["@shikijs/langs@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-If0iDHYRSGbihiA8+7uRsgb1er1Yj11pwpX1c6HLYnizDsKAw5iaT3JXj5ZpaimXSWky/IhxTm7C6nkiYVym+A=="], 99 | 100 | "@shikijs/themes": ["@shikijs/themes@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-k5DKJUT8IldBvAm8WcrDT5+7GA7se6lLksR+2E3SvyqGTyFMzU2F9Gb7rmD+t+Pga1MKrYFxDIeyWjMZWM6uBQ=="], 101 | 102 | "@shikijs/types": ["@shikijs/types@3.2.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/NTWAk4KE2M8uac0RhOsIhYQf4pdU0OywQuYDGIGAJ6Mjunxl2cGiuLkvu4HLCMn+OTTLRWkjZITp+aYJv60yA=="], 103 | 104 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], 105 | 106 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], 107 | 108 | "@total-typescript/tsconfig": ["@total-typescript/tsconfig@1.0.4", "", {}, "sha512-fO4ctMPGz1kOFOQ4RCPBRBfMy3gDn+pegUfrGyUFRMv/Rd0ZM3/SHH3hFCYG4u6bPLG8OlmOGcBLDexvyr3A5w=="], 109 | 110 | "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], 111 | 112 | "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], 113 | 114 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], 115 | 116 | "@types/node": ["@types/node@22.13.8", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ=="], 117 | 118 | "@types/statuses": ["@types/statuses@2.0.5", "", {}, "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A=="], 119 | 120 | "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], 121 | 122 | "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 123 | 124 | "ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], 125 | 126 | "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], 127 | 128 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 129 | 130 | "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], 131 | 132 | "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], 133 | 134 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 135 | 136 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 137 | 138 | "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 139 | 140 | "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], 141 | 142 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 143 | 144 | "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], 145 | 146 | "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 147 | 148 | "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], 149 | 150 | "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], 151 | 152 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], 153 | 154 | "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], 155 | 156 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 157 | 158 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 159 | 160 | "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], 161 | 162 | "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 163 | 164 | "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 165 | 166 | "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], 167 | 168 | "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], 169 | 170 | "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], 171 | 172 | "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 173 | 174 | "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 175 | 176 | "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 177 | 178 | "graphql": ["graphql@16.10.0", "", {}, "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ=="], 179 | 180 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 181 | 182 | "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], 183 | 184 | "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], 185 | 186 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 187 | 188 | "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], 189 | 190 | "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], 191 | 192 | "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], 193 | 194 | "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], 195 | 196 | "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], 197 | 198 | "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], 199 | 200 | "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], 201 | 202 | "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], 203 | 204 | "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 205 | 206 | "msw": ["msw@2.10.2", "", { "dependencies": { "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.39.1", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "strict-event-emitter": "^0.5.1", "type-fest": "^4.26.1", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-RCKM6IZseZQCWcSWlutdf590M8nVfRHG1ImwzOtwz8IYxgT4zhUO0rfTcTvDGiaFE0Rhcc+h43lcF3Jc9gFtwQ=="], 207 | 208 | "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], 209 | 210 | "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], 211 | 212 | "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], 213 | 214 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 215 | 216 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], 217 | 218 | "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], 219 | 220 | "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], 221 | 222 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 223 | 224 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 225 | 226 | "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], 227 | 228 | "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 229 | 230 | "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], 231 | 232 | "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], 233 | 234 | "remix-auth": ["remix-auth@4.1.0", "", {}, "sha512-Xdy42clt+g79GCn+Wl1+B6S/yWnvrStnk62vo1pGuRuUwHC+pjmeEE52ZRkRPuhLGqsQnoDD9TVz/wfEJAGF8g=="], 235 | 236 | "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 237 | 238 | "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], 239 | 240 | "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 241 | 242 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 243 | 244 | "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], 245 | 246 | "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], 247 | 248 | "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], 249 | 250 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 251 | 252 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 253 | 254 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 255 | 256 | "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], 257 | 258 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], 259 | 260 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], 261 | 262 | "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], 263 | 264 | "type-fest": ["type-fest@4.36.0", "", {}, "sha512-3T/PUdKTCnkUmhQU6FFJEHsLwadsRegktX3TNHk+2JJB9HlA8gp1/VXblXVDI93kSnXF2rdPx0GMbHtJIV2LPg=="], 265 | 266 | "typedoc": ["typedoc@0.28.5", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.2.2", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.7.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-5PzUddaA9FbaarUzIsEc4wNXCiO4Ot3bJNeMF2qKpYlTmM9TTaSHQ7162w756ERCkXER/+o2purRG6YOAv6EMA=="], 267 | 268 | "typedoc-plugin-mdn-links": ["typedoc-plugin-mdn-links@5.0.2", "", { "peerDependencies": { "typedoc": "0.27.x || 0.28.x" } }, "sha512-Bd3lsVWPSpDkn6NGZyPHpcK088PUvH4SRq4RD97OjA6l8PQA3yOnJhGACtjmIDdcenRTgWUosH+55ANZhx/wkw=="], 269 | 270 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 271 | 272 | "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], 273 | 274 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 275 | 276 | "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], 277 | 278 | "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], 279 | 280 | "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], 281 | 282 | "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], 283 | 284 | "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], 285 | 286 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 287 | 288 | "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], 289 | 290 | "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], 291 | 292 | "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 293 | 294 | "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], 295 | 296 | "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], 297 | 298 | "@inquirer/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], 299 | 300 | "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], 301 | 302 | "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], 303 | 304 | "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 305 | 306 | "marked-terminal/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], 307 | 308 | "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], 309 | 310 | "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 311 | 312 | "@inquirer/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], 313 | 314 | "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], 315 | 316 | "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], 317 | 318 | "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-auth-oauth2", 3 | "version": "3.4.1", 4 | "description": "A strategy to use and implement OAuth2 framework for authentication with federated services like Google, Facebook, GitHub, etc.", 5 | "license": "MIT", 6 | "funding": [ 7 | "https://github.com/sponsors/sergiodxa" 8 | ], 9 | "keywords": [ 10 | "remix", 11 | "remix-auth", 12 | "auth", 13 | "authentication", 14 | "strategy" 15 | ], 16 | "author": { 17 | "name": "Sergio Xalambrí", 18 | "email": "hello+oss@sergiodxa.com", 19 | "url": "https://sergiodxa.com" 20 | }, 21 | "repository": { 22 | "url": "https://github.com/sergiodxa/remix-auth-oauth2", 23 | "type": "git" 24 | }, 25 | "homepage": "https://github.com/sergiodxa/remix-auth-oauth2#readme", 26 | "bugs": { 27 | "url": "https://github.com/sergiodxa/remix-auth-oauth2/issues" 28 | }, 29 | "scripts": { 30 | "build": "tsc", 31 | "typecheck": "tsc --noEmit", 32 | "quality": "biome check .", 33 | "quality:fix": "biome check . --write --unsafe", 34 | "exports": "bun run ./scripts/exports.ts" 35 | }, 36 | "sideEffects": false, 37 | "type": "module", 38 | "engines": { 39 | "node": "^20.0.0 || >=20.0.0" 40 | }, 41 | "files": [ 42 | "build", 43 | "package.json", 44 | "README.md" 45 | ], 46 | "exports": { 47 | ".": "./build/index.js", 48 | "./package.json": "./package.json" 49 | }, 50 | "dependencies": { 51 | "@edgefirst-dev/data": "^0.0.4", 52 | "@mjackson/headers": "^0.11.1", 53 | "arctic": "^3.0.0" 54 | }, 55 | "peerDependencies": { 56 | "remix-auth": "^4.0.0" 57 | }, 58 | "devDependencies": { 59 | "@arethetypeswrong/cli": "^0.18.1", 60 | "@biomejs/biome": "^1.9.4", 61 | "@total-typescript/tsconfig": "^1.0.4", 62 | "@types/bun": "^1.1.14", 63 | "msw": "^2.6.6", 64 | "remix-auth": "^4.0.0", 65 | "typedoc": "^0.28.0", 66 | "typedoc-plugin-mdn-links": "^5.0.1", 67 | "typescript": "^5.7.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/exports.ts: -------------------------------------------------------------------------------- 1 | async function main() { 2 | let proc = Bun.spawn([ 3 | "bunx", 4 | "attw", 5 | "-f", 6 | "table-flipped", 7 | "--no-emoji", 8 | "--no-color", 9 | "--pack", 10 | ]); 11 | 12 | let text = await new Response(proc.stdout).text(); 13 | 14 | let entrypointLines = text 15 | .slice(text.indexOf('"remix-i18next/')) 16 | .split("\n") 17 | .filter(Boolean) 18 | .filter((line) => !line.includes("─")) 19 | .map((line) => 20 | line 21 | .replaceAll(/[^\d "()/A-Za-z│-]/g, "") 22 | .replaceAll("90m│39m", "│") 23 | .replaceAll(/^│/g, "") 24 | .replaceAll(/│$/g, ""), 25 | ); 26 | 27 | let pkg = await Bun.file("package.json").json(); 28 | let entrypoints = entrypointLines.map((entrypointLine) => { 29 | let [entrypoint, ...resolutionColumns] = entrypointLine.split("│"); 30 | if (!entrypoint) throw new Error("Entrypoint not found"); 31 | if (!resolutionColumns[2]) throw new Error("ESM resolution not found"); 32 | if (!resolutionColumns[3]) throw new Error("Bundler resolution not found"); 33 | return { 34 | entrypoint: entrypoint.replace(pkg.name, ".").trim(), 35 | esm: resolutionColumns[2].trim(), 36 | bundler: resolutionColumns[3].trim(), 37 | }; 38 | }); 39 | 40 | let entrypointsWithProblems = entrypoints.filter( 41 | (item) => item.esm.includes("fail") || item.bundler.includes("fail"), 42 | ); 43 | 44 | if (entrypointsWithProblems.length > 0) { 45 | console.error("Entrypoints with problems:"); 46 | process.exit(1); 47 | } 48 | } 49 | 50 | await main().catch((error) => { 51 | console.error(error); 52 | process.exit(1); 53 | }); 54 | 55 | export {}; 56 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterAll, 3 | afterEach, 4 | beforeAll, 5 | describe, 6 | expect, 7 | mock, 8 | test, 9 | } from "bun:test"; 10 | import { Cookie, SetCookie } from "@mjackson/headers"; 11 | import { http, HttpResponse } from "msw"; 12 | import { setupServer } from "msw/native"; 13 | import { OAuth2Strategy } from "."; 14 | import { StateStore } from "./lib/store"; 15 | 16 | const server = setupServer( 17 | http.post("https://example.app/token", async () => { 18 | return HttpResponse.json({ 19 | access_token: "mocked", 20 | expires_in: 3600, 21 | refresh_token: "mocked", 22 | scope: ["user:email", "user:profile"].join(" "), 23 | token_type: "Bearer", 24 | }); 25 | }), 26 | ); 27 | 28 | describe(OAuth2Strategy.name, () => { 29 | let verify = mock(); 30 | 31 | let options = Object.freeze({ 32 | authorizationEndpoint: "https://example.app/authorize", 33 | tokenEndpoint: "https://example.app/token", 34 | clientId: "MY_CLIENT_ID", 35 | clientSecret: "MY_CLIENT_SECRET", 36 | redirectURI: "https://example.com/callback", 37 | scopes: ["user:email", "user:profile"], 38 | } satisfies OAuth2Strategy.ConstructorOptions); 39 | 40 | interface User { 41 | id: string; 42 | } 43 | 44 | beforeAll(() => { 45 | server.listen(); 46 | }); 47 | 48 | afterEach(() => { 49 | server.resetHandlers(); 50 | }); 51 | 52 | afterAll(() => { 53 | server.close(); 54 | }); 55 | 56 | test("should have the name `oauth2`", () => { 57 | let strategy = new OAuth2Strategy(options, verify); 58 | expect(strategy.name).toBe("oauth2"); 59 | }); 60 | 61 | test("redirects to authorization url if there's no state", async () => { 62 | let strategy = new OAuth2Strategy(options, verify); 63 | 64 | let request = new Request("https://remix.auth/login"); 65 | 66 | let response = await catchResponse(strategy.authenticate(request)); 67 | 68 | // biome-ignore lint/style/noNonNullAssertion: This is a test 69 | let redirect = new URL(response.headers.get("location")!); 70 | 71 | let setCookie = new SetCookie(response.headers.get("set-cookie") ?? ""); 72 | let params = new URLSearchParams(setCookie.value); 73 | 74 | expect(redirect.pathname).toBe("/authorize"); 75 | expect(redirect.searchParams.get("response_type")).toBe("code"); 76 | expect(redirect.searchParams.get("client_id")).toBe(options.clientId); 77 | expect(redirect.searchParams.get("redirect_uri")).toBe(options.redirectURI); 78 | expect(redirect.searchParams.has("state")).toBeTruthy(); 79 | expect(redirect.searchParams.get("scope")).toBe(options.scopes.join(" ")); 80 | expect(params.get("state")).toBe(redirect.searchParams.get("state")); 81 | expect(redirect.searchParams.get("code_challenge_method")).toBe("S256"); 82 | }); 83 | 84 | test("redirects with the audience if configured", async () => { 85 | let strategy = new OAuth2Strategy( 86 | { ...options, audience: "api.example.com" }, 87 | verify, 88 | ); 89 | 90 | let request = new Request("https://remix.auth/login"); 91 | 92 | let response = await catchResponse(strategy.authenticate(request)); 93 | 94 | // biome-ignore lint/style/noNonNullAssertion: This is a test 95 | let redirect = new URL(response.headers.get("location")!); 96 | 97 | expect(redirect.searchParams.get("audience")).toBe("api.example.com"); 98 | }); 99 | 100 | test("redirects with multiple audience if configured as array", async () => { 101 | let strategy = new OAuth2Strategy( 102 | { ...options, audience: ["api.example.com", "internal.example.com"] }, 103 | verify, 104 | ); 105 | 106 | let request = new Request("https://remix.auth/login"); 107 | 108 | let response = await catchResponse(strategy.authenticate(request)); 109 | 110 | // biome-ignore lint/style/noNonNullAssertion: This is a test 111 | let redirect = new URL(response.headers.get("location")!); 112 | 113 | expect(redirect.searchParams.getAll("audience")).toEqual([ 114 | "api.example.com", 115 | "internal.example.com", 116 | ]); 117 | }); 118 | 119 | test("throws if there's no state in the session", async () => { 120 | let strategy = new OAuth2Strategy(options, verify); 121 | 122 | let request = new Request( 123 | "https://example.com/callback?state=random-state&code=random-code", 124 | ); 125 | 126 | expect(strategy.authenticate(request)).rejects.toThrowError( 127 | new ReferenceError("Missing state on cookie."), 128 | ); 129 | }); 130 | 131 | test("throws if the state in the url doesn't match the state in the session", async () => { 132 | let strategy = new OAuth2Strategy(options, verify); 133 | 134 | let store = new StateStore(); 135 | store.set("random-state", "random-code-verifier"); 136 | 137 | let cookie = new Cookie(); 138 | cookie.set("oauth2", store.toString()); 139 | 140 | let request = new Request( 141 | "https://example.com/callback?state=another-state&code=random-code", 142 | { headers: { Cookie: cookie.toString() } }, 143 | ); 144 | 145 | expect(strategy.authenticate(request)).rejects.toThrowError( 146 | new ReferenceError("State in URL doesn't match state in cookie."), 147 | ); 148 | }); 149 | 150 | test("calls verify with the tokens and request", async () => { 151 | let strategy = new OAuth2Strategy(options, verify); 152 | 153 | let store = new StateStore(); 154 | store.set("random-state", "random-code-verifier"); 155 | 156 | let cookie = new Cookie(); 157 | cookie.set(store.toSetCookie()?.name as string, store.toString()); 158 | 159 | let request = new Request( 160 | "https://example.com/callback?state=random-state&code=random-code", 161 | { headers: { cookie: cookie.toString() } }, 162 | ); 163 | 164 | await strategy.authenticate(request); 165 | 166 | expect(verify).toHaveBeenCalled(); 167 | }); 168 | 169 | test("returns the result of verify", () => { 170 | let user = { id: "123" }; 171 | verify.mockResolvedValueOnce(user); 172 | 173 | let strategy = new OAuth2Strategy(options, verify); 174 | 175 | let store = new StateStore(); 176 | store.set("random-state", "random-code-verifier"); 177 | 178 | let cookie = new Cookie(); 179 | cookie.set(store.toSetCookie()?.name as string, store.toString()); 180 | 181 | let request = new Request( 182 | "https://example.com/callback?state=random-state&code=random-code", 183 | { headers: { cookie: cookie.toString() } }, 184 | ); 185 | 186 | expect(strategy.authenticate(request)).resolves.toEqual(user); 187 | }); 188 | 189 | test("discovers provider configuration", async () => { 190 | let handler = mock().mockImplementationOnce(() => 191 | HttpResponse.json({ 192 | authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", 193 | token_endpoint: "https://oauth2.googleapis.com/token", 194 | revocation_endpoint: "https://oauth2.googleapis.com/revoke", 195 | code_challenge_methods_supported: ["plain", "S256"], 196 | }), 197 | ); 198 | 199 | server.use( 200 | http.get( 201 | "https://accounts.google.com/.well-known/openid-configuration", 202 | handler, 203 | ), 204 | ); 205 | 206 | await OAuth2Strategy.discover( 207 | "https://accounts.google.com", 208 | { 209 | clientId: options.clientId, 210 | clientSecret: options.clientSecret, 211 | redirectURI: options.redirectURI, 212 | scopes: options.scopes, 213 | }, 214 | verify, 215 | ); 216 | 217 | expect(handler).toHaveBeenCalledTimes(1); 218 | }); 219 | 220 | test("discover in a subclass returns the subclass", async () => { 221 | class SubStrategy extends OAuth2Strategy {} 222 | 223 | let handler = mock().mockImplementationOnce(() => 224 | HttpResponse.json({ 225 | authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", 226 | token_endpoint: "https://oauth2.googleapis.com/token", 227 | revocation_endpoint: "https://oauth2.googleapis.com/revoke", 228 | code_challenge_methods_supported: ["plain", "S256"], 229 | }), 230 | ); 231 | 232 | server.use( 233 | http.get( 234 | "https://accounts.google.com/.well-known/openid-configuration", 235 | handler, 236 | ), 237 | ); 238 | 239 | let strategy = await SubStrategy.discover>( 240 | "https://accounts.google.com", 241 | { 242 | clientId: options.clientId, 243 | clientSecret: options.clientSecret, 244 | redirectURI: options.redirectURI, 245 | scopes: options.scopes, 246 | }, 247 | verify, 248 | ); 249 | 250 | expect(strategy).toBeInstanceOf(SubStrategy); 251 | expect(handler).toHaveBeenCalledTimes(1); 252 | }); 253 | 254 | test("handles race condition of state and code verifier", async () => { 255 | let verify = mock().mockImplementation(() => ({ id: "123" })); 256 | let strategy = new OAuth2Strategy(options, verify); 257 | 258 | let responses = await Promise.all( 259 | Array.from({ length: random() }, () => 260 | catchResponse( 261 | strategy.authenticate(new Request("https://remix.auth/login")), 262 | ), 263 | ), 264 | ); 265 | 266 | let setCookies: SetCookie[] = responses 267 | .flatMap((res) => res.headers.getSetCookie()) 268 | .map((header) => new SetCookie(header)); 269 | 270 | let cookie = new Cookie(); 271 | 272 | for (let setCookie of setCookies) { 273 | cookie.set(setCookie.name as string, setCookie.value as string); 274 | } 275 | 276 | let urls = setCookies.map((setCookie) => { 277 | let params = new URLSearchParams(setCookie.value); 278 | let url = new URL("https://remix.auth/callback"); 279 | url.searchParams.set("state", params.get("state") as string); 280 | url.searchParams.set("code", crypto.randomUUID()); 281 | return url; 282 | }); 283 | 284 | await Promise.all( 285 | urls.map((url) => 286 | strategy.authenticate( 287 | new Request(url, { headers: { cookie: cookie.toString() } }), 288 | ), 289 | ), 290 | ); 291 | 292 | expect(verify).toHaveBeenCalledTimes(responses.length); 293 | }); 294 | }); 295 | 296 | function isResponse(value: unknown): value is Response { 297 | return value instanceof Response; 298 | } 299 | 300 | async function catchResponse(promise: Promise) { 301 | try { 302 | await promise; 303 | throw new Error("Should have failed."); 304 | } catch (error) { 305 | if (isResponse(error)) return error; 306 | throw error; 307 | } 308 | } 309 | 310 | function random(min = 1, max = 10) { 311 | return Math.floor(Math.random() * (max - min + 1)) + min; 312 | } 313 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 2 | import { type SetCookieInit } from "@mjackson/headers"; 3 | import { 4 | CodeChallengeMethod, 5 | OAuth2Client, 6 | OAuth2RequestError, 7 | type OAuth2Tokens, 8 | UnexpectedErrorResponseBodyError, 9 | UnexpectedResponseError, 10 | generateCodeVerifier, 11 | generateState, 12 | } from "arctic"; 13 | import { Strategy } from "remix-auth/strategy"; 14 | import { redirect } from "./lib/redirect.js"; 15 | import { StateStore } from "./lib/store.js"; 16 | 17 | type URLConstructor = ConstructorParameters[0]; 18 | 19 | const WELL_KNOWN = ".well-known/openid-configuration"; 20 | 21 | export { 22 | OAuth2RequestError, 23 | CodeChallengeMethod, 24 | UnexpectedResponseError, 25 | UnexpectedErrorResponseBodyError, 26 | }; 27 | 28 | export class OAuth2Strategy extends Strategy< 29 | User, 30 | OAuth2Strategy.VerifyOptions 31 | > { 32 | override name = "oauth2"; 33 | 34 | protected client: OAuth2Client; 35 | 36 | constructor( 37 | protected options: OAuth2Strategy.ConstructorOptions, 38 | verify: Strategy.VerifyFunction, 39 | ) { 40 | super(verify); 41 | 42 | this.client = new OAuth2Client( 43 | options.clientId, 44 | options.clientSecret, 45 | options.redirectURI?.toString() ?? null, 46 | ); 47 | } 48 | 49 | private get cookieName() { 50 | if (typeof this.options.cookie === "string") { 51 | return this.options.cookie || "oauth2"; 52 | } 53 | return this.options.cookie?.name ?? "oauth2"; 54 | } 55 | 56 | private get cookieOptions() { 57 | if (typeof this.options.cookie !== "object") return {}; 58 | return this.options.cookie ?? {}; 59 | } 60 | 61 | override async authenticate(request: Request): Promise { 62 | let url = new URL(request.url); 63 | 64 | let stateUrl = url.searchParams.get("state"); 65 | 66 | if (!stateUrl) { 67 | let { state, codeVerifier, url } = this.createAuthorizationURL(); 68 | 69 | if (this.options.audience) { 70 | if (Array.isArray(this.options.audience)) { 71 | for (let audience of this.options.audience) { 72 | url.searchParams.append("audience", audience); 73 | } 74 | } else url.searchParams.append("audience", this.options.audience); 75 | } 76 | 77 | url.search = this.authorizationParams( 78 | url.searchParams, 79 | request, 80 | ).toString(); 81 | 82 | let store = StateStore.fromRequest(request, this.cookieName); 83 | store.set(state, codeVerifier); 84 | 85 | throw redirect(url.toString(), { 86 | headers: { 87 | "Set-Cookie": store 88 | .toSetCookie(this.cookieName, this.cookieOptions) 89 | .toString(), 90 | }, 91 | }); 92 | } 93 | 94 | let store = StateStore.fromRequest(request, this.cookieName); 95 | 96 | if (!store.has()) { 97 | throw new ReferenceError("Missing state on cookie."); 98 | } 99 | 100 | if (!store.has(stateUrl)) { 101 | throw new RangeError("State in URL doesn't match state in cookie."); 102 | } 103 | 104 | let error = url.searchParams.get("error"); 105 | 106 | if (error) { 107 | let description = url.searchParams.get("error_description"); 108 | let uri = url.searchParams.get("error_uri"); 109 | throw new OAuth2RequestError(error, description, uri, stateUrl); 110 | } 111 | 112 | let code = url.searchParams.get("code"); 113 | 114 | if (!code) throw new ReferenceError("Missing code in the URL"); 115 | 116 | let codeVerifier = store.get(stateUrl); 117 | 118 | if (!codeVerifier) { 119 | throw new ReferenceError("Missing code verifier on cookie."); 120 | } 121 | 122 | let tokens = await this.validateAuthorizationCode(code, codeVerifier); 123 | 124 | let user = await this.verify({ request, tokens }); 125 | 126 | return user; 127 | } 128 | 129 | protected createAuthorizationURL() { 130 | let state = generateState(); 131 | let codeVerifier = generateCodeVerifier(); 132 | 133 | let url = this.client.createAuthorizationURLWithPKCE( 134 | this.options.authorizationEndpoint.toString(), 135 | state, 136 | this.options.codeChallengeMethod ?? CodeChallengeMethod.S256, 137 | codeVerifier, 138 | this.options.scopes ?? [], 139 | ); 140 | 141 | return { state, codeVerifier, url }; 142 | } 143 | 144 | protected validateAuthorizationCode(code: string, codeVerifier: string) { 145 | return this.client.validateAuthorizationCode( 146 | this.options.tokenEndpoint.toString(), 147 | code, 148 | codeVerifier, 149 | ); 150 | } 151 | 152 | /** 153 | * Return extra parameters to be included in the authorization request. 154 | * 155 | * Some OAuth 2.0 providers allow additional, non-standard parameters to be 156 | * included when requesting authorization. Since these parameters are not 157 | * standardized by the OAuth 2.0 specification, OAuth 2.0-based authentication 158 | * strategies can override this function in order to populate these 159 | * parameters as required by the provider. 160 | */ 161 | protected authorizationParams( 162 | params: URLSearchParams, 163 | request: Request, 164 | ): URLSearchParams { 165 | return new URLSearchParams(params); 166 | } 167 | 168 | /** 169 | * Get a new OAuth2 Tokens object using the refresh token once the previous 170 | * access token has expired. 171 | * @param refreshToken The refresh token to use to get a new access token 172 | * @returns The new OAuth2 tokens object 173 | * @example 174 | * ```ts 175 | * let tokens = await strategy.refreshToken(refreshToken); 176 | * console.log(tokens.accessToken()); 177 | * ``` 178 | */ 179 | public refreshToken(refreshToken: string) { 180 | return this.client.refreshAccessToken( 181 | this.options.tokenEndpoint.toString(), 182 | refreshToken, 183 | this.options.scopes ?? [], 184 | ); 185 | } 186 | 187 | /** 188 | * Users the token revocation endpoint of the identity provider to revoke the 189 | * access token and make it invalid. 190 | * 191 | * @param token The access token to revoke 192 | * @example 193 | * ```ts 194 | * // Get it from where you stored it 195 | * let accessToken = await getAccessToken(); 196 | * await strategy.revokeToken(tokens.access_token); 197 | * ``` 198 | */ 199 | public revokeToken(token: string) { 200 | let endpoint = this.options.tokenRevocationEndpoint; 201 | if (!endpoint) throw new Error("Token revocation endpoint is not set."); 202 | return this.client.revokeToken(endpoint.toString(), token); 203 | } 204 | 205 | /** 206 | * Discover the OAuth2 issuer and create a new OAuth2Strategy instance from 207 | * the OIDC configuration that is returned. 208 | * 209 | * This method will fetch the OIDC configuration from the issuer and create a 210 | * new OAuth2Strategy instance with the provided options and verify function. 211 | * 212 | * @param uri The URI of the issuer, this can be a full URL or just the domain 213 | * @param options The rest of the options to pass to the OAuth2Strategy constructor, clientId, clientSecret, redirectURI, and scopes are required. 214 | * @param verify The verify function to use with the OAuth2Strategy instance 215 | * @returns A new OAuth2Strategy instance 216 | * @example 217 | * ```ts 218 | * let strategy = await OAuth2Strategy.discover( 219 | * "https://accounts.google.com", 220 | * { 221 | * clientId: "your-client-id", 222 | * clientSecret: "your-client-secret", 223 | * redirectURI: "https://your-app.com/auth/callback", 224 | * scopes: ["openid", "email", "profile"], 225 | * }, 226 | * async ({ tokens }) => { 227 | * return getUserProfile(tokens.access_token); 228 | * }, 229 | * ); 230 | */ 231 | static async discover = OAuth2Strategy>( 232 | this: new ( 233 | options: OAuth2Strategy.ConstructorOptions, 234 | verify: Strategy.VerifyFunction, 235 | ) => M, 236 | uri: string | URL, 237 | options: Pick< 238 | OAuth2Strategy.ConstructorOptions, 239 | "clientId" | "clientSecret" | "cookie" | "redirectURI" | "scopes" 240 | > & 241 | Partial< 242 | Omit< 243 | OAuth2Strategy.ConstructorOptions, 244 | "clientId" | "clientSecret" | "cookie" | "redirectURI" | "scopes" 245 | > 246 | >, 247 | verify: Strategy.VerifyFunction, 248 | ) { 249 | // Parse the URI into a URL object 250 | let url = new URL(uri); 251 | 252 | if (!url.pathname.includes("well-known")) { 253 | // Add the well-known path to the URL if it's not already there 254 | url.pathname = url.pathname.endsWith("/") 255 | ? `${url.pathname}${WELL_KNOWN}` 256 | : `${url.pathname}/${WELL_KNOWN}`; 257 | } 258 | 259 | // Fetch the metadata from the issuer and validate it 260 | let response = await fetch(url, { 261 | headers: { Accept: "application/json" }, 262 | }); 263 | 264 | // If the response is not OK, throw an error 265 | if (!response.ok) throw new Error(`Failed to discover issuer at ${url}`); 266 | 267 | // Parse the response body 268 | let parser = new ObjectParser(await response.json()); 269 | 270 | // biome-ignore lint/complexity/noThisInStatic: This is need for subclasses 271 | return new this( 272 | { 273 | authorizationEndpoint: new URL(parser.string("authorization_endpoint")), 274 | tokenEndpoint: new URL(parser.string("token_endpoint")), 275 | tokenRevocationEndpoint: parser.has("revocation_endpoint") 276 | ? new URL(parser.string("revocation_endpoint")) 277 | : undefined, 278 | codeChallengeMethod: parser.has("code_challenge_methods_supported") 279 | ? parser.array("code_challenge_methods_supported").includes("S256") 280 | ? CodeChallengeMethod.S256 281 | : CodeChallengeMethod.Plain 282 | : undefined, 283 | ...options, 284 | }, 285 | verify, 286 | ); 287 | } 288 | } 289 | 290 | export namespace OAuth2Strategy { 291 | export interface VerifyOptions { 292 | /** The request that triggered the verification flow */ 293 | request: Request; 294 | /** The OAuth2 tokens retrivied from the identity provider */ 295 | tokens: OAuth2Tokens; 296 | } 297 | 298 | export interface ConstructorOptions { 299 | /** 300 | * The name of the cookie used to keep state and code verifier around. 301 | * 302 | * The OAuth2 flow requires generating a random state and code verifier, and 303 | * then checking that the state matches when the user is redirected back to 304 | * the application. This is done to prevent CSRF attacks. 305 | * 306 | * The state and code verifier are stored in a cookie, and this option 307 | * allows you to customize the name of that cookie if needed. 308 | * @default "oauth2" 309 | */ 310 | cookie?: string | (Omit & { name: string }); 311 | 312 | /** 313 | * This is the Client ID of your application, provided to you by the Identity 314 | * Provider you're using to authenticate users. 315 | */ 316 | clientId: string; 317 | /** 318 | * This is the Client Secret of your application, provided to you by the 319 | * Identity Provider you're using to authenticate users. 320 | */ 321 | clientSecret: string | null; 322 | 323 | /** 324 | * The endpoint the Identity Provider asks you to send users to log in, or 325 | * authorize your application. 326 | */ 327 | authorizationEndpoint: URLConstructor; 328 | /** 329 | * The endpoint the Identity Provider uses to let's you exchange an access 330 | * code for an access and refresh token. 331 | */ 332 | tokenEndpoint: URLConstructor; 333 | /** 334 | * The URL of your application where the Identity Provider will redirect the 335 | * user after they've logged in or authorized your application. 336 | */ 337 | redirectURI: URLConstructor | null; 338 | 339 | /** 340 | * The endpoint the Identity Provider uses to revoke an access or refresh 341 | * token, this can be useful to log out the user. 342 | */ 343 | tokenRevocationEndpoint?: URLConstructor; 344 | 345 | /** 346 | * The scopes you want to request from the Identity Provider, this is a list 347 | * of strings that represent the permissions you want to request from the 348 | * user. 349 | */ 350 | scopes?: string[]; 351 | 352 | /** 353 | * The code challenge method to use when sending the authorization request. 354 | * This is used when the Identity Provider requires a code challenge to be 355 | * sent with the authorization request. 356 | * @default "CodeChallengeMethod.S256" 357 | */ 358 | codeChallengeMethod?: CodeChallengeMethod; 359 | 360 | /** 361 | * The audience of the token to request from the Identity Provider. This is 362 | * used when the Identity Provider requires a specific audience to be set on 363 | * the token. 364 | * 365 | * This can be a string or an array of strings. 366 | */ 367 | audience?: string | string[]; 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/lib/redirect.ts: -------------------------------------------------------------------------------- 1 | export function redirect(url: string, init: ResponseInit | number = 302) { 2 | let responseInit = init; 3 | 4 | if (typeof responseInit === "number") { 5 | responseInit = { status: responseInit }; 6 | } else if (typeof responseInit.status === "undefined") { 7 | responseInit.status = 302; 8 | } 9 | 10 | let headers = new Headers(responseInit.headers); 11 | headers.set("Location", url); 12 | 13 | return new Response(null, { ...responseInit, headers }); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { Cookie, SetCookie, type SetCookieInit } from "@mjackson/headers"; 2 | 3 | /** 4 | * This class is used to store the state and code verifier for the OAuth2 flow. 5 | * 6 | * If the user is redirected to the authorization endpoint, we need to store the 7 | * state and code verifier in a cookie so we can check that the state matches 8 | * when the user is redirected back to the application. 9 | * 10 | * The problem is that the user can open multiple tabs, and we need to keep 11 | * track of the state and code verifier for each tab. This class helps us do 12 | * that. 13 | * 14 | * It's a simple class that stores the state in a Set and the code verifier in a 15 | * Map. The state is used as the key to the code verifier, so we can easily 16 | * retrieve it when needed. We also have a method to convert the store to a 17 | * string, so we can store it in a cookie. 18 | * 19 | * The class also has a static method to create a new instance from a Request 20 | * object, this is useful when we need to get the store from the cookie. 21 | */ 22 | export class StateStore { 23 | states = new Set(); 24 | codeVerifiers = new Map(); 25 | 26 | state: string | undefined; 27 | codeVerifier: string | undefined; 28 | 29 | constructor(params = new URLSearchParams()) { 30 | for (let [state, verifier] of params) { 31 | if (state === "state") continue; 32 | this.states.add(state); 33 | this.codeVerifiers.set(state, verifier); 34 | } 35 | } 36 | 37 | /** 38 | * Append a new state and code verifier to the store 39 | */ 40 | set(state: string, verifier?: string) { 41 | this.state = state; 42 | this.codeVerifier = verifier; 43 | 44 | this.states.add(state); 45 | if (verifier) this.codeVerifiers.set(state, verifier); 46 | } 47 | 48 | /** 49 | * Check if the store has the given state 50 | */ 51 | has(state?: string) { 52 | if (state) return this.states.has(state); 53 | return this.states.size > 0; 54 | } 55 | 56 | /** 57 | * Get the code verifier for the given state 58 | */ 59 | get(state: string) { 60 | return this.codeVerifiers.get(state); 61 | } 62 | 63 | /** 64 | * Convert the store to a string 65 | * 66 | * This is useful when we need to store the store in a cookie 67 | */ 68 | toString() { 69 | if (!this.state) return ""; 70 | if (!this.codeVerifier) return ""; 71 | 72 | let params = new URLSearchParams(); 73 | 74 | params.set("state", this.state); 75 | params.set(this.state, this.codeVerifier); 76 | 77 | return params.toString(); 78 | } 79 | 80 | toSetCookie( 81 | cookieName = "oauth2", 82 | options: Omit = {}, 83 | ) { 84 | let id = crypto.randomUUID(); 85 | return new SetCookie({ 86 | value: this.toString(), 87 | httpOnly: true, // Prevents JavaScript from accessing the cookie 88 | maxAge: 60 * 5, // 5 minutes 89 | path: "/", // Allow the cookie to be sent to any path 90 | sameSite: "Lax", // Prevents it from being sent in cross-site requests 91 | ...options, 92 | name: `${cookieName}:${id}`, 93 | }); 94 | } 95 | 96 | /** 97 | * Create a new instance from a Request object by getting the store from a 98 | * cookie with the given name. 99 | */ 100 | static fromRequest(request: Request, cookieName = "oauth2") { 101 | let cookie = new Cookie(request.headers.get("cookie") ?? ""); 102 | 103 | let params = new URLSearchParams(); 104 | 105 | for (let name of cookie.names) { 106 | if (name.startsWith(cookieName)) { 107 | let cookieInstance = cookie.get(name); 108 | if (!cookieInstance) continue; 109 | for (let [key, value] of new URLSearchParams(cookieInstance)) { 110 | params.append(key, value); 111 | } 112 | } 113 | } 114 | 115 | return new StateStore(params); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/tsc/dom/library", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.test.ts"], 5 | "compilerOptions": { 6 | "outDir": "./build" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "includeVersion": true, 4 | "entryPoints": ["./src/index.ts"], 5 | "out": "docs", 6 | "json": "docs/index.json", 7 | "cleanOutputDir": true, 8 | "plugin": ["typedoc-plugin-mdn-links"], 9 | "categorizeByGroup": false 10 | } 11 | --------------------------------------------------------------------------------