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
11 changes: 11 additions & 0 deletions .changeset/oauth-resource-metadata-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@modelcontextprotocol/client": patch
---

Fix OAuth resource metadata URL persistence across browser redirects

Added two optional methods to `OAuthClientProvider`:
- `saveResourceMetadataUrl(url: URL)`: Saves the URL before redirect
- `resourceMetadataUrl()`: Loads the saved URL after redirect

This fixes token exchange failures in browser OAuth flows where the resource metadata URL discovered from the WWW-Authenticate header was lost during redirects.
17 changes: 17 additions & 0 deletions examples/client/src/simpleOAuthClientProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
private _clientInformation?: OAuthClientInformationMixed;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
private _resourceMetadataUrl?: URL;

constructor(
private readonly _redirectUrl: string | URL,
Expand Down Expand Up @@ -62,4 +63,20 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
}
return this._codeVerifier;
}

/**
* Saves the resource metadata URL discovered during the initial OAuth challenge.
* In browser environments, you should persist this to sessionStorage.
*/
saveResourceMetadataUrl(url: URL): void {
this._resourceMetadataUrl = url;
}

/**
* Loads a previously saved resource metadata URL.
* This is called when exchanging the authorization code for tokens after redirect.
*/
resourceMetadataUrl(): URL | undefined {
return this._resourceMetadataUrl;
}
}
58 changes: 57 additions & 1 deletion packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,45 @@ export interface OAuthClientProvider {
* }
*/
prepareTokenRequest?(scope?: string): URLSearchParams | Promise<URLSearchParams | undefined> | undefined;

/**
* Saves the resource metadata URL discovered during the initial OAuth challenge.
*
* This optional method allows browser-based applications to persist the resource
* metadata URL (from the WWW-Authenticate header) before redirecting to the
* authorization server. This is necessary because the URL would otherwise be
* lost during the redirect, causing token exchange to fail.
*
* In browser environments, implementations should persist this to sessionStorage
* or a similar mechanism that survives page navigation.
*
* @param url - The resource metadata URL discovered from the WWW-Authenticate header
*
* @example
* // Browser implementation using sessionStorage:
* saveResourceMetadataUrl(url) {
* sessionStorage.setItem('oauth_resource_metadata_url', url.toString());
* }
*/
saveResourceMetadataUrl?(url: URL): void | Promise<void>;

/**
* Loads a previously saved resource metadata URL.
*
* This optional method retrieves the resource metadata URL that was saved
* before the OAuth redirect. It's called when exchanging the authorization
* code for tokens, allowing the SDK to locate the correct token endpoint.
*
* @returns The saved resource metadata URL, or undefined if none was saved
*
* @example
* // Browser implementation using sessionStorage:
* resourceMetadataUrl() {
* const url = sessionStorage.getItem('oauth_resource_metadata_url');
* return url ? new URL(url) : undefined;
* }
*/
resourceMetadataUrl?(): URL | undefined | Promise<URL | undefined>;
}

export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
Expand Down Expand Up @@ -399,8 +438,19 @@ async function authInternal(
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | URL | undefined;

// If resourceMetadataUrl is not provided, try to load it from the provider
// This handles the case where the URL was saved before a browser redirect
let effectiveResourceMetadataUrl = resourceMetadataUrl;
if (!effectiveResourceMetadataUrl && provider.resourceMetadataUrl) {
effectiveResourceMetadataUrl = await Promise.resolve(provider.resourceMetadataUrl());
}

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: effectiveResourceMetadataUrl },
fetchFn
);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
Expand Down Expand Up @@ -509,6 +559,12 @@ async function authInternal(

const state = provider.state ? await provider.state() : undefined;

// Save the resource metadata URL before redirect so it can be restored after
// This is essential for browser environments where the page navigates away
if (effectiveResourceMetadataUrl && provider.saveResourceMetadataUrl) {
await provider.saveResourceMetadataUrl(effectiveResourceMetadataUrl);
}

// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
Expand Down
Loading
Loading