Skip to content

Commit c2cef38

Browse files
author
Eugene
committed
fix(auth-router): correct PRM for pathful RS + explicit resourceServerUrl
Fixes #600
1 parent a1608a6 commit c2cef38

File tree

1 file changed

+26
-6
lines changed

1 file changed

+26
-6
lines changed

src/server/auth/router.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export type AuthRouterOptions = {
4141
*/
4242
resourceName?: string;
4343

44+
/**
45+
* The URL of the protected resource (RS) whose metadata we advertise.
46+
* If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS).
47+
*/
48+
resourceServerUrl?: URL;
49+
4450
// Individual options per route
4551
authorizationOptions?: Omit<AuthorizationHandlerOptions, "provider">;
4652
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, "clientsStore">;
@@ -130,8 +136,8 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
130136

131137
router.use(mcpAuthMetadataRouter({
132138
oauthMetadata,
133-
// This router is used for AS+RS combo's, so the issuer is also the resource server
134-
resourceServerUrl: new URL(oauthMetadata.issuer),
139+
// Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat)
140+
resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer),
135141
serviceDocumentationUrl: options.serviceDocumentationUrl,
136142
scopesSupported: options.scopesSupported,
137143
resourceName: options.resourceName
@@ -185,13 +191,18 @@ export type AuthMetadataOptions = {
185191
resourceName?: string;
186192
}
187193

188-
export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
194+
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router {
189195
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
190196

191197
const router = express.Router();
192198

199+
// Normalize resource identifier: no trailing slash in the value per RFC 9728 examples
200+
const resourceHref = options.resourceServerUrl.href.endsWith('/')
201+
? options.resourceServerUrl.href.slice(0, -1)
202+
: options.resourceServerUrl.href;
203+
193204
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
194-
resource: options.resourceServerUrl.href,
205+
resource: resourceHref,
195206

196207
authorization_servers: [
197208
options.oauthMetadata.issuer
@@ -202,8 +213,15 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
202213
resource_documentation: options.serviceDocumentationUrl?.href,
203214
};
204215

216+
// Serve PRM at the base well-known URL…
205217
router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata));
206218

219+
// …and also at the path-specific URL per RFC 9728 when the resource has a path (e.g., /mcp)
220+
const rsPath = new URL(resourceHref).pathname;
221+
if (rsPath && rsPath !== "/") {
222+
router.use(`/.well-known/oauth-protected-resource${rsPath}`, metadataHandler(protectedResourceMetadata));
223+
}
224+
207225
// Always add this for backwards compatibility
208226
router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata));
209227

@@ -219,8 +237,10 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
219237
*
220238
* @example
221239
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
222-
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource'
240+
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
223241
*/
224242
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
225-
return new URL('/.well-known/oauth-protected-resource', serverUrl).href;
243+
const u = new URL(serverUrl.href);
244+
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
245+
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
226246
}

0 commit comments

Comments
 (0)