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
20import { createServer } from "node:net";20import { createServer } from "node:net";
21 21
22import { expect } from "@effect/vitest";22import { expect } from "@effect/vitest";
23import { Effect } from "effect";23import { Effect, Predicate } from "effect";
24import { composePluginApi } from "@executor-js/api/server";24import { composePluginApi } from "@executor-js/api/server";
25import { createEmulator, type Emulator } from "@executor-js/emulate";25import { createEmulator, type Emulator } from "@executor-js/emulate";
26import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";26import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
27import type { OAuthProbeError } from "@executor-js/sdk";
27import {28import {
28 AuthTemplateSlug,29 AuthTemplateSlug,
29 ConnectionName,30 ConnectionName,
@@ -121,9 +122,10 @@ scenario( · 91 unmodified lines
121 const failure = yield* Effect.flip(122 const failure = yield* Effect.flip(
122 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),123 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),
123 );124 );
124 expect(String((failure as { readonly _tag?: string })._tag ?? failure)).toContain(125 expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true);
125 "OAuthProbeError",126 const probeError = failure as OAuthProbeError;
126 );127 expect(probeError.message).toContain("issuer");
128 expect(probeError.message).toContain("https://evil.example.com");
127 }),129 }),
128 ),130 ),
129);131);
@@ -150,9 +152,10 @@ scenario( · 20 unmodified lines
150 const failure = yield* Effect.flip(152 const failure = yield* Effect.flip(
151 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),153 client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }),
152 );154 );
153 expect(String((failure as { readonly _tag?: string })._tag ?? failure)).toContain(155 expect(Predicate.isTagged("OAuthProbeError")(failure)).toBe(true);
154 "OAuthProbeError",156 const probeError = failure as OAuthProbeError;
155 );157 expect(probeError.message).toContain("resource");
158 expect(probeError.message).toContain("https://other.example.com/mcp");
156 }),159 }),
157 ),160 ),
158);161);
packages/core/sdk/src/oauth-discovery.ts+11−7
@@ -162,6 +162,17 @@ const validateEndpointUrl = ( · 161 unmodified lines
162 }),162 }),
163 });163 });
164 164
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). */
170const 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
165const validateAuthorizationServerMetadata = (176const validateAuthorizationServerMetadata = (
166 metadata: OAuthAuthorizationServerMetadata,177 metadata: OAuthAuthorizationServerMetadata,
167 expectedIssuer: string,178 expectedIssuer: string,
@@ -169,13 +180,6 @@ const validateAuthorizationServerMetadata = ( · 1 unmodified line
169): Effect.Effect<void, OAuthDiscoveryError> =>180): Effect.Effect<void, OAuthDiscoveryError> =>
170 Effect.gen(function* () {181 Effect.gen(function* () {
171 yield* validateEndpointUrl(metadata.issuer, "issuer", policy);182 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.
179 if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) {183 if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) {
180 return yield* new OAuthDiscoveryError({184 return yield* new OAuthDiscoveryError({
181 message:185 message:
packages/core/sdk/src/oauth-service.ts+5−0
@@ -861,6 +861,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 860 unmodified lines
861 Effect.gen(function* () {861 Effect.gen(function* () {
862 const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint);862 const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint);
863 const requestedResource = input.resource ?? null;863 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.
864 const resource =869 const resource =
865 requestedResource === null870 requestedResource === null
866 ? null871 ? null