├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── __tests__ ├── mocks │ └── cloudflare-workers.ts ├── oauth-provider.test.ts └── setup.ts ├── package.json ├── pnpm-lock.yaml ├── src └── oauth-provider.ts ├── storage-schema.md ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v4 17 | with: 18 | version: 10 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Run tests 28 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yml 3 | *.yaml 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Cloudflare, Inc. 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 | # OAuth 2.1 Provider Framework for Cloudflare Workers 2 | 3 | This is a TypeScript library that implements the provider side of the OAuth 2.1 protocol with PKCE support. The library is intended to be used on Cloudflare Workers. 4 | 5 | ## Beta 6 | 7 | As of March, 2025, this library is very new, prerelease software. The API is still subject to change. 8 | 9 | ## Benefits of this library 10 | 11 | * The library acts as a wrapper around your Worker code, which adds authorization for your API endpoints. 12 | * All token management is handled automatically. 13 | * Your API handler is written like a regular fetch handler, but receives the already-authenticated user details as a parameter. No need to perform any checks of your own. 14 | * The library is agnostic to how you manage and authenticate users. 15 | * The library is agnostic to how you build your UI. Your authorization flow can be implemented using whatever UI framework you use for everything else. 16 | * The library's storage does not store any secrets, only hashes of them. 17 | 18 | ## Usage 19 | 20 | A Worker that uses the library might look like this: 21 | 22 | ```ts 23 | import { OAuthProvider } from "my-oauth"; 24 | import { WorkerEntrypoint } from "cloudflare:workers"; 25 | 26 | // We export the OAuthProvider instance as the entrypoint to our Worker. This means it 27 | // implements the `fetch()` handler, receiving all HTTP requests. 28 | export default new OAuthProvider({ 29 | // Configure API routes. Any requests whose URL starts with any of these prefixes will be 30 | // considered API requests. The OAuth provider will check the access token on these requests, 31 | // and then, if the token is valid, send the request to the API handler. 32 | // You can provide: 33 | // - A single route (string) or multiple routes (array) 34 | // - Full URLs (which will match the hostname) or just paths (which will match any hostname) 35 | apiRoute: [ 36 | "/api/", // Path only - will match any hostname 37 | "https://api.example.com/" // Full URL - will check hostname 38 | ], 39 | 40 | // When the OAuth system receives an API request with a valid access token, it passes the request 41 | // to this handler object's fetch method. 42 | // You can provide either an object with a fetch method (ExportedHandler) 43 | // or a class extending WorkerEntrypoint. 44 | apiHandler: ApiHandler, // Using a WorkerEntrypoint class 45 | 46 | // For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler. 47 | // This allows you to use different handlers for different API routes. 48 | // Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both. 49 | // Example: 50 | // apiHandlers: { 51 | // "/api/users/": UsersApiHandler, 52 | // "/api/documents/": DocumentsApiHandler, 53 | // "https://api.example.com/": ExternalApiHandler, 54 | // }, 55 | 56 | // Any requests which aren't API request will be passed to the default handler instead. 57 | // Again, this can be either an object or a WorkerEntrypoint. 58 | defaultHandler: defaultHandler, // Using an object with a fetch method 59 | 60 | // This specifies the URL of the OAuth authorization flow UI. This UI is NOT implemented by 61 | // the OAuthProvider. It is up to the application to implement a UI here. The only reason why 62 | // this URL is given to the OAuthProvider is so that it can implement the RFC-8414 metadata 63 | // discovery endpoint, i.e. `.well-known/oauth-authorization-server`. 64 | // Can also be specified as just a path (e.g., "/authorize"). 65 | authorizeEndpoint: "https://example.com/authorize", 66 | 67 | // This specifies the OAuth 2 token exchange endpoint. The OAuthProvider will implement this 68 | // endpoint (by directly responding to requests with a matching URL). 69 | // Can also be specified as just a path (e.g., "/oauth/token"). 70 | tokenEndpoint: "https://example.com/oauth/token", 71 | 72 | // This specifies the RFC-7591 dynamic client registration endpoint. This setting is optional, 73 | // but if provided, the OAuthProvider will implement this endpoint to allow dynamic client 74 | // registration. 75 | // Can also be specified as just a path (e.g., "/oauth/register"). 76 | clientRegistrationEndpoint: "https://example.com/oauth/register", 77 | 78 | // Optional list of scopes supported by this OAuth provider. 79 | // If provided, this will be included in the RFC 8414 metadata as 'scopes_supported'. 80 | // If not provided, the 'scopes_supported' field will be omitted from the metadata. 81 | scopesSupported: ["document.read", "document.write", "profile"], 82 | 83 | // Optional: Controls whether the OAuth implicit flow is allowed. 84 | // The implicit flow is discouraged in OAuth 2.1 but may be needed for some clients. 85 | // Defaults to false. 86 | allowImplicitFlow: false, 87 | 88 | // Optional: Controls whether public clients (clients without a secret, like SPAs) 89 | // can register via the dynamic client registration endpoint. 90 | // When true, only confidential clients can register. 91 | // Note: Creating public clients via the OAuthHelpers.createClient() method 92 | // is always allowed regardless of this setting. 93 | // Defaults to false. 94 | disallowPublicClientRegistration: false 95 | }); 96 | 97 | // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method 98 | // if they aren't API requests or do not have a valid access token 99 | const defaultHandler = { 100 | // This fetch method works just like a standard Cloudflare Workers fetch handler 101 | // 102 | // The `request`, `env`, and `ctx` parameters are the same as for a normal Cloudflare Workers fetch 103 | // handler, and are exactly the objects that the `OAuthProvider` itself received from the Workers 104 | // runtime. 105 | // 106 | // The `env.OAUTH_PROVIDER` provides an API by which the application can call back to the 107 | // OAuthProvider. 108 | async fetch(request: Request, env, ctx) { 109 | let url = new URL(request.url); 110 | 111 | if (url.pathname == "/authorize") { 112 | // This is a request for our OAuth authorization flow UI. It is up to the application to 113 | // implement this. However, the OAuthProvider library provides some helpers to assist. 114 | 115 | // `env.OAUTH_PROVIDER.parseAuthRequest()` parses the OAuth authorization request to extract the parameters 116 | // required by the OAuth 2 standard, namely response_type, client_id, redirect_uri, scope, and 117 | // state. It returns an object containing all these (using idiomatic camelCase naming). 118 | let oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request); 119 | 120 | // `env.OAUTH_PROVIDER.lookupClient()` looks up metadata about the client, as definetd by RFC-7591. This 121 | // includes things like redirect_uris, client_name, logo_uri, etc. 122 | let clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); 123 | 124 | // At this point, the application should use `oauthReqInfo` and `clientInfo` to render an 125 | // authorization consent UI to the user. The details of this are up to the app so are not 126 | // shown here. 127 | 128 | // After the user has granted consent, the application calls `env.OAUTH_PROVIDER.completeAuthorization()` to 129 | // grant the authorization. 130 | let {redirectTo} = await env.OAUTH_PROVIDER.completeAuthorization({ 131 | // The application passes back the original OAuth request info that was returned by 132 | // `parseAuthRequest()` earlier. 133 | request: oauthReqInfo, 134 | 135 | // The application must specify the user's ID, which is some sort of string. This is needed 136 | // so that the application can later query the OAuthProvider to enumerate all grants 137 | // belonging to a particular user, e.g. to implement an audit and revocation UI. 138 | userId: "1234", 139 | 140 | // The application can specify some arbitary metadata which describes this grant. The 141 | // metadata can contain any JSON-serializable content. This metadata is not used by the 142 | // OAuthProvider, but the application can read back the metadata attached to specific 143 | // grants when enumerating them later, again e.g. to implement an udit and revocation UI. 144 | metadata: {label: "foo"}, 145 | 146 | // The application specifies the list of OAuth scope identifiers that were granted. This 147 | // may or may not be the same as was requested in `oauthReqInfo.scope`. 148 | scope: ["document.read", "document.write"], 149 | 150 | // `props` is an arbitrary JSON-serializable object which will be passed back to the API 151 | // handler for every request authorized by this grant. 152 | props: { 153 | userId: 1234, 154 | username: "Bob" 155 | } 156 | }); 157 | 158 | // `completeAuthorization()` will have returned the URL to which the user should be redirected 159 | // in order to complete the authorization flow. This is the requesting client's OAuth 160 | // redirect_uri with the appropriate query parameters added to complete the flow and obtain 161 | // tokens. 162 | return Response.redirect(redirectTo, 302); 163 | } 164 | 165 | // ... the application can implement other non-API HTTP endpoints here ... 166 | 167 | return new Response("Not found", {status: 404}); 168 | } 169 | }; 170 | 171 | // The API handler object - the OAuthProivder will pass authorized API requests to this object's fetch method 172 | // (because we provided it as the `apiHandler` setting, above). This is ONLY called for API requests 173 | // that had a valid access token. 174 | class ApiHandler extends WorkerEntrypoint { 175 | // This fetch method works just like any other WorkerEntrypoint fetch method. The `request` is 176 | // passed as a parameter, while `env` and `ctx` are available as `this.env` and `this.ctx`. 177 | // 178 | // The `this.env.OAUTH_PROVIDER` is available just like in the default handler. 179 | // 180 | // The `this.ctx.props` property contains the `props` value that was passed to 181 | // `env.OAUTH_PROVIDER.completeAuthorization()` during the authorization flow that authorized this client. 182 | fetch(request: Request) { 183 | // The application can implement its API endpoints like normal. This app implements a single 184 | // endpoint, `/api/whoami`, which returns the user's authenticated identity. 185 | 186 | let url = new URL(request.url); 187 | if (url.pathname == "/api/whoami") { 188 | // Since the username is embedded in `ctx.props`, which came from the access token that the 189 | // OAuthProivder already verified, we don't need to do any other authentication steps. 190 | return new Response(`You are authenticated as: ${this.ctx.props.username}`); 191 | } 192 | 193 | return new Response("Not found", {status: 404}); 194 | } 195 | }; 196 | ``` 197 | 198 | This implementation requires that your worker is configured with a Workers KV namespace binding called `OAUTH_KV`, which is used to store token information. See the file `storage-schema.md` for details on the schema of this namespace. 199 | 200 | The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some methods to query the storage, including: 201 | 202 | * Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code). 203 | * List all active authorization grants for a particular user. 204 | * Revoke (delete) an authorization grant. 205 | 206 | See the `OAuthHelpers` interface definition for full API details. 207 | 208 | ## Token Exchange Callback 209 | 210 | This library allows you to update the `props` value during token exchanges by configuring a callback function. This is useful for scenarios where the application needs to perform additional processing when tokens are issued or refreshed. 211 | 212 | For example, if your application is also a client to some other OAuth API, you might want to perform an equivalent upstream token exchange and store the result in the `props`. The callback can be used to update the props for both the grant record and specific access tokens. 213 | 214 | To use this feature, provide a `tokenExchangeCallback` in your OAuthProvider options: 215 | 216 | ```ts 217 | new OAuthProvider({ 218 | // ... other options ... 219 | tokenExchangeCallback: async (options) => { 220 | // options.grantType is either 'authorization_code' or 'refresh_token' 221 | // options.props contains the current props 222 | // options.clientId, options.userId, and options.scope are also available 223 | 224 | if (options.grantType === 'authorization_code') { 225 | // For authorization code exchange, might want to obtain upstream tokens 226 | const upstreamTokens = await exchangeUpstreamToken(options.props.someCode); 227 | 228 | return { 229 | // Update the props stored in the access token 230 | accessTokenProps: { 231 | ...options.props, 232 | upstreamAccessToken: upstreamTokens.access_token 233 | }, 234 | // Update the props stored in the grant (for future token refreshes) 235 | newProps: { 236 | ...options.props, 237 | upstreamRefreshToken: upstreamTokens.refresh_token 238 | } 239 | }; 240 | } 241 | 242 | if (options.grantType === 'refresh_token') { 243 | // For refresh token exchanges, might want to refresh upstream tokens too 244 | const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken); 245 | 246 | return { 247 | accessTokenProps: { 248 | ...options.props, 249 | upstreamAccessToken: upstreamTokens.access_token 250 | }, 251 | newProps: { 252 | ...options.props, 253 | upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken 254 | }, 255 | // Optionally override the default access token TTL to match the upstream token 256 | accessTokenTTL: upstreamTokens.expires_in 257 | }; 258 | } 259 | } 260 | }); 261 | ``` 262 | 263 | The callback can: 264 | - Return both `accessTokenProps` and `newProps` to update both 265 | - Return only `accessTokenProps` to update just the current access token 266 | - Return only `newProps` to update both the grant and access token (the access token inherits these props) 267 | - Return `accessTokenTTL` to override the default TTL for this specific access token 268 | - Return nothing to keep the original props unchanged 269 | 270 | The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired. 271 | 272 | The `props` values are end-to-end encrypted, so they can safely contain sensitive information. 273 | 274 | ## Custom Error Responses 275 | 276 | By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted: 277 | 278 | ```ts 279 | new OAuthProvider({ 280 | // ... other options ... 281 | onError({ code, description, status, headers }) { 282 | Sentry.captureMessage(/* ... */) 283 | } 284 | }) 285 | ``` 286 | 287 | By returning a `Response` you can also override what the OAuthProvider returns to your users: 288 | 289 | ```ts 290 | new OAuthProvider({ 291 | // ... other options ... 292 | onError({ code, description, status, headers }) { 293 | if (code === 'unsupported_grant_type') { 294 | return new Response('...', { status, headers }) 295 | } 296 | // returning undefined (i.e. void) uses the default Response generation 297 | } 298 | }) 299 | ``` 300 | 301 | By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``. 302 | 303 | ## Implementation Notes 304 | 305 | ### End-to-end encryption 306 | 307 | This library stores records about authorization tokens in KV. The storage schema is carefully designed such that a complete leak of the storage only reveals mundane metadata about what has been granted. In particular: 308 | 309 | * Secrets (including access tokens, refresh tokens, authorization codes, and client secrets) are stored only by hash. Hence, such secrets cannot be derived from the storage alone. 310 | * The `props` associated with a grant (which are passed back to the application when API requests are performed) are stored encrypted with the secret token as key material. Hence, the contents of `props` are impossible to derive from storage unless a valid token is provided. 311 | 312 | Note that the `userId` and the `metadata` associated with each grant are not encrypted, because the purpose of these values is to allow grants to be enumerated for audit and revocation purposes. However, these values are completely opaque to the library. An application is free to omit them or apply its own encryption to them before passing them into the library, if it desires. 313 | 314 | ### Single-use refresh tokens? 315 | 316 | OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to the client, or are single-use. This library currently does not implement any cryptographic binding, thus seemingly requiring single-use tokens. Under this requirement, every token refresh request invalidates the old refresh token and issues a new one. 317 | 318 | This requirement is seemingly fundamentally flawed as it assumes that every refresh request will complete with no errors. In the real world, a transient network error, machine failure, or software fault could mean that the client fails to store the new refresh token after a refresh request. In this case, the client would be permanently unable to make any further requests, as the only token it has is no longer valid. 319 | 320 | This library implements a compromise: At any particular time, a grant may have two valid refresh tokens. When the client uses one of them, the other one is invalidated, and a new one is generated and returned. Thus, if the client correctly uses the new refresh token each time, then older refresh tokens are continuously invalidated. But if a transient failure prevents the client from updating its token, it can always retry the request with the token it used previously. 321 | 322 | ## Written using Claude 323 | 324 | This library (including the schema documentation) was largely written with the help of [Claude](https://claude.ai), the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced. 325 | 326 | **"NOOOOOOOO!!!! You can't just use an LLM to write an auth library!"** 327 | 328 | "haha gpus go brrr" 329 | 330 | In all seriousness, two months ago (January 2025), I ([@kentonv](https://github.com/kentonv)) would have agreed. I was an AI skeptic. I thought LLMs were glorified Markov chain generators that didn't actually understand code and couldn't produce anything novel. I started this project on a lark, fully expecting the AI to produce terrible code for me to laugh at. And then, uh... the code actually looked pretty good. Not perfect, but I just told the AI to fix things, and it did. I was shocked. 331 | 332 | To emphasize, **this is not "vibe coded"**. Every line was thoroughly reviewed and cross-referenced with relevant RFCs, by security experts with previous experience with those RFCs. I was *trying* to validate my skepticism. I ended up proving myself wrong. 333 | 334 | Again, please check out the commit history -- especially early commits -- to understand how this went. 335 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | https://www.cloudflare.com/disclosure 4 | 5 | ## Reporting a Vulnerability 6 | 7 | * https://hackerone.com/cloudflare 8 | * All Cloudflare products are in scope for reporting. If you submit a valid report on bounty-eligible assets through our disclosure program, we will transfer your report to our private bug bounty program and invite you as a participant. 9 | * `mailto:security@cloudflare.com` 10 | * If you'd like to encrypt your message, please do so within the the body of the message. Our email system doesn't handle PGP-MIME well. 11 | * https://www.cloudflare.com/gpg/security-at-cloudflare-pubkey-06A67236.txt 12 | 13 | All abuse reports should be submitted to our Trust & Safety team through our dedicated page: https://www.cloudflare.com/abuse/ 14 | 15 | -------------------------------------------------------------------------------- /__tests__/mocks/cloudflare-workers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock for cloudflare:workers module 3 | * Provides a minimal implementation of WorkerEntrypoint for testing 4 | */ 5 | 6 | export class WorkerEntrypoint { 7 | ctx: any; 8 | env: any; 9 | 10 | constructor(ctx: any, env: any) { 11 | this.ctx = ctx; 12 | this.env = env; 13 | } 14 | 15 | fetch(request: Request): Response | Promise<Response> { 16 | throw new Error('Method not implemented. This should be overridden by subclasses.'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/oauth-provider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 2 | import { OAuthProvider, ClientInfo, AuthRequest, CompleteAuthorizationOptions } from '../src/oauth-provider'; 3 | import { ExecutionContext } from '@cloudflare/workers-types'; 4 | // We're importing WorkerEntrypoint from our mock implementation 5 | // The actual import is mocked in setup.ts 6 | import { WorkerEntrypoint } from 'cloudflare:workers'; 7 | 8 | /** 9 | * Mock KV namespace implementation that stores data in memory 10 | */ 11 | class MockKV { 12 | private storage: Map<string, { value: any; expiration?: number }> = new Map(); 13 | 14 | async put(key: string, value: string | ArrayBuffer, options?: { expirationTtl?: number }): Promise<void> { 15 | let expirationTime: number | undefined = undefined; 16 | 17 | if (options?.expirationTtl) { 18 | expirationTime = Date.now() + options.expirationTtl * 1000; 19 | } 20 | 21 | this.storage.set(key, { value, expiration: expirationTime }); 22 | } 23 | 24 | async get(key: string, options?: { type: 'text' | 'json' | 'arrayBuffer' | 'stream' }): Promise<any> { 25 | const item = this.storage.get(key); 26 | 27 | if (!item) { 28 | return null; 29 | } 30 | 31 | if (item.expiration && item.expiration < Date.now()) { 32 | this.storage.delete(key); 33 | return null; 34 | } 35 | 36 | if (options?.type === 'json' && typeof item.value === 'string') { 37 | return JSON.parse(item.value); 38 | } 39 | 40 | return item.value; 41 | } 42 | 43 | async delete(key: string): Promise<void> { 44 | this.storage.delete(key); 45 | } 46 | 47 | async list(options: { prefix: string; limit?: number; cursor?: string }): Promise<{ 48 | keys: { name: string }[]; 49 | list_complete: boolean; 50 | cursor?: string; 51 | }> { 52 | const { prefix, limit = 1000 } = options; 53 | let keys: { name: string }[] = []; 54 | 55 | for (const key of this.storage.keys()) { 56 | if (key.startsWith(prefix)) { 57 | const item = this.storage.get(key); 58 | if (item && (!item.expiration || item.expiration >= Date.now())) { 59 | keys.push({ name: key }); 60 | } 61 | } 62 | 63 | if (keys.length >= limit) { 64 | break; 65 | } 66 | } 67 | 68 | return { 69 | keys, 70 | list_complete: true, 71 | }; 72 | } 73 | 74 | clear() { 75 | this.storage.clear(); 76 | } 77 | } 78 | 79 | /** 80 | * Mock execution context for Cloudflare Workers 81 | */ 82 | class MockExecutionContext implements ExecutionContext { 83 | props: any = {}; 84 | 85 | waitUntil(promise: Promise<any>): void { 86 | // In tests, we can just ignore waitUntil 87 | } 88 | 89 | passThroughOnException(): void { 90 | // No-op for tests 91 | } 92 | } 93 | 94 | // Simple API handler for testing 95 | class TestApiHandler extends WorkerEntrypoint { 96 | fetch(request: Request) { 97 | const url = new URL(request.url); 98 | 99 | if (url.pathname === '/api/test') { 100 | // Return authenticated user info from ctx.props 101 | return new Response( 102 | JSON.stringify({ 103 | success: true, 104 | user: this.ctx.props, 105 | }), 106 | { 107 | headers: { 'Content-Type': 'application/json' }, 108 | } 109 | ); 110 | } 111 | 112 | return new Response('Not found', { status: 404 }); 113 | } 114 | } 115 | 116 | // Simple default handler for testing 117 | const testDefaultHandler = { 118 | async fetch(request: Request, env: any, ctx: ExecutionContext) { 119 | const url = new URL(request.url); 120 | 121 | if (url.pathname === '/authorize') { 122 | // Mock authorize endpoint 123 | const oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request); 124 | const clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId); 125 | 126 | // Mock user consent flow - automatically grant consent 127 | const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ 128 | request: oauthReqInfo, 129 | userId: 'test-user-123', 130 | metadata: { testConsent: true }, 131 | scope: oauthReqInfo.scope, 132 | props: { userId: 'test-user-123', username: 'TestUser' }, 133 | }); 134 | 135 | return Response.redirect(redirectTo, 302); 136 | } 137 | 138 | return new Response('Default handler', { status: 200 }); 139 | }, 140 | }; 141 | 142 | // Helper function to create mock requests 143 | function createMockRequest( 144 | url: string, 145 | method: string = 'GET', 146 | headers: Record<string, string> = {}, 147 | body?: string | FormData 148 | ): Request { 149 | const requestInit: RequestInit = { 150 | method, 151 | headers, 152 | }; 153 | 154 | if (body) { 155 | requestInit.body = body; 156 | } 157 | 158 | return new Request(url, requestInit); 159 | } 160 | 161 | // Create a configured mock environment 162 | function createMockEnv() { 163 | return { 164 | OAUTH_KV: new MockKV(), 165 | OAUTH_PROVIDER: null, // Will be populated by the OAuthProvider 166 | }; 167 | } 168 | 169 | describe('OAuthProvider', () => { 170 | let oauthProvider: OAuthProvider; 171 | let mockEnv: ReturnType<typeof createMockEnv>; 172 | let mockCtx: MockExecutionContext; 173 | 174 | beforeEach(() => { 175 | // Reset mocks before each test 176 | vi.resetAllMocks(); 177 | 178 | // Create fresh instances for each test 179 | mockEnv = createMockEnv(); 180 | mockCtx = new MockExecutionContext(); 181 | 182 | // Create OAuth provider with test configuration 183 | oauthProvider = new OAuthProvider({ 184 | apiRoute: ['/api/', 'https://api.example.com/'], 185 | apiHandler: TestApiHandler, 186 | defaultHandler: testDefaultHandler, 187 | authorizeEndpoint: '/authorize', 188 | tokenEndpoint: '/oauth/token', 189 | clientRegistrationEndpoint: '/oauth/register', 190 | scopesSupported: ['read', 'write', 'profile'], 191 | accessTokenTTL: 3600, 192 | allowImplicitFlow: true, // Enable implicit flow for tests 193 | }); 194 | }); 195 | 196 | afterEach(() => { 197 | // Clean up KV storage after each test 198 | mockEnv.OAUTH_KV.clear(); 199 | }); 200 | 201 | describe('API Route Configuration', () => { 202 | it('should support multi-handler configuration with apiHandlers', async () => { 203 | // Create handler classes for different API routes 204 | class UsersApiHandler extends WorkerEntrypoint { 205 | fetch(request: Request) { 206 | return new Response('Users API response', { status: 200 }); 207 | } 208 | } 209 | 210 | class DocumentsApiHandler extends WorkerEntrypoint { 211 | fetch(request: Request) { 212 | return new Response('Documents API response', { status: 200 }); 213 | } 214 | } 215 | 216 | // Create provider with multi-handler configuration 217 | const providerWithMultiHandler = new OAuthProvider({ 218 | apiHandlers: { 219 | '/api/users/': UsersApiHandler, 220 | '/api/documents/': DocumentsApiHandler, 221 | }, 222 | defaultHandler: testDefaultHandler, 223 | authorizeEndpoint: '/authorize', 224 | tokenEndpoint: '/oauth/token', 225 | clientRegistrationEndpoint: '/oauth/register', // Important for registering clients in the test 226 | scopesSupported: ['read', 'write'], 227 | }); 228 | 229 | // Create a client and get an access token 230 | const clientData = { 231 | redirect_uris: ['https://client.example.com/callback'], 232 | client_name: 'Test Client', 233 | token_endpoint_auth_method: 'client_secret_basic', 234 | }; 235 | 236 | const registerRequest = createMockRequest( 237 | 'https://example.com/oauth/register', 238 | 'POST', 239 | { 'Content-Type': 'application/json' }, 240 | JSON.stringify(clientData) 241 | ); 242 | 243 | const registerResponse = await providerWithMultiHandler.fetch(registerRequest, mockEnv, mockCtx); 244 | const client = await registerResponse.json(); 245 | const clientId = client.client_id; 246 | const clientSecret = client.client_secret; 247 | const redirectUri = 'https://client.example.com/callback'; 248 | 249 | // Get an auth code 250 | const authRequest = createMockRequest( 251 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 252 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 253 | `&scope=read%20write&state=xyz123` 254 | ); 255 | 256 | const authResponse = await providerWithMultiHandler.fetch(authRequest, mockEnv, mockCtx); 257 | const location = authResponse.headers.get('Location')!; 258 | const code = new URL(location).searchParams.get('code')!; 259 | 260 | // Exchange for tokens 261 | const params = new URLSearchParams(); 262 | params.append('grant_type', 'authorization_code'); 263 | params.append('code', code); 264 | params.append('redirect_uri', redirectUri); 265 | params.append('client_id', clientId); 266 | params.append('client_secret', clientSecret); 267 | 268 | const tokenRequest = createMockRequest( 269 | 'https://example.com/oauth/token', 270 | 'POST', 271 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 272 | params.toString() 273 | ); 274 | 275 | const tokenResponse = await providerWithMultiHandler.fetch(tokenRequest, mockEnv, mockCtx); 276 | const tokens = await tokenResponse.json(); 277 | const accessToken = tokens.access_token; 278 | 279 | // Make requests to different API routes 280 | const usersApiRequest = createMockRequest('https://example.com/api/users/profile', 'GET', { 281 | Authorization: `Bearer ${accessToken}`, 282 | }); 283 | 284 | const documentsApiRequest = createMockRequest('https://example.com/api/documents/list', 'GET', { 285 | Authorization: `Bearer ${accessToken}`, 286 | }); 287 | 288 | // Request to Users API should be handled by UsersApiHandler 289 | const usersResponse = await providerWithMultiHandler.fetch(usersApiRequest, mockEnv, mockCtx); 290 | expect(usersResponse.status).toBe(200); 291 | expect(await usersResponse.text()).toBe('Users API response'); 292 | 293 | // Request to Documents API should be handled by DocumentsApiHandler 294 | const documentsResponse = await providerWithMultiHandler.fetch(documentsApiRequest, mockEnv, mockCtx); 295 | expect(documentsResponse.status).toBe(200); 296 | expect(await documentsResponse.text()).toBe('Documents API response'); 297 | }); 298 | 299 | it('should throw an error when both single-handler and multi-handler configs are provided', () => { 300 | expect(() => { 301 | new OAuthProvider({ 302 | apiRoute: '/api/', 303 | apiHandler: { 304 | fetch: () => Promise.resolve(new Response()), 305 | }, 306 | apiHandlers: { 307 | '/api/users/': { 308 | fetch: () => Promise.resolve(new Response()), 309 | }, 310 | }, 311 | defaultHandler: testDefaultHandler, 312 | authorizeEndpoint: '/authorize', 313 | tokenEndpoint: '/oauth/token', 314 | }); 315 | }).toThrow('Cannot use both apiRoute/apiHandler and apiHandlers'); 316 | }); 317 | 318 | it('should throw an error when neither single-handler nor multi-handler config is provided', () => { 319 | expect(() => { 320 | new OAuthProvider({ 321 | // Intentionally omitting apiRoute and apiHandler and apiHandlers 322 | defaultHandler: testDefaultHandler, 323 | authorizeEndpoint: '/authorize', 324 | tokenEndpoint: '/oauth/token', 325 | }); 326 | }).toThrow('Must provide either apiRoute + apiHandler OR apiHandlers'); 327 | }); 328 | }); 329 | 330 | describe('OAuth Metadata Discovery', () => { 331 | it('should return correct metadata at .well-known/oauth-authorization-server', async () => { 332 | const request = createMockRequest('https://example.com/.well-known/oauth-authorization-server'); 333 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 334 | 335 | expect(response.status).toBe(200); 336 | 337 | const metadata = await response.json(); 338 | expect(metadata.issuer).toBe('https://example.com'); 339 | expect(metadata.authorization_endpoint).toBe('https://example.com/authorize'); 340 | expect(metadata.token_endpoint).toBe('https://example.com/oauth/token'); 341 | expect(metadata.registration_endpoint).toBe('https://example.com/oauth/register'); 342 | expect(metadata.scopes_supported).toEqual(['read', 'write', 'profile']); 343 | expect(metadata.response_types_supported).toContain('code'); 344 | expect(metadata.response_types_supported).toContain('token'); // Implicit flow enabled 345 | expect(metadata.grant_types_supported).toContain('authorization_code'); 346 | expect(metadata.code_challenge_methods_supported).toContain('S256'); 347 | }); 348 | 349 | it('should not include token response type when implicit flow is disabled', async () => { 350 | // Create a provider with implicit flow disabled 351 | const providerWithoutImplicit = new OAuthProvider({ 352 | apiRoute: ['/api/'], 353 | apiHandler: TestApiHandler, 354 | defaultHandler: testDefaultHandler, 355 | authorizeEndpoint: '/authorize', 356 | tokenEndpoint: '/oauth/token', 357 | scopesSupported: ['read', 'write'], 358 | allowImplicitFlow: false, // Explicitly disable 359 | }); 360 | 361 | const request = createMockRequest('https://example.com/.well-known/oauth-authorization-server'); 362 | const response = await providerWithoutImplicit.fetch(request, mockEnv, mockCtx); 363 | 364 | expect(response.status).toBe(200); 365 | 366 | const metadata = await response.json(); 367 | expect(metadata.response_types_supported).toContain('code'); 368 | expect(metadata.response_types_supported).not.toContain('token'); 369 | }); 370 | }); 371 | 372 | describe('Client Registration', () => { 373 | it('should register a new client', async () => { 374 | const clientData = { 375 | redirect_uris: ['https://client.example.com/callback'], 376 | client_name: 'Test Client', 377 | token_endpoint_auth_method: 'client_secret_basic', 378 | }; 379 | 380 | const request = createMockRequest( 381 | 'https://example.com/oauth/register', 382 | 'POST', 383 | { 'Content-Type': 'application/json' }, 384 | JSON.stringify(clientData) 385 | ); 386 | 387 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 388 | 389 | expect(response.status).toBe(201); 390 | 391 | const registeredClient = await response.json(); 392 | expect(registeredClient.client_id).toBeDefined(); 393 | expect(registeredClient.client_secret).toBeDefined(); 394 | expect(registeredClient.redirect_uris).toEqual(['https://client.example.com/callback']); 395 | expect(registeredClient.client_name).toBe('Test Client'); 396 | 397 | // Verify the client was saved to KV 398 | const savedClient = await mockEnv.OAUTH_KV.get(`client:${registeredClient.client_id}`, { type: 'json' }); 399 | expect(savedClient).not.toBeNull(); 400 | expect(savedClient.clientId).toBe(registeredClient.client_id); 401 | // Secret should be stored as a hash 402 | expect(savedClient.clientSecret).not.toBe(registeredClient.client_secret); 403 | }); 404 | 405 | it('should register a public client', async () => { 406 | const clientData = { 407 | redirect_uris: ['https://spa.example.com/callback'], 408 | client_name: 'SPA Client', 409 | token_endpoint_auth_method: 'none', 410 | }; 411 | 412 | const request = createMockRequest( 413 | 'https://example.com/oauth/register', 414 | 'POST', 415 | { 'Content-Type': 'application/json' }, 416 | JSON.stringify(clientData) 417 | ); 418 | 419 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 420 | 421 | expect(response.status).toBe(201); 422 | 423 | const registeredClient = await response.json(); 424 | expect(registeredClient.client_id).toBeDefined(); 425 | expect(registeredClient.client_secret).toBeUndefined(); // Public client should not have a secret 426 | expect(registeredClient.token_endpoint_auth_method).toBe('none'); 427 | 428 | // Verify the client was saved to KV 429 | const savedClient = await mockEnv.OAUTH_KV.get(`client:${registeredClient.client_id}`, { type: 'json' }); 430 | expect(savedClient).not.toBeNull(); 431 | expect(savedClient.clientSecret).toBeUndefined(); // No secret stored 432 | }); 433 | }); 434 | 435 | describe('Authorization Code Flow', () => { 436 | let clientId: string; 437 | let clientSecret: string; 438 | let redirectUri: string; 439 | 440 | // Helper to create a test client before authorization tests 441 | async function createTestClient() { 442 | const clientData = { 443 | redirect_uris: ['https://client.example.com/callback'], 444 | client_name: 'Test Client', 445 | token_endpoint_auth_method: 'client_secret_basic', 446 | }; 447 | 448 | const request = createMockRequest( 449 | 'https://example.com/oauth/register', 450 | 'POST', 451 | { 'Content-Type': 'application/json' }, 452 | JSON.stringify(clientData) 453 | ); 454 | 455 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 456 | const client = await response.json(); 457 | 458 | clientId = client.client_id; 459 | clientSecret = client.client_secret; 460 | redirectUri = 'https://client.example.com/callback'; 461 | } 462 | 463 | beforeEach(async () => { 464 | await createTestClient(); 465 | }); 466 | 467 | it('should handle the authorization request and redirect', async () => { 468 | // Create an authorization request 469 | const authRequest = createMockRequest( 470 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 471 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 472 | `&scope=read%20write&state=xyz123` 473 | ); 474 | 475 | // The default handler will process this request and generate a redirect 476 | const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 477 | 478 | expect(response.status).toBe(302); 479 | 480 | // Check that we're redirected to the client's redirect_uri with a code 481 | const location = response.headers.get('Location'); 482 | expect(location).toBeDefined(); 483 | expect(location).toContain(redirectUri); 484 | expect(location).toContain('code='); 485 | expect(location).toContain('state=xyz123'); 486 | 487 | // Extract the authorization code from the redirect URL 488 | const url = new URL(location!); 489 | const code = url.searchParams.get('code'); 490 | expect(code).toBeDefined(); 491 | 492 | // Verify a grant was created in KV 493 | const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 494 | expect(grants.keys.length).toBe(1); 495 | }); 496 | 497 | it('should reject authorization request with invalid redirect URI', async () => { 498 | // Create an authorization request with an invalid redirect URI 499 | const invalidRedirectUri = 'https://attacker.example.com/callback'; 500 | const authRequest = createMockRequest( 501 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 502 | `&redirect_uri=${encodeURIComponent(invalidRedirectUri)}` + 503 | `&scope=read%20write&state=xyz123` 504 | ); 505 | 506 | // Expect the request to be rejected 507 | await expect(oauthProvider.fetch(authRequest, mockEnv, mockCtx)).rejects.toThrow('Invalid redirect URI'); 508 | 509 | // Verify no grant was created 510 | const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 511 | expect(grants.keys.length).toBe(0); 512 | }); 513 | 514 | // Add more tests for auth code flow... 515 | }); 516 | 517 | describe('Implicit Flow', () => { 518 | let clientId: string; 519 | let redirectUri: string; 520 | 521 | // Helper to create a test client before authorization tests 522 | async function createPublicClient() { 523 | const clientData = { 524 | redirect_uris: ['https://spa-client.example.com/callback'], 525 | client_name: 'SPA Test Client', 526 | token_endpoint_auth_method: 'none', // Public client 527 | }; 528 | 529 | const request = createMockRequest( 530 | 'https://example.com/oauth/register', 531 | 'POST', 532 | { 'Content-Type': 'application/json' }, 533 | JSON.stringify(clientData) 534 | ); 535 | 536 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 537 | const client = await response.json(); 538 | 539 | clientId = client.client_id; 540 | redirectUri = 'https://spa-client.example.com/callback'; 541 | } 542 | 543 | beforeEach(async () => { 544 | await createPublicClient(); 545 | }); 546 | 547 | it('should handle implicit flow request and redirect with token in fragment', async () => { 548 | // Create an implicit flow authorization request 549 | const authRequest = createMockRequest( 550 | `https://example.com/authorize?response_type=token&client_id=${clientId}` + 551 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 552 | `&scope=read%20write&state=xyz123` 553 | ); 554 | 555 | // The default handler will process this request and generate a redirect 556 | const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 557 | 558 | expect(response.status).toBe(302); 559 | 560 | // Check that we're redirected to the client's redirect_uri with token in fragment 561 | const location = response.headers.get('Location'); 562 | expect(location).toBeDefined(); 563 | expect(location).toContain(redirectUri); 564 | 565 | const url = new URL(location!); 566 | 567 | // Check that there's no code parameter in the query string 568 | expect(url.searchParams.has('code')).toBe(false); 569 | 570 | // Check that we have a hash/fragment with token parameters 571 | expect(url.hash).toBeTruthy(); 572 | 573 | // Parse the fragment 574 | const fragment = new URLSearchParams(url.hash.substring(1)); // Remove the # character 575 | 576 | // Verify token parameters 577 | expect(fragment.get('access_token')).toBeTruthy(); 578 | expect(fragment.get('token_type')).toBe('bearer'); 579 | expect(fragment.get('expires_in')).toBe('3600'); 580 | expect(fragment.get('scope')).toBe('read write'); 581 | expect(fragment.get('state')).toBe('xyz123'); 582 | 583 | // Verify a grant was created in KV 584 | const grants = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 585 | expect(grants.keys.length).toBe(1); 586 | 587 | // Verify access token was stored in KV 588 | const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' }); 589 | expect(tokenEntries.keys.length).toBe(1); 590 | }); 591 | 592 | it('should reject implicit flow when allowImplicitFlow is disabled', async () => { 593 | // Create a provider with implicit flow disabled 594 | const providerWithoutImplicit = new OAuthProvider({ 595 | apiRoute: ['/api/'], 596 | apiHandler: TestApiHandler, 597 | defaultHandler: testDefaultHandler, 598 | authorizeEndpoint: '/authorize', 599 | tokenEndpoint: '/oauth/token', 600 | scopesSupported: ['read', 'write'], 601 | allowImplicitFlow: false, // Explicitly disable 602 | }); 603 | 604 | // Create an implicit flow authorization request 605 | const authRequest = createMockRequest( 606 | `https://example.com/authorize?response_type=token&client_id=${clientId}` + 607 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 608 | `&scope=read%20write&state=xyz123` 609 | ); 610 | 611 | // Mock parseAuthRequest to test error handling 612 | vi.spyOn(authRequest, 'formData').mockImplementation(() => { 613 | throw new Error('The implicit grant flow is not enabled for this provider'); 614 | }); 615 | 616 | // Expect an error response 617 | await expect(providerWithoutImplicit.fetch(authRequest, mockEnv, mockCtx)).rejects.toThrow( 618 | 'The implicit grant flow is not enabled for this provider' 619 | ); 620 | }); 621 | 622 | it('should use the access token to access API directly', async () => { 623 | // Create an implicit flow authorization request 624 | const authRequest = createMockRequest( 625 | `https://example.com/authorize?response_type=token&client_id=${clientId}` + 626 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 627 | `&scope=read%20write&state=xyz123` 628 | ); 629 | 630 | // The default handler will process this request and generate a redirect 631 | const response = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 632 | const location = response.headers.get('Location')!; 633 | 634 | // Parse the fragment to get the access token 635 | const url = new URL(location); 636 | const fragment = new URLSearchParams(url.hash.substring(1)); 637 | const accessToken = fragment.get('access_token')!; 638 | 639 | // Now use the access token for an API request 640 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 641 | Authorization: `Bearer ${accessToken}`, 642 | }); 643 | 644 | const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); 645 | 646 | expect(apiResponse.status).toBe(200); 647 | 648 | const apiData = await apiResponse.json(); 649 | expect(apiData.success).toBe(true); 650 | expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' }); 651 | }); 652 | }); 653 | 654 | describe('Authorization Code Flow Exchange', () => { 655 | let clientId: string; 656 | let clientSecret: string; 657 | let redirectUri: string; 658 | 659 | // Helper to create a test client before authorization tests 660 | async function createTestClient() { 661 | const clientData = { 662 | redirect_uris: ['https://client.example.com/callback'], 663 | client_name: 'Test Client', 664 | token_endpoint_auth_method: 'client_secret_basic', 665 | }; 666 | 667 | const request = createMockRequest( 668 | 'https://example.com/oauth/register', 669 | 'POST', 670 | { 'Content-Type': 'application/json' }, 671 | JSON.stringify(clientData) 672 | ); 673 | 674 | const response = await oauthProvider.fetch(request, mockEnv, mockCtx); 675 | const client = await response.json(); 676 | 677 | clientId = client.client_id; 678 | clientSecret = client.client_secret; 679 | redirectUri = 'https://client.example.com/callback'; 680 | } 681 | 682 | beforeEach(async () => { 683 | await createTestClient(); 684 | }); 685 | 686 | it('should exchange auth code for tokens', async () => { 687 | // First get an auth code 688 | const authRequest = createMockRequest( 689 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 690 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 691 | `&scope=read%20write&state=xyz123` 692 | ); 693 | 694 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 695 | const location = authResponse.headers.get('Location')!; 696 | const url = new URL(location); 697 | const code = url.searchParams.get('code')!; 698 | 699 | // Now exchange the code for tokens 700 | // Use URLSearchParams which is proper for application/x-www-form-urlencoded 701 | const params = new URLSearchParams(); 702 | params.append('grant_type', 'authorization_code'); 703 | params.append('code', code); 704 | params.append('redirect_uri', redirectUri); 705 | params.append('client_id', clientId); 706 | params.append('client_secret', clientSecret); 707 | 708 | // Use the URLSearchParams object as the body - correctly encoded for Content-Type: application/x-www-form-urlencoded 709 | const tokenRequest = createMockRequest( 710 | 'https://example.com/oauth/token', 711 | 'POST', 712 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 713 | params.toString() 714 | ); 715 | 716 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 717 | 718 | expect(tokenResponse.status).toBe(200); 719 | 720 | const tokens = await tokenResponse.json(); 721 | expect(tokens.access_token).toBeDefined(); 722 | expect(tokens.refresh_token).toBeDefined(); 723 | expect(tokens.token_type).toBe('bearer'); 724 | expect(tokens.expires_in).toBe(3600); 725 | 726 | // Verify token was stored in KV 727 | const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' }); 728 | expect(tokenEntries.keys.length).toBe(1); 729 | 730 | // Verify grant was updated (auth code removed, refresh token added) 731 | const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 732 | const grantKey = grantEntries.keys[0].name; 733 | const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' }); 734 | 735 | expect(grant.authCodeId).toBeUndefined(); // Auth code should be removed 736 | expect(grant.refreshTokenId).toBeDefined(); // Refresh token should be added 737 | }); 738 | 739 | it('should reject token exchange without redirect_uri when not using PKCE', async () => { 740 | // First get an auth code 741 | const authRequest = createMockRequest( 742 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 743 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 744 | `&scope=read%20write&state=xyz123` 745 | ); 746 | 747 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 748 | const location = authResponse.headers.get('Location')!; 749 | const url = new URL(location); 750 | const code = url.searchParams.get('code')!; 751 | 752 | // Now exchange the code without providing redirect_uri 753 | const params = new URLSearchParams(); 754 | params.append('grant_type', 'authorization_code'); 755 | params.append('code', code); 756 | // redirect_uri intentionally omitted 757 | params.append('client_id', clientId); 758 | params.append('client_secret', clientSecret); 759 | 760 | const tokenRequest = createMockRequest( 761 | 'https://example.com/oauth/token', 762 | 'POST', 763 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 764 | params.toString() 765 | ); 766 | 767 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 768 | 769 | // Should fail because redirect_uri is required when not using PKCE 770 | expect(tokenResponse.status).toBe(400); 771 | const error = await tokenResponse.json(); 772 | expect(error.error).toBe('invalid_request'); 773 | expect(error.error_description).toBe('redirect_uri is required when not using PKCE'); 774 | }); 775 | 776 | it('should reject token exchange with code_verifier when PKCE was not used in authorization', async () => { 777 | // First get an auth code WITHOUT using PKCE 778 | const authRequest = createMockRequest( 779 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 780 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 781 | `&scope=read%20write&state=xyz123` 782 | ); 783 | 784 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 785 | const location = authResponse.headers.get('Location')!; 786 | const url = new URL(location); 787 | const code = url.searchParams.get('code')!; 788 | 789 | // Now exchange the code and incorrectly provide a code_verifier 790 | const params = new URLSearchParams(); 791 | params.append('grant_type', 'authorization_code'); 792 | params.append('code', code); 793 | params.append('redirect_uri', redirectUri); 794 | params.append('client_id', clientId); 795 | params.append('client_secret', clientSecret); 796 | params.append('code_verifier', 'some_random_verifier_that_wasnt_used_in_auth'); 797 | 798 | const tokenRequest = createMockRequest( 799 | 'https://example.com/oauth/token', 800 | 'POST', 801 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 802 | params.toString() 803 | ); 804 | 805 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 806 | 807 | // Should fail because code_verifier is provided but PKCE wasn't used in authorization 808 | expect(tokenResponse.status).toBe(400); 809 | const error = await tokenResponse.json(); 810 | expect(error.error).toBe('invalid_request'); 811 | expect(error.error_description).toBe('code_verifier provided for a flow that did not use PKCE'); 812 | }); 813 | 814 | // Helper function for PKCE tests 815 | function generateRandomString(length: number): string { 816 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 817 | let result = ''; 818 | const values = new Uint8Array(length); 819 | crypto.getRandomValues(values); 820 | for (let i = 0; i < length; i++) { 821 | result += characters.charAt(values[i] % characters.length); 822 | } 823 | return result; 824 | } 825 | 826 | // Helper function for PKCE tests 827 | function base64UrlEncode(str: string): string { 828 | return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 829 | } 830 | 831 | it('should accept token exchange without redirect_uri when using PKCE', async () => { 832 | // Generate PKCE code verifier and challenge 833 | const codeVerifier = generateRandomString(43); // Recommended length 834 | const encoder = new TextEncoder(); 835 | const data = encoder.encode(codeVerifier); 836 | const hashBuffer = await crypto.subtle.digest('SHA-256', data); 837 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 838 | const codeChallenge = base64UrlEncode(String.fromCharCode(...hashArray)); 839 | 840 | // First get an auth code with PKCE 841 | const authRequest = createMockRequest( 842 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 843 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 844 | `&scope=read%20write&state=xyz123` + 845 | `&code_challenge=${codeChallenge}&code_challenge_method=S256` 846 | ); 847 | 848 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 849 | const location = authResponse.headers.get('Location')!; 850 | const url = new URL(location); 851 | const code = url.searchParams.get('code')!; 852 | 853 | // Now exchange the code without providing redirect_uri 854 | const params = new URLSearchParams(); 855 | params.append('grant_type', 'authorization_code'); 856 | params.append('code', code); 857 | // redirect_uri intentionally omitted 858 | params.append('client_id', clientId); 859 | params.append('client_secret', clientSecret); 860 | params.append('code_verifier', codeVerifier); 861 | 862 | const tokenRequest = createMockRequest( 863 | 'https://example.com/oauth/token', 864 | 'POST', 865 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 866 | params.toString() 867 | ); 868 | 869 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 870 | 871 | // Should succeed because redirect_uri is optional when using PKCE 872 | expect(tokenResponse.status).toBe(200); 873 | 874 | const tokens = await tokenResponse.json(); 875 | expect(tokens.access_token).toBeDefined(); 876 | expect(tokens.refresh_token).toBeDefined(); 877 | expect(tokens.token_type).toBe('bearer'); 878 | expect(tokens.expires_in).toBe(3600); 879 | }); 880 | 881 | it('should accept the access token for API requests', async () => { 882 | // Get an auth code 883 | const authRequest = createMockRequest( 884 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 885 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 886 | `&scope=read%20write&state=xyz123` 887 | ); 888 | 889 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 890 | const location = authResponse.headers.get('Location')!; 891 | const code = new URL(location).searchParams.get('code')!; 892 | 893 | // Exchange for tokens 894 | const params = new URLSearchParams(); 895 | params.append('grant_type', 'authorization_code'); 896 | params.append('code', code); 897 | params.append('redirect_uri', redirectUri); 898 | params.append('client_id', clientId); 899 | params.append('client_secret', clientSecret); 900 | 901 | const tokenRequest = createMockRequest( 902 | 'https://example.com/oauth/token', 903 | 'POST', 904 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 905 | params.toString() 906 | ); 907 | 908 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 909 | const tokens = await tokenResponse.json(); 910 | 911 | // Now use the access token for an API request 912 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 913 | Authorization: `Bearer ${tokens.access_token}`, 914 | }); 915 | 916 | const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); 917 | 918 | expect(apiResponse.status).toBe(200); 919 | 920 | const apiData = await apiResponse.json(); 921 | expect(apiData.success).toBe(true); 922 | expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' }); 923 | }); 924 | }); 925 | 926 | describe('Refresh Token Flow', () => { 927 | let clientId: string; 928 | let clientSecret: string; 929 | let refreshToken: string; 930 | 931 | // Helper to get through authorization and token exchange to get a refresh token 932 | async function getRefreshToken() { 933 | // Create a client 934 | const clientData = { 935 | redirect_uris: ['https://client.example.com/callback'], 936 | client_name: 'Test Client', 937 | token_endpoint_auth_method: 'client_secret_basic', 938 | }; 939 | 940 | const registerRequest = createMockRequest( 941 | 'https://example.com/oauth/register', 942 | 'POST', 943 | { 'Content-Type': 'application/json' }, 944 | JSON.stringify(clientData) 945 | ); 946 | 947 | const registerResponse = await oauthProvider.fetch(registerRequest, mockEnv, mockCtx); 948 | const client = await registerResponse.json(); 949 | clientId = client.client_id; 950 | clientSecret = client.client_secret; 951 | const redirectUri = 'https://client.example.com/callback'; 952 | 953 | // Get an auth code 954 | const authRequest = createMockRequest( 955 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 956 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 957 | `&scope=read%20write&state=xyz123` 958 | ); 959 | 960 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 961 | const location = authResponse.headers.get('Location')!; 962 | const code = new URL(location).searchParams.get('code')!; 963 | 964 | // Exchange for tokens 965 | const params = new URLSearchParams(); 966 | params.append('grant_type', 'authorization_code'); 967 | params.append('code', code); 968 | params.append('redirect_uri', redirectUri); 969 | params.append('client_id', clientId); 970 | params.append('client_secret', clientSecret); 971 | 972 | const tokenRequest = createMockRequest( 973 | 'https://example.com/oauth/token', 974 | 'POST', 975 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 976 | params.toString() 977 | ); 978 | 979 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 980 | const tokens = await tokenResponse.json(); 981 | refreshToken = tokens.refresh_token; 982 | } 983 | 984 | beforeEach(async () => { 985 | await getRefreshToken(); 986 | }); 987 | 988 | it('should issue new tokens with refresh token', async () => { 989 | // Use the refresh token to get a new access token 990 | const params = new URLSearchParams(); 991 | params.append('grant_type', 'refresh_token'); 992 | params.append('refresh_token', refreshToken); 993 | params.append('client_id', clientId); 994 | params.append('client_secret', clientSecret); 995 | 996 | const refreshRequest = createMockRequest( 997 | 'https://example.com/oauth/token', 998 | 'POST', 999 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1000 | params.toString() 1001 | ); 1002 | 1003 | const refreshResponse = await oauthProvider.fetch(refreshRequest, mockEnv, mockCtx); 1004 | 1005 | expect(refreshResponse.status).toBe(200); 1006 | 1007 | const newTokens = await refreshResponse.json(); 1008 | expect(newTokens.access_token).toBeDefined(); 1009 | expect(newTokens.refresh_token).toBeDefined(); 1010 | expect(newTokens.refresh_token).not.toBe(refreshToken); // Should get a new refresh token 1011 | 1012 | // Verify we now have a new token in storage 1013 | const tokenEntries = await mockEnv.OAUTH_KV.list({ prefix: 'token:' }); 1014 | expect(tokenEntries.keys.length).toBe(2); // The old one and the new one 1015 | 1016 | // Verify the grant was updated 1017 | const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 1018 | const grantKey = grantEntries.keys[0].name; 1019 | const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' }); 1020 | 1021 | expect(grant.previousRefreshTokenId).toBeDefined(); // Old refresh token should be tracked 1022 | expect(grant.refreshTokenId).toBeDefined(); // New refresh token should be set 1023 | }); 1024 | 1025 | it('should allow using the previous refresh token once', async () => { 1026 | // Use the refresh token to get a new access token (first refresh) 1027 | const params1 = new URLSearchParams(); 1028 | params1.append('grant_type', 'refresh_token'); 1029 | params1.append('refresh_token', refreshToken); 1030 | params1.append('client_id', clientId); 1031 | params1.append('client_secret', clientSecret); 1032 | 1033 | const refreshRequest1 = createMockRequest( 1034 | 'https://example.com/oauth/token', 1035 | 'POST', 1036 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1037 | params1.toString() 1038 | ); 1039 | 1040 | const refreshResponse1 = await oauthProvider.fetch(refreshRequest1, mockEnv, mockCtx); 1041 | const newTokens1 = await refreshResponse1.json(); 1042 | const newRefreshToken = newTokens1.refresh_token; 1043 | 1044 | // Now try to use the original refresh token again (simulating a retry after failure) 1045 | const params2 = new URLSearchParams(); 1046 | params2.append('grant_type', 'refresh_token'); 1047 | params2.append('refresh_token', refreshToken); // Original token 1048 | params2.append('client_id', clientId); 1049 | params2.append('client_secret', clientSecret); 1050 | 1051 | const refreshRequest2 = createMockRequest( 1052 | 'https://example.com/oauth/token', 1053 | 'POST', 1054 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1055 | params2.toString() 1056 | ); 1057 | 1058 | const refreshResponse2 = await oauthProvider.fetch(refreshRequest2, mockEnv, mockCtx); 1059 | 1060 | // The request should succeed 1061 | expect(refreshResponse2.status).toBe(200); 1062 | 1063 | const newTokens2 = await refreshResponse2.json(); 1064 | expect(newTokens2.access_token).toBeDefined(); 1065 | expect(newTokens2.refresh_token).toBeDefined(); 1066 | 1067 | // Now the grant should have the newest refresh token and the token from the first refresh 1068 | // as the previous token 1069 | const grantEntries = await mockEnv.OAUTH_KV.list({ prefix: 'grant:' }); 1070 | const grantKey = grantEntries.keys[0].name; 1071 | const grant = await mockEnv.OAUTH_KV.get(grantKey, { type: 'json' }); 1072 | 1073 | // The previousRefreshTokenId should now be from the first refresh, not the original 1074 | expect(grant.previousRefreshTokenId).toBeDefined(); 1075 | }); 1076 | }); 1077 | 1078 | describe('Token Validation and API Access', () => { 1079 | let accessToken: string; 1080 | 1081 | // Helper to get through authorization and token exchange to get an access token 1082 | async function getAccessToken() { 1083 | // Create a client 1084 | const clientData = { 1085 | redirect_uris: ['https://client.example.com/callback'], 1086 | client_name: 'Test Client', 1087 | token_endpoint_auth_method: 'client_secret_basic', 1088 | }; 1089 | 1090 | const registerRequest = createMockRequest( 1091 | 'https://example.com/oauth/register', 1092 | 'POST', 1093 | { 'Content-Type': 'application/json' }, 1094 | JSON.stringify(clientData) 1095 | ); 1096 | 1097 | const registerResponse = await oauthProvider.fetch(registerRequest, mockEnv, mockCtx); 1098 | const client = await registerResponse.json(); 1099 | const clientId = client.client_id; 1100 | const clientSecret = client.client_secret; 1101 | const redirectUri = 'https://client.example.com/callback'; 1102 | 1103 | // Get an auth code 1104 | const authRequest = createMockRequest( 1105 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 1106 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 1107 | `&scope=read%20write&state=xyz123` 1108 | ); 1109 | 1110 | const authResponse = await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 1111 | const location = authResponse.headers.get('Location')!; 1112 | const code = new URL(location).searchParams.get('code')!; 1113 | 1114 | // Exchange for tokens 1115 | const params = new URLSearchParams(); 1116 | params.append('grant_type', 'authorization_code'); 1117 | params.append('code', code); 1118 | params.append('redirect_uri', redirectUri); 1119 | params.append('client_id', clientId); 1120 | params.append('client_secret', clientSecret); 1121 | 1122 | const tokenRequest = createMockRequest( 1123 | 'https://example.com/oauth/token', 1124 | 'POST', 1125 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1126 | params.toString() 1127 | ); 1128 | 1129 | const tokenResponse = await oauthProvider.fetch(tokenRequest, mockEnv, mockCtx); 1130 | const tokens = await tokenResponse.json(); 1131 | accessToken = tokens.access_token; 1132 | } 1133 | 1134 | beforeEach(async () => { 1135 | await getAccessToken(); 1136 | }); 1137 | 1138 | it('should reject API requests without a token', async () => { 1139 | const apiRequest = createMockRequest('https://example.com/api/test'); 1140 | 1141 | const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); 1142 | 1143 | expect(apiResponse.status).toBe(401); 1144 | 1145 | const error = await apiResponse.json(); 1146 | expect(error.error).toBe('invalid_token'); 1147 | }); 1148 | 1149 | it('should reject API requests with an invalid token', async () => { 1150 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1151 | Authorization: 'Bearer invalid-token', 1152 | }); 1153 | 1154 | const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); 1155 | 1156 | expect(apiResponse.status).toBe(401); 1157 | 1158 | const error = await apiResponse.json(); 1159 | expect(error.error).toBe('invalid_token'); 1160 | }); 1161 | 1162 | it('should accept valid token and pass props to API handler', async () => { 1163 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1164 | Authorization: `Bearer ${accessToken}`, 1165 | }); 1166 | 1167 | const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); 1168 | 1169 | expect(apiResponse.status).toBe(200); 1170 | 1171 | const data = await apiResponse.json(); 1172 | expect(data.success).toBe(true); 1173 | expect(data.user).toEqual({ userId: 'test-user-123', username: 'TestUser' }); 1174 | }); 1175 | 1176 | it('should handle CORS preflight for API requests', async () => { 1177 | const preflightRequest = createMockRequest('https://example.com/api/test', 'OPTIONS', { 1178 | Origin: 'https://client.example.com', 1179 | 'Access-Control-Request-Method': 'GET', 1180 | 'Access-Control-Request-Headers': 'Authorization', 1181 | }); 1182 | 1183 | const preflightResponse = await oauthProvider.fetch(preflightRequest, mockEnv, mockCtx); 1184 | 1185 | expect(preflightResponse.status).toBe(204); 1186 | expect(preflightResponse.headers.get('Access-Control-Allow-Origin')).toBe('https://client.example.com'); 1187 | expect(preflightResponse.headers.get('Access-Control-Allow-Methods')).toBe('*'); 1188 | expect(preflightResponse.headers.get('Access-Control-Allow-Headers')).toContain('Authorization'); 1189 | }); 1190 | }); 1191 | 1192 | describe('Token Exchange Callback', () => { 1193 | // Test with provider that has token exchange callback 1194 | let oauthProviderWithCallback: OAuthProvider; 1195 | let callbackInvocations: any[] = []; 1196 | let mockEnv: ReturnType<typeof createMockEnv>; 1197 | let mockCtx: MockExecutionContext; 1198 | 1199 | // Helper function to create a test OAuth provider with a token exchange callback 1200 | function createProviderWithCallback() { 1201 | callbackInvocations = []; 1202 | 1203 | const tokenExchangeCallback = async (options: any) => { 1204 | // Record that the callback was called and with what arguments 1205 | callbackInvocations.push({ ...options }); 1206 | 1207 | // Return different props based on the grant type 1208 | if (options.grantType === 'authorization_code') { 1209 | return { 1210 | accessTokenProps: { 1211 | ...options.props, 1212 | tokenSpecific: true, 1213 | tokenUpdatedAt: 'auth_code_flow', 1214 | }, 1215 | newProps: { 1216 | ...options.props, 1217 | grantUpdated: true, 1218 | }, 1219 | }; 1220 | } else if (options.grantType === 'refresh_token') { 1221 | return { 1222 | accessTokenProps: { 1223 | ...options.props, 1224 | tokenSpecific: true, 1225 | tokenUpdatedAt: 'refresh_token_flow', 1226 | }, 1227 | newProps: { 1228 | ...options.props, 1229 | grantUpdated: true, 1230 | refreshCount: (options.props.refreshCount || 0) + 1, 1231 | }, 1232 | }; 1233 | } 1234 | }; 1235 | 1236 | return new OAuthProvider({ 1237 | apiRoute: ['/api/', 'https://api.example.com/'], 1238 | apiHandler: TestApiHandler, 1239 | defaultHandler: testDefaultHandler, 1240 | authorizeEndpoint: '/authorize', 1241 | tokenEndpoint: '/oauth/token', 1242 | clientRegistrationEndpoint: '/oauth/register', 1243 | scopesSupported: ['read', 'write', 'profile'], 1244 | accessTokenTTL: 3600, 1245 | allowImplicitFlow: true, 1246 | tokenExchangeCallback, 1247 | }); 1248 | } 1249 | 1250 | let clientId: string; 1251 | let clientSecret: string; 1252 | let redirectUri: string; 1253 | 1254 | // Helper to create a test client 1255 | async function createTestClient() { 1256 | const clientData = { 1257 | redirect_uris: ['https://client.example.com/callback'], 1258 | client_name: 'Test Client', 1259 | token_endpoint_auth_method: 'client_secret_basic', 1260 | }; 1261 | 1262 | const request = createMockRequest( 1263 | 'https://example.com/oauth/register', 1264 | 'POST', 1265 | { 'Content-Type': 'application/json' }, 1266 | JSON.stringify(clientData) 1267 | ); 1268 | 1269 | const response = await oauthProviderWithCallback.fetch(request, mockEnv, mockCtx); 1270 | const client = await response.json(); 1271 | 1272 | clientId = client.client_id; 1273 | clientSecret = client.client_secret; 1274 | redirectUri = 'https://client.example.com/callback'; 1275 | } 1276 | 1277 | beforeEach(async () => { 1278 | // Reset mocks before each test 1279 | vi.resetAllMocks(); 1280 | 1281 | // Create fresh instances for each test 1282 | mockEnv = createMockEnv(); 1283 | mockCtx = new MockExecutionContext(); 1284 | 1285 | // Create OAuth provider with test configuration and callback 1286 | oauthProviderWithCallback = createProviderWithCallback(); 1287 | 1288 | // Create a test client 1289 | await createTestClient(); 1290 | }); 1291 | 1292 | afterEach(() => { 1293 | // Clean up KV storage after each test 1294 | mockEnv.OAUTH_KV.clear(); 1295 | }); 1296 | 1297 | it('should call the callback during authorization code flow', async () => { 1298 | // First get an auth code 1299 | const authRequest = createMockRequest( 1300 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 1301 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 1302 | `&scope=read%20write&state=xyz123` 1303 | ); 1304 | 1305 | const authResponse = await oauthProviderWithCallback.fetch(authRequest, mockEnv, mockCtx); 1306 | const location = authResponse.headers.get('Location')!; 1307 | const code = new URL(location).searchParams.get('code')!; 1308 | 1309 | // Reset callback invocations tracking before token exchange 1310 | callbackInvocations = []; 1311 | 1312 | // Exchange code for tokens 1313 | const params = new URLSearchParams(); 1314 | params.append('grant_type', 'authorization_code'); 1315 | params.append('code', code); 1316 | params.append('redirect_uri', redirectUri); 1317 | params.append('client_id', clientId); 1318 | params.append('client_secret', clientSecret); 1319 | 1320 | const tokenRequest = createMockRequest( 1321 | 'https://example.com/oauth/token', 1322 | 'POST', 1323 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1324 | params.toString() 1325 | ); 1326 | 1327 | const tokenResponse = await oauthProviderWithCallback.fetch(tokenRequest, mockEnv, mockCtx); 1328 | 1329 | // Check that the token exchange was successful 1330 | expect(tokenResponse.status).toBe(200); 1331 | const tokens = await tokenResponse.json(); 1332 | expect(tokens.access_token).toBeDefined(); 1333 | 1334 | // Check that the callback was called once 1335 | expect(callbackInvocations.length).toBe(1); 1336 | 1337 | // Check that callback was called with correct arguments 1338 | const callbackArgs = callbackInvocations[0]; 1339 | expect(callbackArgs.grantType).toBe('authorization_code'); 1340 | expect(callbackArgs.clientId).toBe(clientId); 1341 | expect(callbackArgs.props).toEqual({ userId: 'test-user-123', username: 'TestUser' }); 1342 | 1343 | // Use the token to access API 1344 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1345 | Authorization: `Bearer ${tokens.access_token}`, 1346 | }); 1347 | 1348 | const apiResponse = await oauthProviderWithCallback.fetch(apiRequest, mockEnv, mockCtx); 1349 | expect(apiResponse.status).toBe(200); 1350 | 1351 | // Check that the API received the token-specific props from the callback 1352 | const apiData = await apiResponse.json(); 1353 | expect(apiData.user).toEqual({ 1354 | userId: 'test-user-123', 1355 | username: 'TestUser', 1356 | tokenSpecific: true, 1357 | tokenUpdatedAt: 'auth_code_flow', 1358 | }); 1359 | }); 1360 | 1361 | it('should call the callback during refresh token flow', async () => { 1362 | // First get an auth code and exchange it for tokens 1363 | const authRequest = createMockRequest( 1364 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 1365 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 1366 | `&scope=read%20write&state=xyz123` 1367 | ); 1368 | 1369 | const authResponse = await oauthProviderWithCallback.fetch(authRequest, mockEnv, mockCtx); 1370 | const location = authResponse.headers.get('Location')!; 1371 | const code = new URL(location).searchParams.get('code')!; 1372 | 1373 | // Exchange code for tokens 1374 | const codeParams = new URLSearchParams(); 1375 | codeParams.append('grant_type', 'authorization_code'); 1376 | codeParams.append('code', code); 1377 | codeParams.append('redirect_uri', redirectUri); 1378 | codeParams.append('client_id', clientId); 1379 | codeParams.append('client_secret', clientSecret); 1380 | 1381 | const tokenRequest = createMockRequest( 1382 | 'https://example.com/oauth/token', 1383 | 'POST', 1384 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1385 | codeParams.toString() 1386 | ); 1387 | 1388 | const tokenResponse = await oauthProviderWithCallback.fetch(tokenRequest, mockEnv, mockCtx); 1389 | const tokens = await tokenResponse.json(); 1390 | 1391 | // Reset the callback invocations tracking before refresh 1392 | callbackInvocations = []; 1393 | 1394 | // Now use the refresh token 1395 | const refreshParams = new URLSearchParams(); 1396 | refreshParams.append('grant_type', 'refresh_token'); 1397 | refreshParams.append('refresh_token', tokens.refresh_token); 1398 | refreshParams.append('client_id', clientId); 1399 | refreshParams.append('client_secret', clientSecret); 1400 | 1401 | const refreshRequest = createMockRequest( 1402 | 'https://example.com/oauth/token', 1403 | 'POST', 1404 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1405 | refreshParams.toString() 1406 | ); 1407 | 1408 | const refreshResponse = await oauthProviderWithCallback.fetch(refreshRequest, mockEnv, mockCtx); 1409 | 1410 | // Check that the refresh was successful 1411 | expect(refreshResponse.status).toBe(200); 1412 | const newTokens = await refreshResponse.json(); 1413 | expect(newTokens.access_token).toBeDefined(); 1414 | 1415 | // Check that the callback was called once 1416 | expect(callbackInvocations.length).toBe(1); 1417 | 1418 | // Check that callback was called with correct arguments 1419 | const callbackArgs = callbackInvocations[0]; 1420 | expect(callbackArgs.grantType).toBe('refresh_token'); 1421 | expect(callbackArgs.clientId).toBe(clientId); 1422 | 1423 | // The props are from the updated grant during auth code flow 1424 | expect(callbackArgs.props).toEqual({ 1425 | userId: 'test-user-123', 1426 | username: 'TestUser', 1427 | grantUpdated: true, 1428 | }); 1429 | 1430 | // Use the new token to access API 1431 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1432 | Authorization: `Bearer ${newTokens.access_token}`, 1433 | }); 1434 | 1435 | const apiResponse = await oauthProviderWithCallback.fetch(apiRequest, mockEnv, mockCtx); 1436 | expect(apiResponse.status).toBe(200); 1437 | 1438 | // Check that the API received the token-specific props from the refresh callback 1439 | const apiData = await apiResponse.json(); 1440 | expect(apiData.user).toEqual({ 1441 | userId: 'test-user-123', 1442 | username: 'TestUser', 1443 | grantUpdated: true, 1444 | tokenSpecific: true, 1445 | tokenUpdatedAt: 'refresh_token_flow', 1446 | }); 1447 | 1448 | // Do a second refresh to verify that grant props are properly updated 1449 | const refresh2Params = new URLSearchParams(); 1450 | refresh2Params.append('grant_type', 'refresh_token'); 1451 | refresh2Params.append('refresh_token', newTokens.refresh_token); 1452 | refresh2Params.append('client_id', clientId); 1453 | refresh2Params.append('client_secret', clientSecret); 1454 | 1455 | // Reset the callback invocations before second refresh 1456 | callbackInvocations = []; 1457 | 1458 | const refresh2Request = createMockRequest( 1459 | 'https://example.com/oauth/token', 1460 | 'POST', 1461 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1462 | refresh2Params.toString() 1463 | ); 1464 | 1465 | const refresh2Response = await oauthProviderWithCallback.fetch(refresh2Request, mockEnv, mockCtx); 1466 | const newerTokens = await refresh2Response.json(); 1467 | 1468 | // Check that the refresh count was incremented in the grant props 1469 | expect(callbackInvocations.length).toBe(1); 1470 | expect(callbackInvocations[0].props.refreshCount).toBe(1); 1471 | }); 1472 | 1473 | it('should update token props during refresh when explicitly provided', async () => { 1474 | // Create a provider with a callback that returns both accessTokenProps and newProps 1475 | // but with different values for each 1476 | const differentPropsCallback = async (options: any) => { 1477 | if (options.grantType === 'refresh_token') { 1478 | return { 1479 | accessTokenProps: { 1480 | ...options.props, 1481 | refreshed: true, 1482 | tokenOnly: true, 1483 | }, 1484 | newProps: { 1485 | ...options.props, 1486 | grantUpdated: true, 1487 | }, 1488 | }; 1489 | } 1490 | return undefined; 1491 | }; 1492 | 1493 | const refreshPropsProvider = new OAuthProvider({ 1494 | apiRoute: ['/api/'], 1495 | apiHandler: TestApiHandler, 1496 | defaultHandler: testDefaultHandler, 1497 | authorizeEndpoint: '/authorize', 1498 | tokenEndpoint: '/oauth/token', 1499 | clientRegistrationEndpoint: '/oauth/register', 1500 | scopesSupported: ['read', 'write'], 1501 | tokenExchangeCallback: differentPropsCallback, 1502 | }); 1503 | 1504 | // Create a client 1505 | const clientData = { 1506 | redirect_uris: ['https://client.example.com/callback'], 1507 | client_name: 'Refresh Props Test', 1508 | token_endpoint_auth_method: 'client_secret_basic', 1509 | }; 1510 | 1511 | const registerRequest = createMockRequest( 1512 | 'https://example.com/oauth/register', 1513 | 'POST', 1514 | { 'Content-Type': 'application/json' }, 1515 | JSON.stringify(clientData) 1516 | ); 1517 | 1518 | const registerResponse = await refreshPropsProvider.fetch(registerRequest, mockEnv, mockCtx); 1519 | const client = await registerResponse.json(); 1520 | const testClientId = client.client_id; 1521 | const testClientSecret = client.client_secret; 1522 | const testRedirectUri = 'https://client.example.com/callback'; 1523 | 1524 | // Get an auth code and exchange it for tokens 1525 | const authRequest = createMockRequest( 1526 | `https://example.com/authorize?response_type=code&client_id=${testClientId}` + 1527 | `&redirect_uri=${encodeURIComponent(testRedirectUri)}` + 1528 | `&scope=read%20write&state=xyz123` 1529 | ); 1530 | 1531 | const authResponse = await refreshPropsProvider.fetch(authRequest, mockEnv, mockCtx); 1532 | const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!; 1533 | 1534 | // Exchange for tokens 1535 | const params = new URLSearchParams(); 1536 | params.append('grant_type', 'authorization_code'); 1537 | params.append('code', code); 1538 | params.append('redirect_uri', testRedirectUri); 1539 | params.append('client_id', testClientId); 1540 | params.append('client_secret', testClientSecret); 1541 | 1542 | const tokenRequest = createMockRequest( 1543 | 'https://example.com/oauth/token', 1544 | 'POST', 1545 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1546 | params.toString() 1547 | ); 1548 | 1549 | const tokenResponse = await refreshPropsProvider.fetch(tokenRequest, mockEnv, mockCtx); 1550 | const tokens = await tokenResponse.json(); 1551 | 1552 | // Now do a refresh token exchange 1553 | const refreshParams = new URLSearchParams(); 1554 | refreshParams.append('grant_type', 'refresh_token'); 1555 | refreshParams.append('refresh_token', tokens.refresh_token); 1556 | refreshParams.append('client_id', testClientId); 1557 | refreshParams.append('client_secret', testClientSecret); 1558 | 1559 | const refreshRequest = createMockRequest( 1560 | 'https://example.com/oauth/token', 1561 | 'POST', 1562 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1563 | refreshParams.toString() 1564 | ); 1565 | 1566 | const refreshResponse = await refreshPropsProvider.fetch(refreshRequest, mockEnv, mockCtx); 1567 | const newTokens = await refreshResponse.json(); 1568 | 1569 | // Use the new token to access API 1570 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1571 | Authorization: `Bearer ${newTokens.access_token}`, 1572 | }); 1573 | 1574 | const apiResponse = await refreshPropsProvider.fetch(apiRequest, mockEnv, mockCtx); 1575 | const apiData = await apiResponse.json(); 1576 | 1577 | // The access token should contain the token-specific props from the refresh callback 1578 | expect(apiData.user).toHaveProperty('refreshed', true); 1579 | expect(apiData.user).toHaveProperty('tokenOnly', true); 1580 | expect(apiData.user).not.toHaveProperty('grantUpdated'); 1581 | }); 1582 | 1583 | it('should handle callback that returns only accessTokenProps or only newProps', async () => { 1584 | // Create a provider with a callback that returns only accessTokenProps for auth code 1585 | // and only newProps for refresh token 1586 | // Note: With the enhanced implementation, when only newProps is returned 1587 | // without accessTokenProps, the token props will inherit from newProps 1588 | const propsCallback = async (options: any) => { 1589 | if (options.grantType === 'authorization_code') { 1590 | return { 1591 | accessTokenProps: { ...options.props, tokenOnly: true }, 1592 | }; 1593 | } else if (options.grantType === 'refresh_token') { 1594 | return { 1595 | newProps: { ...options.props, grantOnly: true }, 1596 | }; 1597 | } 1598 | }; 1599 | 1600 | const specialProvider = new OAuthProvider({ 1601 | apiRoute: ['/api/'], 1602 | apiHandler: TestApiHandler, 1603 | defaultHandler: testDefaultHandler, 1604 | authorizeEndpoint: '/authorize', 1605 | tokenEndpoint: '/oauth/token', 1606 | clientRegistrationEndpoint: '/oauth/register', 1607 | scopesSupported: ['read', 'write'], 1608 | tokenExchangeCallback: propsCallback, 1609 | }); 1610 | 1611 | // Create a client 1612 | const clientData = { 1613 | redirect_uris: ['https://client.example.com/callback'], 1614 | client_name: 'Token Props Only Test', 1615 | token_endpoint_auth_method: 'client_secret_basic', 1616 | }; 1617 | 1618 | const registerRequest = createMockRequest( 1619 | 'https://example.com/oauth/register', 1620 | 'POST', 1621 | { 'Content-Type': 'application/json' }, 1622 | JSON.stringify(clientData) 1623 | ); 1624 | 1625 | const registerResponse = await specialProvider.fetch(registerRequest, mockEnv, mockCtx); 1626 | const client = await registerResponse.json(); 1627 | const testClientId = client.client_id; 1628 | const testClientSecret = client.client_secret; 1629 | const testRedirectUri = 'https://client.example.com/callback'; 1630 | 1631 | // Get an auth code 1632 | const authRequest = createMockRequest( 1633 | `https://example.com/authorize?response_type=code&client_id=${testClientId}` + 1634 | `&redirect_uri=${encodeURIComponent(testRedirectUri)}` + 1635 | `&scope=read%20write&state=xyz123` 1636 | ); 1637 | 1638 | const authResponse = await specialProvider.fetch(authRequest, mockEnv, mockCtx); 1639 | const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!; 1640 | 1641 | // Exchange code for tokens 1642 | const params = new URLSearchParams(); 1643 | params.append('grant_type', 'authorization_code'); 1644 | params.append('code', code); 1645 | params.append('redirect_uri', testRedirectUri); 1646 | params.append('client_id', testClientId); 1647 | params.append('client_secret', testClientSecret); 1648 | 1649 | const tokenRequest = createMockRequest( 1650 | 'https://example.com/oauth/token', 1651 | 'POST', 1652 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1653 | params.toString() 1654 | ); 1655 | 1656 | const tokenResponse = await specialProvider.fetch(tokenRequest, mockEnv, mockCtx); 1657 | const tokens = await tokenResponse.json(); 1658 | 1659 | // Verify the token has the tokenOnly property when used for API access 1660 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1661 | Authorization: `Bearer ${tokens.access_token}`, 1662 | }); 1663 | 1664 | const apiResponse = await specialProvider.fetch(apiRequest, mockEnv, mockCtx); 1665 | const apiData = await apiResponse.json(); 1666 | expect(apiData.user.tokenOnly).toBe(true); 1667 | 1668 | // Now do a refresh token exchange 1669 | const refreshParams = new URLSearchParams(); 1670 | refreshParams.append('grant_type', 'refresh_token'); 1671 | refreshParams.append('refresh_token', tokens.refresh_token); 1672 | refreshParams.append('client_id', testClientId); 1673 | refreshParams.append('client_secret', testClientSecret); 1674 | 1675 | const refreshRequest = createMockRequest( 1676 | 'https://example.com/oauth/token', 1677 | 'POST', 1678 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1679 | refreshParams.toString() 1680 | ); 1681 | 1682 | const refreshResponse = await specialProvider.fetch(refreshRequest, mockEnv, mockCtx); 1683 | const newTokens = await refreshResponse.json(); 1684 | 1685 | // Use the new token to access API 1686 | const api2Request = createMockRequest('https://example.com/api/test', 'GET', { 1687 | Authorization: `Bearer ${newTokens.access_token}`, 1688 | }); 1689 | 1690 | const api2Response = await specialProvider.fetch(api2Request, mockEnv, mockCtx); 1691 | const api2Data = await api2Response.json(); 1692 | 1693 | // With the enhanced implementation, the token props now inherit from grant props 1694 | // when only newProps is returned but accessTokenProps is not specified 1695 | expect(api2Data.user).toEqual({ 1696 | userId: 'test-user-123', 1697 | username: 'TestUser', 1698 | grantOnly: true, // This is now included in the token props 1699 | }); 1700 | }); 1701 | 1702 | it('should allow customizing access token TTL via callback', async () => { 1703 | // Create a provider with a callback that customizes TTL 1704 | const customTtlCallback = async (options: any) => { 1705 | if (options.grantType === 'refresh_token') { 1706 | // Return custom TTL for the access token 1707 | return { 1708 | accessTokenProps: { ...options.props, customTtl: true }, 1709 | accessTokenTTL: 7200, // 2 hours instead of default 1710 | }; 1711 | } 1712 | return undefined; 1713 | }; 1714 | 1715 | const customTtlProvider = new OAuthProvider({ 1716 | apiRoute: ['/api/'], 1717 | apiHandler: TestApiHandler, 1718 | defaultHandler: testDefaultHandler, 1719 | authorizeEndpoint: '/authorize', 1720 | tokenEndpoint: '/oauth/token', 1721 | clientRegistrationEndpoint: '/oauth/register', 1722 | scopesSupported: ['read', 'write'], 1723 | accessTokenTTL: 3600, // Default 1 hour 1724 | tokenExchangeCallback: customTtlCallback, 1725 | }); 1726 | 1727 | // Create a client 1728 | const clientData = { 1729 | redirect_uris: ['https://client.example.com/callback'], 1730 | client_name: 'Custom TTL Test', 1731 | token_endpoint_auth_method: 'client_secret_basic', 1732 | }; 1733 | 1734 | const registerRequest = createMockRequest( 1735 | 'https://example.com/oauth/register', 1736 | 'POST', 1737 | { 'Content-Type': 'application/json' }, 1738 | JSON.stringify(clientData) 1739 | ); 1740 | 1741 | const registerResponse = await customTtlProvider.fetch(registerRequest, mockEnv, mockCtx); 1742 | const client = await registerResponse.json(); 1743 | const testClientId = client.client_id; 1744 | const testClientSecret = client.client_secret; 1745 | const testRedirectUri = 'https://client.example.com/callback'; 1746 | 1747 | // Get an auth code 1748 | const authRequest = createMockRequest( 1749 | `https://example.com/authorize?response_type=code&client_id=${testClientId}` + 1750 | `&redirect_uri=${encodeURIComponent(testRedirectUri)}` + 1751 | `&scope=read%20write&state=xyz123` 1752 | ); 1753 | 1754 | const authResponse = await customTtlProvider.fetch(authRequest, mockEnv, mockCtx); 1755 | const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!; 1756 | 1757 | // Exchange code for tokens 1758 | const params = new URLSearchParams(); 1759 | params.append('grant_type', 'authorization_code'); 1760 | params.append('code', code); 1761 | params.append('redirect_uri', testRedirectUri); 1762 | params.append('client_id', testClientId); 1763 | params.append('client_secret', testClientSecret); 1764 | 1765 | const tokenRequest = createMockRequest( 1766 | 'https://example.com/oauth/token', 1767 | 'POST', 1768 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1769 | params.toString() 1770 | ); 1771 | 1772 | const tokenResponse = await customTtlProvider.fetch(tokenRequest, mockEnv, mockCtx); 1773 | const tokens = await tokenResponse.json(); 1774 | 1775 | // Now do a refresh 1776 | const refreshParams = new URLSearchParams(); 1777 | refreshParams.append('grant_type', 'refresh_token'); 1778 | refreshParams.append('refresh_token', tokens.refresh_token); 1779 | refreshParams.append('client_id', testClientId); 1780 | refreshParams.append('client_secret', testClientSecret); 1781 | 1782 | const refreshRequest = createMockRequest( 1783 | 'https://example.com/oauth/token', 1784 | 'POST', 1785 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1786 | refreshParams.toString() 1787 | ); 1788 | 1789 | const refreshResponse = await customTtlProvider.fetch(refreshRequest, mockEnv, mockCtx); 1790 | const newTokens = await refreshResponse.json(); 1791 | 1792 | // Verify that the TTL is from the callback, not the default 1793 | expect(newTokens.expires_in).toBe(7200); 1794 | 1795 | // Verify the token contains our custom property 1796 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1797 | Authorization: `Bearer ${newTokens.access_token}`, 1798 | }); 1799 | 1800 | const apiResponse = await customTtlProvider.fetch(apiRequest, mockEnv, mockCtx); 1801 | const apiData = await apiResponse.json(); 1802 | expect(apiData.user.customTtl).toBe(true); 1803 | }); 1804 | 1805 | it('should handle callback that returns undefined (keeping original props)', async () => { 1806 | // Create a provider with a callback that returns undefined 1807 | const noopCallback = async (options: any) => { 1808 | // Don't return anything, which should keep the original props 1809 | return undefined; 1810 | }; 1811 | 1812 | const noopProvider = new OAuthProvider({ 1813 | apiRoute: ['/api/'], 1814 | apiHandler: TestApiHandler, 1815 | defaultHandler: testDefaultHandler, 1816 | authorizeEndpoint: '/authorize', 1817 | tokenEndpoint: '/oauth/token', 1818 | clientRegistrationEndpoint: '/oauth/register', 1819 | scopesSupported: ['read', 'write'], 1820 | tokenExchangeCallback: noopCallback, 1821 | }); 1822 | 1823 | // Create a client 1824 | const clientData = { 1825 | redirect_uris: ['https://client.example.com/callback'], 1826 | client_name: 'Noop Callback Test', 1827 | token_endpoint_auth_method: 'client_secret_basic', 1828 | }; 1829 | 1830 | const registerRequest = createMockRequest( 1831 | 'https://example.com/oauth/register', 1832 | 'POST', 1833 | { 'Content-Type': 'application/json' }, 1834 | JSON.stringify(clientData) 1835 | ); 1836 | 1837 | const registerResponse = await noopProvider.fetch(registerRequest, mockEnv, mockCtx); 1838 | const client = await registerResponse.json(); 1839 | const testClientId = client.client_id; 1840 | const testClientSecret = client.client_secret; 1841 | const testRedirectUri = 'https://client.example.com/callback'; 1842 | 1843 | // Get an auth code 1844 | const authRequest = createMockRequest( 1845 | `https://example.com/authorize?response_type=code&client_id=${testClientId}` + 1846 | `&redirect_uri=${encodeURIComponent(testRedirectUri)}` + 1847 | `&scope=read%20write&state=xyz123` 1848 | ); 1849 | 1850 | const authResponse = await noopProvider.fetch(authRequest, mockEnv, mockCtx); 1851 | const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!; 1852 | 1853 | // Exchange code for tokens 1854 | const params = new URLSearchParams(); 1855 | params.append('grant_type', 'authorization_code'); 1856 | params.append('code', code); 1857 | params.append('redirect_uri', testRedirectUri); 1858 | params.append('client_id', testClientId); 1859 | params.append('client_secret', testClientSecret); 1860 | 1861 | const tokenRequest = createMockRequest( 1862 | 'https://example.com/oauth/token', 1863 | 'POST', 1864 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1865 | params.toString() 1866 | ); 1867 | 1868 | const tokenResponse = await noopProvider.fetch(tokenRequest, mockEnv, mockCtx); 1869 | const tokens = await tokenResponse.json(); 1870 | 1871 | // Verify the token has the original props when used for API access 1872 | const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { 1873 | Authorization: `Bearer ${tokens.access_token}`, 1874 | }); 1875 | 1876 | const apiResponse = await noopProvider.fetch(apiRequest, mockEnv, mockCtx); 1877 | const apiData = await apiResponse.json(); 1878 | 1879 | // The props should be the original ones (no change) 1880 | expect(apiData.user).toEqual({ userId: 'test-user-123', username: 'TestUser' }); 1881 | }); 1882 | 1883 | it('should correctly handle the previous refresh token when callback updates grant props', async () => { 1884 | // This test verifies fixes for two bugs: 1885 | // 1. previousRefreshTokenWrappedKey not being re-wrapped when grant props change 1886 | // 2. accessTokenProps not inheriting from newProps when only newProps is returned 1887 | let callCount = 0; 1888 | const propUpdatingCallback = async (options: any) => { 1889 | callCount++; 1890 | if (options.grantType === 'refresh_token') { 1891 | const updatedProps = { 1892 | ...options.props, 1893 | updatedCount: (options.props.updatedCount || 0) + 1, 1894 | }; 1895 | 1896 | // Only return newProps to test that accessTokenProps will inherit from it 1897 | return { 1898 | // Return new props to trigger the re-encryption with a new key 1899 | newProps: updatedProps, 1900 | // Intentionally not setting accessTokenProps to verify inheritance works 1901 | }; 1902 | } 1903 | return undefined; 1904 | }; 1905 | 1906 | const testProvider = new OAuthProvider({ 1907 | apiRoute: ['/api/'], 1908 | apiHandler: TestApiHandler, 1909 | defaultHandler: testDefaultHandler, 1910 | authorizeEndpoint: '/authorize', 1911 | tokenEndpoint: '/oauth/token', 1912 | clientRegistrationEndpoint: '/oauth/register', 1913 | scopesSupported: ['read', 'write'], 1914 | tokenExchangeCallback: propUpdatingCallback, 1915 | }); 1916 | 1917 | // Create a client 1918 | const clientData = { 1919 | redirect_uris: ['https://client.example.com/callback'], 1920 | client_name: 'Key-Rewrapping Test', 1921 | token_endpoint_auth_method: 'client_secret_basic', 1922 | }; 1923 | 1924 | const registerRequest = createMockRequest( 1925 | 'https://example.com/oauth/register', 1926 | 'POST', 1927 | { 'Content-Type': 'application/json' }, 1928 | JSON.stringify(clientData) 1929 | ); 1930 | 1931 | const registerResponse = await testProvider.fetch(registerRequest, mockEnv, mockCtx); 1932 | const client = await registerResponse.json(); 1933 | const testClientId = client.client_id; 1934 | const testClientSecret = client.client_secret; 1935 | const testRedirectUri = 'https://client.example.com/callback'; 1936 | 1937 | // Get an auth code 1938 | const authRequest = createMockRequest( 1939 | `https://example.com/authorize?response_type=code&client_id=${testClientId}` + 1940 | `&redirect_uri=${encodeURIComponent(testRedirectUri)}` + 1941 | `&scope=read%20write&state=xyz123` 1942 | ); 1943 | 1944 | const authResponse = await testProvider.fetch(authRequest, mockEnv, mockCtx); 1945 | const code = new URL(authResponse.headers.get('Location')!).searchParams.get('code')!; 1946 | 1947 | // Exchange code for tokens 1948 | const params = new URLSearchParams(); 1949 | params.append('grant_type', 'authorization_code'); 1950 | params.append('code', code); 1951 | params.append('redirect_uri', testRedirectUri); 1952 | params.append('client_id', testClientId); 1953 | params.append('client_secret', testClientSecret); 1954 | 1955 | const tokenRequest = createMockRequest( 1956 | 'https://example.com/oauth/token', 1957 | 'POST', 1958 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1959 | params.toString() 1960 | ); 1961 | 1962 | const tokenResponse = await testProvider.fetch(tokenRequest, mockEnv, mockCtx); 1963 | const tokens = await tokenResponse.json(); 1964 | const refreshToken = tokens.refresh_token; 1965 | 1966 | // Reset the callback invocations before refresh 1967 | callCount = 0; 1968 | 1969 | // First refresh - this will update the grant props and re-encrypt them with a new key 1970 | const refreshParams = new URLSearchParams(); 1971 | refreshParams.append('grant_type', 'refresh_token'); 1972 | refreshParams.append('refresh_token', refreshToken); 1973 | refreshParams.append('client_id', testClientId); 1974 | refreshParams.append('client_secret', testClientSecret); 1975 | 1976 | const refreshRequest = createMockRequest( 1977 | 'https://example.com/oauth/token', 1978 | 'POST', 1979 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 1980 | refreshParams.toString() 1981 | ); 1982 | 1983 | const refreshResponse = await testProvider.fetch(refreshRequest, mockEnv, mockCtx); 1984 | expect(refreshResponse.status).toBe(200); 1985 | 1986 | // The callback should have been called once for the refresh 1987 | expect(callCount).toBe(1); 1988 | 1989 | // Get the new tokens from the first refresh 1990 | const newTokens = await refreshResponse.json(); 1991 | 1992 | // Get the refresh token's corresponding token data to verify it has the updated props 1993 | const apiRequest1 = createMockRequest('https://example.com/api/test', 'GET', { 1994 | Authorization: `Bearer ${newTokens.access_token}`, 1995 | }); 1996 | 1997 | const apiResponse1 = await testProvider.fetch(apiRequest1, mockEnv, mockCtx); 1998 | const apiData1 = await apiResponse1.json(); 1999 | 2000 | // Print the actual API response to debug 2001 | console.log('First API response:', JSON.stringify(apiData1)); 2002 | 2003 | // Verify that the token has the updated props (updatedCount should be 1) 2004 | expect(apiData1.user.updatedCount).toBe(1); 2005 | 2006 | // Reset callCount before the second refresh 2007 | callCount = 0; 2008 | 2009 | // Now try to use the SAME refresh token again (which should work once due to token rotation) 2010 | // With the bug, this would fail because previousRefreshTokenWrappedKey wasn't re-wrapped with the new key 2011 | const secondRefreshRequest = createMockRequest( 2012 | 'https://example.com/oauth/token', 2013 | 'POST', 2014 | { 'Content-Type': 'application/x-www-form-urlencoded' }, 2015 | refreshParams.toString() // Using same params with the same refresh token 2016 | ); 2017 | 2018 | const secondRefreshResponse = await testProvider.fetch(secondRefreshRequest, mockEnv, mockCtx); 2019 | 2020 | // With the bug, this would fail with an error. 2021 | // When fixed, it should succeed because the previous refresh token is still valid once. 2022 | expect(secondRefreshResponse.status).toBe(200); 2023 | 2024 | const secondTokens = await secondRefreshResponse.json(); 2025 | expect(secondTokens.access_token).toBeDefined(); 2026 | 2027 | // The callback should have been called again 2028 | expect(callCount).toBe(1); 2029 | 2030 | // Use the token to access API and verify it has the updated props 2031 | const apiRequest2 = createMockRequest('https://example.com/api/test', 'GET', { 2032 | Authorization: `Bearer ${secondTokens.access_token}`, 2033 | }); 2034 | 2035 | const apiResponse2 = await testProvider.fetch(apiRequest2, mockEnv, mockCtx); 2036 | const apiData2 = await apiResponse2.json(); 2037 | 2038 | // The updatedCount should be 2 now (incremented again during the second refresh) 2039 | expect(apiData2.user.updatedCount).toBe(2); 2040 | }); 2041 | }); 2042 | 2043 | describe('Error Handling with onError Callback', () => { 2044 | it('should use the default onError callback that logs a warning', async () => { 2045 | // Spy on console.warn to check default behavior 2046 | const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 2047 | 2048 | // Create a request that will trigger an error 2049 | const invalidTokenRequest = createMockRequest('https://example.com/api/test', 'GET', { 2050 | Authorization: 'Bearer invalid-token', 2051 | }); 2052 | 2053 | const response = await oauthProvider.fetch(invalidTokenRequest, mockEnv, mockCtx); 2054 | 2055 | // Verify the error response 2056 | expect(response.status).toBe(401); 2057 | const error = await response.json(); 2058 | expect(error.error).toBe('invalid_token'); 2059 | 2060 | // Verify the default onError callback was triggered and logged a warning 2061 | expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('OAuth error response: 401 invalid_token')); 2062 | 2063 | // Restore the spy 2064 | consoleWarnSpy.mockRestore(); 2065 | }); 2066 | 2067 | it('should allow custom onError callback to modify the error response', async () => { 2068 | // Create a provider with custom onError callback 2069 | const customErrorProvider = new OAuthProvider({ 2070 | apiRoute: ['/api/'], 2071 | apiHandler: TestApiHandler, 2072 | defaultHandler: testDefaultHandler, 2073 | authorizeEndpoint: '/authorize', 2074 | tokenEndpoint: '/oauth/token', 2075 | scopesSupported: ['read', 'write'], 2076 | onError: ({ code, description, status }) => { 2077 | // Return a completely different response 2078 | return new Response( 2079 | JSON.stringify({ 2080 | custom_error: true, 2081 | original_code: code, 2082 | custom_message: `Custom error handler: ${description}`, 2083 | }), 2084 | { 2085 | status, 2086 | headers: { 2087 | 'Content-Type': 'application/json', 2088 | 'X-Custom-Error': 'true', 2089 | }, 2090 | } 2091 | ); 2092 | }, 2093 | }); 2094 | 2095 | // Create a request that will trigger an error 2096 | const invalidTokenRequest = createMockRequest('https://example.com/api/test', 'GET', { 2097 | Authorization: 'Bearer invalid-token', 2098 | }); 2099 | 2100 | const response = await customErrorProvider.fetch(invalidTokenRequest, mockEnv, mockCtx); 2101 | 2102 | // Verify the custom error response 2103 | expect(response.status).toBe(401); // Status should be preserved 2104 | expect(response.headers.get('X-Custom-Error')).toBe('true'); 2105 | 2106 | const error = await response.json(); 2107 | expect(error.custom_error).toBe(true); 2108 | expect(error.original_code).toBe('invalid_token'); 2109 | expect(error.custom_message).toContain('Custom error handler'); 2110 | }); 2111 | 2112 | it('should use standard error response when onError returns void', async () => { 2113 | // Create a provider with a callback that performs a side effect but doesn't return a response 2114 | let callbackInvoked = false; 2115 | const sideEffectProvider = new OAuthProvider({ 2116 | apiRoute: ['/api/'], 2117 | apiHandler: TestApiHandler, 2118 | defaultHandler: testDefaultHandler, 2119 | authorizeEndpoint: '/authorize', 2120 | tokenEndpoint: '/oauth/token', 2121 | scopesSupported: ['read', 'write'], 2122 | onError: () => { 2123 | callbackInvoked = true; 2124 | // No return - should use standard error response 2125 | }, 2126 | }); 2127 | 2128 | // Create a request that will trigger an error 2129 | const invalidRequest = createMockRequest('https://example.com/oauth/token', 'POST', { 2130 | 'Content-Type': 'application/x-www-form-urlencoded', 2131 | }); 2132 | 2133 | const response = await sideEffectProvider.fetch(invalidRequest, mockEnv, mockCtx); 2134 | 2135 | // Verify the standard error response 2136 | expect(response.status).toBe(401); 2137 | const error = await response.json(); 2138 | expect(error.error).toBe('invalid_client'); 2139 | 2140 | // Verify callback was invoked 2141 | expect(callbackInvoked).toBe(true); 2142 | }); 2143 | }); 2144 | 2145 | describe('OAuthHelpers', () => { 2146 | it('should allow listing and revoking grants', async () => { 2147 | // Create a client 2148 | const clientData = { 2149 | redirect_uris: ['https://client.example.com/callback'], 2150 | client_name: 'Test Client', 2151 | token_endpoint_auth_method: 'client_secret_basic', 2152 | }; 2153 | 2154 | const registerRequest = createMockRequest( 2155 | 'https://example.com/oauth/register', 2156 | 'POST', 2157 | { 'Content-Type': 'application/json' }, 2158 | JSON.stringify(clientData) 2159 | ); 2160 | 2161 | await oauthProvider.fetch(registerRequest, mockEnv, mockCtx); 2162 | 2163 | // Create a grant by going through auth flow 2164 | const clientId = (await mockEnv.OAUTH_KV.list({ prefix: 'client:' })).keys[0].name.substring(7); 2165 | const redirectUri = 'https://client.example.com/callback'; 2166 | 2167 | const authRequest = createMockRequest( 2168 | `https://example.com/authorize?response_type=code&client_id=${clientId}` + 2169 | `&redirect_uri=${encodeURIComponent(redirectUri)}` + 2170 | `&scope=read%20write&state=xyz123` 2171 | ); 2172 | 2173 | await oauthProvider.fetch(authRequest, mockEnv, mockCtx); 2174 | 2175 | // Ensure OAUTH_PROVIDER was injected 2176 | expect(mockEnv.OAUTH_PROVIDER).not.toBeNull(); 2177 | 2178 | // List grants for the user 2179 | const grants = await mockEnv.OAUTH_PROVIDER.listUserGrants('test-user-123'); 2180 | 2181 | expect(grants.items.length).toBe(1); 2182 | expect(grants.items[0].clientId).toBe(clientId); 2183 | expect(grants.items[0].userId).toBe('test-user-123'); 2184 | expect(grants.items[0].metadata).toEqual({ testConsent: true }); 2185 | 2186 | // Revoke the grant 2187 | await mockEnv.OAUTH_PROVIDER.revokeGrant(grants.items[0].id, 'test-user-123'); 2188 | 2189 | // Verify grant was deleted 2190 | const grantsAfterRevoke = await mockEnv.OAUTH_PROVIDER.listUserGrants('test-user-123'); 2191 | expect(grantsAfterRevoke.items.length).toBe(0); 2192 | }); 2193 | 2194 | it('should allow listing, updating, and deleting clients', async () => { 2195 | // First make a simple request to initialize the OAUTH_PROVIDER in the environment 2196 | const initRequest = createMockRequest('https://example.com/'); 2197 | await oauthProvider.fetch(initRequest, mockEnv, mockCtx); 2198 | 2199 | // Now OAUTH_PROVIDER should be initialized 2200 | expect(mockEnv.OAUTH_PROVIDER).not.toBeNull(); 2201 | 2202 | // Create a client 2203 | const client = await mockEnv.OAUTH_PROVIDER.createClient({ 2204 | redirectUris: ['https://client.example.com/callback'], 2205 | clientName: 'Test Client', 2206 | tokenEndpointAuthMethod: 'client_secret_basic', 2207 | }); 2208 | 2209 | expect(client.clientId).toBeDefined(); 2210 | expect(client.clientSecret).toBeDefined(); 2211 | 2212 | // List clients 2213 | const clients = await mockEnv.OAUTH_PROVIDER.listClients(); 2214 | expect(clients.items.length).toBe(1); 2215 | expect(clients.items[0].clientId).toBe(client.clientId); 2216 | 2217 | // Update client 2218 | const updatedClient = await mockEnv.OAUTH_PROVIDER.updateClient(client.clientId, { 2219 | clientName: 'Updated Client Name', 2220 | }); 2221 | 2222 | expect(updatedClient).not.toBeNull(); 2223 | expect(updatedClient!.clientName).toBe('Updated Client Name'); 2224 | 2225 | // Delete client 2226 | await mockEnv.OAUTH_PROVIDER.deleteClient(client.clientId); 2227 | 2228 | // Verify client was deleted 2229 | const clientsAfterDelete = await mockEnv.OAUTH_PROVIDER.listClients(); 2230 | expect(clientsAfterDelete.items.length).toBe(0); 2231 | }); 2232 | }); 2233 | }); 2234 | -------------------------------------------------------------------------------- /__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | import { WorkerEntrypoint } from './mocks/cloudflare-workers'; 3 | 4 | // Mock the 'cloudflare:workers' module 5 | vi.mock('cloudflare:workers', () => { 6 | return { 7 | WorkerEntrypoint, 8 | }; 9 | }); 10 | 11 | // Add any other global setup needed for the tests 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/workers-oauth-provider", 3 | "version": "0.0.5", 4 | "description": "OAuth provider for Cloudflare Workers", 5 | "main": "dist/oauth-provider.js", 6 | "types": "dist/oauth-provider.d.ts", 7 | "author": "Kenton Varda <kenton@cloudflare.com>", 8 | "license": "MIT", 9 | "files": [ 10 | "dist" 11 | ], 12 | "type": "module", 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "scripts": { 17 | "build": "tsup", 18 | "build:watch": "tsup --watch", 19 | "test": "vitest run", 20 | "test:watch": "vitest", 21 | "prepublishOnly": "npm run build", 22 | "prettier": "prettier -w ." 23 | }, 24 | "devDependencies": { 25 | "@cloudflare/workers-types": "^4.20250311.0", 26 | "prettier": "^3.5.3", 27 | "tsup": "^8.4.0", 28 | "typescript": "^5.8.2", 29 | "vitest": "^3.0.8" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/cloudflare/workers-oauth-provider" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/cloudflare/workers-oauth-provider/issues" 37 | }, 38 | "homepage": "https://github.com/cloudflare/workers-oauth-provider#readme" 39 | } 40 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@cloudflare/workers-types': 12 | specifier: ^4.20250311.0 13 | version: 4.20250311.0 14 | prettier: 15 | specifier: ^3.5.3 16 | version: 3.5.3 17 | tsup: 18 | specifier: ^8.4.0 19 | version: 8.4.0(postcss@8.5.3)(typescript@5.8.2) 20 | typescript: 21 | specifier: ^5.8.2 22 | version: 5.8.2 23 | vitest: 24 | specifier: ^3.0.8 25 | version: 3.0.9(@types/node@22.13.10) 26 | 27 | packages: 28 | 29 | '@cloudflare/workers-types@4.20250311.0': 30 | resolution: {integrity: sha512-5ftSdP1vEdKM6in4p3DZ5SIgaJtRh6LqVeitQtFFsHCyHSPES0KX5HaqTYai+T/5UwmZrB2a3fBUKpGmfDOXBg==} 31 | 32 | '@esbuild/aix-ppc64@0.21.5': 33 | resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} 34 | engines: {node: '>=12'} 35 | cpu: [ppc64] 36 | os: [aix] 37 | 38 | '@esbuild/aix-ppc64@0.25.1': 39 | resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} 40 | engines: {node: '>=18'} 41 | cpu: [ppc64] 42 | os: [aix] 43 | 44 | '@esbuild/android-arm64@0.21.5': 45 | resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} 46 | engines: {node: '>=12'} 47 | cpu: [arm64] 48 | os: [android] 49 | 50 | '@esbuild/android-arm64@0.25.1': 51 | resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==} 52 | engines: {node: '>=18'} 53 | cpu: [arm64] 54 | os: [android] 55 | 56 | '@esbuild/android-arm@0.21.5': 57 | resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} 58 | engines: {node: '>=12'} 59 | cpu: [arm] 60 | os: [android] 61 | 62 | '@esbuild/android-arm@0.25.1': 63 | resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==} 64 | engines: {node: '>=18'} 65 | cpu: [arm] 66 | os: [android] 67 | 68 | '@esbuild/android-x64@0.21.5': 69 | resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} 70 | engines: {node: '>=12'} 71 | cpu: [x64] 72 | os: [android] 73 | 74 | '@esbuild/android-x64@0.25.1': 75 | resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==} 76 | engines: {node: '>=18'} 77 | cpu: [x64] 78 | os: [android] 79 | 80 | '@esbuild/darwin-arm64@0.21.5': 81 | resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} 82 | engines: {node: '>=12'} 83 | cpu: [arm64] 84 | os: [darwin] 85 | 86 | '@esbuild/darwin-arm64@0.25.1': 87 | resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==} 88 | engines: {node: '>=18'} 89 | cpu: [arm64] 90 | os: [darwin] 91 | 92 | '@esbuild/darwin-x64@0.21.5': 93 | resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} 94 | engines: {node: '>=12'} 95 | cpu: [x64] 96 | os: [darwin] 97 | 98 | '@esbuild/darwin-x64@0.25.1': 99 | resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==} 100 | engines: {node: '>=18'} 101 | cpu: [x64] 102 | os: [darwin] 103 | 104 | '@esbuild/freebsd-arm64@0.21.5': 105 | resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} 106 | engines: {node: '>=12'} 107 | cpu: [arm64] 108 | os: [freebsd] 109 | 110 | '@esbuild/freebsd-arm64@0.25.1': 111 | resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==} 112 | engines: {node: '>=18'} 113 | cpu: [arm64] 114 | os: [freebsd] 115 | 116 | '@esbuild/freebsd-x64@0.21.5': 117 | resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} 118 | engines: {node: '>=12'} 119 | cpu: [x64] 120 | os: [freebsd] 121 | 122 | '@esbuild/freebsd-x64@0.25.1': 123 | resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==} 124 | engines: {node: '>=18'} 125 | cpu: [x64] 126 | os: [freebsd] 127 | 128 | '@esbuild/linux-arm64@0.21.5': 129 | resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} 130 | engines: {node: '>=12'} 131 | cpu: [arm64] 132 | os: [linux] 133 | 134 | '@esbuild/linux-arm64@0.25.1': 135 | resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==} 136 | engines: {node: '>=18'} 137 | cpu: [arm64] 138 | os: [linux] 139 | 140 | '@esbuild/linux-arm@0.21.5': 141 | resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} 142 | engines: {node: '>=12'} 143 | cpu: [arm] 144 | os: [linux] 145 | 146 | '@esbuild/linux-arm@0.25.1': 147 | resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==} 148 | engines: {node: '>=18'} 149 | cpu: [arm] 150 | os: [linux] 151 | 152 | '@esbuild/linux-ia32@0.21.5': 153 | resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} 154 | engines: {node: '>=12'} 155 | cpu: [ia32] 156 | os: [linux] 157 | 158 | '@esbuild/linux-ia32@0.25.1': 159 | resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==} 160 | engines: {node: '>=18'} 161 | cpu: [ia32] 162 | os: [linux] 163 | 164 | '@esbuild/linux-loong64@0.21.5': 165 | resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} 166 | engines: {node: '>=12'} 167 | cpu: [loong64] 168 | os: [linux] 169 | 170 | '@esbuild/linux-loong64@0.25.1': 171 | resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==} 172 | engines: {node: '>=18'} 173 | cpu: [loong64] 174 | os: [linux] 175 | 176 | '@esbuild/linux-mips64el@0.21.5': 177 | resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} 178 | engines: {node: '>=12'} 179 | cpu: [mips64el] 180 | os: [linux] 181 | 182 | '@esbuild/linux-mips64el@0.25.1': 183 | resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==} 184 | engines: {node: '>=18'} 185 | cpu: [mips64el] 186 | os: [linux] 187 | 188 | '@esbuild/linux-ppc64@0.21.5': 189 | resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} 190 | engines: {node: '>=12'} 191 | cpu: [ppc64] 192 | os: [linux] 193 | 194 | '@esbuild/linux-ppc64@0.25.1': 195 | resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==} 196 | engines: {node: '>=18'} 197 | cpu: [ppc64] 198 | os: [linux] 199 | 200 | '@esbuild/linux-riscv64@0.21.5': 201 | resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} 202 | engines: {node: '>=12'} 203 | cpu: [riscv64] 204 | os: [linux] 205 | 206 | '@esbuild/linux-riscv64@0.25.1': 207 | resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==} 208 | engines: {node: '>=18'} 209 | cpu: [riscv64] 210 | os: [linux] 211 | 212 | '@esbuild/linux-s390x@0.21.5': 213 | resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} 214 | engines: {node: '>=12'} 215 | cpu: [s390x] 216 | os: [linux] 217 | 218 | '@esbuild/linux-s390x@0.25.1': 219 | resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==} 220 | engines: {node: '>=18'} 221 | cpu: [s390x] 222 | os: [linux] 223 | 224 | '@esbuild/linux-x64@0.21.5': 225 | resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} 226 | engines: {node: '>=12'} 227 | cpu: [x64] 228 | os: [linux] 229 | 230 | '@esbuild/linux-x64@0.25.1': 231 | resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==} 232 | engines: {node: '>=18'} 233 | cpu: [x64] 234 | os: [linux] 235 | 236 | '@esbuild/netbsd-arm64@0.25.1': 237 | resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==} 238 | engines: {node: '>=18'} 239 | cpu: [arm64] 240 | os: [netbsd] 241 | 242 | '@esbuild/netbsd-x64@0.21.5': 243 | resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} 244 | engines: {node: '>=12'} 245 | cpu: [x64] 246 | os: [netbsd] 247 | 248 | '@esbuild/netbsd-x64@0.25.1': 249 | resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==} 250 | engines: {node: '>=18'} 251 | cpu: [x64] 252 | os: [netbsd] 253 | 254 | '@esbuild/openbsd-arm64@0.25.1': 255 | resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==} 256 | engines: {node: '>=18'} 257 | cpu: [arm64] 258 | os: [openbsd] 259 | 260 | '@esbuild/openbsd-x64@0.21.5': 261 | resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} 262 | engines: {node: '>=12'} 263 | cpu: [x64] 264 | os: [openbsd] 265 | 266 | '@esbuild/openbsd-x64@0.25.1': 267 | resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==} 268 | engines: {node: '>=18'} 269 | cpu: [x64] 270 | os: [openbsd] 271 | 272 | '@esbuild/sunos-x64@0.21.5': 273 | resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} 274 | engines: {node: '>=12'} 275 | cpu: [x64] 276 | os: [sunos] 277 | 278 | '@esbuild/sunos-x64@0.25.1': 279 | resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==} 280 | engines: {node: '>=18'} 281 | cpu: [x64] 282 | os: [sunos] 283 | 284 | '@esbuild/win32-arm64@0.21.5': 285 | resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} 286 | engines: {node: '>=12'} 287 | cpu: [arm64] 288 | os: [win32] 289 | 290 | '@esbuild/win32-arm64@0.25.1': 291 | resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==} 292 | engines: {node: '>=18'} 293 | cpu: [arm64] 294 | os: [win32] 295 | 296 | '@esbuild/win32-ia32@0.21.5': 297 | resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} 298 | engines: {node: '>=12'} 299 | cpu: [ia32] 300 | os: [win32] 301 | 302 | '@esbuild/win32-ia32@0.25.1': 303 | resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==} 304 | engines: {node: '>=18'} 305 | cpu: [ia32] 306 | os: [win32] 307 | 308 | '@esbuild/win32-x64@0.21.5': 309 | resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} 310 | engines: {node: '>=12'} 311 | cpu: [x64] 312 | os: [win32] 313 | 314 | '@esbuild/win32-x64@0.25.1': 315 | resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==} 316 | engines: {node: '>=18'} 317 | cpu: [x64] 318 | os: [win32] 319 | 320 | '@isaacs/cliui@8.0.2': 321 | resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 322 | engines: {node: '>=12'} 323 | 324 | '@jridgewell/gen-mapping@0.3.8': 325 | resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} 326 | engines: {node: '>=6.0.0'} 327 | 328 | '@jridgewell/resolve-uri@3.1.2': 329 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 330 | engines: {node: '>=6.0.0'} 331 | 332 | '@jridgewell/set-array@1.2.1': 333 | resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} 334 | engines: {node: '>=6.0.0'} 335 | 336 | '@jridgewell/sourcemap-codec@1.5.0': 337 | resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} 338 | 339 | '@jridgewell/trace-mapping@0.3.25': 340 | resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} 341 | 342 | '@pkgjs/parseargs@0.11.0': 343 | resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 344 | engines: {node: '>=14'} 345 | 346 | '@rollup/rollup-android-arm-eabi@4.35.0': 347 | resolution: {integrity: sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==} 348 | cpu: [arm] 349 | os: [android] 350 | 351 | '@rollup/rollup-android-arm64@4.35.0': 352 | resolution: {integrity: sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==} 353 | cpu: [arm64] 354 | os: [android] 355 | 356 | '@rollup/rollup-darwin-arm64@4.35.0': 357 | resolution: {integrity: sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==} 358 | cpu: [arm64] 359 | os: [darwin] 360 | 361 | '@rollup/rollup-darwin-x64@4.35.0': 362 | resolution: {integrity: sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==} 363 | cpu: [x64] 364 | os: [darwin] 365 | 366 | '@rollup/rollup-freebsd-arm64@4.35.0': 367 | resolution: {integrity: sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==} 368 | cpu: [arm64] 369 | os: [freebsd] 370 | 371 | '@rollup/rollup-freebsd-x64@4.35.0': 372 | resolution: {integrity: sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==} 373 | cpu: [x64] 374 | os: [freebsd] 375 | 376 | '@rollup/rollup-linux-arm-gnueabihf@4.35.0': 377 | resolution: {integrity: sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==} 378 | cpu: [arm] 379 | os: [linux] 380 | 381 | '@rollup/rollup-linux-arm-musleabihf@4.35.0': 382 | resolution: {integrity: sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==} 383 | cpu: [arm] 384 | os: [linux] 385 | 386 | '@rollup/rollup-linux-arm64-gnu@4.35.0': 387 | resolution: {integrity: sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==} 388 | cpu: [arm64] 389 | os: [linux] 390 | 391 | '@rollup/rollup-linux-arm64-musl@4.35.0': 392 | resolution: {integrity: sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==} 393 | cpu: [arm64] 394 | os: [linux] 395 | 396 | '@rollup/rollup-linux-loongarch64-gnu@4.35.0': 397 | resolution: {integrity: sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==} 398 | cpu: [loong64] 399 | os: [linux] 400 | 401 | '@rollup/rollup-linux-powerpc64le-gnu@4.35.0': 402 | resolution: {integrity: sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==} 403 | cpu: [ppc64] 404 | os: [linux] 405 | 406 | '@rollup/rollup-linux-riscv64-gnu@4.35.0': 407 | resolution: {integrity: sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==} 408 | cpu: [riscv64] 409 | os: [linux] 410 | 411 | '@rollup/rollup-linux-s390x-gnu@4.35.0': 412 | resolution: {integrity: sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==} 413 | cpu: [s390x] 414 | os: [linux] 415 | 416 | '@rollup/rollup-linux-x64-gnu@4.35.0': 417 | resolution: {integrity: sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==} 418 | cpu: [x64] 419 | os: [linux] 420 | 421 | '@rollup/rollup-linux-x64-musl@4.35.0': 422 | resolution: {integrity: sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==} 423 | cpu: [x64] 424 | os: [linux] 425 | 426 | '@rollup/rollup-win32-arm64-msvc@4.35.0': 427 | resolution: {integrity: sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==} 428 | cpu: [arm64] 429 | os: [win32] 430 | 431 | '@rollup/rollup-win32-ia32-msvc@4.35.0': 432 | resolution: {integrity: sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==} 433 | cpu: [ia32] 434 | os: [win32] 435 | 436 | '@rollup/rollup-win32-x64-msvc@4.35.0': 437 | resolution: {integrity: sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==} 438 | cpu: [x64] 439 | os: [win32] 440 | 441 | '@types/estree@1.0.6': 442 | resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} 443 | 444 | '@types/node@22.13.10': 445 | resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} 446 | 447 | '@vitest/expect@3.0.9': 448 | resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} 449 | 450 | '@vitest/mocker@3.0.9': 451 | resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==} 452 | peerDependencies: 453 | msw: ^2.4.9 454 | vite: ^5.0.0 || ^6.0.0 455 | peerDependenciesMeta: 456 | msw: 457 | optional: true 458 | vite: 459 | optional: true 460 | 461 | '@vitest/pretty-format@3.0.9': 462 | resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} 463 | 464 | '@vitest/runner@3.0.9': 465 | resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==} 466 | 467 | '@vitest/snapshot@3.0.9': 468 | resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==} 469 | 470 | '@vitest/spy@3.0.9': 471 | resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} 472 | 473 | '@vitest/utils@3.0.9': 474 | resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} 475 | 476 | ansi-regex@5.0.1: 477 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 478 | engines: {node: '>=8'} 479 | 480 | ansi-regex@6.1.0: 481 | resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} 482 | engines: {node: '>=12'} 483 | 484 | ansi-styles@4.3.0: 485 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 486 | engines: {node: '>=8'} 487 | 488 | ansi-styles@6.2.1: 489 | resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} 490 | engines: {node: '>=12'} 491 | 492 | any-promise@1.3.0: 493 | resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} 494 | 495 | assertion-error@2.0.1: 496 | resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 497 | engines: {node: '>=12'} 498 | 499 | balanced-match@1.0.2: 500 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 501 | 502 | brace-expansion@2.0.1: 503 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 504 | 505 | bundle-require@5.1.0: 506 | resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} 507 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 508 | peerDependencies: 509 | esbuild: '>=0.18' 510 | 511 | cac@6.7.14: 512 | resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} 513 | engines: {node: '>=8'} 514 | 515 | chai@5.2.0: 516 | resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} 517 | engines: {node: '>=12'} 518 | 519 | check-error@2.1.1: 520 | resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} 521 | engines: {node: '>= 16'} 522 | 523 | chokidar@4.0.3: 524 | resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 525 | engines: {node: '>= 14.16.0'} 526 | 527 | color-convert@2.0.1: 528 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 529 | engines: {node: '>=7.0.0'} 530 | 531 | color-name@1.1.4: 532 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 533 | 534 | commander@4.1.1: 535 | resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} 536 | engines: {node: '>= 6'} 537 | 538 | consola@3.4.0: 539 | resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} 540 | engines: {node: ^14.18.0 || >=16.10.0} 541 | 542 | cross-spawn@7.0.6: 543 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 544 | engines: {node: '>= 8'} 545 | 546 | debug@4.4.0: 547 | resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} 548 | engines: {node: '>=6.0'} 549 | peerDependencies: 550 | supports-color: '*' 551 | peerDependenciesMeta: 552 | supports-color: 553 | optional: true 554 | 555 | deep-eql@5.0.2: 556 | resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 557 | engines: {node: '>=6'} 558 | 559 | eastasianwidth@0.2.0: 560 | resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} 561 | 562 | emoji-regex@8.0.0: 563 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 564 | 565 | emoji-regex@9.2.2: 566 | resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 567 | 568 | es-module-lexer@1.6.0: 569 | resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} 570 | 571 | esbuild@0.21.5: 572 | resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} 573 | engines: {node: '>=12'} 574 | hasBin: true 575 | 576 | esbuild@0.25.1: 577 | resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} 578 | engines: {node: '>=18'} 579 | hasBin: true 580 | 581 | estree-walker@3.0.3: 582 | resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 583 | 584 | expect-type@1.2.0: 585 | resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} 586 | engines: {node: '>=12.0.0'} 587 | 588 | fdir@6.4.3: 589 | resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} 590 | peerDependencies: 591 | picomatch: ^3 || ^4 592 | peerDependenciesMeta: 593 | picomatch: 594 | optional: true 595 | 596 | foreground-child@3.3.1: 597 | resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 598 | engines: {node: '>=14'} 599 | 600 | fsevents@2.3.3: 601 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 602 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 603 | os: [darwin] 604 | 605 | glob@10.4.5: 606 | resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} 607 | hasBin: true 608 | 609 | is-fullwidth-code-point@3.0.0: 610 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 611 | engines: {node: '>=8'} 612 | 613 | isexe@2.0.0: 614 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 615 | 616 | jackspeak@3.4.3: 617 | resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 618 | 619 | joycon@3.1.1: 620 | resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} 621 | engines: {node: '>=10'} 622 | 623 | lilconfig@3.1.3: 624 | resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} 625 | engines: {node: '>=14'} 626 | 627 | lines-and-columns@1.2.4: 628 | resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} 629 | 630 | load-tsconfig@0.2.5: 631 | resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} 632 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 633 | 634 | lodash.sortby@4.7.0: 635 | resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} 636 | 637 | loupe@3.1.3: 638 | resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} 639 | 640 | lru-cache@10.4.3: 641 | resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 642 | 643 | magic-string@0.30.17: 644 | resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} 645 | 646 | minimatch@9.0.5: 647 | resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 648 | engines: {node: '>=16 || 14 >=14.17'} 649 | 650 | minipass@7.1.2: 651 | resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} 652 | engines: {node: '>=16 || 14 >=14.17'} 653 | 654 | ms@2.1.3: 655 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 656 | 657 | mz@2.7.0: 658 | resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} 659 | 660 | nanoid@3.3.9: 661 | resolution: {integrity: sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==} 662 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 663 | hasBin: true 664 | 665 | object-assign@4.1.1: 666 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 667 | engines: {node: '>=0.10.0'} 668 | 669 | package-json-from-dist@1.0.1: 670 | resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 671 | 672 | path-key@3.1.1: 673 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 674 | engines: {node: '>=8'} 675 | 676 | path-scurry@1.11.1: 677 | resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} 678 | engines: {node: '>=16 || 14 >=14.18'} 679 | 680 | pathe@2.0.3: 681 | resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 682 | 683 | pathval@2.0.0: 684 | resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} 685 | engines: {node: '>= 14.16'} 686 | 687 | picocolors@1.1.1: 688 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 689 | 690 | picomatch@4.0.2: 691 | resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} 692 | engines: {node: '>=12'} 693 | 694 | pirates@4.0.6: 695 | resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} 696 | engines: {node: '>= 6'} 697 | 698 | postcss-load-config@6.0.1: 699 | resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} 700 | engines: {node: '>= 18'} 701 | peerDependencies: 702 | jiti: '>=1.21.0' 703 | postcss: '>=8.0.9' 704 | tsx: ^4.8.1 705 | yaml: ^2.4.2 706 | peerDependenciesMeta: 707 | jiti: 708 | optional: true 709 | postcss: 710 | optional: true 711 | tsx: 712 | optional: true 713 | yaml: 714 | optional: true 715 | 716 | postcss@8.5.3: 717 | resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} 718 | engines: {node: ^10 || ^12 || >=14} 719 | 720 | prettier@3.5.3: 721 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} 722 | engines: {node: '>=14'} 723 | hasBin: true 724 | 725 | punycode@2.3.1: 726 | resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 727 | engines: {node: '>=6'} 728 | 729 | readdirp@4.1.2: 730 | resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 731 | engines: {node: '>= 14.18.0'} 732 | 733 | resolve-from@5.0.0: 734 | resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} 735 | engines: {node: '>=8'} 736 | 737 | rollup@4.35.0: 738 | resolution: {integrity: sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==} 739 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} 740 | hasBin: true 741 | 742 | shebang-command@2.0.0: 743 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 744 | engines: {node: '>=8'} 745 | 746 | shebang-regex@3.0.0: 747 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 748 | engines: {node: '>=8'} 749 | 750 | siginfo@2.0.0: 751 | resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 752 | 753 | signal-exit@4.1.0: 754 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 755 | engines: {node: '>=14'} 756 | 757 | source-map-js@1.2.1: 758 | resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 759 | engines: {node: '>=0.10.0'} 760 | 761 | source-map@0.8.0-beta.0: 762 | resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} 763 | engines: {node: '>= 8'} 764 | 765 | stackback@0.0.2: 766 | resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 767 | 768 | std-env@3.8.1: 769 | resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} 770 | 771 | string-width@4.2.3: 772 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 773 | engines: {node: '>=8'} 774 | 775 | string-width@5.1.2: 776 | resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} 777 | engines: {node: '>=12'} 778 | 779 | strip-ansi@6.0.1: 780 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 781 | engines: {node: '>=8'} 782 | 783 | strip-ansi@7.1.0: 784 | resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} 785 | engines: {node: '>=12'} 786 | 787 | sucrase@3.35.0: 788 | resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} 789 | engines: {node: '>=16 || 14 >=14.17'} 790 | hasBin: true 791 | 792 | thenify-all@1.6.0: 793 | resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} 794 | engines: {node: '>=0.8'} 795 | 796 | thenify@3.3.1: 797 | resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} 798 | 799 | tinybench@2.9.0: 800 | resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 801 | 802 | tinyexec@0.3.2: 803 | resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} 804 | 805 | tinyglobby@0.2.12: 806 | resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} 807 | engines: {node: '>=12.0.0'} 808 | 809 | tinypool@1.0.2: 810 | resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} 811 | engines: {node: ^18.0.0 || >=20.0.0} 812 | 813 | tinyrainbow@2.0.0: 814 | resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} 815 | engines: {node: '>=14.0.0'} 816 | 817 | tinyspy@3.0.2: 818 | resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} 819 | engines: {node: '>=14.0.0'} 820 | 821 | tr46@1.0.1: 822 | resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} 823 | 824 | tree-kill@1.2.2: 825 | resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} 826 | hasBin: true 827 | 828 | ts-interface-checker@0.1.13: 829 | resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} 830 | 831 | tsup@8.4.0: 832 | resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} 833 | engines: {node: '>=18'} 834 | hasBin: true 835 | peerDependencies: 836 | '@microsoft/api-extractor': ^7.36.0 837 | '@swc/core': ^1 838 | postcss: ^8.4.12 839 | typescript: '>=4.5.0' 840 | peerDependenciesMeta: 841 | '@microsoft/api-extractor': 842 | optional: true 843 | '@swc/core': 844 | optional: true 845 | postcss: 846 | optional: true 847 | typescript: 848 | optional: true 849 | 850 | typescript@5.8.2: 851 | resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} 852 | engines: {node: '>=14.17'} 853 | hasBin: true 854 | 855 | undici-types@6.20.0: 856 | resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} 857 | 858 | vite-node@3.0.9: 859 | resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==} 860 | engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} 861 | hasBin: true 862 | 863 | vite@5.4.14: 864 | resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==} 865 | engines: {node: ^18.0.0 || >=20.0.0} 866 | hasBin: true 867 | peerDependencies: 868 | '@types/node': ^18.0.0 || >=20.0.0 869 | less: '*' 870 | lightningcss: ^1.21.0 871 | sass: '*' 872 | sass-embedded: '*' 873 | stylus: '*' 874 | sugarss: '*' 875 | terser: ^5.4.0 876 | peerDependenciesMeta: 877 | '@types/node': 878 | optional: true 879 | less: 880 | optional: true 881 | lightningcss: 882 | optional: true 883 | sass: 884 | optional: true 885 | sass-embedded: 886 | optional: true 887 | stylus: 888 | optional: true 889 | sugarss: 890 | optional: true 891 | terser: 892 | optional: true 893 | 894 | vitest@3.0.9: 895 | resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==} 896 | engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} 897 | hasBin: true 898 | peerDependencies: 899 | '@edge-runtime/vm': '*' 900 | '@types/debug': ^4.1.12 901 | '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 902 | '@vitest/browser': 3.0.9 903 | '@vitest/ui': 3.0.9 904 | happy-dom: '*' 905 | jsdom: '*' 906 | peerDependenciesMeta: 907 | '@edge-runtime/vm': 908 | optional: true 909 | '@types/debug': 910 | optional: true 911 | '@types/node': 912 | optional: true 913 | '@vitest/browser': 914 | optional: true 915 | '@vitest/ui': 916 | optional: true 917 | happy-dom: 918 | optional: true 919 | jsdom: 920 | optional: true 921 | 922 | webidl-conversions@4.0.2: 923 | resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} 924 | 925 | whatwg-url@7.1.0: 926 | resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} 927 | 928 | which@2.0.2: 929 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 930 | engines: {node: '>= 8'} 931 | hasBin: true 932 | 933 | why-is-node-running@2.3.0: 934 | resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 935 | engines: {node: '>=8'} 936 | hasBin: true 937 | 938 | wrap-ansi@7.0.0: 939 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 940 | engines: {node: '>=10'} 941 | 942 | wrap-ansi@8.1.0: 943 | resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} 944 | engines: {node: '>=12'} 945 | 946 | snapshots: 947 | 948 | '@cloudflare/workers-types@4.20250311.0': {} 949 | 950 | '@esbuild/aix-ppc64@0.21.5': 951 | optional: true 952 | 953 | '@esbuild/aix-ppc64@0.25.1': 954 | optional: true 955 | 956 | '@esbuild/android-arm64@0.21.5': 957 | optional: true 958 | 959 | '@esbuild/android-arm64@0.25.1': 960 | optional: true 961 | 962 | '@esbuild/android-arm@0.21.5': 963 | optional: true 964 | 965 | '@esbuild/android-arm@0.25.1': 966 | optional: true 967 | 968 | '@esbuild/android-x64@0.21.5': 969 | optional: true 970 | 971 | '@esbuild/android-x64@0.25.1': 972 | optional: true 973 | 974 | '@esbuild/darwin-arm64@0.21.5': 975 | optional: true 976 | 977 | '@esbuild/darwin-arm64@0.25.1': 978 | optional: true 979 | 980 | '@esbuild/darwin-x64@0.21.5': 981 | optional: true 982 | 983 | '@esbuild/darwin-x64@0.25.1': 984 | optional: true 985 | 986 | '@esbuild/freebsd-arm64@0.21.5': 987 | optional: true 988 | 989 | '@esbuild/freebsd-arm64@0.25.1': 990 | optional: true 991 | 992 | '@esbuild/freebsd-x64@0.21.5': 993 | optional: true 994 | 995 | '@esbuild/freebsd-x64@0.25.1': 996 | optional: true 997 | 998 | '@esbuild/linux-arm64@0.21.5': 999 | optional: true 1000 | 1001 | '@esbuild/linux-arm64@0.25.1': 1002 | optional: true 1003 | 1004 | '@esbuild/linux-arm@0.21.5': 1005 | optional: true 1006 | 1007 | '@esbuild/linux-arm@0.25.1': 1008 | optional: true 1009 | 1010 | '@esbuild/linux-ia32@0.21.5': 1011 | optional: true 1012 | 1013 | '@esbuild/linux-ia32@0.25.1': 1014 | optional: true 1015 | 1016 | '@esbuild/linux-loong64@0.21.5': 1017 | optional: true 1018 | 1019 | '@esbuild/linux-loong64@0.25.1': 1020 | optional: true 1021 | 1022 | '@esbuild/linux-mips64el@0.21.5': 1023 | optional: true 1024 | 1025 | '@esbuild/linux-mips64el@0.25.1': 1026 | optional: true 1027 | 1028 | '@esbuild/linux-ppc64@0.21.5': 1029 | optional: true 1030 | 1031 | '@esbuild/linux-ppc64@0.25.1': 1032 | optional: true 1033 | 1034 | '@esbuild/linux-riscv64@0.21.5': 1035 | optional: true 1036 | 1037 | '@esbuild/linux-riscv64@0.25.1': 1038 | optional: true 1039 | 1040 | '@esbuild/linux-s390x@0.21.5': 1041 | optional: true 1042 | 1043 | '@esbuild/linux-s390x@0.25.1': 1044 | optional: true 1045 | 1046 | '@esbuild/linux-x64@0.21.5': 1047 | optional: true 1048 | 1049 | '@esbuild/linux-x64@0.25.1': 1050 | optional: true 1051 | 1052 | '@esbuild/netbsd-arm64@0.25.1': 1053 | optional: true 1054 | 1055 | '@esbuild/netbsd-x64@0.21.5': 1056 | optional: true 1057 | 1058 | '@esbuild/netbsd-x64@0.25.1': 1059 | optional: true 1060 | 1061 | '@esbuild/openbsd-arm64@0.25.1': 1062 | optional: true 1063 | 1064 | '@esbuild/openbsd-x64@0.21.5': 1065 | optional: true 1066 | 1067 | '@esbuild/openbsd-x64@0.25.1': 1068 | optional: true 1069 | 1070 | '@esbuild/sunos-x64@0.21.5': 1071 | optional: true 1072 | 1073 | '@esbuild/sunos-x64@0.25.1': 1074 | optional: true 1075 | 1076 | '@esbuild/win32-arm64@0.21.5': 1077 | optional: true 1078 | 1079 | '@esbuild/win32-arm64@0.25.1': 1080 | optional: true 1081 | 1082 | '@esbuild/win32-ia32@0.21.5': 1083 | optional: true 1084 | 1085 | '@esbuild/win32-ia32@0.25.1': 1086 | optional: true 1087 | 1088 | '@esbuild/win32-x64@0.21.5': 1089 | optional: true 1090 | 1091 | '@esbuild/win32-x64@0.25.1': 1092 | optional: true 1093 | 1094 | '@isaacs/cliui@8.0.2': 1095 | dependencies: 1096 | string-width: 5.1.2 1097 | string-width-cjs: string-width@4.2.3 1098 | strip-ansi: 7.1.0 1099 | strip-ansi-cjs: strip-ansi@6.0.1 1100 | wrap-ansi: 8.1.0 1101 | wrap-ansi-cjs: wrap-ansi@7.0.0 1102 | 1103 | '@jridgewell/gen-mapping@0.3.8': 1104 | dependencies: 1105 | '@jridgewell/set-array': 1.2.1 1106 | '@jridgewell/sourcemap-codec': 1.5.0 1107 | '@jridgewell/trace-mapping': 0.3.25 1108 | 1109 | '@jridgewell/resolve-uri@3.1.2': {} 1110 | 1111 | '@jridgewell/set-array@1.2.1': {} 1112 | 1113 | '@jridgewell/sourcemap-codec@1.5.0': {} 1114 | 1115 | '@jridgewell/trace-mapping@0.3.25': 1116 | dependencies: 1117 | '@jridgewell/resolve-uri': 3.1.2 1118 | '@jridgewell/sourcemap-codec': 1.5.0 1119 | 1120 | '@pkgjs/parseargs@0.11.0': 1121 | optional: true 1122 | 1123 | '@rollup/rollup-android-arm-eabi@4.35.0': 1124 | optional: true 1125 | 1126 | '@rollup/rollup-android-arm64@4.35.0': 1127 | optional: true 1128 | 1129 | '@rollup/rollup-darwin-arm64@4.35.0': 1130 | optional: true 1131 | 1132 | '@rollup/rollup-darwin-x64@4.35.0': 1133 | optional: true 1134 | 1135 | '@rollup/rollup-freebsd-arm64@4.35.0': 1136 | optional: true 1137 | 1138 | '@rollup/rollup-freebsd-x64@4.35.0': 1139 | optional: true 1140 | 1141 | '@rollup/rollup-linux-arm-gnueabihf@4.35.0': 1142 | optional: true 1143 | 1144 | '@rollup/rollup-linux-arm-musleabihf@4.35.0': 1145 | optional: true 1146 | 1147 | '@rollup/rollup-linux-arm64-gnu@4.35.0': 1148 | optional: true 1149 | 1150 | '@rollup/rollup-linux-arm64-musl@4.35.0': 1151 | optional: true 1152 | 1153 | '@rollup/rollup-linux-loongarch64-gnu@4.35.0': 1154 | optional: true 1155 | 1156 | '@rollup/rollup-linux-powerpc64le-gnu@4.35.0': 1157 | optional: true 1158 | 1159 | '@rollup/rollup-linux-riscv64-gnu@4.35.0': 1160 | optional: true 1161 | 1162 | '@rollup/rollup-linux-s390x-gnu@4.35.0': 1163 | optional: true 1164 | 1165 | '@rollup/rollup-linux-x64-gnu@4.35.0': 1166 | optional: true 1167 | 1168 | '@rollup/rollup-linux-x64-musl@4.35.0': 1169 | optional: true 1170 | 1171 | '@rollup/rollup-win32-arm64-msvc@4.35.0': 1172 | optional: true 1173 | 1174 | '@rollup/rollup-win32-ia32-msvc@4.35.0': 1175 | optional: true 1176 | 1177 | '@rollup/rollup-win32-x64-msvc@4.35.0': 1178 | optional: true 1179 | 1180 | '@types/estree@1.0.6': {} 1181 | 1182 | '@types/node@22.13.10': 1183 | dependencies: 1184 | undici-types: 6.20.0 1185 | optional: true 1186 | 1187 | '@vitest/expect@3.0.9': 1188 | dependencies: 1189 | '@vitest/spy': 3.0.9 1190 | '@vitest/utils': 3.0.9 1191 | chai: 5.2.0 1192 | tinyrainbow: 2.0.0 1193 | 1194 | '@vitest/mocker@3.0.9(vite@5.4.14(@types/node@22.13.10))': 1195 | dependencies: 1196 | '@vitest/spy': 3.0.9 1197 | estree-walker: 3.0.3 1198 | magic-string: 0.30.17 1199 | optionalDependencies: 1200 | vite: 5.4.14(@types/node@22.13.10) 1201 | 1202 | '@vitest/pretty-format@3.0.9': 1203 | dependencies: 1204 | tinyrainbow: 2.0.0 1205 | 1206 | '@vitest/runner@3.0.9': 1207 | dependencies: 1208 | '@vitest/utils': 3.0.9 1209 | pathe: 2.0.3 1210 | 1211 | '@vitest/snapshot@3.0.9': 1212 | dependencies: 1213 | '@vitest/pretty-format': 3.0.9 1214 | magic-string: 0.30.17 1215 | pathe: 2.0.3 1216 | 1217 | '@vitest/spy@3.0.9': 1218 | dependencies: 1219 | tinyspy: 3.0.2 1220 | 1221 | '@vitest/utils@3.0.9': 1222 | dependencies: 1223 | '@vitest/pretty-format': 3.0.9 1224 | loupe: 3.1.3 1225 | tinyrainbow: 2.0.0 1226 | 1227 | ansi-regex@5.0.1: {} 1228 | 1229 | ansi-regex@6.1.0: {} 1230 | 1231 | ansi-styles@4.3.0: 1232 | dependencies: 1233 | color-convert: 2.0.1 1234 | 1235 | ansi-styles@6.2.1: {} 1236 | 1237 | any-promise@1.3.0: {} 1238 | 1239 | assertion-error@2.0.1: {} 1240 | 1241 | balanced-match@1.0.2: {} 1242 | 1243 | brace-expansion@2.0.1: 1244 | dependencies: 1245 | balanced-match: 1.0.2 1246 | 1247 | bundle-require@5.1.0(esbuild@0.25.1): 1248 | dependencies: 1249 | esbuild: 0.25.1 1250 | load-tsconfig: 0.2.5 1251 | 1252 | cac@6.7.14: {} 1253 | 1254 | chai@5.2.0: 1255 | dependencies: 1256 | assertion-error: 2.0.1 1257 | check-error: 2.1.1 1258 | deep-eql: 5.0.2 1259 | loupe: 3.1.3 1260 | pathval: 2.0.0 1261 | 1262 | check-error@2.1.1: {} 1263 | 1264 | chokidar@4.0.3: 1265 | dependencies: 1266 | readdirp: 4.1.2 1267 | 1268 | color-convert@2.0.1: 1269 | dependencies: 1270 | color-name: 1.1.4 1271 | 1272 | color-name@1.1.4: {} 1273 | 1274 | commander@4.1.1: {} 1275 | 1276 | consola@3.4.0: {} 1277 | 1278 | cross-spawn@7.0.6: 1279 | dependencies: 1280 | path-key: 3.1.1 1281 | shebang-command: 2.0.0 1282 | which: 2.0.2 1283 | 1284 | debug@4.4.0: 1285 | dependencies: 1286 | ms: 2.1.3 1287 | 1288 | deep-eql@5.0.2: {} 1289 | 1290 | eastasianwidth@0.2.0: {} 1291 | 1292 | emoji-regex@8.0.0: {} 1293 | 1294 | emoji-regex@9.2.2: {} 1295 | 1296 | es-module-lexer@1.6.0: {} 1297 | 1298 | esbuild@0.21.5: 1299 | optionalDependencies: 1300 | '@esbuild/aix-ppc64': 0.21.5 1301 | '@esbuild/android-arm': 0.21.5 1302 | '@esbuild/android-arm64': 0.21.5 1303 | '@esbuild/android-x64': 0.21.5 1304 | '@esbuild/darwin-arm64': 0.21.5 1305 | '@esbuild/darwin-x64': 0.21.5 1306 | '@esbuild/freebsd-arm64': 0.21.5 1307 | '@esbuild/freebsd-x64': 0.21.5 1308 | '@esbuild/linux-arm': 0.21.5 1309 | '@esbuild/linux-arm64': 0.21.5 1310 | '@esbuild/linux-ia32': 0.21.5 1311 | '@esbuild/linux-loong64': 0.21.5 1312 | '@esbuild/linux-mips64el': 0.21.5 1313 | '@esbuild/linux-ppc64': 0.21.5 1314 | '@esbuild/linux-riscv64': 0.21.5 1315 | '@esbuild/linux-s390x': 0.21.5 1316 | '@esbuild/linux-x64': 0.21.5 1317 | '@esbuild/netbsd-x64': 0.21.5 1318 | '@esbuild/openbsd-x64': 0.21.5 1319 | '@esbuild/sunos-x64': 0.21.5 1320 | '@esbuild/win32-arm64': 0.21.5 1321 | '@esbuild/win32-ia32': 0.21.5 1322 | '@esbuild/win32-x64': 0.21.5 1323 | 1324 | esbuild@0.25.1: 1325 | optionalDependencies: 1326 | '@esbuild/aix-ppc64': 0.25.1 1327 | '@esbuild/android-arm': 0.25.1 1328 | '@esbuild/android-arm64': 0.25.1 1329 | '@esbuild/android-x64': 0.25.1 1330 | '@esbuild/darwin-arm64': 0.25.1 1331 | '@esbuild/darwin-x64': 0.25.1 1332 | '@esbuild/freebsd-arm64': 0.25.1 1333 | '@esbuild/freebsd-x64': 0.25.1 1334 | '@esbuild/linux-arm': 0.25.1 1335 | '@esbuild/linux-arm64': 0.25.1 1336 | '@esbuild/linux-ia32': 0.25.1 1337 | '@esbuild/linux-loong64': 0.25.1 1338 | '@esbuild/linux-mips64el': 0.25.1 1339 | '@esbuild/linux-ppc64': 0.25.1 1340 | '@esbuild/linux-riscv64': 0.25.1 1341 | '@esbuild/linux-s390x': 0.25.1 1342 | '@esbuild/linux-x64': 0.25.1 1343 | '@esbuild/netbsd-arm64': 0.25.1 1344 | '@esbuild/netbsd-x64': 0.25.1 1345 | '@esbuild/openbsd-arm64': 0.25.1 1346 | '@esbuild/openbsd-x64': 0.25.1 1347 | '@esbuild/sunos-x64': 0.25.1 1348 | '@esbuild/win32-arm64': 0.25.1 1349 | '@esbuild/win32-ia32': 0.25.1 1350 | '@esbuild/win32-x64': 0.25.1 1351 | 1352 | estree-walker@3.0.3: 1353 | dependencies: 1354 | '@types/estree': 1.0.6 1355 | 1356 | expect-type@1.2.0: {} 1357 | 1358 | fdir@6.4.3(picomatch@4.0.2): 1359 | optionalDependencies: 1360 | picomatch: 4.0.2 1361 | 1362 | foreground-child@3.3.1: 1363 | dependencies: 1364 | cross-spawn: 7.0.6 1365 | signal-exit: 4.1.0 1366 | 1367 | fsevents@2.3.3: 1368 | optional: true 1369 | 1370 | glob@10.4.5: 1371 | dependencies: 1372 | foreground-child: 3.3.1 1373 | jackspeak: 3.4.3 1374 | minimatch: 9.0.5 1375 | minipass: 7.1.2 1376 | package-json-from-dist: 1.0.1 1377 | path-scurry: 1.11.1 1378 | 1379 | is-fullwidth-code-point@3.0.0: {} 1380 | 1381 | isexe@2.0.0: {} 1382 | 1383 | jackspeak@3.4.3: 1384 | dependencies: 1385 | '@isaacs/cliui': 8.0.2 1386 | optionalDependencies: 1387 | '@pkgjs/parseargs': 0.11.0 1388 | 1389 | joycon@3.1.1: {} 1390 | 1391 | lilconfig@3.1.3: {} 1392 | 1393 | lines-and-columns@1.2.4: {} 1394 | 1395 | load-tsconfig@0.2.5: {} 1396 | 1397 | lodash.sortby@4.7.0: {} 1398 | 1399 | loupe@3.1.3: {} 1400 | 1401 | lru-cache@10.4.3: {} 1402 | 1403 | magic-string@0.30.17: 1404 | dependencies: 1405 | '@jridgewell/sourcemap-codec': 1.5.0 1406 | 1407 | minimatch@9.0.5: 1408 | dependencies: 1409 | brace-expansion: 2.0.1 1410 | 1411 | minipass@7.1.2: {} 1412 | 1413 | ms@2.1.3: {} 1414 | 1415 | mz@2.7.0: 1416 | dependencies: 1417 | any-promise: 1.3.0 1418 | object-assign: 4.1.1 1419 | thenify-all: 1.6.0 1420 | 1421 | nanoid@3.3.9: {} 1422 | 1423 | object-assign@4.1.1: {} 1424 | 1425 | package-json-from-dist@1.0.1: {} 1426 | 1427 | path-key@3.1.1: {} 1428 | 1429 | path-scurry@1.11.1: 1430 | dependencies: 1431 | lru-cache: 10.4.3 1432 | minipass: 7.1.2 1433 | 1434 | pathe@2.0.3: {} 1435 | 1436 | pathval@2.0.0: {} 1437 | 1438 | picocolors@1.1.1: {} 1439 | 1440 | picomatch@4.0.2: {} 1441 | 1442 | pirates@4.0.6: {} 1443 | 1444 | postcss-load-config@6.0.1(postcss@8.5.3): 1445 | dependencies: 1446 | lilconfig: 3.1.3 1447 | optionalDependencies: 1448 | postcss: 8.5.3 1449 | 1450 | postcss@8.5.3: 1451 | dependencies: 1452 | nanoid: 3.3.9 1453 | picocolors: 1.1.1 1454 | source-map-js: 1.2.1 1455 | 1456 | prettier@3.5.3: {} 1457 | 1458 | punycode@2.3.1: {} 1459 | 1460 | readdirp@4.1.2: {} 1461 | 1462 | resolve-from@5.0.0: {} 1463 | 1464 | rollup@4.35.0: 1465 | dependencies: 1466 | '@types/estree': 1.0.6 1467 | optionalDependencies: 1468 | '@rollup/rollup-android-arm-eabi': 4.35.0 1469 | '@rollup/rollup-android-arm64': 4.35.0 1470 | '@rollup/rollup-darwin-arm64': 4.35.0 1471 | '@rollup/rollup-darwin-x64': 4.35.0 1472 | '@rollup/rollup-freebsd-arm64': 4.35.0 1473 | '@rollup/rollup-freebsd-x64': 4.35.0 1474 | '@rollup/rollup-linux-arm-gnueabihf': 4.35.0 1475 | '@rollup/rollup-linux-arm-musleabihf': 4.35.0 1476 | '@rollup/rollup-linux-arm64-gnu': 4.35.0 1477 | '@rollup/rollup-linux-arm64-musl': 4.35.0 1478 | '@rollup/rollup-linux-loongarch64-gnu': 4.35.0 1479 | '@rollup/rollup-linux-powerpc64le-gnu': 4.35.0 1480 | '@rollup/rollup-linux-riscv64-gnu': 4.35.0 1481 | '@rollup/rollup-linux-s390x-gnu': 4.35.0 1482 | '@rollup/rollup-linux-x64-gnu': 4.35.0 1483 | '@rollup/rollup-linux-x64-musl': 4.35.0 1484 | '@rollup/rollup-win32-arm64-msvc': 4.35.0 1485 | '@rollup/rollup-win32-ia32-msvc': 4.35.0 1486 | '@rollup/rollup-win32-x64-msvc': 4.35.0 1487 | fsevents: 2.3.3 1488 | 1489 | shebang-command@2.0.0: 1490 | dependencies: 1491 | shebang-regex: 3.0.0 1492 | 1493 | shebang-regex@3.0.0: {} 1494 | 1495 | siginfo@2.0.0: {} 1496 | 1497 | signal-exit@4.1.0: {} 1498 | 1499 | source-map-js@1.2.1: {} 1500 | 1501 | source-map@0.8.0-beta.0: 1502 | dependencies: 1503 | whatwg-url: 7.1.0 1504 | 1505 | stackback@0.0.2: {} 1506 | 1507 | std-env@3.8.1: {} 1508 | 1509 | string-width@4.2.3: 1510 | dependencies: 1511 | emoji-regex: 8.0.0 1512 | is-fullwidth-code-point: 3.0.0 1513 | strip-ansi: 6.0.1 1514 | 1515 | string-width@5.1.2: 1516 | dependencies: 1517 | eastasianwidth: 0.2.0 1518 | emoji-regex: 9.2.2 1519 | strip-ansi: 7.1.0 1520 | 1521 | strip-ansi@6.0.1: 1522 | dependencies: 1523 | ansi-regex: 5.0.1 1524 | 1525 | strip-ansi@7.1.0: 1526 | dependencies: 1527 | ansi-regex: 6.1.0 1528 | 1529 | sucrase@3.35.0: 1530 | dependencies: 1531 | '@jridgewell/gen-mapping': 0.3.8 1532 | commander: 4.1.1 1533 | glob: 10.4.5 1534 | lines-and-columns: 1.2.4 1535 | mz: 2.7.0 1536 | pirates: 4.0.6 1537 | ts-interface-checker: 0.1.13 1538 | 1539 | thenify-all@1.6.0: 1540 | dependencies: 1541 | thenify: 3.3.1 1542 | 1543 | thenify@3.3.1: 1544 | dependencies: 1545 | any-promise: 1.3.0 1546 | 1547 | tinybench@2.9.0: {} 1548 | 1549 | tinyexec@0.3.2: {} 1550 | 1551 | tinyglobby@0.2.12: 1552 | dependencies: 1553 | fdir: 6.4.3(picomatch@4.0.2) 1554 | picomatch: 4.0.2 1555 | 1556 | tinypool@1.0.2: {} 1557 | 1558 | tinyrainbow@2.0.0: {} 1559 | 1560 | tinyspy@3.0.2: {} 1561 | 1562 | tr46@1.0.1: 1563 | dependencies: 1564 | punycode: 2.3.1 1565 | 1566 | tree-kill@1.2.2: {} 1567 | 1568 | ts-interface-checker@0.1.13: {} 1569 | 1570 | tsup@8.4.0(postcss@8.5.3)(typescript@5.8.2): 1571 | dependencies: 1572 | bundle-require: 5.1.0(esbuild@0.25.1) 1573 | cac: 6.7.14 1574 | chokidar: 4.0.3 1575 | consola: 3.4.0 1576 | debug: 4.4.0 1577 | esbuild: 0.25.1 1578 | joycon: 3.1.1 1579 | picocolors: 1.1.1 1580 | postcss-load-config: 6.0.1(postcss@8.5.3) 1581 | resolve-from: 5.0.0 1582 | rollup: 4.35.0 1583 | source-map: 0.8.0-beta.0 1584 | sucrase: 3.35.0 1585 | tinyexec: 0.3.2 1586 | tinyglobby: 0.2.12 1587 | tree-kill: 1.2.2 1588 | optionalDependencies: 1589 | postcss: 8.5.3 1590 | typescript: 5.8.2 1591 | transitivePeerDependencies: 1592 | - jiti 1593 | - supports-color 1594 | - tsx 1595 | - yaml 1596 | 1597 | typescript@5.8.2: {} 1598 | 1599 | undici-types@6.20.0: 1600 | optional: true 1601 | 1602 | vite-node@3.0.9(@types/node@22.13.10): 1603 | dependencies: 1604 | cac: 6.7.14 1605 | debug: 4.4.0 1606 | es-module-lexer: 1.6.0 1607 | pathe: 2.0.3 1608 | vite: 5.4.14(@types/node@22.13.10) 1609 | transitivePeerDependencies: 1610 | - '@types/node' 1611 | - less 1612 | - lightningcss 1613 | - sass 1614 | - sass-embedded 1615 | - stylus 1616 | - sugarss 1617 | - supports-color 1618 | - terser 1619 | 1620 | vite@5.4.14(@types/node@22.13.10): 1621 | dependencies: 1622 | esbuild: 0.21.5 1623 | postcss: 8.5.3 1624 | rollup: 4.35.0 1625 | optionalDependencies: 1626 | '@types/node': 22.13.10 1627 | fsevents: 2.3.3 1628 | 1629 | vitest@3.0.9(@types/node@22.13.10): 1630 | dependencies: 1631 | '@vitest/expect': 3.0.9 1632 | '@vitest/mocker': 3.0.9(vite@5.4.14(@types/node@22.13.10)) 1633 | '@vitest/pretty-format': 3.0.9 1634 | '@vitest/runner': 3.0.9 1635 | '@vitest/snapshot': 3.0.9 1636 | '@vitest/spy': 3.0.9 1637 | '@vitest/utils': 3.0.9 1638 | chai: 5.2.0 1639 | debug: 4.4.0 1640 | expect-type: 1.2.0 1641 | magic-string: 0.30.17 1642 | pathe: 2.0.3 1643 | std-env: 3.8.1 1644 | tinybench: 2.9.0 1645 | tinyexec: 0.3.2 1646 | tinypool: 1.0.2 1647 | tinyrainbow: 2.0.0 1648 | vite: 5.4.14(@types/node@22.13.10) 1649 | vite-node: 3.0.9(@types/node@22.13.10) 1650 | why-is-node-running: 2.3.0 1651 | optionalDependencies: 1652 | '@types/node': 22.13.10 1653 | transitivePeerDependencies: 1654 | - less 1655 | - lightningcss 1656 | - msw 1657 | - sass 1658 | - sass-embedded 1659 | - stylus 1660 | - sugarss 1661 | - supports-color 1662 | - terser 1663 | 1664 | webidl-conversions@4.0.2: {} 1665 | 1666 | whatwg-url@7.1.0: 1667 | dependencies: 1668 | lodash.sortby: 4.7.0 1669 | tr46: 1.0.1 1670 | webidl-conversions: 4.0.2 1671 | 1672 | which@2.0.2: 1673 | dependencies: 1674 | isexe: 2.0.0 1675 | 1676 | why-is-node-running@2.3.0: 1677 | dependencies: 1678 | siginfo: 2.0.0 1679 | stackback: 0.0.2 1680 | 1681 | wrap-ansi@7.0.0: 1682 | dependencies: 1683 | ansi-styles: 4.3.0 1684 | string-width: 4.2.3 1685 | strip-ansi: 6.0.1 1686 | 1687 | wrap-ansi@8.1.0: 1688 | dependencies: 1689 | ansi-styles: 6.2.1 1690 | string-width: 5.1.2 1691 | strip-ansi: 7.1.0 1692 | -------------------------------------------------------------------------------- /storage-schema.md: -------------------------------------------------------------------------------- 1 | # OAuth KV Storage Schema 2 | 3 | This document describes the schema used in the OAUTH_KV storage for the OAuth 2.0 provider library. The library uses Cloudflare Workers KV to store all OAuth-related data, including client registrations, authorization grants, and tokens. 4 | 5 | ## Overview 6 | 7 | The OAUTH_KV namespace stores several types of objects, each with a distinct key prefix to identify the type of data. The storage leverages KV's built-in TTL (Time-To-Live) functionality for automatic expiration of short-lived data like tokens and authorization codes. 8 | 9 | The system implements end-to-end encryption for sensitive application-specific properties (`props`) to ensure that only holders of valid tokens can access this data. 10 | 11 | ## Key Naming Conventions 12 | 13 | All keys in the KV namespace follow a consistent pattern to make them easily identifiable: 14 | 15 | | Prefix | Purpose | Example | 16 | |--------|---------|---------| 17 | | `client:` | Client registration data | `client:abc123` | 18 | | `grant:{userId}:` | Authorization grant data | `grant:user123:xyz789` | 19 | | `token:` | Access and refresh tokens | `token:ghi789` | 20 | 21 | ## Data Structures 22 | 23 | ### Clients 24 | 25 | Client records store OAuth client application information. 26 | 27 | **Key format:** `client:{clientId}` 28 | 29 | **Content Example:** 30 | ```json 31 | { 32 | "clientId": "abc123", 33 | "clientSecret": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", 34 | "redirectUris": ["https://app.example.com/callback"], 35 | "clientName": "Example App", 36 | "logoUri": "https://app.example.com/logo.png", 37 | "clientUri": "https://app.example.com", 38 | "policyUri": "https://app.example.com/privacy", 39 | "tosUri": "https://app.example.com/terms", 40 | "jwksUri": null, 41 | "contacts": ["dev@example.com"], 42 | "grantTypes": ["authorization_code", "refresh_token"], 43 | "responseTypes": ["code"], 44 | "registrationDate": 1644256123 45 | } 46 | ``` 47 | 48 | > **Note:** The `clientSecret` is stored as a SHA-256 hash, not in plaintext. The actual secret is only returned to the client when initially created or updated, and never stored. 49 | 50 | **TTL:** No expiration (persistent storage) 51 | 52 | ### Authorization Grants 53 | 54 | Grant records store information about permissions a user has granted to an application, along with the authorization code (initially) and refresh token (after code exchange). 55 | 56 | **Key format:** `grant:{userId}:{grantId}` 57 | 58 | **Content Example (during authorization):** 59 | ```json 60 | { 61 | "id": "xyz789", 62 | "clientId": "abc123", 63 | "userId": "user123", 64 | "scope": ["document.read", "document.write"], 65 | "metadata": { 66 | "label": "My Files Access", 67 | "deviceInfo": "Chrome on Windows" 68 | }, 69 | "encryptedProps": "AES-GCM encrypted base64-encoded string", 70 | "createdAt": 1644256123, 71 | "authCodeId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", 72 | "authCodeWrappedKey": "base64-encoded wrapped encryption key", 73 | "codeChallenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", 74 | "codeChallengeMethod": "S256" 75 | } 76 | ``` 77 | 78 | **Content Example (after code exchange):** 79 | ```json 80 | { 81 | "id": "xyz789", 82 | "clientId": "abc123", 83 | "userId": "user123", 84 | "scope": ["document.read", "document.write"], 85 | "metadata": { 86 | "label": "My Files Access", 87 | "deviceInfo": "Chrome on Windows" 88 | }, 89 | "encryptedProps": "AES-GCM encrypted base64-encoded string", 90 | "createdAt": 1644256123, 91 | "refreshTokenId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", 92 | "refreshTokenWrappedKey": "base64-encoded wrapped encryption key" 93 | } 94 | ``` 95 | 96 | **Content Example (after refresh token rotation):** 97 | ```json 98 | { 99 | "id": "xyz789", 100 | "clientId": "abc123", 101 | "userId": "user123", 102 | "scope": ["document.read", "document.write"], 103 | "metadata": { 104 | "label": "My Files Access", 105 | "deviceInfo": "Chrome on Windows" 106 | }, 107 | "encryptedProps": "AES-GCM encrypted base64-encoded string", 108 | "createdAt": 1644256123, 109 | "refreshTokenId": "7f2ab876c546a9e9f988ba7645af78239cfe980a4231ab38fcb895cb244a0a12", 110 | "refreshTokenWrappedKey": "base64-encoded wrapped encryption key", 111 | "previousRefreshTokenId": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", 112 | "previousRefreshTokenWrappedKey": "base64-encoded wrapped encryption key for previous token" 113 | } 114 | ``` 115 | 116 | **TTL:** 117 | - Initially 10 minutes (during authorization process) 118 | - No expiration after the authorization code is exchanged for tokens 119 | 120 | > **Note:** The grant record includes the hash of the authorization code initially, which is replaced by the hash of the refresh token after the code is exchanged. The record also has a TTL during the authorization process, which is removed when the code is exchanged for tokens to make the grant permanent. 121 | 122 | ### Tokens 123 | 124 | Token records store metadata about issued access tokens, including denormalized grant information for faster access. 125 | 126 | **Key format:** `token:{userId}:{grantId}:{tokenId}` 127 | 128 | **Content Example:** 129 | ```json 130 | { 131 | "id": "ghi789", 132 | "grantId": "xyz789", 133 | "userId": "user123", 134 | "createdAt": 1644256123, 135 | "expiresAt": 1644259723, 136 | "wrappedEncryptionKey": "base64-encoded wrapped encryption key", 137 | "grant": { 138 | "clientId": "abc123", 139 | "scope": ["document.read", "document.write"], 140 | "encryptedProps": "AES-GCM encrypted base64-encoded string" 141 | } 142 | } 143 | ``` 144 | 145 | > **Note:** The token format is `{userId}:{grantId}:{random-secret}` which embeds the identifiers needed for efficient lookups. The token key format includes the user ID and grant ID to enable efficient revocation of all tokens for a specific grant. The token record contains denormalized grant information to eliminate the need for a separate grant lookup during token validation. The token also carries a wrapped encryption key that can only be unwrapped using the actual token string, allowing decryption of the encrypted props. 146 | 147 | **TTL:** Access tokens typically have a 1 hour (3600 seconds) TTL by default 148 | 149 | ## Security Considerations 150 | 151 | 1. **Sensitive Value Storage**: No sensitive values are stored in plaintext in KV storage: 152 | - Access tokens and refresh tokens are stored as SHA-256 hashes 153 | - Client secrets are stored as SHA-256 hashes 154 | - Authorization codes are stored as SHA-256 hashes 155 | - For PKCE, only the code challenge is stored, never the code verifier 156 | - Application-specific properties (`props`) are encrypted using AES-GCM 157 | 158 | This ensures that even if the KV data is compromised, the actual sensitive values cannot be retrieved. 159 | 160 | 2. **End-to-End Encryption for Props**: 161 | - Each grant has its own unique AES-256 key for encrypting props 162 | - A constant all-zero initialization vector (IV) is used with AES-GCM encryption 163 | - This is cryptographically secure because each key is only used exactly once 164 | - Using unique keys eliminates the IV randomization requirement of AES-GCM 165 | - The encryption key is wrapped (encrypted) using each token as key material 166 | - The wrapped key can only be unwrapped by someone with the actual token 167 | - No backup of the encryption key is stored anywhere 168 | - Even system administrators cannot decrypt the props without a valid token 169 | 170 | 3. **Key Wrapping Security**: 171 | - Token wrapping keys are derived using HMAC-SHA256 with a static key 172 | - The derivation method is different from token ID generation for security separation 173 | - Each token type (authorization code, refresh token, access token) has its own wrapped key 174 | - The wrapping algorithm used is AES-KW (AES Key Wrap) 175 | 176 | 4. **Token Format**: Tokens use the format `{userId}:{grantId}:{random-secret}` which allows: 177 | - Direct access to token records without needing to look up grants separately 178 | - Verification that the token was issued for the specific grant and user 179 | - Enhanced security through proper validation checks 180 | 181 | 5. **TTL-based Expiration**: Access tokens automatically expire using KV's TTL feature, reducing the need for manual cleanup. 182 | 183 | 6. **Efficient Storage**: 184 | - Refresh tokens are stored within the grant records, eliminating redundant storage 185 | - Grant data is denormalized into token records for faster validation 186 | - Token keys include user ID and grant ID to enable efficient revocation 187 | 188 | 7. **Structured Key Design**: The key format `token:{userId}:{grantId}:{tokenId}` enables: 189 | - Efficient revocation of all tokens for a specific grant 190 | - Easy lookup of all tokens issued to a specific user 191 | - Clean organization of the key-value namespace 192 | 193 | 8. **Cryptographic Hash Verification**: When validating credentials, the system hashes the provided value and compares it with the stored hash, rather than comparing plaintext values. 194 | 195 | ## Example Workflow with Encrypted Props 196 | 197 | 1. A client is registered, creating a `client:{clientId}` entry with a hashed client secret. 198 | 199 | 2. A user authorizes the client, creating a `grant:{userId}:{grantId}` entry that includes: 200 | - The hashed authorization code in the `authCodeId` field 201 | - PKCE code challenge and method (if PKCE is used) 202 | - A new AES-256 encryption key is generated specifically for this grant 203 | - The `props` data is encrypted using this key with AES-GCM and a constant zero IV 204 | - The encryption key is wrapped using the authorization code 205 | - The wrapped key is stored in `authCodeWrappedKey` 206 | - A 10-minute TTL on the grant record 207 | 208 | 3. The client exchanges the authorization code for tokens: 209 | - The code is validated by comparing its hash to the one stored in the grant 210 | - If PKCE was used, the code_verifier is validated against the stored code_challenge 211 | - The encryption key is unwrapped using the authorization code 212 | - The key is re-wrapped for both the access token and refresh token 213 | - The `authCodeId`, `authCodeWrappedKey`, and PKCE fields are removed from the grant 214 | - A refresh token is generated and its hash is stored in the grant's `refreshTokenId` field 215 | - The wrapped key for the refresh token is stored in `refreshTokenWrappedKey` 216 | - The grant's TTL is removed, making it permanent 217 | - A new access token is generated and stored as `token:{userId}:{grantId}:{accessTokenId}` 218 | - The access token record includes the encrypted props, IV, and wrapped key 219 | - Both tokens are returned to the client 220 | 221 | 4. When the client makes API requests with the access token: 222 | - The system looks up the token directly using the structured key format 223 | - The wrapped encryption key is unwrapped using the access token 224 | - The props are decrypted using the unwrapped key 225 | - The decrypted props are made available to the API handler 226 | 227 | 5. Access tokens expire automatically after their TTL. 228 | 229 | 6. Refresh tokens do not expire and are stored directly in the grant; they remain valid until the grant is revoked. 230 | - For security, the provider issues a new refresh token with each refresh operation 231 | - It keeps track of both the current and previous tokens, along with their wrapped keys 232 | - When the new token is used, the previous token is invalidated, but can still be used until replaced 233 | 234 | 7. When a grant is revoked: 235 | - All associated access tokens are found using the key prefix `token:{userId}:{grantId}:` and deleted 236 | - The grant record is deleted, which also effectively revokes the refresh token and all encrypted data 237 | 238 | ## Implementation Notes 239 | 240 | - For high-traffic applications, consider using a caching layer in front of KV to reduce read operations on frequently accessed data. 241 | - Monitor KV usage metrics to ensure you stay within Cloudflare's limits for your plan. 242 | - The design uses KV's `list()` capability with key prefixes to efficiently query related data like all grants for a user, eliminating the need for separate list indexes. 243 | - If a grant is revoked, associated tokens are not immediately deleted from KV, but rely on TTL expiration. Add a cleanup process if immediate revocation is required. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "es2022", 5 | "lib": ["es2021"], 6 | "moduleResolution": "node", 7 | "types": ["@cloudflare/workers-types"], 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": ["src/**/*.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/oauth-provider.ts'], 5 | format: ['esm'], 6 | dts: true, 7 | clean: true, 8 | outDir: 'dist', 9 | external: ['cloudflare:workers'], 10 | }); 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | setupFiles: ['./__tests__/setup.ts'], 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------