auth: fetch AS metadata in well-known subpath from serverUrl when PRM returns external AS

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2025-07-09 17:58:53 +01:00
commit 87b453d23f
No known key found for this signature in database
2 changed files with 78 additions and 6 deletions

View file

@ -1476,5 +1476,63 @@ describe("OAuth Authorization", () => {
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh123");
});
it("fetches AS metadata with path from serverUrl when PRM returns external AS", async () => {
// Mock PRM discovery that returns an external AS
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: "https://my.resource.com/",
authorization_servers: ["https://auth.example.com/"],
}),
});
} else if (urlString === "https://auth.example.com/.well-known/oauth-authorization-server/path/name") {
// Path-aware discovery on AS with path from serverUrl
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
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"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call auth with serverUrl that has a path
const result = await auth(mockProvider, {
serverUrl: "https://my.resource.com/path/name",
});
expect(result).toBe("REDIRECT");
// Verify the correct URLs were fetched
const calls = mockFetch.mock.calls;
// First call should be to PRM
expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource");
// Second call should be to AS metadata with the path from serverUrl
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
});
});
});

View file

@ -122,7 +122,9 @@ export async function auth(
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
const metadata = await discoverOAuthMetadata(serverUrl, {
authorizationServerUrl
});
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
@ -354,15 +356,27 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string)
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthMetadata(
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
issuer: string | URL,
{
authorizationServerUrl
}: {
authorizationServerUrl?: string | URL
} = {},
): Promise<OAuthMetadata | undefined> {
const issuer = new URL(authorizationServerUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
if (typeof issuer === 'string') {
issuer = new URL(issuer);
}
if (!authorizationServerUrl) {
authorizationServerUrl = issuer;
}
if (typeof authorizationServerUrl === 'string') {
authorizationServerUrl = new URL(authorizationServerUrl);
}
protocolVersion ??= LATEST_PROTOCOL_VERSION;
// Try path-aware discovery first (RFC 8414 compliant)
const wellKnownPath = buildWellKnownPath(issuer.pathname);
const pathAwareUrl = new URL(wellKnownPath, issuer);
const pathAwareUrl = new URL(wellKnownPath, authorizationServerUrl);
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
// If path-aware discovery fails with 404, try fallback to root discovery