Skip to content

Commit dc6b645

Browse files
committed
Added a new apiHandlers property that lets you specify more than one API handler
The bulk of the code in this commit was generated by Claude Sonnet 3.7 using Claude Code.
1 parent c2258d0 commit dc6b645

File tree

3 files changed

+244
-21
lines changed

3 files changed

+244
-21
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ export default new OAuthProvider({
4343
// You can provide either an object with a fetch method (ExportedHandler)
4444
// or a class extending WorkerEntrypoint.
4545
apiHandler: ApiHandler, // Using a WorkerEntrypoint class
46+
47+
// For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
48+
// This allows you to use different handlers for different API routes.
49+
// Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
50+
// Example:
51+
// apiHandlers: {
52+
// "/api/users/": UsersApiHandler,
53+
// "/api/documents/": DocumentsApiHandler,
54+
// "https://api.example.com/": ExternalApiHandler,
55+
// },
4656

4757
// Any requests which aren't API request will be passed to the default handler instead.
4858
// Again, this can be either an object or a WorkerEntrypoint.

__tests__/oauth-provider.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,135 @@ describe('OAuthProvider', () => {
198198
mockEnv.OAUTH_KV.clear();
199199
});
200200

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+
201330
describe('OAuth Metadata Discovery', () => {
202331
it('should return correct metadata at .well-known/oauth-authorization-server', async () => {
203332
const request = createMockRequest('https://example.com/.well-known/oauth-authorization-server');

src/oauth-provider.ts

Lines changed: 105 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,33 @@ export interface OAuthProviderOptions {
9999
* URL(s) for API routes. Requests with URLs starting with any of these prefixes
100100
* will be treated as API requests and require a valid access token.
101101
* Can be a single route or an array of routes. Each route can be a full URL or just a path.
102+
*
103+
* Used with `apiHandler` for the single-handler configuration. This is incompatible with
104+
* the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
102105
*/
103-
apiRoute: string | string[];
106+
apiRoute?: string | string[];
104107

105108
/**
106109
* Handler for API requests that have a valid access token.
107110
* This handler will receive the authenticated user properties in ctx.props.
108111
* Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint.
112+
*
113+
* Used with `apiRoute` for the single-handler configuration. This is incompatible with
114+
* the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
109115
*/
110-
apiHandler: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
116+
apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
117+
118+
/**
119+
* Map of API routes to their corresponding handlers for the multi-handler configuration.
120+
* The keys are the API routes (strings only, not arrays), and the values are the handlers.
121+
* Each route can be a full URL or just a path, and each handler can be either an ExportedHandler
122+
* object with a fetch method or a class extending WorkerEntrypoint.
123+
*
124+
* This is incompatible with the `apiRoute` and `apiHandler` properties. You must use either
125+
* `apiRoute` + `apiHandler` (single-handler configuration) OR `apiHandlers` (multi-handler
126+
* configuration), not both.
127+
*/
128+
apiHandlers?: Record<string, ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch)>;
111129

112130
/**
113131
* Handler for all non-API requests or API requests without a valid token.
@@ -671,26 +689,68 @@ class OAuthProviderImpl {
671689
/**
672690
* Represents the validated type of a handler (ExportedHandler or WorkerEntrypoint)
673691
*/
674-
private typedApiHandler: TypedHandler;
675692
private typedDefaultHandler: TypedHandler;
693+
694+
/**
695+
* Array of tuples of API routes and their validated handlers
696+
* In the simple case, this will be a single entry with the route and handler from options.apiRoute/apiHandler
697+
* In the advanced case, this will contain entries from options.apiHandlers
698+
*/
699+
private typedApiHandlers: Array<[string, TypedHandler]>;
676700

677701
/**
678702
* Creates a new OAuth provider instance
679703
* @param options - Configuration options for the provider
680704
*/
681705
constructor(options: OAuthProviderOptions) {
682-
// Validate and determine handler types
683-
this.typedApiHandler = this.validateHandler(options.apiHandler, 'apiHandler');
706+
// Initialize typedApiHandlers as an array
707+
this.typedApiHandlers = [];
708+
709+
// Check if we have incompatible configuration
710+
const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
711+
const hasMultiHandlerConfig = !!options.apiHandlers;
712+
713+
if (hasSingleHandlerConfig && hasMultiHandlerConfig) {
714+
throw new TypeError(
715+
'Cannot use both apiRoute/apiHandler and apiHandlers. ' +
716+
'Use either apiRoute + apiHandler OR apiHandlers, not both.'
717+
);
718+
}
719+
720+
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) {
721+
throw new TypeError(
722+
'Must provide either apiRoute + apiHandler OR apiHandlers. ' +
723+
'No API route configuration provided.'
724+
);
725+
}
726+
727+
// Validate default handler
684728
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, 'defaultHandler');
685729

686-
// Validate that the endpoints are either absolute paths or full URLs
687-
if (Array.isArray(options.apiRoute)) {
688-
options.apiRoute.forEach((route, index) => {
689-
this.validateEndpoint(route, `apiRoute[${index}]`);
690-
});
730+
// Process and validate the API handlers
731+
if (hasSingleHandlerConfig) {
732+
// Single handler mode with apiRoute + apiHandler
733+
const apiHandler = this.validateHandler(options.apiHandler!, 'apiHandler');
734+
735+
// For single handler mode, process the apiRoute(s) and map them all to the single apiHandler
736+
if (Array.isArray(options.apiRoute)) {
737+
options.apiRoute.forEach((route, index) => {
738+
this.validateEndpoint(route, `apiRoute[${index}]`);
739+
this.typedApiHandlers.push([route, apiHandler]);
740+
});
741+
} else {
742+
this.validateEndpoint(options.apiRoute!, 'apiRoute');
743+
this.typedApiHandlers.push([options.apiRoute!, apiHandler]);
744+
}
691745
} else {
692-
this.validateEndpoint(options.apiRoute, 'apiRoute');
746+
// Multiple handlers mode with apiHandlers map
747+
for (const [route, handler] of Object.entries(options.apiHandlers!)) {
748+
this.validateEndpoint(route, `apiHandlers key: ${route}`);
749+
this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
750+
}
693751
}
752+
753+
// Validate that the oauth endpoints are either absolute paths or full URLs
694754
this.validateEndpoint(options.authorizeEndpoint, 'authorizeEndpoint');
695755
this.validateEndpoint(options.tokenEndpoint, 'tokenEndpoint');
696756
if (options.clientRegistrationEndpoint) {
@@ -896,14 +956,28 @@ class OAuthProviderImpl {
896956
* @returns True if the URL matches any of the API routes
897957
*/
898958
private isApiRequest(url: URL): boolean {
899-
// Handle array of routes
900-
if (Array.isArray(this.options.apiRoute)) {
901-
// Return true if any route matches
902-
return this.options.apiRoute.some((route) => this.matchApiRoute(url, route));
903-
} else {
904-
// Handle single route
905-
return this.matchApiRoute(url, this.options.apiRoute);
959+
// Check each route in our array of validated API handlers
960+
for (const [route, _] of this.typedApiHandlers) {
961+
if (this.matchApiRoute(url, route)) {
962+
return true;
963+
}
906964
}
965+
return false;
966+
}
967+
968+
/**
969+
* Finds the appropriate API handler for a URL
970+
* @param url - The URL to find a handler for
971+
* @returns The TypedHandler for the URL, or undefined if no handler matches
972+
*/
973+
private findApiHandlerForUrl(url: URL): TypedHandler | undefined {
974+
// Check each route in our array of validated API handlers
975+
for (const [route, handler] of this.typedApiHandlers) {
976+
if (this.matchApiRoute(url, route)) {
977+
return handler;
978+
}
979+
}
980+
return undefined;
907981
}
908982

909983
/**
@@ -1760,13 +1834,23 @@ class OAuthProviderImpl {
17601834
env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
17611835
}
17621836

1837+
// Find the appropriate API handler for this URL
1838+
const url = new URL(request.url);
1839+
const apiHandler = this.findApiHandlerForUrl(url);
1840+
1841+
if (!apiHandler) {
1842+
// This shouldn't happen since we already checked with isApiRequest,
1843+
// but handle it gracefully just in case
1844+
return this.createErrorResponse('invalid_request', 'No handler found for API route', 404);
1845+
}
1846+
17631847
// Call the API handler based on its type
1764-
if (this.typedApiHandler.type === HandlerType.EXPORTED_HANDLER) {
1848+
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) {
17651849
// It's an object with a fetch method
1766-
return this.typedApiHandler.handler.fetch(request as Parameters<ExportedHandlerWithFetch['fetch']>[0], env, ctx);
1850+
return apiHandler.handler.fetch(request as Parameters<ExportedHandlerWithFetch['fetch']>[0], env, ctx);
17671851
} else {
17681852
// It's a WorkerEntrypoint class - instantiate it with ctx and env in that order
1769-
const handler = new this.typedApiHandler.handler(ctx, env);
1853+
const handler = new apiHandler.handler(ctx, env);
17701854
return handler.fetch(request);
17711855
}
17721856
}

0 commit comments

Comments
 (0)