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