History

Merge remote-tracking branch 'origin/codex/effect-lint-core-sdk-oauth' into codex/effect-lint-integration

Rhys Sullivan committed 6 months ago

Commit fbcc52b2 parents dc75098 0bbd7564 files · +103 −123Browse files

Unified · Split

4 files
  1. packages/core/sdk/src/oauth-discovery.test.ts +16 −7
  2. packages/core/sdk/src/oauth-discovery.ts +63 −91
  3. packages/core/sdk/src/oauth-helpers.test.ts +8 −6
  4. packages/core/sdk/src/oauth-helpers.ts +16 −19
packages/core/sdk/src/oauth-discovery.test.ts+16−7
@@ -1,5 +1,5 @@
11 import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2−import { Cause, Effect, Exit } from "effect";
2+import { Cause, Effect, Exit, Schema } from "effect";
33
44 import {
55 OAuthDiscoveryError,
@@ -11,6 +11,14 @@ import { · 5 unmodified lines
1111
1212 type Handler = (url: string, init: RequestInit) => Response | Promise<Response>;
1313
14+const DcrRequestBody = Schema.Struct({
15+ redirect_uris: Schema.Array(Schema.String),
16+ token_endpoint_auth_method: Schema.String,
17+});
18+const decodeDcrRequestBody = Schema.decodeUnknownSync(
19+ Schema.fromJsonString(DcrRequestBody),
20+);
21+
1422 const installFetchRouter = (
1523 handlers: readonly { match: (url: string) => boolean; handle: Handler }[],
1624 ): { calls: Array<{ url: string; init: RequestInit }> } => {
@@ -196,7 +204,7 @@ describe("registerDynamicClient", () => { · 179 unmodified lines
196204
197205 const call = calls[0]!;
198206 expect(call.init.method).toBe("POST");
199− const body = JSON.parse(call.init.body as string);
207+ const body = decodeDcrRequestBody(call.init.body);
200208 expect(body.redirect_uris).toEqual(["https://app.example.com/cb"]);
201209 expect(body.token_endpoint_auth_method).toBe("none");
202210 });
@@ -251,11 +259,12 @@ describe("registerDynamicClient", () => { · 48 unmodified lines
251259 expect(Exit.isFailure(exit)).toBe(true);
252260 if (!Exit.isFailure(exit)) return;
253261 const reason = exit.cause.reasons.find(Cause.isFailReason);
254− if (!(reason?.error instanceof OAuthDiscoveryError)) {
255− throw new Error("expected OAuthDiscoveryError");
256− }
257− expect(reason.error.status).toBe(400);
258− expect(reason.error.message).toMatch(/invalid_client_metadata/);
262+ const error = reason?.error;
263+ expect(error).toEqual(expect.objectContaining({
264+ _tag: "OAuthDiscoveryError",
265+ status: 400,
266+ message: expect.stringMatching(/invalid_client_metadata/),
267+ }));
259268 });
260269 });
261270
packages/core/sdk/src/oauth-discovery.ts+63−91
@@ -18,7 +18,7 @@ · 17 unmodified lines
1818 // callers actually need.
1919 // ---------------------------------------------------------------------------
2020
21−import { Data, Effect, Result, Schema } from "effect";
21+import { Data, Effect, Option, Result, Schema } from "effect";
2222 import * as oauth from "oauth4webapi";
2323
2424 import {
@@ -45,22 +45,15 @@ export class OAuthDiscoveryError extends Data.TaggedError( · 20 unmodified lines
4545 readonly cause?: unknown;
4646 }> {}
4747
48−const discoveryError = (
49− message: string,
50− options: { status?: number; cause?: unknown } = {},
51−): OAuthDiscoveryError =>
52− new OAuthDiscoveryError({
53− message,
54− status: options.status,
55− cause: options.cause,
56− });
57−
5848 // ---------------------------------------------------------------------------
5949 // Schemas (narrow structural parsing — the RFCs leave many fields
6050 // optional; we validate only the subset consumers read)
6151 // ---------------------------------------------------------------------------
6252
6353 const StringArray = Schema.Array(Schema.String);
54+const JsonUnknownFromString = Schema.fromJsonString(Schema.Unknown);
55+const decodeJsonUnknownSync = Schema.decodeUnknownSync(JsonUnknownFromString);
56+const decodeJsonUnknownOption = Schema.decodeUnknownOption(JsonUnknownFromString);
6457
6558 export const OAuthProtectedResourceMetadataSchema = Schema.Struct({
6659 resource: Schema.optional(Schema.String),
@@ -155,20 +148,17 @@ export interface DiscoveryRequestOptions { · 88 unmodified lines
155148 const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
156149
157150 const isLoopbackHttpUrl = (value: string): boolean => {
158− try {
159− const url = new URL(value);
160− if (url.protocol !== "http:") return false;
161− const hostname = url.hostname.toLowerCase();
162− return (
163− hostname === "localhost" ||
164− hostname === "0.0.0.0" ||
165− hostname === "::1" ||
166− hostname === "[::1]" ||
167− hostname.startsWith("127.")
168− );
169− } catch {
170− return false;
171− }
151+ if (!URL.canParse(value)) return false;
152+ const url = new URL(value);
153+ if (url.protocol !== "http:") return false;
154+ const hostname = url.hostname.toLowerCase();
155+ return (
156+ hostname === "localhost" ||
157+ hostname === "0.0.0.0" ||
158+ hostname === "::1" ||
159+ hostname === "[::1]" ||
160+ hostname.startsWith("127.")
161+ );
172162 };
173163
174164 const oauth4webapiOptions = (
@@ -260,30 +250,26 @@ export const discoverProtectedResourceMetadata = ( · 85 unmodified lines
260250 }
261251 const text = await response.text();
262252 if (text.length === 0) return "skip" as const;
263− return { status: response.status, body: JSON.parse(text) } as const;
253+ return { status: response.status, body: decodeJsonUnknownSync(text) } as const;
264254 },
265255 catch: (cause) =>
266− discoveryError(
267− `Failed to fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}`,
268− { cause },
269− ),
256+ new OAuthDiscoveryError({
257+ message: `Failed to fetch protected resource metadata at ${url}`,
258+ cause,
259+ }),
270260 });
271261 if (result === "skip") continue;
272262 if (!("body" in result)) {
273− return yield* Effect.fail(
274− discoveryError(
275− `Protected resource metadata returned status ${result.status}`,
276− { status: result.status },
277− ),
278− );
263+ return yield* new OAuthDiscoveryError({
264+ message: `Protected resource metadata returned status ${result.status}`,
265+ status: result.status,
266+ });
279267 }
280268 const metadata = yield* decodeResourceMetadata(result.body).pipe(
281269 Effect.mapError(
282270 (err) =>
283271 new OAuthDiscoveryError({
284− message: `Protected resource metadata is malformed: ${
285− Schema.isSchemaError(err) ? err.message : String(err)
286− }`,
272+ message: "Protected resource metadata is malformed",
287273 cause: err,
288274 }),
289275 ),
@@ -348,15 +334,11 @@ export const discoverAuthorizationServerMetadata = ( · 58 unmodified lines
348334 raw: as,
349335 };
350336 },
351− catch: (cause) => {
352− if (cause instanceof OAuthDiscoveryError) return cause;
353− return discoveryError(
354− `Discovery (${algorithm}) failed for ${issuer}: ${
355− cause instanceof Error ? cause.message : String(cause)
356− }`,
357− { cause },
358− );
359− },
337+ catch: (cause) =>
338+ new OAuthDiscoveryError({
339+ message: `Discovery (${algorithm}) failed for ${issuer}`,
340+ cause,
341+ }),
360342 }).pipe(
361343 // If one algorithm fails mid-roundtrip (network, parse, issuer
362344 // mismatch) we still want to try the other before giving up.
@@ -370,9 +352,7 @@ export const discoverAuthorizationServerMetadata = ( · 7 unmodified lines
370352 Effect.mapError(
371353 (err) =>
372354 new OAuthDiscoveryError({
373− message: `Authorization server metadata is malformed: ${
374− Schema.isSchemaError(err) ? err.message : String(err)
375− }`,
355+ message: "Authorization server metadata is malformed",
376356 cause: err,
377357 }),
378358 ),
@@ -437,11 +417,7 @@ const interpretDcrFailure = ( · 58 unmodified lines
437417 ): DcrErrorBody | DcrTransport => {
438418 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.
439419 if (status >= 400 && status < 500) {
440− const parsed = Result.try({
441− try: () => (text ? (JSON.parse(text) as unknown) : null),
442− catch: () => null,
443− });
444− const body = Result.isSuccess(parsed) ? parsed.success : null;
420+ const body = text ? Option.getOrNull(decodeJsonUnknownOption(text)) : null;
445421 if (
446422 body &&
447423 typeof body === "object" &&
@@ -497,7 +473,7 @@ export const registerDynamicClient = ( · 49 unmodified lines
497473 }),
498474 catch: (cause) =>
499475 new DcrTransport({
500− message: `Dynamic Client Registration request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
476+ message: "Dynamic Client Registration request failed",
501477 cause,
502478 }),
503479 });
@@ -505,9 +481,10 @@ export const registerDynamicClient = ( · 1 unmodified line
505481 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but
506482 // Todoist (and others) return 200 OK with the client information body.
507483 if (response.status !== 200 && response.status !== 201) {
508− const text = yield* Effect.promise(() =>
509− response.text().catch(() => ""),
510− );
484+ const text = yield* Effect.tryPromise({
485+ try: () => response.text(),
486+ catch: () => "",
487+ });
511488 return yield* interpretDcrFailure(response.status, text);
512489 }
513490
@@ -520,22 +497,20 @@ export const registerDynamicClient = ( · 6 unmodified lines
520497 cause,
521498 }),
522499 });
523− const json = yield* Effect.try({
524− try: () => JSON.parse(text) as unknown,
525− catch: (cause) =>
500+ const json = yield* Schema.decodeUnknownEffect(JsonUnknownFromString)(text).pipe(
501+ Effect.mapError((cause) =>
526502 new DcrTransport({
527503 message: "Dynamic Client Registration response was not valid JSON",
528504 status: response.status,
529505 cause,
530506 }),
531− });
507+ ),
508+ );
532509 return yield* decodeClientInformation(json).pipe(
533510 Effect.mapError(
534511 (err) =>
535512 new OAuthDiscoveryError({
536− message: `Dynamic Client Registration response is malformed: ${
537− Schema.isSchemaError(err) ? err.message : String(err)
538− }`,
513+ message: "Dynamic Client Registration response is malformed",
539514 cause: err,
540515 }),
541516 ),
@@ -544,16 +519,18 @@ export const registerDynamicClient = ( · 2 unmodified lines
544519 Effect.catchTags({
545520 DcrErrorBody: (err) =>
546521 Effect.fail(
547− discoveryError(
548− `Dynamic Client Registration failed: ${err.error}${
549− err.error_description ? ` — ${err.error_description}` : ""
522+ new OAuthDiscoveryError({
523+ message: `Dynamic Client Registration failed: ${err.error}${
524+ err.error_description ? ` - ${err.error_description}` : ""
550525 }`,
551− { status: err.status, cause: err },
552− ),
526+ status: err.status,
527+ cause: err,
528+ }),
553529 ),
554530 DcrTransport: (err) =>
555531 Effect.fail(
556− discoveryError(`Dynamic Client Registration failed: ${err.message}`, {
532+ new OAuthDiscoveryError({
533+ message: "Dynamic Client Registration failed",
557534 status: err.status,
558535 cause: err.cause ?? err,
559536 }),
@@ -649,29 +626,23 @@ export const beginDynamicAuthorization = ( · 89 unmodified lines
649626 );
650627
651628 if (!authServer) {
652− return yield* Effect.fail(
653− discoveryError(
654− `No OAuth authorization server metadata at ${authorizationServerUrl}`,
655− ),
656− );
629+ return yield* new OAuthDiscoveryError({
630+ message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
631+ });
657632 }
658633
659634 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
660635 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
661− return yield* Effect.fail(
662− discoveryError(
663− `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
664− ),
665− );
636+ return yield* new OAuthDiscoveryError({
637+ message: `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
638+ });
666639 }
667640
668641 const responseTypes = authServer.metadata.response_types_supported ?? [];
669642 if (responseTypes.length > 0 && !responseTypes.includes("code")) {
670− return yield* Effect.fail(
671− discoveryError(
672− `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
673− ),
674− );
643+ return yield* new OAuthDiscoveryError({
644+ message: `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
645+ });
675646 }
676647
677648 const baseClientMetadata: DynamicClientMetadata = {
@@ -689,9 +660,10 @@ export const beginDynamicAuthorization = ( · 11 unmodified lines
689660 const reg = authServer.metadata.registration_endpoint;
690661 if (!reg) {
691662 return Effect.fail(
692− discoveryError(
693− "Authorization server does not advertise registration_endpoint — cannot auto-register a client",
694− ),
663+ new OAuthDiscoveryError({
664+ message:
665+ "Authorization server does not advertise registration_endpoint - cannot auto-register a client",
666+ }),
695667 );
696668 }
697669 return registerDynamicClient(
packages/core/sdk/src/oauth-helpers.test.ts+8−6
@@ -365,7 +365,7 @@ describe("exchangeAuthorizationCode", () => { · 364 unmodified lines
365365 });
366366
367367 it("returns a typed OAuth2Error on transport failure", async () => {
368− globalThis.fetch = vi.fn().mockRejectedValue(new Error("boom")) as typeof fetch;
368+ globalThis.fetch = vi.fn().mockRejectedValue({ message: "boom" }) as typeof fetch;
369369 const exit = await Effect.runPromiseExit(
370370 exchangeAuthorizationCode({
371371 tokenUrl: "https://example.com/token",
@@ -380,7 +380,7 @@ describe("exchangeAuthorizationCode", () => { · 8 unmodified lines
380380 const err = exit.cause;
381381 const failure = JSON.stringify(err);
382382 expect(failure).toContain("OAuth2Error");
383− expect(failure).toContain("boom");
383+ expect(failure).toContain("OAuth token exchange failed");
384384 });
385385
386386 it("propagates RFC 6749 error_description text in the OAuth2Error", async () => {
@@ -563,7 +563,7 @@ describe("shouldRefreshToken", () => { · 176 unmodified lines
563563
564564 describe("OAuth2Error tagging", () => {
565565 beforeEach(() => {
566− globalThis.fetch = vi.fn().mockRejectedValue(new Error("network down")) as typeof fetch;
566+ globalThis.fetch = vi.fn().mockRejectedValue({ message: "network down" }) as typeof fetch;
567567 });
568568 afterEach(() => {
569569 globalThis.fetch = originalFetch;
@@ -585,8 +585,10 @@ describe("OAuth2Error tagging", () => { · 15 unmodified lines
585585
586586 it("OAuth2Error is constructable directly with message and cause", () => {
587587 const err = new OAuth2Error({ message: "test", cause: { foo: 1 } });
588− expect(err._tag).toBe("OAuth2Error");
589− expect(err.message).toBe("test");
590− expect(err.cause).toEqual({ foo: 1 });
588+ expect(err).toMatchObject({
589+ _tag: "OAuth2Error",
590+ message: "test",
591+ cause: { foo: 1 },
592+ });
591593 });
592594 });
packages/core/sdk/src/oauth-helpers.ts+16−19
@@ -134,7 +134,7 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 133 unmodified lines
134134 });
135135 }
136136 return new OAuth2Error({
137− message: `OAuth token exchange failed: ${String(cause)}`,
137+ message: "OAuth token exchange failed",
138138 cause,
139139 });
140140 };
@@ -173,20 +173,17 @@ const asFromTokenUrlAndIssuer = ( · 32 unmodified lines
173173 };
174174
175175 const isLoopbackHttpUrl = (value: string): boolean => {
176− try {
177− const url = new URL(value);
178− if (url.protocol !== "http:") return false;
179− const hostname = url.hostname.toLowerCase();
180− return (
181− hostname === "localhost" ||
182− hostname === "0.0.0.0" ||
183− hostname === "::1" ||
184− hostname === "[::1]" ||
185− hostname.startsWith("127.")
186− );
187− } catch {
188− return false;
189− }
176+ if (!URL.canParse(value)) return false;
177+ const url = new URL(value);
178+ if (url.protocol !== "http:") return false;
179+ const hostname = url.hostname.toLowerCase();
180+ return (
181+ hostname === "localhost" ||
182+ hostname === "0.0.0.0" ||
183+ hostname === "::1" ||
184+ hostname === "[::1]" ||
185+ hostname.startsWith("127.")
186+ );
190187 };
191188
192189 const oauth4webapiRequestOptions = (
@@ -230,10 +227,10 @@ const tokenResponseFrom = ( · 37 unmodified lines
230227 // its claims against the AS metadata and rejects mismatches we don't care
231228 // about. Strip the field before delegation.
232229 const stripIdToken = async (response: Response): Promise<Response> => {
233− const body = await response
234− .clone()
235− .json()
236− .catch(() => null);
230+ const body = await response.clone().json().then(
231+ (value: unknown) => value,
232+ () => null,
233+ );
237234 if (
238235 !body ||
239236 typeof body !== "object" ||