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 @@
1import { afterEach, describe, expect, it, vi } from "@effect/vitest";1import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2import { Cause, Effect, Exit } from "effect";2import { Cause, Effect, Exit, Schema } from "effect";
3 3
4import {4import {
5 OAuthDiscoveryError,5 OAuthDiscoveryError,
@@ -11,6 +11,14 @@ import { · 5 unmodified lines
11 11
12type Handler = (url: string, init: RequestInit) => Response | Promise<Response>;12type Handler = (url: string, init: RequestInit) => Response | Promise<Response>;
13 13
14const DcrRequestBody = Schema.Struct({
15 redirect_uris: Schema.Array(Schema.String),
16 token_endpoint_auth_method: Schema.String,
17});
18const decodeDcrRequestBody = Schema.decodeUnknownSync(
19 Schema.fromJsonString(DcrRequestBody),
20);
21
14const installFetchRouter = (22const installFetchRouter = (
15 handlers: readonly { match: (url: string) => boolean; handle: Handler }[],23 handlers: readonly { match: (url: string) => boolean; handle: Handler }[],
16): { calls: Array<{ url: string; init: RequestInit }> } => {24): { calls: Array<{ url: string; init: RequestInit }> } => {
@@ -196,7 +204,7 @@ describe("registerDynamicClient", () => { · 179 unmodified lines
196 204
197 const call = calls[0]!;205 const call = calls[0]!;
198 expect(call.init.method).toBe("POST");206 expect(call.init.method).toBe("POST");
199 const body = JSON.parse(call.init.body as string);207 const body = decodeDcrRequestBody(call.init.body);
200 expect(body.redirect_uris).toEqual(["https://app.example.com/cb"]);208 expect(body.redirect_uris).toEqual(["https://app.example.com/cb"]);
201 expect(body.token_endpoint_auth_method).toBe("none");209 expect(body.token_endpoint_auth_method).toBe("none");
202 });210 });
@@ -251,11 +259,12 @@ describe("registerDynamicClient", () => { · 48 unmodified lines
251 expect(Exit.isFailure(exit)).toBe(true);259 expect(Exit.isFailure(exit)).toBe(true);
252 if (!Exit.isFailure(exit)) return;260 if (!Exit.isFailure(exit)) return;
253 const reason = exit.cause.reasons.find(Cause.isFailReason);261 const reason = exit.cause.reasons.find(Cause.isFailReason);
254 if (!(reason?.error instanceof OAuthDiscoveryError)) {262 const error = reason?.error;
255 throw new Error("expected OAuthDiscoveryError");263 expect(error).toEqual(expect.objectContaining({
256 }264 _tag: "OAuthDiscoveryError",
257 expect(reason.error.status).toBe(400);265 status: 400,
258 expect(reason.error.message).toMatch(/invalid_client_metadata/);266 message: expect.stringMatching(/invalid_client_metadata/),
267 }));
259 });268 });
260});269});
261 270
packages/core/sdk/src/oauth-discovery.ts+63−91
@@ -18,7 +18,7 @@ · 17 unmodified lines
18// callers actually need.18// callers actually need.
19// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
20 20
21import { Data, Effect, Result, Schema } from "effect";21import { Data, Effect, Option, Result, Schema } from "effect";
22import * as oauth from "oauth4webapi";22import * as oauth from "oauth4webapi";
23 23
24import {24import {
@@ -45,22 +45,15 @@ export class OAuthDiscoveryError extends Data.TaggedError( · 20 unmodified lines
45 readonly cause?: unknown;45 readonly cause?: unknown;
46}> {}46}> {}
47 47
48const 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
58// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
59// Schemas (narrow structural parsing — the RFCs leave many fields49// Schemas (narrow structural parsing — the RFCs leave many fields
60// optional; we validate only the subset consumers read)50// optional; we validate only the subset consumers read)
61// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
62 52
63const StringArray = Schema.Array(Schema.String);53const StringArray = Schema.Array(Schema.String);
54const JsonUnknownFromString = Schema.fromJsonString(Schema.Unknown);
55const decodeJsonUnknownSync = Schema.decodeUnknownSync(JsonUnknownFromString);
56const decodeJsonUnknownOption = Schema.decodeUnknownOption(JsonUnknownFromString);
64 57
65export const OAuthProtectedResourceMetadataSchema = Schema.Struct({58export const OAuthProtectedResourceMetadataSchema = Schema.Struct({
66 resource: Schema.optional(Schema.String),59 resource: Schema.optional(Schema.String),
@@ -155,20 +148,17 @@ export interface DiscoveryRequestOptions { · 88 unmodified lines
155const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";148const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
156 149
157const isLoopbackHttpUrl = (value: string): boolean => {150const isLoopbackHttpUrl = (value: string): boolean => {
158 try {151 if (!URL.canParse(value)) return false;
159 const url = new URL(value);152 const url = new URL(value);
160 if (url.protocol !== "http:") return false;153 if (url.protocol !== "http:") return false;
161 const hostname = url.hostname.toLowerCase();154 const hostname = url.hostname.toLowerCase();
162 return (155 return (
163 hostname === "localhost" ||156 hostname === "localhost" ||
164 hostname === "0.0.0.0" ||157 hostname === "0.0.0.0" ||
165 hostname === "::1" ||158 hostname === "::1" ||
166 hostname === "[::1]" ||159 hostname === "[::1]" ||
167 hostname.startsWith("127.")160 hostname.startsWith("127.")
168 );161 );
169 } catch {
170 return false;
171 }
172};162};
173 163
174const oauth4webapiOptions = (164const oauth4webapiOptions = (
@@ -260,30 +250,26 @@ export const discoverProtectedResourceMetadata = ( · 85 unmodified lines
260 }250 }
261 const text = await response.text();251 const text = await response.text();
262 if (text.length === 0) return "skip" as const;252 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;
264 },254 },
265 catch: (cause) =>255 catch: (cause) =>
266 discoveryError(256 new OAuthDiscoveryError({
267 `Failed to fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}`,257 message: `Failed to fetch protected resource metadata at ${url}`,
268 { cause },258 cause,
269 ),259 }),
270 });260 });
271 if (result === "skip") continue;261 if (result === "skip") continue;
272 if (!("body" in result)) {262 if (!("body" in result)) {
273 return yield* Effect.fail(263 return yield* new OAuthDiscoveryError({
274 discoveryError(264 message: `Protected resource metadata returned status ${result.status}`,
275 `Protected resource metadata returned status ${result.status}`,265 status: result.status,
276 { status: result.status },266 });
277 ),
278 );
279 }267 }
280 const metadata = yield* decodeResourceMetadata(result.body).pipe(268 const metadata = yield* decodeResourceMetadata(result.body).pipe(
281 Effect.mapError(269 Effect.mapError(
282 (err) =>270 (err) =>
283 new OAuthDiscoveryError({271 new OAuthDiscoveryError({
284 message: `Protected resource metadata is malformed: ${272 message: "Protected resource metadata is malformed",
285 Schema.isSchemaError(err) ? err.message : String(err)
286 }`,
287 cause: err,273 cause: err,
288 }),274 }),
289 ),275 ),
@@ -348,15 +334,11 @@ export const discoverAuthorizationServerMetadata = ( · 58 unmodified lines
348 raw: as,334 raw: as,
349 };335 };
350 },336 },
351 catch: (cause) => {337 catch: (cause) =>
352 if (cause instanceof OAuthDiscoveryError) return cause;338 new OAuthDiscoveryError({
353 return discoveryError(339 message: `Discovery (${algorithm}) failed for ${issuer}`,
354 `Discovery (${algorithm}) failed for ${issuer}: ${340 cause,
355 cause instanceof Error ? cause.message : String(cause)341 }),
356 }`,
357 { cause },
358 );
359 },
360 }).pipe(342 }).pipe(
361 // If one algorithm fails mid-roundtrip (network, parse, issuer343 // If one algorithm fails mid-roundtrip (network, parse, issuer
362 // mismatch) we still want to try the other before giving up.344 // mismatch) we still want to try the other before giving up.
@@ -370,9 +352,7 @@ export const discoverAuthorizationServerMetadata = ( · 7 unmodified lines
370 Effect.mapError(352 Effect.mapError(
371 (err) =>353 (err) =>
372 new OAuthDiscoveryError({354 new OAuthDiscoveryError({
373 message: `Authorization server metadata is malformed: ${355 message: "Authorization server metadata is malformed",
374 Schema.isSchemaError(err) ? err.message : String(err)
375 }`,
376 cause: err,356 cause: err,
377 }),357 }),
378 ),358 ),
@@ -437,11 +417,7 @@ const interpretDcrFailure = ( · 58 unmodified lines
437): DcrErrorBody | DcrTransport => {417): DcrErrorBody | DcrTransport => {
438 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.418 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.
439 if (status >= 400 && status < 500) {419 if (status >= 400 && status < 500) {
440 const parsed = Result.try({420 const body = text ? Option.getOrNull(decodeJsonUnknownOption(text)) : null;
441 try: () => (text ? (JSON.parse(text) as unknown) : null),
442 catch: () => null,
443 });
444 const body = Result.isSuccess(parsed) ? parsed.success : null;
445 if (421 if (
446 body &&422 body &&
447 typeof body === "object" &&423 typeof body === "object" &&
@@ -497,7 +473,7 @@ export const registerDynamicClient = ( · 49 unmodified lines
497 }),473 }),
498 catch: (cause) =>474 catch: (cause) =>
499 new DcrTransport({475 new DcrTransport({
500 message: `Dynamic Client Registration request failed: ${cause instanceof Error ? cause.message : String(cause)}`,476 message: "Dynamic Client Registration request failed",
501 cause,477 cause,
502 }),478 }),
503 });479 });
@@ -505,9 +481,10 @@ export const registerDynamicClient = ( · 1 unmodified line
505 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but481 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but
506 // Todoist (and others) return 200 OK with the client information body.482 // Todoist (and others) return 200 OK with the client information body.
507 if (response.status !== 200 && response.status !== 201) {483 if (response.status !== 200 && response.status !== 201) {
508 const text = yield* Effect.promise(() =>484 const text = yield* Effect.tryPromise({
509 response.text().catch(() => ""),485 try: () => response.text(),
510 );486 catch: () => "",
487 });
511 return yield* interpretDcrFailure(response.status, text);488 return yield* interpretDcrFailure(response.status, text);
512 }489 }
513 490
@@ -520,22 +497,20 @@ export const registerDynamicClient = ( · 6 unmodified lines
520 cause,497 cause,
521 }),498 }),
522 });499 });
523 const json = yield* Effect.try({500 const json = yield* Schema.decodeUnknownEffect(JsonUnknownFromString)(text).pipe(
524 try: () => JSON.parse(text) as unknown,501 Effect.mapError((cause) =>
525 catch: (cause) =>
526 new DcrTransport({502 new DcrTransport({
527 message: "Dynamic Client Registration response was not valid JSON",503 message: "Dynamic Client Registration response was not valid JSON",
528 status: response.status,504 status: response.status,
529 cause,505 cause,
530 }),506 }),
531 });507 ),
508 );
532 return yield* decodeClientInformation(json).pipe(509 return yield* decodeClientInformation(json).pipe(
533 Effect.mapError(510 Effect.mapError(
534 (err) =>511 (err) =>
535 new OAuthDiscoveryError({512 new OAuthDiscoveryError({
536 message: `Dynamic Client Registration response is malformed: ${513 message: "Dynamic Client Registration response is malformed",
537 Schema.isSchemaError(err) ? err.message : String(err)
538 }`,
539 cause: err,514 cause: err,
540 }),515 }),
541 ),516 ),
@@ -544,16 +519,18 @@ export const registerDynamicClient = ( · 2 unmodified lines
544 Effect.catchTags({519 Effect.catchTags({
545 DcrErrorBody: (err) =>520 DcrErrorBody: (err) =>
546 Effect.fail(521 Effect.fail(
547 discoveryError(522 new OAuthDiscoveryError({
548 `Dynamic Client Registration failed: ${err.error}${523 message: `Dynamic Client Registration failed: ${err.error}${
549 err.error_description ? ` — ${err.error_description}` : ""524 err.error_description ? ` - ${err.error_description}` : ""
550 }`,525 }`,
551 { status: err.status, cause: err },526 status: err.status,
552 ),527 cause: err,
528 }),
553 ),529 ),
554 DcrTransport: (err) =>530 DcrTransport: (err) =>
555 Effect.fail(531 Effect.fail(
556 discoveryError(`Dynamic Client Registration failed: ${err.message}`, {532 new OAuthDiscoveryError({
533 message: "Dynamic Client Registration failed",
557 status: err.status,534 status: err.status,
558 cause: err.cause ?? err,535 cause: err.cause ?? err,
559 }),536 }),
@@ -649,29 +626,23 @@ export const beginDynamicAuthorization = ( · 89 unmodified lines
649 );626 );
650 627
651 if (!authServer) {628 if (!authServer) {
652 return yield* Effect.fail(629 return yield* new OAuthDiscoveryError({
653 discoveryError(630 message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
654 `No OAuth authorization server metadata at ${authorizationServerUrl}`,631 });
655 ),
656 );
657 }632 }
658 633
659 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];634 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
660 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {635 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
661 return yield* Effect.fail(636 return yield* new OAuthDiscoveryError({
662 discoveryError(637 message: `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
663 `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,638 });
664 ),
665 );
666 }639 }
667 640
668 const responseTypes = authServer.metadata.response_types_supported ?? [];641 const responseTypes = authServer.metadata.response_types_supported ?? [];
669 if (responseTypes.length > 0 && !responseTypes.includes("code")) {642 if (responseTypes.length > 0 && !responseTypes.includes("code")) {
670 return yield* Effect.fail(643 return yield* new OAuthDiscoveryError({
671 discoveryError(644 message: `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
672 `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,645 });
673 ),
674 );
675 }646 }
676 647
677 const baseClientMetadata: DynamicClientMetadata = {648 const baseClientMetadata: DynamicClientMetadata = {
@@ -689,9 +660,10 @@ export const beginDynamicAuthorization = ( · 11 unmodified lines
689 const reg = authServer.metadata.registration_endpoint;660 const reg = authServer.metadata.registration_endpoint;
690 if (!reg) {661 if (!reg) {
691 return Effect.fail(662 return Effect.fail(
692 discoveryError(663 new OAuthDiscoveryError({
693 "Authorization server does not advertise registration_endpoint — cannot auto-register a client",664 message:
694 ),665 "Authorization server does not advertise registration_endpoint - cannot auto-register a client",
666 }),
695 );667 );
696 }668 }
697 return registerDynamicClient(669 return registerDynamicClient(
packages/core/sdk/src/oauth-helpers.test.ts+8−6
@@ -365,7 +365,7 @@ describe("exchangeAuthorizationCode", () => { · 364 unmodified lines
365 });365 });
366 366
367 it("returns a typed OAuth2Error on transport failure", async () => {367 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;
369 const exit = await Effect.runPromiseExit(369 const exit = await Effect.runPromiseExit(
370 exchangeAuthorizationCode({370 exchangeAuthorizationCode({
371 tokenUrl: "https://example.com/token",371 tokenUrl: "https://example.com/token",
@@ -380,7 +380,7 @@ describe("exchangeAuthorizationCode", () => { · 8 unmodified lines
380 const err = exit.cause;380 const err = exit.cause;
381 const failure = JSON.stringify(err);381 const failure = JSON.stringify(err);
382 expect(failure).toContain("OAuth2Error");382 expect(failure).toContain("OAuth2Error");
383 expect(failure).toContain("boom");383 expect(failure).toContain("OAuth token exchange failed");
384 });384 });
385 385
386 it("propagates RFC 6749 error_description text in the OAuth2Error", async () => {386 it("propagates RFC 6749 error_description text in the OAuth2Error", async () => {
@@ -563,7 +563,7 @@ describe("shouldRefreshToken", () => { · 176 unmodified lines
563 563
564describe("OAuth2Error tagging", () => {564describe("OAuth2Error tagging", () => {
565 beforeEach(() => {565 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;
567 });567 });
568 afterEach(() => {568 afterEach(() => {
569 globalThis.fetch = originalFetch;569 globalThis.fetch = originalFetch;
@@ -585,8 +585,10 @@ describe("OAuth2Error tagging", () => { · 15 unmodified lines
585 585
586 it("OAuth2Error is constructable directly with message and cause", () => {586 it("OAuth2Error is constructable directly with message and cause", () => {
587 const err = new OAuth2Error({ message: "test", cause: { foo: 1 } });587 const err = new OAuth2Error({ message: "test", cause: { foo: 1 } });
588 expect(err._tag).toBe("OAuth2Error");588 expect(err).toMatchObject({
589 expect(err.message).toBe("test");589 _tag: "OAuth2Error",
590 expect(err.cause).toEqual({ foo: 1 });590 message: "test",
591 cause: { foo: 1 },
592 });
591 });593 });
592});594});
packages/core/sdk/src/oauth-helpers.ts+16−19
@@ -134,7 +134,7 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 133 unmodified lines
134 });134 });
135 }135 }
136 return new OAuth2Error({136 return new OAuth2Error({
137 message: `OAuth token exchange failed: ${String(cause)}`,137 message: "OAuth token exchange failed",
138 cause,138 cause,
139 });139 });
140};140};
@@ -173,20 +173,17 @@ const asFromTokenUrlAndIssuer = ( · 32 unmodified lines
173};173};
174 174
175const isLoopbackHttpUrl = (value: string): boolean => {175const isLoopbackHttpUrl = (value: string): boolean => {
176 try {176 if (!URL.canParse(value)) return false;
177 const url = new URL(value);177 const url = new URL(value);
178 if (url.protocol !== "http:") return false;178 if (url.protocol !== "http:") return false;
179 const hostname = url.hostname.toLowerCase();179 const hostname = url.hostname.toLowerCase();
180 return (180 return (
181 hostname === "localhost" ||181 hostname === "localhost" ||
182 hostname === "0.0.0.0" ||182 hostname === "0.0.0.0" ||
183 hostname === "::1" ||183 hostname === "::1" ||
184 hostname === "[::1]" ||184 hostname === "[::1]" ||
185 hostname.startsWith("127.")185 hostname.startsWith("127.")
186 );186 );
187 } catch {
188 return false;
189 }
190};187};
191 188
192const oauth4webapiRequestOptions = (189const oauth4webapiRequestOptions = (
@@ -230,10 +227,10 @@ const tokenResponseFrom = ( · 37 unmodified lines
230// its claims against the AS metadata and rejects mismatches we don't care227// its claims against the AS metadata and rejects mismatches we don't care
231// about. Strip the field before delegation.228// about. Strip the field before delegation.
232const stripIdToken = async (response: Response): Promise<Response> => {229const stripIdToken = async (response: Response): Promise<Response> => {
233 const body = await response230 const body = await response.clone().json().then(
234 .clone()231 (value: unknown) => value,
235 .json()232 () => null,
236 .catch(() => null);233 );
237 if (234 if (
238 !body ||235 !body ||
239 typeof body !== "object" ||236 typeof body !== "object" ||