Skip to content

Commit 50e37a4

Browse files
Fallback to .well-known/oauth-protected-resource on 401s without WWW-Authenticate (microsoft#268977)
* refactor resource metadata reading into oauth base file the logic is going to get more complicated, so I want to encapsulate it where it should go. * fix test * Fallback to .well-known/oauth-protected-resource on 401s without WWW-Authenticate Fixes microsoft#268210
1 parent 9f2fcb6 commit 50e37a4

3 files changed

Lines changed: 604 additions & 39 deletions

File tree

src/vs/base/common/oauth.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,3 +957,120 @@ export function scopesMatch(scopes1: readonly string[], scopes2: readonly string
957957

958958
return sortedScopes1.every((scope, index) => scope === sortedScopes2[index]);
959959
}
960+
961+
interface CommonResponse {
962+
status: number;
963+
statusText: string;
964+
json(): Promise<any>;
965+
text(): Promise<string>;
966+
}
967+
968+
interface IFetcher {
969+
(input: string, init: { method: string; headers: Record<string, string> }): Promise<CommonResponse>;
970+
}
971+
972+
export interface IFetchResourceMetadataOptions {
973+
/**
974+
* Headers to include only when the resource metadata URL has the same origin as the target resource
975+
*/
976+
sameOriginHeaders?: Record<string, string>;
977+
/**
978+
* Optional custom fetch implementation (defaults to global fetch)
979+
*/
980+
fetch?: IFetcher;
981+
}
982+
983+
/**
984+
* Fetches and validates OAuth 2.0 protected resource metadata from the given URL.
985+
*
986+
* @param targetResource The target resource URL to compare origins with (e.g., the MCP server URL)
987+
* @param resourceMetadataUrl Optional URL to fetch the resource metadata from. If not provided, will try well-known URIs.
988+
* @param options Configuration options for the fetch operation
989+
* @returns Promise that resolves to the validated resource metadata
990+
* @throws Error if the fetch fails, returns non-200 status, or the response is invalid
991+
*/
992+
export async function fetchResourceMetadata(
993+
targetResource: string,
994+
resourceMetadataUrl: string | undefined,
995+
options: IFetchResourceMetadataOptions = {}
996+
): Promise<IAuthorizationProtectedResourceMetadata> {
997+
const {
998+
sameOriginHeaders = {},
999+
fetch: fetchImpl = fetch
1000+
} = options;
1001+
1002+
const targetResourceUrlObj = new URL(targetResource);
1003+
1004+
// If no resourceMetadataUrl is provided, try well-known URIs as per RFC 9728
1005+
let urlsToTry: string[];
1006+
if (!resourceMetadataUrl) {
1007+
// Try in order: 1) with path appended, 2) at root
1008+
const pathComponent = targetResourceUrlObj.pathname === '/' ? undefined : targetResourceUrlObj.pathname;
1009+
const rootUrl = `${targetResourceUrlObj.origin}${AUTH_PROTECTED_RESOURCE_METADATA_DISCOVERY_PATH}`;
1010+
if (pathComponent) {
1011+
// Only try both URLs if we have a path component
1012+
urlsToTry = [
1013+
`${rootUrl}${pathComponent}`,
1014+
rootUrl
1015+
];
1016+
} else {
1017+
// If target is already at root, only try the root URL once
1018+
urlsToTry = [rootUrl];
1019+
}
1020+
} else {
1021+
urlsToTry = [resourceMetadataUrl];
1022+
}
1023+
1024+
const errors: Error[] = [];
1025+
for (const urlToTry of urlsToTry) {
1026+
try {
1027+
// Determine if we should include same-origin headers
1028+
let headers: Record<string, string> = {
1029+
'Accept': 'application/json'
1030+
};
1031+
1032+
const resourceMetadataUrlObj = new URL(urlToTry);
1033+
if (resourceMetadataUrlObj.origin === targetResourceUrlObj.origin) {
1034+
headers = {
1035+
...headers,
1036+
...sameOriginHeaders
1037+
};
1038+
}
1039+
1040+
const response = await fetchImpl(urlToTry, { method: 'GET', headers });
1041+
if (response.status !== 200) {
1042+
let errorText: string;
1043+
try {
1044+
errorText = await response.text();
1045+
} catch {
1046+
errorText = response.statusText;
1047+
}
1048+
errors.push(new Error(`Failed to fetch resource metadata from ${urlToTry}: ${response.status} ${errorText}`));
1049+
continue;
1050+
}
1051+
1052+
const body = await response.json();
1053+
if (isAuthorizationProtectedResourceMetadata(body)) {
1054+
// Use URL constructor for normalization - it handles hostname case and trailing slashes
1055+
const prmValue = new URL(body.resource).toString();
1056+
const targetValue = targetResourceUrlObj.toString();
1057+
if (prmValue !== targetValue) {
1058+
throw new Error(`Protected Resource Metadata resource property value "${prmValue}" (length: ${prmValue.length}) does not match target server url "${targetValue}" (length: ${targetValue.length}). These MUST match to follow OAuth spec https://datatracker.ietf.org/doc/html/rfc9728#PRConfigurationValidation`);
1059+
}
1060+
return body;
1061+
} else {
1062+
errors.push(new Error(`Invalid resource metadata from ${urlToTry}. Expected to follow shape of https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata (Hints: is scopes_supported an array? Is resource a string?). Current payload: ${JSON.stringify(body)}`));
1063+
continue;
1064+
}
1065+
} catch (e) {
1066+
errors.push(e instanceof Error ? e : new Error(String(e)));
1067+
continue;
1068+
}
1069+
}
1070+
// If we've tried all URLs and none worked, throw the error(s)
1071+
if (errors.length === 1) {
1072+
throw errors[0];
1073+
} else {
1074+
throw new AggregateError(errors, 'Failed to fetch resource metadata from all attempted URLs');
1075+
}
1076+
}

0 commit comments

Comments
 (0)