| 490 | }); |
| 491 | |
| 492 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| 493 | const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; |
| 494 | const fetch = deps.fetch; |
| 495 | // EXPLICIT — no localhost default. `null` means this executor has no OAuth |
| 496 | // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. |
| 497 | const redirectUri = deps.redirectUri; |
| 498 | const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy }; |
| 499 | |
| 500 | const filterAuthorizationCodeScopes = ( |
| 501 | client: LoadedOAuthClient, |
| 502 | requestedScopes: readonly string[], |
| 503 | ): Effect.Effect<readonly string[], never> => |
| 504 | Effect.gen(function* () { |
| 505 | if (requestedScopes.length === 0) return requestedScopes; |
| 506 | const resource = client.resource |
| 507 | ? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe( |
| 508 | Effect.catch(() => Effect.succeed(null)), |
| 509 | Effect.provide(httpClientLayer), |
| 510 | ) |
| 511 | : null; |
| 512 | const issuer = |
| 513 | resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin; |
| 514 | const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe( |
| 515 | Effect.catch(() => Effect.succeed(null)), |
| 516 | Effect.provide(httpClientLayer), |
| 517 | ); |
| 518 | if (!as || !oauthMetadataMatchesClient(client, as.metadata)) return requestedScopes; |
| 519 | return intersectScopes(requestedScopes, as.metadata.scopes_supported); |
| 520 | }).pipe(Effect.catch(() => Effect.succeed(requestedScopes))); |
| 521 | |
| 522 | // Caps on server-controlled discovery input — a hostile or buggy server must |
| 523 | // not be able to hang `oauth.start` or overflow the authorize URL. |
| 524 | const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice |
| 525 | const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template |
| 526 | const capScopes = (scopes: readonly string[]): readonly string[] => |
| 527 | dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); |
| 528 | |
| 529 | // Discover the scopes to request when the integration declares none — only |
| 530 | // reached for integrations that opt in (MCP-style). The resource's own RFC |
| 531 | // 9728 `scopes_supported` is authoritative when present, even when empty (§2 |
| 532 | // defines the field; §7.2 cautions against requesting more than it lists). |
| 533 | // Only when the resource is SILENT do we read the scopes advertised by the |
| 534 | // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs. |
| 535 | const discoverScopesForResource = ( |
| 536 | resource: string | null, |
| 537 | ): Effect.Effect<readonly string[], OAuthDiscoveryError> => |
| 538 | Effect.gen(function* () { |
| 539 | if (resource == null) { |
| 540 | return yield* new OAuthDiscoveryError({ |
| 541 | message: "Cannot discover OAuth scopes: the client has no resource configured", |
| 542 | }); |
| 543 | } |
| 544 | // `httpClientLayer` flows through `options` so discovery uses the host's |
| 545 | // configured client (discovery self-provides from `options.httpClientLayer`). |
| 546 | const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; |
| 547 | |
| 548 | const protectedResource = yield* discoverProtectedResourceMetadata( |
| 549 | resource, |