From 5d653265dcd3f2fe9c3db65a4269655908895117 Mon Sep 17 00:00:00 2001 From: bokhi Date: Mon, 10 Nov 2025 16:40:00 +0900 Subject: [PATCH 1/2] fix: Include pathname in resourceServer for RFC 8707 path-aware audience validation Fixes #108 When using RFC 8707 Resource Indicators with path components (e.g., resource=https://example.com/api), token validation was failing because resourceServer was computed using only the origin (protocol + host), while the token audience contained the full URL with path. This commit updates the resourceServer computation in both internal and external token validation flows to include the pathname component: const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`; This ensures that audience validation succeeds when the resource indicator includes the path component, as recommended by RFC 8707. --- src/oauth-provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 84e6870..e639403 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -2191,7 +2191,7 @@ class OAuthProviderImpl { // 'aud' claim when this claim is present, then the JWT MUST be rejected." if (tokenData.audience) { const requestUrl = new URL(request.url); - const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`; + const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`; const audiences = Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]; // Check if any audience matches (RFC 3986: case-insensitive hostname comparison) @@ -2225,7 +2225,7 @@ class OAuthProviderImpl { // Validate that tokens were issued specifically for them if (ext.audience) { const requestUrl = new URL(request.url); - const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`; + const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`; const audiences = Array.isArray(ext.audience) ? ext.audience : [ext.audience]; // Check if any audience matches (RFC 3986: case-insensitive hostname comparison) From 249513ad94163b57948d79afe0e1137e4ff315cf Mon Sep 17 00:00:00 2001 From: bokhi Date: Mon, 17 Nov 2025 16:39:35 +0900 Subject: [PATCH 2/2] fix: Add smart audience matching for backward compatibility - Modify audienceMatches() to support both origin-only and path-aware audiences - Origin-only audiences (e.g., https://example.com) match by origin for backward compatibility - Path-aware audiences (e.g., https://example.com/api) require exact match per RFC 8707 - Add 3 test cases for path-aware audience validation - Update TestApiHandler to handle all /api/* paths This fixes the CI failures in PR #109 while maintaining backward compatibility with existing code that uses origin-only resource indicators. Fixes #108 --- __tests__/oauth-provider.test.ts | 52 +++++++++++++++++++++++++++++++- src/oauth-provider.ts | 26 +++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/__tests__/oauth-provider.test.ts b/__tests__/oauth-provider.test.ts index 5047de9..afde537 100644 --- a/__tests__/oauth-provider.test.ts +++ b/__tests__/oauth-provider.test.ts @@ -96,7 +96,7 @@ class TestApiHandler extends WorkerEntrypoint { fetch(request: Request) { const url = new URL(request.url); - if (url.pathname === '/api/test') { + if (url.pathname.startsWith('/api/')) { // Return authenticated user info from ctx.props return new Response( JSON.stringify({ @@ -2562,6 +2562,56 @@ describe('OAuthProvider', () => { expect(error.error).toBe('invalid_token'); expect(error.error_description).toContain('audience'); }); + + it('should accept token with path-aware audience at matching path (RFC 8707)', async () => { + // Request token with path-specific resource indicator + const accessToken = await getAccessTokenWithResource('https://example.com/api/test'); + + // Request to exact matching path should succeed + const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { + Authorization: `Bearer ${accessToken}`, + }); + + const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(200); + const data = await apiResponse.json(); + expect(data.success).toBe(true); + }); + + it('should reject token with path-aware audience at different path (RFC 8707)', async () => { + // Request token with path-specific resource indicator + const accessToken = await getAccessTokenWithResource('https://example.com/api/test'); + + // Request to different path should fail (strict path matching) + const apiRequest = createMockRequest('https://example.com/api/other', 'GET', { + Authorization: `Bearer ${accessToken}`, + }); + + const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(401); + const error = await apiResponse.json(); + expect(error.error).toBe('invalid_token'); + expect(error.error_description).toContain('audience'); + }); + + it('should accept token with path-aware audience for ChatGPT connector use case (RFC 8707)', async () => { + // Simulates ChatGPT custom connector flow (issue #108) + // ChatGPT sends resource=https://server/api/connector with path component + const accessToken = await getAccessTokenWithResource('https://example.com/api/connector'); + + // API request to exact matching path should succeed + const apiRequest = createMockRequest('https://example.com/api/connector', 'GET', { + Authorization: `Bearer ${accessToken}`, + }); + + const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(200); + const data = await apiResponse.json(); + expect(data.success).toBe(true); + }); }); describe('Resource Parameter Downscoping (RFC 8707)', () => { diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e639403..94d1b1f 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -2392,7 +2392,31 @@ function validateResourceUri(uri: string): boolean { */ function audienceMatches(resourceServerUrl: string, audienceValue: string): boolean { // RFC 7519 Section 4.1.3: "aud" value is an array of case-sensitive strings - return resourceServerUrl === audienceValue; + + // Exact match (fast path) + if (resourceServerUrl === audienceValue) { + return true; + } + + // Smart matching for backward compatibility with origin-only audiences + // while supporting RFC 8707 path-aware resource indicators + try { + const audienceUrl = new URL(audienceValue); + const resourceUrl = new URL(resourceServerUrl); + + // If audience is origin-only (no path or just '/'), match by origin + // This maintains backward compatibility with existing code + if (audienceUrl.pathname === '/' || audienceUrl.pathname === '') { + return audienceUrl.origin === resourceUrl.origin; + } + + // If audience has a path component, require exact match + // This supports RFC 8707 path-aware resource indicators + } catch { + // If URL parsing fails, no match + } + + return false; } /**