| 422 | }); |
| 423 | |
| 424 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| 425 | const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; |
| 426 | const fetch = deps.fetch; |
| 427 | // EXPLICIT — no localhost default. `null` means this executor has no OAuth |
| 428 | // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. |
| 429 | const redirectUri = deps.redirectUri; |
| 430 | const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy }; |
| 431 | |
| 432 | const filterAuthorizationCodeScopes = ( |
| 433 | client: LoadedOAuthClient, |
| 434 | requestedScopes: readonly string[], |
| 435 | ): Effect.Effect<readonly string[], never> => |
| 436 | Effect.gen(function* () { |
| 437 | if (requestedScopes.length === 0) return requestedScopes; |
| 438 | const resource = client.resource |
| 439 | ? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe( |
| 440 | Effect.catch(() => Effect.succeed(null)), |
| 441 | Effect.provide(httpClientLayer), |
| 442 | ) |
| 443 | : null; |
| 444 | const issuer = |
| 445 | resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin; |
| 446 | const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe( |
| 447 | Effect.catch(() => Effect.succeed(null)), |
| 448 | Effect.provide(httpClientLayer), |
| 449 | ); |
| 450 | return intersectScopes(requestedScopes, as?.metadata.scopes_supported); |
| 451 | }).pipe(Effect.catch(() => Effect.succeed(requestedScopes))); |
| 452 | |
| 453 | // Caps on server-controlled discovery input — a hostile or buggy server must |
| 454 | // not be able to hang `oauth.start` or overflow the authorize URL. |
| 455 | const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice |
| 456 | const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template |
| 457 | const capScopes = (scopes: readonly string[]): readonly string[] => |
| 458 | dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); |
| 459 | |
| 460 | // Discover the scopes to request when the integration declares none — only |
| 461 | // reached for integrations that opt in (MCP-style). The resource's own RFC |
| 462 | // 9728 `scopes_supported` is authoritative when present, even when empty (§2 |
| 463 | // defines the field; §7.2 cautions against requesting more than it lists). |
| 464 | // Only when the resource is SILENT do we read the scopes advertised by the |
| 465 | // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs. |
| 466 | const discoverScopesForResource = ( |
| 467 | resource: string | null, |
| 468 | ): Effect.Effect<readonly string[], OAuthDiscoveryError> => |
| 469 | Effect.gen(function* () { |
| 470 | if (resource == null) { |
| 471 | return yield* new OAuthDiscoveryError({ |
| 472 | message: "Cannot discover OAuth scopes: the client has no resource configured", |
| 473 | }); |
| 474 | } |
| 475 | // `httpClientLayer` flows through `options` so discovery uses the host's |
| 476 | // configured client (discovery self-provides from `options.httpClientLayer`). |
| 477 | const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; |
| 478 | |
| 479 | const protectedResource = yield* discoverProtectedResourceMetadata( |
| 480 | resource, |
| 481 | discoveryOptions, |