| @@ -18,7 +18,7 @@ · 17 unmodified lines |
| 18 | 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 | 22 | import * as oauth from "oauth4webapi"; |
| 23 | 23 | |
| 24 | 24 | import { |
| @@ -45,22 +45,15 @@ export class OAuthDiscoveryError extends Data.TaggedError( · 20 unmodified lines |
| 45 | 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 | 49 | // Schemas (narrow structural parsing — the RFCs leave many fields |
| 60 | 50 | // optional; we validate only the subset consumers read) |
| 61 | 51 | // --------------------------------------------------------------------------- |
| 62 | 52 | |
| 63 | 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 | 58 | export const OAuthProtectedResourceMetadataSchema = Schema.Struct({ |
| 66 | 59 | resource: Schema.optional(Schema.String), |
| @@ -155,20 +148,17 @@ export interface DiscoveryRequestOptions { · 88 unmodified lines |
| 155 | 148 | const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; |
| 156 | 149 | |
| 157 | 150 | 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 | + ); |
| 172 | 162 | }; |
| 173 | 163 | |
| 174 | 164 | const oauth4webapiOptions = ( |
| @@ -260,30 +250,26 @@ export const discoverProtectedResourceMetadata = ( · 85 unmodified lines |
| 260 | 250 | } |
| 261 | 251 | const text = await response.text(); |
| 262 | 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 | 255 | 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 | + }), |
| 270 | 260 | }); |
| 271 | 261 | if (result === "skip") continue; |
| 272 | 262 | 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 | + }); |
| 279 | 267 | } |
| 280 | 268 | const metadata = yield* decodeResourceMetadata(result.body).pipe( |
| 281 | 269 | Effect.mapError( |
| 282 | 270 | (err) => |
| 283 | 271 | 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", |
| 287 | 273 | cause: err, |
| 288 | 274 | }), |
| 289 | 275 | ), |
| @@ -348,15 +334,11 @@ export const discoverAuthorizationServerMetadata = ( · 58 unmodified lines |
| 348 | 334 | raw: as, |
| 349 | 335 | }; |
| 350 | 336 | }, |
| 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 | + }), |
| 360 | 342 | }).pipe( |
| 361 | 343 | // If one algorithm fails mid-roundtrip (network, parse, issuer |
| 362 | 344 | // mismatch) we still want to try the other before giving up. |
| @@ -370,9 +352,7 @@ export const discoverAuthorizationServerMetadata = ( · 7 unmodified lines |
| 370 | 352 | Effect.mapError( |
| 371 | 353 | (err) => |
| 372 | 354 | 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", |
| 376 | 356 | cause: err, |
| 377 | 357 | }), |
| 378 | 358 | ), |
| @@ -437,11 +417,7 @@ const interpretDcrFailure = ( · 58 unmodified lines |
| 437 | 417 | ): DcrErrorBody | DcrTransport => { |
| 438 | 418 | // RFC 6749 error envelope: `{error, error_description?}` with 4xx. |
| 439 | 419 | 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; |
| 445 | 421 | if ( |
| 446 | 422 | body && |
| 447 | 423 | typeof body === "object" && |
| @@ -497,7 +473,7 @@ export const registerDynamicClient = ( · 49 unmodified lines |
| 497 | 473 | }), |
| 498 | 474 | catch: (cause) => |
| 499 | 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 | 477 | cause, |
| 502 | 478 | }), |
| 503 | 479 | }); |
| @@ -505,9 +481,10 @@ export const registerDynamicClient = ( · 1 unmodified line |
| 505 | 481 | // Accept both 200 and 201 as success — RFC 7591 mandates 201, but |
| 506 | 482 | // Todoist (and others) return 200 OK with the client information body. |
| 507 | 483 | 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 | + }); |
| 511 | 488 | return yield* interpretDcrFailure(response.status, text); |
| 512 | 489 | } |
| 513 | 490 | |
| @@ -520,22 +497,20 @@ export const registerDynamicClient = ( · 6 unmodified lines |
| 520 | 497 | cause, |
| 521 | 498 | }), |
| 522 | 499 | }); |
| 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) => |
| 526 | 502 | new DcrTransport({ |
| 527 | 503 | message: "Dynamic Client Registration response was not valid JSON", |
| 528 | 504 | status: response.status, |
| 529 | 505 | cause, |
| 530 | 506 | }), |
| 531 | | − }); |
| 507 | + ), |
| 508 | + ); |
| 532 | 509 | return yield* decodeClientInformation(json).pipe( |
| 533 | 510 | Effect.mapError( |
| 534 | 511 | (err) => |
| 535 | 512 | 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", |
| 539 | 514 | cause: err, |
| 540 | 515 | }), |
| 541 | 516 | ), |
| @@ -544,16 +519,18 @@ export const registerDynamicClient = ( · 2 unmodified lines |
| 544 | 519 | Effect.catchTags({ |
| 545 | 520 | DcrErrorBody: (err) => |
| 546 | 521 | 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}` : "" |
| 550 | 525 | }`, |
| 551 | | − { status: err.status, cause: err }, |
| 552 | | − ), |
| 526 | + status: err.status, |
| 527 | + cause: err, |
| 528 | + }), |
| 553 | 529 | ), |
| 554 | 530 | DcrTransport: (err) => |
| 555 | 531 | Effect.fail( |
| 556 | | − discoveryError(`Dynamic Client Registration failed: ${err.message}`, { |
| 532 | + new OAuthDiscoveryError({ |
| 533 | + message: "Dynamic Client Registration failed", |
| 557 | 534 | status: err.status, |
| 558 | 535 | cause: err.cause ?? err, |
| 559 | 536 | }), |
| @@ -649,29 +626,23 @@ export const beginDynamicAuthorization = ( · 89 unmodified lines |
| 649 | 626 | ); |
| 650 | 627 | |
| 651 | 628 | 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 | + }); |
| 657 | 632 | } |
| 658 | 633 | |
| 659 | 634 | const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? []; |
| 660 | 635 | 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 | + }); |
| 666 | 639 | } |
| 667 | 640 | |
| 668 | 641 | const responseTypes = authServer.metadata.response_types_supported ?? []; |
| 669 | 642 | 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 | + }); |
| 675 | 646 | } |
| 676 | 647 | |
| 677 | 648 | const baseClientMetadata: DynamicClientMetadata = { |
| @@ -689,9 +660,10 @@ export const beginDynamicAuthorization = ( · 11 unmodified lines |
| 689 | 660 | const reg = authServer.metadata.registration_endpoint; |
| 690 | 661 | if (!reg) { |
| 691 | 662 | 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 | + }), |
| 695 | 667 | ); |
| 696 | 668 | } |
| 697 | 669 | return registerDynamicClient( |