diff --git a/README.md b/README.md index 61dac56..0b7542a 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,36 @@ new OAuthProvider({ By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``. +## Passing custom or per-request props + +If your application needs to pass through context from each request to the ApiHandler, you can pass a `customProps` callback. This receives the same parameters (`request`, `env`, and `ctx`) as a standard Workers event handler, and lets you return arbitrary data that will be forwarded to your handler inside `ctx.props.customProps`. + +```ts +new OAuthProvider({ + // ... other options ... + customProps: async (request, env, ctx) => { + // Will be added to ctx.props.customProps when invoking apiHandler + return { + customToken: request.headers.get('x-custom-token'), + featureFlags: await env.FEATURES.parse(request.headers.get('x-feature-flags')), + } + } +}) +``` + +For use with Cloudflare's [`McpAgent`](https://developers.cloudflare.com/agents/model-context-protocol/mcp-agent-api/), this will be automatically available on `this.props.customProps`: + +```ts +export class MyMCP extends McpAgent { + server = new McpServer({ /* ... */ }); + + async init() { + const { customToken, featureFlags } = this.props.customProps + // ... + } +} +``` + ## Implementation Notes ### End-to-end encryption diff --git a/__tests__/oauth-provider.test.ts b/__tests__/oauth-provider.test.ts index bf42f1f..ef13a33 100644 --- a/__tests__/oauth-provider.test.ts +++ b/__tests__/oauth-provider.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { OAuthProvider, ClientInfo, AuthRequest, CompleteAuthorizationOptions } from '../src/oauth-provider'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OAuthProvider } from '../src/oauth-provider'; import { ExecutionContext } from '@cloudflare/workers-types'; // We're importing WorkerEntrypoint from our mock implementation // The actual import is mocked in setup.ts @@ -2230,4 +2230,121 @@ describe('OAuthProvider', () => { expect(clientsAfterDelete.items.length).toBe(0); }); }); + + describe('Custom Props', () => { + it('should call customProps callback and include result in ctx.props.customProps', async () => { + const oauthProviderWithCustomProps = new OAuthProvider({ + apiRoute: ['/api/', 'https://api.example.com/'], + apiHandler: TestApiHandler, + defaultHandler: testDefaultHandler, + authorizeEndpoint: '/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', + customProps: async (request: Request, env: any, ctx: ExecutionContext) => { + // Return custom props based on request headers + return { + customToken: request.headers.get('x-custom-token'), + requestId: request.headers.get('x-request-id'), + userAgent: request.headers.get('user-agent'), + }; + }, + }); + + // Create a client + const clientData = { + redirect_uris: ['https://client.example.com/callback'], + client_name: 'Test Client', + token_endpoint_auth_method: 'client_secret_basic', + }; + + const registerRequest = createMockRequest( + 'https://example.com/oauth/register', + 'POST', + { 'Content-Type': 'application/json' }, + JSON.stringify(clientData) + ); + + const registerResponse = await oauthProviderWithCustomProps.fetch(registerRequest, mockEnv, mockCtx); + const client = await registerResponse.json(); + const clientId = client.client_id; + const clientSecret = client.client_secret; + + // Get an auth code + const redirectUri = 'https://client.example.com/callback'; + const authRequest = createMockRequest( + `https://example.com/authorize?response_type=code&client_id=${clientId}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=read%20write&state=xyz123` + ); + + const authResponse = await oauthProviderWithCustomProps.fetch(authRequest, mockEnv, mockCtx); + const location = authResponse.headers.get('Location')!; + const code = new URL(location).searchParams.get('code')!; + + // Exchange for tokens + const params = new URLSearchParams(); + params.append('grant_type', 'authorization_code'); + params.append('code', code); + params.append('redirect_uri', redirectUri); + params.append('client_id', clientId); + params.append('client_secret', clientSecret); + + const tokenRequest = createMockRequest( + 'https://example.com/oauth/token', + 'POST', + { 'Content-Type': 'application/x-www-form-urlencoded' }, + params.toString() + ); + + const tokenResponse = await oauthProviderWithCustomProps.fetch(tokenRequest, mockEnv, mockCtx); + const tokens = await tokenResponse.json(); + const accessToken = tokens.access_token; + + { + // Make an API request with custom headers + const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { + Authorization: `Bearer ${accessToken}`, + 'x-custom-token': 'custom-token-123', + 'x-request-id': 'req-456', + 'user-agent': 'Test User Agent', + }); + + const apiResponse = await oauthProviderWithCustomProps.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(200); + + const result = await apiResponse.json(); + expect(result.success).toBe(true); + + // Verify the custom props were added to ctx.props.customProps + expect(result.user.customProps).toBeDefined(); + expect(result.user.customProps.customToken).toBe('custom-token-123'); + expect(result.user.customProps.requestId).toBe('req-456'); + expect(result.user.customProps.userAgent).toBe('Test User Agent'); + } + + { + // Make an API request with different custom headers + const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { + Authorization: `Bearer ${accessToken}`, + 'x-custom-token': 'custom-token-456', + 'x-request-id': 'req-789', + 'user-agent': 'Test User Agent 2.0', + }); + + const apiResponse = await oauthProviderWithCustomProps.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(200); + + const result = await apiResponse.json(); + expect(result.success).toBe(true); + + // Verify the custom props were added to ctx.props.customProps + expect(result.user.customProps).toBeDefined(); + expect(result.user.customProps.customToken).toBe('custom-token-456'); + expect(result.user.customProps.requestId).toBe('req-789'); + expect(result.user.customProps.userAgent).toBe('Test User Agent 2.0'); + } + }); + }); }); diff --git a/package.json b/package.json index 4978dde..7d222c3 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,6 @@ "tsup": "^8.4.0", "typescript": "^5.8.2", "vitest": "^3.0.8" - } + }, + "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" } diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 1706ac7..84405a7 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -205,6 +205,13 @@ export interface OAuthProviderOptions { status: number; headers: Record; }) => Response | void; + + /** + * Optional callback function that is called for each API request to provide custom props. + * This receives the same parameters (request, env, ctx) as a standard Workers event handler, + * and lets you return arbitrary data that will be forwarded to your handler inside ctx.props.customProps. + */ + customProps?: (request: Request, env: any, ctx: ExecutionContext) => Promise | any; } // Using ExportedHandler from Cloudflare Workers Types for both API and default handlers @@ -1835,6 +1842,11 @@ class OAuthProviderImpl { // Set the decrypted props on the context object ctx.props = decryptedProps; + // Add custom props if callback is provided + if (this.options.customProps) { + ctx.props.customProps = await this.options.customProps(request, env, ctx); + } + // Inject OAuth helpers into env if not already present if (!env.OAUTH_PROVIDER) { env.OAUTH_PROVIDER = this.createOAuthHelpers(env);