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 | 20 | import { createServer } from "node:net"; |
| 21 | 21 | |
| 22 | 22 | import { expect } from "@effect/vitest"; |
| 23 | −import { Effect } from "effect"; | |
| 23 | +import { Effect, Predicate } from "effect"; | |
| 24 | 24 | import { composePluginApi } from "@executor-js/api/server"; |
| 25 | 25 | import { createEmulator, type Emulator } from "@executor-js/emulate"; |
| 26 | 26 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 27 | +import type { OAuthProbeError } from "@executor-js/sdk"; | |
| 27 | 28 | import { |
| 28 | 29 | AuthTemplateSlug, |
| 29 | 30 | ConnectionName, |
| @@ -121,9 +122,10 @@ scenario( · 91 unmodified lines | ||
| 121 | 122 | const failure = yield* Effect.flip( |
| 122 | 123 | client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), |
| 123 | 124 | ); |
| 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"); | |
| 127 | 129 | }), |
| 128 | 130 | ), |
| 129 | 131 | ); |
| @@ -150,9 +152,10 @@ scenario( · 20 unmodified lines | ||
| 150 | 152 | const failure = yield* Effect.flip( |
| 151 | 153 | client.oauth.probe({ payload: { url: `${emulator.url}/mcp` } }), |
| 152 | 154 | ); |
| 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"); | |
| 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 | 176 | const validateAuthorizationServerMetadata = ( |
| 166 | 177 | metadata: OAuthAuthorizationServerMetadata, |
| 167 | 178 | expectedIssuer: string, |
| @@ -169,13 +180,6 @@ const validateAuthorizationServerMetadata = ( · 1 unmodified line | ||
| 169 | 180 | ): Effect.Effect<void, OAuthDiscoveryError> => |
| 170 | 181 | Effect.gen(function* () { |
| 171 | 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 | 183 | if (canonicalIssuer(metadata.issuer) !== canonicalIssuer(expectedIssuer)) { |
| 180 | 184 | return yield* new OAuthDiscoveryError({ |
| 181 | 185 | message: |
| @@ -861,6 +861,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 860 unmodified lines | ||
| 861 | 861 | Effect.gen(function* () { |
| 862 | 862 | const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); |
| 863 | 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 | 869 | const resource = |
| 865 | 870 | requestedResource === null |
| 866 | 871 | ? null |