feat: backport discoverOAuthServerInfo() and discovery caching to v1.x (#1533)
This commit is contained in:
parent
b0cf83716b
commit
825e9ab803
2 changed files with 542 additions and 19 deletions
|
|
@ -150,7 +150,7 @@ export interface OAuthClientProvider {
|
|||
* credentials, in the case where the server has indicated that they are no longer valid.
|
||||
* This avoids requiring the user to intervene manually.
|
||||
*/
|
||||
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
|
||||
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Prepares grant-specific parameters for a token request.
|
||||
|
|
@ -189,6 +189,46 @@ export interface OAuthClientProvider {
|
|||
* }
|
||||
*/
|
||||
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;
|
||||
|
||||
/**
|
||||
* Saves the OAuth discovery state after RFC 9728 and authorization server metadata
|
||||
* discovery. Providers can persist this state to avoid redundant discovery requests
|
||||
* on subsequent {@linkcode auth} calls.
|
||||
*
|
||||
* This state can also be provided out-of-band (e.g., from a previous session or
|
||||
* external configuration) to bootstrap the OAuth flow without discovery.
|
||||
*
|
||||
* Called by {@linkcode auth} after successful discovery.
|
||||
*/
|
||||
saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns previously saved discovery state, or `undefined` if none is cached.
|
||||
*
|
||||
* When available, {@linkcode auth} restores the discovery state (authorization server
|
||||
* URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing
|
||||
* latency on subsequent calls.
|
||||
*
|
||||
* Providers should clear cached discovery state on repeated authentication failures
|
||||
* (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow
|
||||
* re-discovery in case the authorization server has changed.
|
||||
*/
|
||||
discoveryState?(): OAuthDiscoveryState | undefined | Promise<OAuthDiscoveryState | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}.
|
||||
*
|
||||
* Contains the results of RFC 9728 protected resource metadata discovery and
|
||||
* authorization server metadata discovery. Persisting this state avoids
|
||||
* redundant discovery HTTP requests on subsequent {@linkcode auth} calls.
|
||||
*/
|
||||
// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL
|
||||
// at which authorization server metadata was discovered. This would require
|
||||
// `discoverAuthorizationServerMetadata()` to return the successful discovery URL.
|
||||
export interface OAuthDiscoveryState extends OAuthServerInfo {
|
||||
/** The URL at which the protected resource metadata was found, if available. */
|
||||
resourceMetadataUrl?: string;
|
||||
}
|
||||
|
||||
export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
|
||||
|
|
@ -397,32 +437,70 @@ async function authInternal(
|
|||
fetchFn?: FetchLike;
|
||||
}
|
||||
): Promise<AuthResult> {
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl: string | URL | undefined;
|
||||
// Check if the provider has cached discovery state to skip discovery
|
||||
const cachedState = await provider.discoveryState?.();
|
||||
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors and fall back to /.well-known/oauth-authorization-server
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl: string | URL;
|
||||
let metadata: AuthorizationServerMetadata | undefined;
|
||||
|
||||
// If resourceMetadataUrl is not provided, try to load it from cached state
|
||||
// This handles browser redirects where the URL was saved before navigation
|
||||
let effectiveResourceMetadataUrl = resourceMetadataUrl;
|
||||
if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) {
|
||||
effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we don't get a valid authorization server metadata from protected resource metadata,
|
||||
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
|
||||
*/
|
||||
if (!authorizationServerUrl) {
|
||||
authorizationServerUrl = new URL('/', serverUrl);
|
||||
if (cachedState?.authorizationServerUrl) {
|
||||
// Restore discovery state from cache
|
||||
authorizationServerUrl = cachedState.authorizationServerUrl;
|
||||
resourceMetadata = cachedState.resourceMetadata;
|
||||
metadata =
|
||||
cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }));
|
||||
|
||||
// If resource metadata wasn't cached, try to fetch it for selectResourceURL
|
||||
if (!resourceMetadata) {
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
serverUrl,
|
||||
{ resourceMetadataUrl: effectiveResourceMetadataUrl },
|
||||
fetchFn
|
||||
);
|
||||
} catch {
|
||||
// RFC 9728 not available — selectResourceURL will handle undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Re-save if we enriched the cached state with missing metadata
|
||||
if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) {
|
||||
await provider.saveDiscoveryState?.({
|
||||
authorizationServerUrl: String(authorizationServerUrl),
|
||||
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
|
||||
resourceMetadata,
|
||||
authorizationServerMetadata: metadata
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Full discovery via RFC 9728
|
||||
const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
|
||||
authorizationServerUrl = serverInfo.authorizationServerUrl;
|
||||
metadata = serverInfo.authorizationServerMetadata;
|
||||
resourceMetadata = serverInfo.resourceMetadata;
|
||||
|
||||
// Persist discovery state for future use
|
||||
// TODO: resourceMetadataUrl is only populated when explicitly provided via options
|
||||
// or loaded from cached state. The URL derived internally by
|
||||
// discoverOAuthProtectedResourceMetadata() is not captured back here.
|
||||
await provider.saveDiscoveryState?.({
|
||||
authorizationServerUrl: String(authorizationServerUrl),
|
||||
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
|
||||
resourceMetadata,
|
||||
authorizationServerMetadata: metadata
|
||||
});
|
||||
}
|
||||
|
||||
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, {
|
||||
fetchFn
|
||||
});
|
||||
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
if (!clientInformation) {
|
||||
|
|
@ -937,6 +1015,87 @@ export async function discoverAuthorizationServerMetadata(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@linkcode discoverOAuthServerInfo}.
|
||||
*/
|
||||
export interface OAuthServerInfo {
|
||||
/**
|
||||
* The authorization server URL, either discovered via RFC 9728
|
||||
* or derived from the MCP server URL as a fallback.
|
||||
*/
|
||||
authorizationServerUrl: string;
|
||||
|
||||
/**
|
||||
* The authorization server metadata (endpoints, capabilities),
|
||||
* or `undefined` if metadata discovery failed.
|
||||
*/
|
||||
authorizationServerMetadata?: AuthorizationServerMetadata;
|
||||
|
||||
/**
|
||||
* The OAuth 2.0 Protected Resource Metadata from RFC 9728,
|
||||
* or `undefined` if the server does not support it.
|
||||
*/
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the authorization server for an MCP server following
|
||||
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected
|
||||
* Resource Metadata), with fallback to treating the server URL as the
|
||||
* authorization server.
|
||||
*
|
||||
* This function combines two discovery steps into one call:
|
||||
* 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the
|
||||
* authorization server URL (RFC 9728).
|
||||
* 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery).
|
||||
*
|
||||
* Use this when you need the authorization server metadata for operations outside the
|
||||
* {@linkcode auth} orchestrator, such as token refresh or token revocation.
|
||||
*
|
||||
* @param serverUrl - The MCP resource server URL
|
||||
* @param opts - Optional configuration
|
||||
* @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint
|
||||
* @param opts.fetchFn - Custom fetch function for HTTP requests
|
||||
* @returns Authorization server URL, metadata, and resource metadata (if available)
|
||||
*/
|
||||
export async function discoverOAuthServerInfo(
|
||||
serverUrl: string | URL,
|
||||
opts?: {
|
||||
resourceMetadataUrl?: URL;
|
||||
fetchFn?: FetchLike;
|
||||
}
|
||||
): Promise<OAuthServerInfo> {
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl: string | undefined;
|
||||
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
serverUrl,
|
||||
{ resourceMetadataUrl: opts?.resourceMetadataUrl },
|
||||
opts?.fetchFn
|
||||
);
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
||||
}
|
||||
} catch {
|
||||
// RFC 9728 not supported -- fall back to treating the server URL as the authorization server
|
||||
}
|
||||
|
||||
// If we don't get a valid authorization server from protected resource metadata,
|
||||
// fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
|
||||
if (!authorizationServerUrl) {
|
||||
authorizationServerUrl = String(new URL('/', serverUrl));
|
||||
}
|
||||
|
||||
const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn });
|
||||
|
||||
return {
|
||||
authorizationServerUrl,
|
||||
authorizationServerMetadata,
|
||||
resourceMetadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue