feat: backport discoverOAuthServerInfo() and discovery caching to v1.x (#1533)

This commit is contained in:
Felix Weinberger 2026-02-13 18:20:54 +00:00 • committed by GitHub
commit 825e9ab803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -8,6 +8,7 @@ import {
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
discoverOAuthServerInfo,
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
@ -916,6 +917,369 @@ describe('OAuth Authorization', () => {
});
});
describe('discoverOAuthServerInfo', () => {
const validResourceMetadata = {
resource: 'https://resource.example.com',
authorization_servers: ['https://auth.example.com']
};
const validAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
};
it('returns auth server from RFC 9728 protected resource metadata', async () => {
mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validResourceMetadata
});
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validAuthMetadata
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
const result = await discoverOAuthServerInfo('https://resource.example.com');
expect(result.authorizationServerUrl).toBe('https://auth.example.com');
expect(result.resourceMetadata).toEqual(validResourceMetadata);
expect(result.authorizationServerMetadata).toEqual(validAuthMetadata);
});
it('falls back to server URL when RFC 9728 is not supported', async () => {
mockFetch.mockImplementation(url => {
const urlString = url.toString();
// RFC 9728 returns 404
if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: false,
status: 404
});
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
...validAuthMetadata,
issuer: 'https://resource.example.com'
})
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
const result = await discoverOAuthServerInfo('https://resource.example.com');
// Should fall back to server URL origin
expect(result.authorizationServerUrl).toBe('https://resource.example.com/');
expect(result.resourceMetadata).toBeUndefined();
expect(result.authorizationServerMetadata).toBeDefined();
});
it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => {
const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource');
mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString === overrideUrl.toString()) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validResourceMetadata
});
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validAuthMetadata
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
const result = await discoverOAuthServerInfo('https://resource.example.com', {
resourceMetadataUrl: overrideUrl
});
expect(result.resourceMetadata).toEqual(validResourceMetadata);
// Verify the override URL was used instead of the default well-known path
expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString());
});
});
describe('auth with provider authorization server URL caching', () => {
const validResourceMetadata = {
resource: 'https://resource.example.com',
authorization_servers: ['https://auth.example.com']
};
const validAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
};
function createMockProvider(overrides: Partial<OAuthClientProvider> = {}): OAuthClientProvider {
return {
get redirectUrl() {
return 'http://localhost:3000/callback';
},
get clientMetadata() {
return {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
};
},
clientInformation: vi.fn().mockResolvedValue({
client_id: 'test-client-id',
client_secret: 'test-client-secret'
}),
tokens: vi.fn().mockResolvedValue(undefined),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
...overrides
};
}
beforeEach(() => {
vi.clearAllMocks();
});
it('calls saveDiscoveryState after discovery when provider implements it', async () => {
const saveDiscoveryState = vi.fn();
const provider = createMockProvider({ saveDiscoveryState });
mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validResourceMetadata
});
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validAuthMetadata
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
await auth(provider, { serverUrl: 'https://resource.example.com' });
expect(saveDiscoveryState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationServerUrl: 'https://auth.example.com',
resourceMetadata: validResourceMetadata,
authorizationServerMetadata: validAuthMetadata
})
);
});
it('restores full discovery state from cache including resource metadata', async () => {
const provider = createMockProvider({
discoveryState: vi.fn().mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
resourceMetadata: validResourceMetadata,
authorizationServerMetadata: validAuthMetadata
}),
tokens: vi.fn().mockResolvedValue({
access_token: 'valid-token',
refresh_token: 'refresh-token',
token_type: 'bearer'
})
});
mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString.includes('/token')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
})
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
const result = await auth(provider, {
serverUrl: 'https://resource.example.com'
});
expect(result).toBe('AUTHORIZED');
// Should NOT have called any discovery endpoints -- all from cache
const discoveryCalls = mockFetch.mock.calls.filter(
call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server')
);
expect(discoveryCalls).toHaveLength(0);
// Verify the token request includes the resource parameter from cached metadata
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();
const body = tokenCall![1].body as URLSearchParams;
expect(body.get('resource')).toBe('https://resource.example.com/');
});
it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => {
const saveDiscoveryState = vi.fn();
const provider = createMockProvider({
// Partial cache: auth server URL only, no metadata
discoveryState: vi.fn().mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com'
}),
saveDiscoveryState,
tokens: vi.fn().mockResolvedValue({
access_token: 'valid-token',
refresh_token: 'refresh-token',
token_type: 'bearer'
})
});
mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validResourceMetadata
});
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validAuthMetadata
});
}
if (urlString.includes('/token')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
})
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
await auth(provider, { serverUrl: 'https://resource.example.com' });
// Should re-save with the enriched state including fetched metadata
expect(saveDiscoveryState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationServerUrl: 'https://auth.example.com',
authorizationServerMetadata: validAuthMetadata,
resourceMetadata: validResourceMetadata
})
);
});
it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => {
const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource';
const provider = createMockProvider({
// Cache has auth server URL + resourceMetadataUrl but no resourceMetadata
// (simulates browser redirect where PRM URL was saved but metadata wasn't)
discoveryState: vi.fn().mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
resourceMetadataUrl: cachedPrmUrl,
authorizationServerMetadata: validAuthMetadata
}),
tokens: vi.fn().mockResolvedValue({
access_token: 'valid-token',
refresh_token: 'refresh-token',
token_type: 'bearer'
})
});
mockFetch.mockImplementation(url => {
const urlString = url.toString();
// The cached PRM URL should be used for resource metadata discovery
if (urlString === cachedPrmUrl) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => validResourceMetadata
});
}
if (urlString.includes('/token')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
})
});
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});
const result = await auth(provider, {
serverUrl: 'https://resource.example.com'
});
expect(result).toBe('AUTHORIZED');
// Should have used the cached PRM URL, not the default well-known path
const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource'));
expect(prmCalls).toHaveLength(1);
expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl);
});
});
describe('selectClientAuthMethod', () => {
it('selects the correct client authentication method from client information', () => {
const clientInfo = {