Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion __tests__/oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<any>();
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<any>();
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<any>();
expect(data.success).toBe(true);
});
});

describe('Resource Parameter Downscoping (RFC 8707)', () => {
Expand Down
30 changes: 27 additions & 3 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}

/**
Expand Down