| @@ -18,7 +18,7 @@ · 17 unmodified lines |
| 18 | // callers actually need. | 18 | // callers actually need. |
| 19 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 20 | | 20 | |
| 21 | import { Data, Effect, Result, Schema } from "effect"; | 21 | import { Data, Effect, Option, Result, Schema } from "effect"; |
| 22 | import * as oauth from "oauth4webapi"; | 22 | import * as oauth from "oauth4webapi"; |
| 23 | | 23 | |
| 24 | import { | 24 | import { |
| @@ -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 | |
| 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 | | | |
| 58 | // --------------------------------------------------------------------------- | 48 | // --------------------------------------------------------------------------- |
| 59 | // Schemas (narrow structural parsing — the RFCs leave many fields | 49 | // 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 | |
| 63 | const StringArray = Schema.Array(Schema.String); | 53 | 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); |
| 64 | | 57 | |
| 65 | export const OAuthProtectedResourceMetadataSchema = Schema.Struct({ | 58 | export 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 |
| 155 | const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; | 148 | const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; |
| 156 | | 149 | |
| 157 | const isLoopbackHttpUrl = (value: string): boolean => { | 150 | const 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 | |
| 174 | const oauth4webapiOptions = ( | 164 | const 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, issuer | 343 | // 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, but | 481 | // 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( |