fix(auth): accept RFC 8414 metadata served at the OIDC well-known path
The SDK's `discoverAuthorizationServerMetadata` picks its validation schema from the well-known filename that resolved rather than from the document that came back, so anything found at `/.well-known/openid-configuration` is validated as an OpenID provider document — requiring `jwks_uri`, `subject_types_supported` and `id_token_signing_alg_values_supported`, three fields RFC 8414 does not define. RFC 8414 §5 permits that filename for general OAuth metadata, so a conforming plain OAuth 2.0 authorization server is rejected; and because the parse throws instead of continuing the candidate loop, discovery aborts and the connection fails outright. Filed upstream as modelcontextprotocol/typescript-sdk#2733. `core/auth/oidcDiscoveryCompat.ts` works around it without fabricating a field: on a failed RFC 8414 candidate it probes the OIDC candidates the SDK would try next and, when one returns a document that satisfies `OAuthMetadataSchema` but fails the OIDC schema, serves that body as the RFC 8414 response so the SDK picks the schema that describes it. A genuine OpenID provider document is left to the SDK's own OIDC leg. Issuer validation runs unchanged, since the substituted document is the one the server published. It wraps `effectiveAuthFetch` — above the fetch tracker, the opposite of `withOAuthEndpointOverrides` — so the Network tab still records the real 404 and the real probe rather than the substitution. The candidate derivation mirrors the SDK's `buildDiscoveryUrls`, which a fetch wrapper cannot call, and a test pins it against that export so the two cannot drift. Adds `oauth.asMetadataPath` to the composable test server (mirroring `oauth.resourceMetadataPath`) and the `oauth-rfc8414-at-oidc-path-http.json` showcase config that reproduces the failure. Closes #2172 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
baff393675
commit
7ca753bc4c
13 changed files with 844 additions and 36 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -120,6 +120,25 @@ v2/main/
|
|||
│ │ # metadata document, the one seam SDK v2 routes
|
||||
│ │ # BOTH endpoints through (neither reaches the
|
||||
│ │ # OAuthClientProvider) — #1906;
|
||||
│ │ # oidcDiscoveryCompat.ts workaround for
|
||||
│ │ # typescript-sdk#2733: the SDK picks the
|
||||
│ │ # metadata schema from the well-known
|
||||
│ │ # FILENAME, so a plain OAuth 2.0 AS
|
||||
│ │ # publishing RFC 8414 metadata at
|
||||
│ │ # /.well-known/openid-configuration (which
|
||||
│ │ # RFC 8414 §5 permits) is parsed as an
|
||||
│ │ # OpenID provider document and THROWS,
|
||||
│ │ # aborting discovery rather than trying the
|
||||
│ │ # next candidate. The wrapper fabricates
|
||||
│ │ # NOTHING — on a failed RFC 8414 candidate
|
||||
│ │ # it probes the OIDC candidates and, when
|
||||
│ │ # one is RFC 8414 metadata that is not a
|
||||
│ │ # valid OIDC document, serves that body as
|
||||
│ │ # the RFC 8414 response so the SDK picks the
|
||||
│ │ # right schema. Sits ABOVE the fetch tracker
|
||||
│ │ # (unlike endpointOverrides, which sits
|
||||
│ │ # below) so the Network tab records the real
|
||||
│ │ # 404 and the real probe — #2172;
|
||||
│ │ # secret-storage-info.ts browser-safe
|
||||
│ │ # descriptor of WHERE a typed secret lands
|
||||
│ │ # — kind/plaintext/durable plus the label,
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -159,6 +159,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
|
||||
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
|
||||
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
|
||||
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
|
||||
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
||||
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
|
||||
|
|
@ -438,6 +439,22 @@ The same server is worth running against `--cli` / `--tui`, which reach it by a
|
|||
|
||||
The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
|
||||
|
||||
#### Plain OAuth 2.0 metadata at the OIDC well-known path
|
||||
|
||||
`oauth-rfc8414-at-oidc-path-http.json` is an OAuth-protected server (combined AS + resource, DCR enabled) whose RFC 8414 authorization-server metadata is served **only** from `/.well-known/openid-configuration`. It is a plain OAuth 2.0 authorization server — no ID tokens, no `jwks_uri`, no `sub` claims — and RFC 8414 §5 explicitly permits that filename for general OAuth metadata. `/.well-known/oauth-authorization-server` is deliberately left unserved. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
|
||||
Add the server and click **Connect**: authorization must proceed normally. On the broken build it failed before the browser ever opened, with a `ZodError` naming three fields the server had no reason to publish:
|
||||
|
||||
```
|
||||
"path":["jwks_uri"] … "path":["subject_types_supported"] … "path":["id_token_signing_alg_values_supported"]
|
||||
```
|
||||
|
||||
The cause is upstream. `discoverAuthorizationServerMetadata` in `@modelcontextprotocol/client@2.0.0` picks its validation schema from the well-known **filename that resolved**, not from the document that came back — anything found at `openid-configuration` is parsed as OpenID Connect Discovery 1.0 provider metadata, which requires those three fields. And because the parse *throws* rather than continuing the candidate loop, discovery aborts outright instead of falling through ([#2172](https://github.com/modelcontextprotocol/inspector/issues/2172), filed upstream as [typescript-sdk#2733](https://github.com/modelcontextprotocol/typescript-sdk/issues/2733)).
|
||||
|
||||
`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating anything. When the RFC 8414 candidate comes back 4xx, it fetches the OIDC candidates the SDK would try next; if one returns a document that satisfies `OAuthMetadataSchema` but *fails* the OIDC schema, that document is returned as the response to the RFC 8414 request — so the SDK validates it under the schema that actually describes it. A genuine OpenID provider document is left alone and takes the SDK's normal OIDC leg. Issuer validation is untouched, since the substituted document is the one the server published, `issuer` included.
|
||||
|
||||
Watch the Network tab to confirm the Inspector is not hiding anything: the real 404 on `/.well-known/oauth-authorization-server` and the real request to `/.well-known/openid-configuration` are both recorded, and the metadata shown in the Auth tab is exactly what the server sent — no invented `jwks_uri`. The wrapper sits above the fetch tracker for that reason.
|
||||
|
||||
#### Logging, both eras
|
||||
|
||||
`logging-legacy-http.json` and `logging-modern-http.json` both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets `transport.modern: true`.
|
||||
|
|
|
|||
330
clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts
Normal file
330
clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { buildDiscoveryUrls } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
oidcDiscoveryCandidates,
|
||||
isRfc8414OnlyMetadata,
|
||||
withRfc8414OidcCompat,
|
||||
} from "@inspector/core/auth/oidcDiscoveryCompat.js";
|
||||
|
||||
/** Minimal RFC 8414 authorization-server metadata — no OIDC-only fields. */
|
||||
const RFC8414_DOC = {
|
||||
issuer: "https://as.example.com/tenant",
|
||||
authorization_endpoint: "https://as.example.com/tenant/authorize",
|
||||
token_endpoint: "https://as.example.com/tenant/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
/** The same document plus the three fields OpenID Connect Discovery requires. */
|
||||
const OIDC_DOC = {
|
||||
...RFC8414_DOC,
|
||||
jwks_uri: "https://as.example.com/tenant/jwks",
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
};
|
||||
|
||||
const RFC8414_URL =
|
||||
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
|
||||
const OIDC_SUFFIXED =
|
||||
"https://as.example.com/.well-known/openid-configuration/tenant";
|
||||
const OIDC_APPENDED =
|
||||
"https://as.example.com/tenant/.well-known/openid-configuration";
|
||||
|
||||
function json(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(): Response {
|
||||
return new Response("nope", { status: 404 });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("oidcDiscoveryCandidates", () => {
|
||||
// The derivation is a hand-written mirror of the SDK's `buildDiscoveryUrls`,
|
||||
// which a fetch wrapper cannot call (it sees a request URL, not the
|
||||
// authorization-server URL). Pinning it against the SDK's own export is what
|
||||
// keeps the two from drifting when the SDK changes its candidate list.
|
||||
it.each([
|
||||
"https://as.example.com",
|
||||
"https://as.example.com/",
|
||||
"https://as.example.com/tenant",
|
||||
"https://as.example.com/tenant/",
|
||||
"https://misc.poodll.com/mod/minilesson/mcp.php",
|
||||
])("matches the SDK's OIDC candidates for %s", (authServerUrl) => {
|
||||
const sdkUrls = buildDiscoveryUrls(authServerUrl);
|
||||
const rfc8414 = sdkUrls.find((entry) => entry.type === "oauth");
|
||||
expect(rfc8414).toBeDefined();
|
||||
const expected = sdkUrls
|
||||
.filter((entry) => entry.type === "oidc")
|
||||
.map((entry) => entry.url.href);
|
||||
expect(oidcDiscoveryCandidates(rfc8414!.url.href)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns nothing for a URL that is not an RFC 8414 candidate", () => {
|
||||
expect(oidcDiscoveryCandidates("https://as.example.com/tokens")).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an unparseable URL", () => {
|
||||
expect(oidcDiscoveryCandidates("not a url")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRfc8414OnlyMetadata", () => {
|
||||
it("accepts plain RFC 8414 metadata", () => {
|
||||
expect(isRfc8414OnlyMetadata(RFC8414_DOC)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a document that is a valid OpenID provider document", () => {
|
||||
expect(isRfc8414OnlyMetadata(OIDC_DOC)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a body that is not authorization-server metadata at all", () => {
|
||||
expect(isRfc8414OnlyMetadata({ hello: "world" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRfc8414OidcCompat", () => {
|
||||
it("passes a successful response through untouched", async () => {
|
||||
const original = json(RFC8414_DOC);
|
||||
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes a status the SDK will not walk past through untouched", async () => {
|
||||
const original = new Response("boom", { status: 500 });
|
||||
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not probe when the failed request is not a discovery request", async () => {
|
||||
const original = notFound();
|
||||
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped("https://as.example.com/tokens")).resolves.toBe(
|
||||
original,
|
||||
);
|
||||
expect(base).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves an RFC 8414 document found on the appended OIDC path", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === OIDC_APPENDED) return json(RFC8414_DOC);
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
const response = await wrapped(RFC8414_URL);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
await expect(response.json()).resolves.toEqual(RFC8414_DOC);
|
||||
// The failed original, then both OIDC candidates.
|
||||
expect(base).toHaveBeenCalledTimes(3);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(OIDC_APPENDED));
|
||||
});
|
||||
|
||||
it("serves an RFC 8414 document found on the path-suffixed OIDC path", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === OIDC_SUFFIXED) return json(RFC8414_DOC);
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
|
||||
RFC8414_DOC,
|
||||
);
|
||||
// The failed original, then the first OIDC candidate — no need for a second.
|
||||
expect(base).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("leaves a genuine OpenID provider document to the SDK's own OIDC leg", async () => {
|
||||
const original = notFound();
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === OIDC_SUFFIXED) return json(OIDC_DOC);
|
||||
return original;
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
// Stops at the document discovery would have used; the appended candidate
|
||||
// is never probed.
|
||||
expect(base).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips a candidate that is not JSON and keeps looking", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === OIDC_SUFFIXED) {
|
||||
return new Response("<html>login</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
if (url === OIDC_APPENDED) return json(RFC8414_DOC);
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
|
||||
RFC8414_DOC,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a candidate whose JSON body will not parse", async () => {
|
||||
const original = notFound();
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input).includes("openid-configuration")) {
|
||||
return new Response("{ not json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return original;
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("skips a candidate that itself fails", async () => {
|
||||
const original = notFound();
|
||||
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("treats a probe that throws as a candidate to skip", async () => {
|
||||
const original = notFound();
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input).includes("openid-configuration")) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return original;
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(wrapped(RFC8414_URL)).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("probes on a 502, which the SDK also walks past", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === OIDC_APPENDED) return json(RFC8414_DOC);
|
||||
return new Response("bad gateway", { status: 502 });
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect((await wrapped(RFC8414_URL)).json()).resolves.toEqual(
|
||||
RFC8414_DOC,
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the discovery headers from the failed request onto the probe", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const seen: Headers[] = [];
|
||||
const base = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.includes("openid-configuration")) {
|
||||
seen.push(new Headers(init?.headers));
|
||||
return json(RFC8414_DOC);
|
||||
}
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await wrapped(RFC8414_URL, {
|
||||
headers: {
|
||||
"MCP-Protocol-Version": "2025-11-25",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
expect(seen[0]?.get("mcp-protocol-version")).toBe("2025-11-25");
|
||||
expect(seen[0]?.get("accept")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("reads the headers off a Request input when there is no init", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const seen: Headers[] = [];
|
||||
const base = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.includes("openid-configuration")) {
|
||||
seen.push(new Headers(init?.headers));
|
||||
return json(RFC8414_DOC);
|
||||
}
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await wrapped(
|
||||
new Request(RFC8414_URL, {
|
||||
headers: { "MCP-Protocol-Version": "2025-11-25" },
|
||||
}),
|
||||
);
|
||||
expect(seen[0]?.get("mcp-protocol-version")).toBe("2025-11-25");
|
||||
});
|
||||
|
||||
it("accepts a URL instance as the request input", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === OIDC_APPENDED) return json(RFC8414_DOC);
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect((await wrapped(new URL(RFC8414_URL))).json()).resolves.toEqual(
|
||||
RFC8414_DOC,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a root authorization server, which has a single OIDC candidate", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const rootDoc = {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
if (
|
||||
String(input) ===
|
||||
"https://as.example.com/.well-known/openid-configuration"
|
||||
) {
|
||||
return json(rootDoc);
|
||||
}
|
||||
return notFound();
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(
|
||||
(
|
||||
await wrapped(
|
||||
"https://as.example.com/.well-known/oauth-authorization-server",
|
||||
)
|
||||
).json(),
|
||||
).resolves.toEqual(rootDoc);
|
||||
expect(base).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
100
clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts
Normal file
100
clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* RFC 8414 authorization-server metadata served at the OpenID Connect
|
||||
* well-known path, end to end against a real OAuth test server (#2172).
|
||||
*
|
||||
* The unit tests prove the wrapper substitutes the right document. This file
|
||||
* proves the two halves only a real server can: that the SDK genuinely rejects
|
||||
* a conforming plain-OAuth document found at `openid-configuration`, and that
|
||||
* the wrapper recovers discovery against the same server without adding a
|
||||
* field the server never published.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TestServerHttp,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
|
||||
|
||||
const OIDC_PATH = "/.well-known/openid-configuration";
|
||||
|
||||
/** The three fields OpenID Connect Discovery requires and RFC 8414 does not. */
|
||||
const OIDC_ONLY_FIELDS = [
|
||||
"jwks_uri",
|
||||
"subject_types_supported",
|
||||
"id_token_signing_alg_values_supported",
|
||||
] as const;
|
||||
|
||||
describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
let serverUrl = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpServer = new TestServerHttp({
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
asMetadataPath: OIDC_PATH,
|
||||
}),
|
||||
});
|
||||
const port = await mcpServer.start();
|
||||
serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH });
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mcpServer?.stop();
|
||||
mcpServer = null;
|
||||
}, 30_000);
|
||||
|
||||
it("serves plain RFC 8414 metadata only from the OIDC path", async () => {
|
||||
const oidc = await fetch(`${serverUrl}${OIDC_PATH}`);
|
||||
expect(oidc.status).toBe(200);
|
||||
const metadata = (await oidc.json()) as Record<string, unknown>;
|
||||
expect(metadata.issuer).toBe(serverUrl);
|
||||
for (const field of OIDC_ONLY_FIELDS) {
|
||||
expect(metadata).not.toHaveProperty(field);
|
||||
}
|
||||
|
||||
const rfc8414 = await fetch(
|
||||
`${serverUrl}/.well-known/oauth-authorization-server`,
|
||||
);
|
||||
expect(rfc8414.status).toBe(404);
|
||||
});
|
||||
|
||||
it("is rejected by unwrapped SDK discovery", async () => {
|
||||
// The upstream defect this module exists for
|
||||
// (modelcontextprotocol/typescript-sdk#2733). If this ever starts
|
||||
// resolving, the SDK has been fixed and `oidcDiscoveryCompat` can go.
|
||||
await expect(
|
||||
discoverAuthorizationServerMetadata(serverUrl),
|
||||
).rejects.toThrow(/jwks_uri/);
|
||||
});
|
||||
|
||||
it("resolves through the compat wrapper without fabricating a field", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const metadata = await discoverAuthorizationServerMetadata(serverUrl, {
|
||||
fetchFn: withRfc8414OidcCompat(fetch),
|
||||
});
|
||||
|
||||
expect(metadata?.issuer).toBe(serverUrl);
|
||||
expect(metadata?.authorization_endpoint).toBe(
|
||||
`${serverUrl}/oauth/authorize`,
|
||||
);
|
||||
expect(metadata?.token_endpoint).toBe(`${serverUrl}/oauth/token`);
|
||||
for (const field of OIDC_ONLY_FIELDS) {
|
||||
expect(metadata).not.toHaveProperty(field);
|
||||
}
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${serverUrl}${OIDC_PATH}`),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,11 @@ export {
|
|||
withOAuthEndpointOverrides,
|
||||
} from "./endpointOverrides.js";
|
||||
export type { OAuthEndpointOverrides } from "./endpointOverrides.js";
|
||||
export {
|
||||
oidcDiscoveryCandidates,
|
||||
isRfc8414OnlyMetadata,
|
||||
withRfc8414OidcCompat,
|
||||
} from "./oidcDiscoveryCompat.js";
|
||||
|
||||
// Storage
|
||||
export type {
|
||||
|
|
|
|||
254
core/auth/oidcDiscoveryCompat.ts
Normal file
254
core/auth/oidcDiscoveryCompat.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* Compatibility shim for a plain OAuth 2.0 authorization server that publishes
|
||||
* its RFC 8414 metadata at the OpenID Connect well-known path (#2172).
|
||||
*
|
||||
* ## The upstream defect
|
||||
*
|
||||
* Filed as modelcontextprotocol/typescript-sdk#2733.
|
||||
*
|
||||
* `discoverAuthorizationServerMetadata` in `@modelcontextprotocol/client@2.0.0`
|
||||
* tries a fixed list of well-known URLs (`buildDiscoveryUrls`) and picks the
|
||||
* validation schema from the **filename that resolved**, not from the document
|
||||
* that came back:
|
||||
*
|
||||
* ```js
|
||||
* const parsed = type === "oauth"
|
||||
* ? OAuthMetadataSchema.parse(body)
|
||||
* : OpenIdProviderDiscoveryMetadataSchema.parse(body);
|
||||
* ```
|
||||
*
|
||||
* `type` is `"oidc"` for every `openid-configuration` candidate, so a document
|
||||
* served there is required to carry `jwks_uri`, `subject_types_supported` and
|
||||
* `id_token_signing_alg_values_supported` — three fields OpenID Connect
|
||||
* Discovery 1.0 requires and RFC 8414 does not. A plain OAuth 2.0 authorization
|
||||
* server — no ID tokens, no JWKS, no `sub` — that publishes at that path is
|
||||
* therefore rejected with a `ZodError`, and because the parse **throws** rather
|
||||
* than continuing the loop, discovery aborts outright: the connection fails and
|
||||
* no later candidate is tried.
|
||||
*
|
||||
* RFC 8414 §5 explicitly anticipates this deployment. `openid-configuration` is
|
||||
* a permitted location for general OAuth authorization server metadata, and the
|
||||
* OIDC-only fields are not part of the RFC 8414 document. So the server in
|
||||
* #2172 is conforming and the client is wrong.
|
||||
*
|
||||
* ## What this wrapper does
|
||||
*
|
||||
* It never fabricates a field. It changes *which candidate URL* the document is
|
||||
* handed back on, so the SDK validates it under the schema that actually
|
||||
* describes it:
|
||||
*
|
||||
* 1. It watches only for the RFC 8414 candidate,
|
||||
* `/.well-known/oauth-authorization-server[<path>]`, and only when that
|
||||
* request came back with a status the SDK treats as "try the next
|
||||
* candidate" (4xx, or 502).
|
||||
* 2. It then fetches the OIDC candidates the SDK would try next — derived from
|
||||
* the request URL by the same rule `buildDiscoveryUrls` uses, which
|
||||
* `oidcDiscoveryCandidates` mirrors and a test pins against the SDK's own
|
||||
* exported function so the two cannot drift.
|
||||
* 3. If one of them returns a document that satisfies `OAuthMetadataSchema` but
|
||||
* **fails** `OpenIdProviderDiscoveryMetadataSchema` — i.e. it is RFC 8414
|
||||
* metadata and is not an OpenID provider document — that body is returned as
|
||||
* the response to the RFC 8414 request. The SDK parses it with
|
||||
* `OAuthMetadataSchema`, which is the correct schema for it, and discovery
|
||||
* succeeds.
|
||||
*
|
||||
* A document that *does* satisfy the OIDC schema is left alone: the original
|
||||
* response is returned unchanged and the SDK proceeds to its own OIDC candidate
|
||||
* as usual. That costs one duplicate request on a genuine OIDC server, which is
|
||||
* the price of not changing behavior for the case that already works.
|
||||
*
|
||||
* ## What it deliberately does not do
|
||||
*
|
||||
* - **It does not weaken issuer validation.** The substituted document is the
|
||||
* one the server actually published, `issuer` included, so the SDK's RFC 8414
|
||||
* §3.3 issuer-echo check runs on it exactly as before. A document whose
|
||||
* `issuer` does not match is still rejected.
|
||||
* - **It does not invent `jwks_uri` (or any other field).** Back-filling the
|
||||
* three OIDC-required fields would also make the parse succeed, but the
|
||||
* Inspector would then be displaying, in the Auth and Network tabs, a
|
||||
* metadata document the server never published — which is the one thing a
|
||||
* debugging tool must not do.
|
||||
* - **It does not hide the real traffic.** It is installed *above* the fetch
|
||||
* tracker (`InspectorClient.effectiveAuthFetch`), so the Network tab records
|
||||
* the genuine 404 on the RFC 8414 path and the genuine request to the OIDC
|
||||
* path. The substitution is visible only to the SDK's parse.
|
||||
*
|
||||
* ## Removing this
|
||||
*
|
||||
* This is a workaround for an upstream bug, not a feature. When
|
||||
* modelcontextprotocol/typescript-sdk#2733 lands — the SDK selecting the schema
|
||||
* from the document rather than from the filename, or treating a schema failure
|
||||
* as a candidate to skip rather than as fatal — delete this module and its
|
||||
* wiring in `core/mcp/inspectorClient.ts`.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAuthMetadataSchema,
|
||||
OpenIdProviderDiscoveryMetadataSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
|
||||
/** The RFC 8414 well-known path, which every OAuth-typed candidate starts with. */
|
||||
const RFC8414_WELL_KNOWN = "/.well-known/oauth-authorization-server";
|
||||
|
||||
/** The OpenID Connect Discovery well-known path. */
|
||||
const OIDC_WELL_KNOWN = "/.well-known/openid-configuration";
|
||||
|
||||
/**
|
||||
* The OIDC discovery candidates the SDK would try after the RFC 8414 candidate
|
||||
* `rfc8414Url` failed, in the SDK's own order.
|
||||
*
|
||||
* Derived from the RFC 8414 URL rather than from the authorization-server URL
|
||||
* because that is all a `fetch` wrapper sees. The two are equivalent: the SDK
|
||||
* builds the RFC 8414 candidate as
|
||||
* `${origin}/.well-known/oauth-authorization-server${path}` with the
|
||||
* authorization server's (trailing-slash-stripped) pathname as `path`, so
|
||||
* recovering `path` recovers everything `buildDiscoveryUrls` keyed off.
|
||||
*
|
||||
* Returns an empty array when the URL is not an RFC 8414 candidate at all.
|
||||
*/
|
||||
export function oidcDiscoveryCandidates(rfc8414Url: string): string[] {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rfc8414Url);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!url.pathname.startsWith(RFC8414_WELL_KNOWN)) return [];
|
||||
const path = url.pathname.slice(RFC8414_WELL_KNOWN.length);
|
||||
// The authorization server had no path, so the SDK emitted a single OIDC
|
||||
// candidate at the origin.
|
||||
if (path === "") return [new URL(OIDC_WELL_KNOWN, url.origin).href];
|
||||
// A path-suffixed RFC 8414 candidate always has two OIDC siblings: the
|
||||
// path-suffixed form and the path-prefixed ("appended") form.
|
||||
return [
|
||||
new URL(`${OIDC_WELL_KNOWN}${path}`, url.origin).href,
|
||||
new URL(`${path}${OIDC_WELL_KNOWN}`, url.origin).href,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a status makes the SDK move on to the next discovery candidate.
|
||||
*
|
||||
* Mirrors `discoverAuthorizationServerMetadata`: a 4xx or a 502 continues the
|
||||
* loop, anything else throws. Probing on a status the SDK will not walk past
|
||||
* would be wasted work — the flow is already over.
|
||||
*/
|
||||
function continuesDiscovery(status: number): boolean {
|
||||
return (status >= 400 && status < 500) || status === 502;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `content-type` names a whole JSON document.
|
||||
*
|
||||
* An exact media-type test rather than a substring search for "json", for the
|
||||
* same reason `endpointOverrides.ts` uses one: a streaming media type would be
|
||||
* read to completion and never resolve.
|
||||
*/
|
||||
function isJsonDocumentResponse(contentType: string | null): boolean {
|
||||
if (!contentType) return false;
|
||||
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
||||
return mediaType === "application/json" || mediaType.endsWith("+json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a parsed body is RFC 8414 authorization-server metadata that is *not*
|
||||
* a valid OpenID provider document — the exact shape the upstream schema
|
||||
* selection rejects.
|
||||
*
|
||||
* Both halves matter. Without the first, any JSON body would be substituted;
|
||||
* without the second, a genuine OIDC document would be diverted onto the RFC
|
||||
* 8414 path and silently change behavior for servers that work today.
|
||||
*/
|
||||
export function isRfc8414OnlyMetadata(value: unknown): boolean {
|
||||
if (!OAuthMetadataSchema.safeParse(value).success) return false;
|
||||
return !OpenIdProviderDiscoveryMetadataSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/** The request URL a `fetch` call was made with, in any of its three forms. */
|
||||
function requestUrlOf(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headers the failed discovery request carried, so the probe is answered
|
||||
* the same way the SDK's own request would have been (it sends
|
||||
* `MCP-Protocol-Version` and `Accept`). Copied from the request rather than
|
||||
* reconstructed, so a header the SDK adds later is carried without this module
|
||||
* needing to know about it.
|
||||
*/
|
||||
function discoveryHeaders(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
): Headers {
|
||||
if (init?.headers) return new Headers(init.headers);
|
||||
if (typeof input !== "string" && !(input instanceof URL)) {
|
||||
return new Headers(input.headers);
|
||||
}
|
||||
return new Headers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a `fetch` so a plain OAuth 2.0 authorization server publishing RFC 8414
|
||||
* metadata at `/.well-known/openid-configuration` is discoverable.
|
||||
*
|
||||
* Inert for every request that is not a *failed* RFC 8414 discovery request, so
|
||||
* the overwhelmingly common case costs one status check and one string
|
||||
* comparison.
|
||||
*/
|
||||
export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
||||
return async (input, init) => {
|
||||
const response = await fetchFn(input, init);
|
||||
if (response.ok) return response;
|
||||
if (!continuesDiscovery(response.status)) return response;
|
||||
|
||||
const candidates = oidcDiscoveryCandidates(requestUrlOf(input));
|
||||
if (candidates.length === 0) return response;
|
||||
|
||||
const headers = discoveryHeaders(input, init);
|
||||
for (const candidate of candidates) {
|
||||
let probe: Response;
|
||||
try {
|
||||
probe = await fetchFn(candidate, { headers });
|
||||
} catch {
|
||||
// A network or CORS failure on a URL the SDK had not asked for yet is
|
||||
// not this wrapper's to report — the SDK will make the same request and
|
||||
// handle it. Try the next candidate.
|
||||
continue;
|
||||
}
|
||||
if (!probe.ok) continue;
|
||||
if (!isJsonDocumentResponse(probe.headers.get("content-type"))) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
let body: string;
|
||||
try {
|
||||
body = await probe.text();
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRfc8414OnlyMetadata(parsed)) {
|
||||
// Either not metadata at all, or a genuine OpenID provider document the
|
||||
// SDK can already read. Stop looking: this is the document discovery
|
||||
// would have used, and it needs no help.
|
||||
return response;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[oauth] ${candidate} returned RFC 8414 OAuth 2.0 authorization server ` +
|
||||
`metadata, not an OpenID provider document. The MCP TypeScript SDK ` +
|
||||
`validates that path as OpenID Connect Discovery and would reject it ` +
|
||||
`(modelcontextprotocol/typescript-sdk#2733), so the Inspector is ` +
|
||||
`handing it to discovery as the RFC 8414 document it is.`,
|
||||
);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
|
@ -238,6 +238,7 @@ import {
|
|||
type HandleAuthChallengeOptions,
|
||||
} from "../auth/challenge.js";
|
||||
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
|
||||
import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
|
|
@ -791,7 +792,15 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
|
||||
this.oauthManager?.getEndpointOverrides(),
|
||||
);
|
||||
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
|
||||
// #2172: recover discovery when a plain OAuth 2.0 authorization server
|
||||
// publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which
|
||||
// the SDK validates as an OpenID provider document and rejects. Wraps the
|
||||
// tracked fetch rather than the base one — the opposite of the overrides
|
||||
// above — so the Network tab records the real 404 and the real probe rather
|
||||
// than the substitution the SDK's parse sees.
|
||||
this.effectiveAuthFetch = withRfc8414OidcCompat(
|
||||
this.buildEffectiveAuthFetch(),
|
||||
);
|
||||
|
||||
this.sessionId = options.sessionId;
|
||||
|
||||
|
|
|
|||
19
test-servers/configs/oauth-rfc8414-at-oidc-path-http.json
Normal file
19
test-servers/configs/oauth-rfc8414-at-oidc-path-http.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "oauth-rfc8414-at-oidc-path",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [{ "preset": "echo" }],
|
||||
"oauth": {
|
||||
"enabled": true,
|
||||
"mode": "combined",
|
||||
"requireAuth": true,
|
||||
"asMetadataPath": "/.well-known/openid-configuration",
|
||||
"scopesSupported": ["mcp"],
|
||||
"supportDCR": true
|
||||
},
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 8083
|
||||
}
|
||||
}
|
||||
|
|
@ -613,6 +613,21 @@ export interface ServerConfig {
|
|||
*/
|
||||
resourceMetadataPath?: string;
|
||||
|
||||
/**
|
||||
* Serve the RFC 8414 authorization-server metadata document from this
|
||||
* non-default path *instead of* `/.well-known/oauth-authorization-server`
|
||||
* (combined mode only).
|
||||
*
|
||||
* Set it to `/.well-known/openid-configuration` to reproduce #2172: a
|
||||
* plain OAuth 2.0 authorization server — no `jwks_uri`, no
|
||||
* `subject_types_supported`, no `id_token_signing_alg_values_supported` —
|
||||
* publishing RFC 8414 metadata at the OIDC well-known path, which RFC 8414
|
||||
* §5 permits. As with `resourceMetadataPath`, the default route is left
|
||||
* unserved, so a client that cannot read the document where it actually
|
||||
* lives fails outright rather than quietly succeeding elsewhere.
|
||||
*/
|
||||
asMetadataPath?: string;
|
||||
|
||||
/**
|
||||
* OAuth authorization server issuer URL (combined mode AS metadata).
|
||||
* If not provided, defaults to the test server's base URL.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export interface ConfigFileOAuth {
|
|||
resource?: string;
|
||||
/** Serve RFC 9728 metadata from this path and advertise it on 401 (#2071). */
|
||||
resourceMetadataPath?: string;
|
||||
/** Serve RFC 8414 AS metadata from this path instead of the default (#2172). */
|
||||
asMetadataPath?: string;
|
||||
issuerUrl?: string;
|
||||
accessTokenIssuers?: string[];
|
||||
jwksUri?: string;
|
||||
|
|
@ -222,6 +224,12 @@ function validateConfig(
|
|||
`Invalid config in ${filePath}: oauth.resourceMetadataPath must be an origin-relative path (e.g. "/custom/protected-resource") — a value such as "//host/doc" would advertise a document the server does not serve`,
|
||||
);
|
||||
}
|
||||
const asPath = oauth.asMetadataPath;
|
||||
if (asPath !== undefined && !isOriginRelativePath(asPath)) {
|
||||
throw new Error(
|
||||
`Invalid config in ${filePath}: oauth.asMetadataPath must be an origin-relative path (e.g. "/.well-known/openid-configuration") — a value such as "//host/doc" would move the document off this server entirely`,
|
||||
);
|
||||
}
|
||||
if (transportType === "stdio" && oauth.enabled === true) {
|
||||
throw new Error(
|
||||
`Invalid config in ${filePath}: oauth requires streamable-http or sse transport`,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,13 @@ export interface WaitForOAuthWellKnownOptions {
|
|||
interval?: number;
|
||||
/** Max time per fetch attempt (so one hung request doesn't burn the whole timeout). Default 1000. */
|
||||
requestTimeout?: number;
|
||||
/**
|
||||
* Origin-relative path the AS metadata document is served from. Defaults to
|
||||
* the RFC 8414 well-known path; pass the configured `oauth.asMetadataPath`
|
||||
* when the fixture moved the document (#2172), since polling a route the
|
||||
* server deliberately does not serve can only ever time out.
|
||||
*/
|
||||
metadataPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,8 +104,9 @@ export async function waitForOAuthWellKnown(
|
|||
timeout = 5000,
|
||||
interval = 50,
|
||||
requestTimeout = 1000,
|
||||
metadataPath = "/.well-known/oauth-authorization-server",
|
||||
} = options ?? {};
|
||||
const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`;
|
||||
const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}${metadataPath}`;
|
||||
const start = Date.now();
|
||||
let lastStatus: number | undefined;
|
||||
let lastError: unknown;
|
||||
|
|
|
|||
|
|
@ -2915,6 +2915,13 @@ export function createOAuthTestServerConfig(options: {
|
|||
* it via `WWW-Authenticate: Bearer resource_metadata="…"` (#2071).
|
||||
*/
|
||||
resourceMetadataPath?: string;
|
||||
/**
|
||||
* Move the RFC 8414 authorization-server metadata document off the
|
||||
* well-known path — set it to `/.well-known/openid-configuration` to serve
|
||||
* plain OAuth 2.0 metadata where the SDK expects an OpenID provider
|
||||
* document (#2172).
|
||||
*/
|
||||
asMetadataPath?: string;
|
||||
}): Partial<ServerConfig> {
|
||||
return {
|
||||
oauth: {
|
||||
|
|
@ -2928,6 +2935,11 @@ export function createOAuthTestServerConfig(options: {
|
|||
...(options.resourceMetadataPath !== undefined
|
||||
? { resourceMetadataPath: options.resourceMetadataPath }
|
||||
: {}),
|
||||
// Same `!== undefined` reasoning as above: an explicit `""` is invalid
|
||||
// and must reach the server so it reports the bad fixture.
|
||||
...(options.asMetadataPath !== undefined
|
||||
? { asMetadataPath: options.asMetadataPath }
|
||||
: {}),
|
||||
staticClients: options.staticClients,
|
||||
supportDCR: options.supportDCR ?? false,
|
||||
supportCIMD: options.supportCIMD ?? false,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,24 @@ function resourceMetadataPath(config: OAuthConfig): string | undefined {
|
|||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the RFC 8414 authorization-server metadata document is served from,
|
||||
* validated the same way `resourceMetadataPath` is. Defaults to the well-known
|
||||
* location; `asMetadataPath` moves it (see the field's doc comment).
|
||||
*/
|
||||
function asMetadataPath(config: OAuthConfig): string {
|
||||
const path = config.asMetadataPath;
|
||||
if (path === undefined) {
|
||||
return "/.well-known/oauth-authorization-server";
|
||||
}
|
||||
if (!isOriginRelativePath(path)) {
|
||||
throw new Error(
|
||||
`oauth.asMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `WWW-Authenticate` challenge sent with every 401.
|
||||
*
|
||||
|
|
@ -219,42 +237,36 @@ function setupMetadataEndpoints(
|
|||
|
||||
if (mode === "combined") {
|
||||
// OAuth Authorization Server Metadata (local AS)
|
||||
app.get(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
(req: Request, res: Response) => {
|
||||
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
|
||||
const metadata = {
|
||||
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
|
||||
// well-known path was appended to — i.e. no trailing slash. SDK v2's
|
||||
// client enforces this exactly (IssuerMismatchError otherwise).
|
||||
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
|
||||
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
|
||||
app.get(asMetadataPath(config), (req: Request, res: Response) => {
|
||||
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
|
||||
const metadata = {
|
||||
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
|
||||
// well-known path was appended to — i.e. no trailing slash. SDK v2's
|
||||
// client enforces this exactly (IssuerMismatchError otherwise).
|
||||
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
|
||||
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
|
||||
.href,
|
||||
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
|
||||
scopes_supported: scopes,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "none"],
|
||||
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
|
||||
// clients must validate (and our e2e can exercise reject paths).
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
...(config.supportDCR && {
|
||||
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
|
||||
.href,
|
||||
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
|
||||
scopes_supported: scopes,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
"client_secret_basic",
|
||||
"none",
|
||||
],
|
||||
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
|
||||
// clients must validate (and our e2e can exercise reject paths).
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
...(config.supportDCR && {
|
||||
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
|
||||
.href,
|
||||
}),
|
||||
...(config.supportCIMD && {
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
...(config.supportCIMD && {
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
};
|
||||
|
||||
res.json(metadata);
|
||||
},
|
||||
);
|
||||
res.json(metadata);
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth Protected Resource Metadata. `resourceMetadataPath` moves the
|
||||
|
|
|
|||
Loading…
Reference in a new issue