diff --git a/.changeset/cuddly-apes-hammer.md b/.changeset/cuddly-apes-hammer.md new file mode 100644 index 0000000..2a2c8f7 --- /dev/null +++ b/.changeset/cuddly-apes-hammer.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/workers-oauth-provider': patch +--- + +Fix RFC 8707 path-aware audience validation for resource indicators with path components. Tokens with path-specific audiences (e.g., `https://example.com/api`) now correctly validate against matching request paths. diff --git a/__tests__/oauth-provider.test.ts b/__tests__/oauth-provider.test.ts index 5047de9..a51a4ef 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,157 @@ 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 () => { + const accessToken = await getAccessTokenWithResource('https://example.com/api/test'); + + 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 () => { + const accessToken = await getAccessTokenWithResource('https://example.com/api/connector'); + + 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); + }); + + it('should accept external token with path-aware audience at matching path (RFC 8707)', async () => { + const externalProvider = new OAuthProvider({ + apiRoute: ['/api/', 'https://example.com/'], + apiHandler: TestApiHandler, + defaultHandler: testDefaultHandler, + authorizeEndpoint: '/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', + scopesSupported: ['read', 'write', 'profile'], + resolveExternalToken: async ({ token }) => { + if (token === 'external-token-path-audience') { + return { + props: { userId: 'external-user', source: 'external' }, + audience: 'https://example.com/api/test', + }; + } + return null; + }, + }); + + const apiRequest = createMockRequest('https://example.com/api/test', 'GET', { + Authorization: 'Bearer external-token-path-audience', + }); + + const apiResponse = await externalProvider.fetch(apiRequest, mockEnv, mockCtx); + + expect(apiResponse.status).toBe(200); + const data = await apiResponse.json(); + expect(data.success).toBe(true); + }); + + it('should reject external token with path-aware audience at different path (RFC 8707)', async () => { + const externalProvider = new OAuthProvider({ + apiRoute: ['/api/', 'https://example.com/'], + apiHandler: TestApiHandler, + defaultHandler: testDefaultHandler, + authorizeEndpoint: '/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', + scopesSupported: ['read', 'write', 'profile'], + resolveExternalToken: async ({ token }) => { + if (token === 'external-token-path-mismatch') { + return { + props: { userId: 'external-user', source: 'external' }, + audience: 'https://example.com/api/test', + }; + } + return null; + }, + }); + + const apiRequest = createMockRequest('https://example.com/api/other', 'GET', { + Authorization: 'Bearer external-token-path-mismatch', + }); + + const apiResponse = await externalProvider.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 reject sub-path access with parent path audience (no prefix matching)', async () => { + const accessToken = await getAccessTokenWithResource('https://example.com/api'); + + const apiRequest = createMockRequest('https://example.com/api/admin', '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 match path-aware audience when request includes query string', async () => { + const accessToken = await getAccessTokenWithResource('https://example.com/api/test'); + + const apiRequest = createMockRequest('https://example.com/api/test?foo=bar&baz=qux', '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 treat trailing slash as different path (strict URI matching)', async () => { + const accessToken = await getAccessTokenWithResource('https://example.com/api/test'); + + const apiRequest = createMockRequest('https://example.com/api/test/', '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'); + }); }); describe('Resource Parameter Downscoping (RFC 8707)', () => { diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 84e6870..94d1b1f 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) @@ -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; } /**