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.
Unified · Split
| @@ -20,10 +20,11 @@ import { randomBytes } from "node:crypto"; · 19 unmodified lines | |||
| 20 | import { createServer } from "node:net"; | 20 | import { createServer } from "node:net"; |
| 21 | 21 | ||
| 22 | import { expect } from "@effect/vitest"; | 22 | import { expect } from "@effect/vitest"; |
| 23 | import { Effect } from "effect"; | 23 | import { Effect, Predicate } from "effect"; |
| 24 | import { composePluginApi } from "@executor-js/api/server"; | 24 | import { composePluginApi } from "@executor-js/api/server"; |
| 25 | import { createEmulator, type Emulator } from "@executor-js/emulate"; | 25 | import { createEmulator, type Emulator } from "@executor-js/emulate"; |
| 26 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 26 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 27 | import type { OAuthProbeError } from "@executor-js/sdk"; | ||
| 27 | import { | 28 | import { |
| 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 | ); |
| @@ -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). */ | ||
| 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 | |||
| 165 | const validateAuthorizationServerMetadata = ( | 176 | const 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: |
| @@ -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 === null | 870 | requestedResource === null |
| 866 | ? null | 871 | ? null |