| @@ -108,6 +108,78 @@ const isOAuthErrorBody = (body: string): boolean => { · 107 unmodified lines |
| 108 | 108 | return typeof obj.error === "string"; |
| 109 | 109 | }; |
| 110 | 110 | |
| 111 | +/** RFC 9728 protected-resource-metadata document. We only need the two |
| 112 | + * fields that prove the document genuinely describes an OAuth-protected |
| 113 | + * resource: `resource` (the resource identifier) and a non-empty |
| 114 | + * `authorization_servers` list. */ |
| 115 | +const ProtectedResourceMetadata = Schema.Struct({ |
| 116 | + resource: Schema.String, |
| 117 | + authorization_servers: Schema.Array(Schema.String), |
| 118 | +}); |
| 119 | +const decodeProtectedResourceMetadata = Schema.decodeUnknownOption( |
| 120 | + Schema.fromJsonString(ProtectedResourceMetadata), |
| 121 | +); |
| 122 | + |
| 123 | +/** RFC 9728 §3.1 path-scoped well-known URL: insert |
| 124 | + * `/.well-known/oauth-protected-resource` before the resource's path |
| 125 | + * component. `https://host/api/mcp` → `https://host/.well-known/oauth- |
| 126 | + * protected-resource/api/mcp`. This is exactly the URL the MCP |
| 127 | + * authorization spec tells clients to construct. */ |
| 128 | +const protectedResourceMetadataUrl = (endpoint: URL): string => { |
| 129 | + const path = endpoint.pathname === "/" ? "" : endpoint.pathname; |
| 130 | + return `${endpoint.origin}/.well-known/oauth-protected-resource${path}`; |
| 131 | +}; |
| 132 | + |
| 133 | +/** The RFC 9728 `resource` value must actually describe this endpoint |
| 134 | + * before we trust the document — an exact URL match, or a same-origin |
| 135 | + * parent whose path is a prefix of the endpoint's. Guards against a |
| 136 | + * shared host serving protected-resource metadata for some unrelated |
| 137 | + * resource. */ |
| 138 | +const resourceMatchesEndpoint = (resource: string, endpoint: URL): boolean => { |
| 139 | + if (!URL.canParse(resource)) return false; |
| 140 | + const parsed = new URL(resource); |
| 141 | + if (parsed.origin !== endpoint.origin) return false; |
| 142 | + const resourcePath = parsed.pathname.replace(/\/+$/, ""); |
| 143 | + const endpointPath = endpoint.pathname.replace(/\/+$/, ""); |
| 144 | + return endpointPath === resourcePath || endpointPath.startsWith(`${resourcePath}/`); |
| 145 | +}; |
| 146 | + |
| 147 | +/** Workaround for MCP servers that omit (or under-specify) the |
| 148 | + * `WWW-Authenticate` challenge on their 401 — e.g. Datadog's |
| 149 | + * `mcp.datadoghq.com` returns a bare `401 {"errors":["Unauthorized"]}` |
| 150 | + * with no header at all, so the wire-shape gate above can't tell it |
| 151 | + * apart from an unrelated OAuth-protected API and the user lands on the |
| 152 | + * manual-credentials prompt instead of an OAuth sign-in. |
| 153 | + * |
| 154 | + * The MCP authorization spec still requires such servers to publish |
| 155 | + * RFC 9728 metadata at the path-scoped well-known URL. A document there |
| 156 | + * whose `resource` matches this endpoint is a deliberate, MCP-spec- |
| 157 | + * specific signal a generic OAuth API would not emit — strong enough to |
| 158 | + * classify the endpoint as MCP so the OAuth flow can start. */ |
| 159 | +const probeProtectedResourceMetadata = ( |
| 160 | + client: HttpClient.HttpClient, |
| 161 | + endpoint: URL, |
| 162 | + timeoutMs: number, |
| 163 | +): Effect.Effect<boolean> => |
| 164 | + Effect.gen(function* () { |
| 165 | + const response = yield* client |
| 166 | + .execute( |
| 167 | + HttpClientRequest.get(protectedResourceMetadataUrl(endpoint)).pipe( |
| 168 | + HttpClientRequest.setHeader("accept", "application/json"), |
| 169 | + ), |
| 170 | + ) |
| 171 | + .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| 172 | + if (response.status < 200 || response.status >= 300) return false; |
| 173 | + const body = yield* response.text.pipe( |
| 174 | + Effect.timeout(Duration.millis(timeoutMs)), |
| 175 | + Effect.catch(() => Effect.succeed("")), |
| 176 | + ); |
| 177 | + const metadata = decodeProtectedResourceMetadata(body); |
| 178 | + if (Option.isNone(metadata)) return false; |
| 179 | + if (metadata.value.authorization_servers.length === 0) return false; |
| 180 | + return resourceMatchesEndpoint(metadata.value.resource, endpoint); |
| 181 | + }).pipe(Effect.catch(() => Effect.succeed(false))); |
| 182 | + |
| 111 | 183 | const ErrorMessageShape = Schema.Struct({ message: Schema.String }); |
| 112 | 184 | const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); |
| 113 | 185 | |
| @@ -203,6 +275,13 @@ export const probeMcpEndpointShape = ( · 89 unmodified lines |
| 203 | 275 | if (response.status === 401) { |
| 204 | 276 | const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 205 | 277 | if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) { |
| 278 | + // Spec-non-compliant 401 (no `Bearer` challenge). Before |
| 279 | + // giving up, check whether the server still publishes |
| 280 | + // RFC 9728 protected-resource metadata for this path — |
| 281 | + // some real MCP servers (Datadog) do exactly this. |
| 282 | + if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { |
| 283 | + return { kind: "mcp", requiresAuth: true } as const; |
| 284 | + } |
| 206 | 285 | return { |
| 207 | 286 | kind: "not-mcp", |
| 208 | 287 | category: "auth-required", |
| @@ -245,6 +324,11 @@ export const probeMcpEndpointShape = ( · 36 unmodified lines |
| 245 | 324 | // arrays or other shapes that fail both checks. |
| 246 | 325 | const body = yield* readBody(response); |
| 247 | 326 | if (!isJsonRpcEnvelope(body) && !isOAuthErrorBody(body)) { |
| 327 | + // Bearer challenge with no usable accept signal. Same |
| 328 | + // RFC 9728 fallback as the no-`Bearer` case above. |
| 329 | + if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { |
| 330 | + return { kind: "mcp", requiresAuth: true } as const; |
| 331 | + } |
| 248 | 332 | return { |
| 249 | 333 | kind: "not-mcp", |
| 250 | 334 | category: "auth-required", |