History

Address review: typed e2e assertions, hoisted issuer canonicalizer

The e2e probe-failure assertions now narrow with Predicate.isTagged and assert the typed message instead of stringifying a cast. canonicalIssuer is hoisted to module scope with the issuer-vs-resource canonicalization difference documented in one place, and the DCR resource canonicalization records why legacy raw-string rows need no backfill.

Rhys Sullivan committed 3 months ago

Commit 4dff9e51 parent b6868e53 files · +26 −14Browse files

Unified · Split

3 files
  1. e2e/scenarios/oauth-dcr-compliance.test.ts +10 −7
  2. packages/core/sdk/src/oauth-discovery.ts +11 −7
  3. packages/core/sdk/src/oauth-service.ts +5 −0
e2e/scenarios/oauth-dcr-compliance.test.ts+10−7
@@ -20,10 +20,11 @@ import { randomBytes } from "node:crypto"; · 19 unmodified lines
2020 import { createServer } from "node:net";
2121
2222 import { expect } from "@effect/vitest";
23−import { Effect } from "effect";
23+import { Effect, Predicate } from "effect";
2424 import { composePluginApi } from "@executor-js/api/server";
2525 import { createEmulator, type Emulator } from "@executor-js/emulate";
2626 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
27+import type { OAuthProbeError } from "@executor-js/sdk";
2728 import {
2829 AuthTemplateSlug,
2930 ConnectionName,
@@ -121,9 +122,10 @@ scenario( · 91 unmodified lines
121122 const failure = yield* Effect.flip(
122123 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),
123124 );
124− expect(String((failure as { readonly _tag?: string })._tag ?? failure)).toContain(
125− "OAuthProbeError",
126− );
125+ expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true);
126+ const probeError = failure as OAuthProbeError;
127+ expect(probeError.message).toContain("issuer");
128+ expect(probeError.message).toContain("https://evil.example.com");
127129 }),
128130 ),
129131 );
@@ -150,9 +152,10 @@ scenario( · 20 unmodified lines
150152 const failure = yield* Effect.flip(
151153 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),
152154 );
153− expect(String((failure as { readonly _tag?: string })._tag ?? failure)).toContain(
154− "OAuthProbeError",
155− );
155+ expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true);
156+ const probeError = failure as OAuthProbeError;
157+ expect(probeError.message).toContain("resource");
158+ expect(probeError.message).toContain("https://other.example.com/mcp");
156159 }),
157160 ),
158161 );
packages/core/sdk/src/oauth-discovery.ts+11−7
@@ -162,6 +162,17 @@ const validateEndpointUrl = ( · 161 unmodified lines
162162 }),
163163 });
164164
165+/** RFC 8414 issuer identity: scheme, host, port, and path after URL
166+ * normalization; a trailing slash alone does not create a different issuer.
167+ * Deliberately NOT `canonicalResourceUrl`: an issuer never carries a query,
168+ * and its trailing slash is insignificant (RFC 8414 §2), whereas a resource
169+ * identifier's query and trailing slash are part of the identity (RFC 8707). */
170+const canonicalIssuer = (value: string): string => {
171+ const url = new URL(value);
172+ const path = url.pathname.replace(/\/+$/, "");
173+ return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`;
174+};
175+
165176 const validateAuthorizationServerMetadata = (
166177 metadata: OAuthAuthorizationServerMetadata,
167178 expectedIssuer: string,
@@ -169,13 +180,6 @@ const validateAuthorizationServerMetadata = ( · 1 unmodified line
169180 ): Effect.Effect<void, OAuthDiscoveryError> =>
170181 Effect.gen(function* () {
171182 yield* validateEndpointUrl(metadata.issuer, "issuer", policy);
172− const canonicalIssuer = (value: string): string => {
173− const url = new URL(value);
174− const path = url.pathname.replace(/\/+$/, "");
175− return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`;
176− };
177− // RFC 8414 issuer identity compares scheme, host, port, and path after URL
178− // normalization; a trailing slash alone does not create a different issuer.
179183 if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) {
180184 return yield* new OAuthDiscoveryError({
181185 message:
packages/core/sdk/src/oauth-service.ts+5−0
@@ -861,6 +861,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 860 unmodified lines
861861 Effect.gen(function* () {
862862 const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint);
863863 const requestedResource = input.resource ?? null;
864+ // Canonicalized ONCE here; reuse-matching, persistence, and every later
865+ // authorize/token request all see this exact value. Rows persisted
866+ // before canonicalization may hold a raw string that no longer matches
867+ // (case, trailing slash) — those clients simply re-register, which is
868+ // safe and self-healing, so no backfill migration is warranted.
864869 const resource =
865870 requestedResource === null
866871 ? null