Validate OAuth endpoint URLs
Unified · Split
| @@ -195,6 +195,7 @@ const createLocalExecutorLayer = () => { · 194 unmodified lines | |||
| 195 | blobs, | 195 | blobs, |
| 196 | plugins, | 196 | plugins, |
| 197 | onElicitation: "accept-all", | 197 | onElicitation: "accept-all", |
| 198 | oauthEndpointUrlPolicy: { allowHttp: true }, | ||
| 198 | }); | 199 | }); |
| 199 | 200 | ||
| 200 | // Sync sources from executor.jsonc (idempotent — plugins upsert). | 201 | // Sync sources from executor.jsonc (idempotent — plugins upsert). |
| @@ -259,6 +259,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 258 unmodified lines | |||
| 259 | blobs, | 259 | blobs, |
| 260 | plugins, | 260 | plugins, |
| 261 | onElicitation: "accept-all", | 261 | onElicitation: "accept-all", |
| 262 | oauthEndpointUrlPolicy: { allowHttp: true }, | ||
| 262 | }), | 263 | }), |
| 263 | ); | 264 | ); |
| 264 | 265 | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect"; | 1 | import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect"; |
| 2 | import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; | 2 | import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; |
| 3 | import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; | ||
| 3 | import { generateKeyBetween } from "fractional-indexing"; | 4 | import { generateKeyBetween } from "fractional-indexing"; |
| 4 | import { | 5 | import { |
| 5 | StorageError, | 6 | StorageError, |
| @@ -343,6 +344,7 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 337 unmodified lines | |||
| 343 | */ | 344 | */ |
| 344 | readonly onElicitation: OnElicitation; | 345 | readonly onElicitation: OnElicitation; |
| 345 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | 346 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 347 | readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 346 | } | 348 | } |
| 347 | 349 | ||
| 348 | // --------------------------------------------------------------------------- | 350 | // --------------------------------------------------------------------------- |
| @@ -2517,6 +2519,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2168 unmodified lines | |||
| 2517 | secretsSet: (input) => secretsSet(input), | 2519 | secretsSet: (input) => secretsSet(input), |
| 2518 | connectionsCreate: (input) => connectionsCreate(input), | 2520 | connectionsCreate: (input) => connectionsCreate(input), |
| 2519 | httpClientLayer: config.httpClientLayer, | 2521 | httpClientLayer: config.httpClientLayer, |
| 2522 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, | ||
| 2520 | }); | 2523 | }); |
| 2521 | connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider); | 2524 | connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider); |
| 2522 | 2525 | ||
| @@ -215,11 +215,13 @@ export { · 214 unmodified lines | |||
| 215 | OAuth2Error, | 215 | OAuth2Error, |
| 216 | OAUTH2_DEFAULT_TIMEOUT_MS, | 216 | OAUTH2_DEFAULT_TIMEOUT_MS, |
| 217 | OAUTH2_REFRESH_SKEW_MS, | 217 | OAUTH2_REFRESH_SKEW_MS, |
| 218 | assertSupportedOAuthEndpointUrl, | ||
| 218 | buildAuthorizationUrl, | 219 | buildAuthorizationUrl, |
| 219 | createPkceCodeChallenge, | 220 | createPkceCodeChallenge, |
| 220 | createPkceCodeVerifier, | 221 | createPkceCodeVerifier, |
| 221 | exchangeAuthorizationCode, | 222 | exchangeAuthorizationCode, |
| 222 | exchangeClientCredentials, | 223 | exchangeClientCredentials, |
| 224 | isSupportedOAuthEndpointUrl, | ||
| 223 | refreshAccessToken, | 225 | refreshAccessToken, |
| 224 | shouldRefreshToken, | 226 | shouldRefreshToken, |
| 225 | type OAuth2TokenResponse, | 227 | type OAuth2TokenResponse, |
| @@ -180,6 +180,52 @@ describe("discoverAuthorizationServerMetadata", () => { · 179 unmodified lines | |||
| 180 | }), | 180 | }), |
| 181 | ), | 181 | ), |
| 182 | ); | 182 | ); |
| 183 | |||
| 184 | it.effect("rejects unsupported authorization server endpoint URLs", () => | ||
| 185 | withOAuthFixture( | ||
| 186 | () => | ||
| 187 | sendJson({ | ||
| 188 | issuer: "http://example.com", | ||
| 189 | authorization_endpoint: "javascript:alert(1)", | ||
| 190 | token_endpoint: "http://example.com/token", | ||
| 191 | response_types_supported: ["code"], | ||
| 192 | }), | ||
| 193 | ({ baseUrl }) => | ||
| 194 | Effect.gen(function* () { | ||
| 195 | const exit = yield* Effect.exit(discoverAuthorizationServerMetadata(baseUrl)); | ||
| 196 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 197 | if (!Exit.isFailure(exit)) return; | ||
| 198 | const reason = exit.cause.reasons.find(Cause.isFailReason); | ||
| 199 | expect(reason?.error).toEqual( | ||
| 200 | expect.objectContaining({ | ||
| 201 | _tag: "OAuthDiscoveryError", | ||
| 202 | message: expect.stringMatching(/must use https: or loopback http:/), | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | }), | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("allows HTTP authorization server endpoints when the caller opts into local HTTP", () => | ||
| 210 | withOAuthFixture( | ||
| 211 | () => | ||
| 212 | sendJson({ | ||
| 213 | issuer: "http://example.com", | ||
| 214 | authorization_endpoint: "http://example.com/authorize", | ||
| 215 | token_endpoint: "http://example.com/token", | ||
| 216 | response_types_supported: ["code"], | ||
| 217 | }), | ||
| 218 | ({ baseUrl }) => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const result = yield* discoverAuthorizationServerMetadata(baseUrl, { | ||
| 221 | endpointUrlPolicy: { allowHttp: true }, | ||
| 222 | }); | ||
| 223 | expect(result).not.toBeNull(); | ||
| 224 | expect(result!.metadata.authorization_endpoint).toBe("http://example.com/authorize"); | ||
| 225 | expect(result!.metadata.token_endpoint).toBe("http://example.com/token"); | ||
| 226 | }), | ||
| 227 | ), | ||
| 228 | ); | ||
| 183 | }); | 229 | }); |
| 184 | 230 | ||
| 185 | describe("registerDynamicClient", () => { | 231 | describe("registerDynamicClient", () => { |
| @@ -480,7 +526,7 @@ describe("beginDynamicAuthorization", () => { · 294 unmodified lines | |||
| 480 | ), | 526 | ), |
| 481 | ); | 527 | ); |
| 482 | 528 | ||
| 483 | it.effect("includes RFC 8707 resource parameter on the authorization URL", () => | 529 | it.effect("rejects protected-resource metadata for a different resource", () => |
| 484 | withOAuthFixture( | 530 | withOAuthFixture( |
| 485 | (request, baseUrl) => { | 531 | (request, baseUrl) => { |
| 486 | if (request.url === "/.well-known/oauth-protected-resource/v1/mcp") { | 532 | if (request.url === "/.well-known/oauth-protected-resource/v1/mcp") { |
| @@ -513,15 +559,22 @@ describe("beginDynamicAuthorization", () => { · 26 unmodified lines | |||
| 513 | }, | 559 | }, |
| 514 | ({ baseUrl }) => | 560 | ({ baseUrl }) => |
| 515 | Effect.gen(function* () { | 561 | Effect.gen(function* () { |
| 516 | const result = yield* beginDynamicAuthorization({ | 562 | const exit = yield* Effect.exit( |
| 517 | endpoint: `${baseUrl}/v1/mcp`, | 563 | beginDynamicAuthorization({ |
| 518 | redirectUrl: "https://app/cb", | 564 | endpoint: `${baseUrl}/v1/mcp`, |
| 519 | state: "s", | 565 | redirectUrl: "https://app/cb", |
| 520 | }); | 566 | state: "s", |
| 521 | 567 | }), | |
| 522 | const authUrl = new URL(result.authorizationUrl); | 568 | ); |
| 523 | expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/canonical-id`); | 569 | expect(Exit.isFailure(exit)).toBe(true); |
| 524 | expect(result.state.resource).toBe(`${baseUrl}/canonical-id`); | 570 | if (!Exit.isFailure(exit)) return; |
| 571 | const reason = exit.cause.reasons.find(Cause.isFailReason); | ||
| 572 | expect(reason?.error).toEqual( | ||
| 573 | expect.objectContaining({ | ||
| 574 | _tag: "OAuthDiscoveryError", | ||
| 575 | message: expect.stringMatching(/resource does not match requested endpoint/), | ||
| 576 | }), | ||
| 577 | ); | ||
| 525 | }), | 578 | }), |
| 526 | ), | 579 | ), |
| 527 | ); | 580 | ); |
| @@ -23,9 +23,11 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { | 24 | import { |
| 25 | OAUTH2_DEFAULT_TIMEOUT_MS, | 25 | OAUTH2_DEFAULT_TIMEOUT_MS, |
| 26 | assertSupportedOAuthEndpointUrl, | ||
| 26 | buildAuthorizationUrl, | 27 | buildAuthorizationUrl, |
| 27 | createPkceCodeChallenge, | 28 | createPkceCodeChallenge, |
| 28 | createPkceCodeVerifier, | 29 | createPkceCodeVerifier, |
| 30 | type OAuthEndpointUrlPolicy, | ||
| 29 | } from "./oauth-helpers"; | 31 | } from "./oauth-helpers"; |
| 30 | 32 | ||
| 31 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| @@ -140,27 +142,37 @@ export interface DiscoveryRequestOptions { · 108 unmodified lines | |||
| 140 | * authorization-server metadata, DCR, authorization, or token calls. */ | 142 | * authorization-server metadata, DCR, authorization, or token calls. */ |
| 141 | readonly resourceHeaders?: Readonly<Record<string, string>>; | 143 | readonly resourceHeaders?: Readonly<Record<string, string>>; |
| 142 | readonly resourceQueryParams?: Readonly<Record<string, string>>; | 144 | readonly resourceQueryParams?: Readonly<Record<string, string>>; |
| 145 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 143 | } | 146 | } |
| 144 | 147 | ||
| 145 | const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; | 148 | const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; |
| 146 | 149 | ||
| 147 | const isLoopbackHttpUrl = (value: string): boolean => { | 150 | const validateEndpointUrl = ( |
| 148 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform parser; invalid URLs are not loopback HTTP | 151 | value: string, |
| 149 | try { | 152 | label: string, |
| 150 | const url = new URL(value); | 153 | policy: OAuthEndpointUrlPolicy = {}, |
| 151 | if (url.protocol !== "http:") return false; | 154 | ): Effect.Effect<string, OAuthDiscoveryError> => |
| 152 | const hostname = url.hostname.toLowerCase(); | 155 | Effect.try({ |
| 153 | return ( | 156 | try: () => assertSupportedOAuthEndpointUrl(value, label, policy), |
| 154 | hostname === "localhost" || | 157 | catch: (cause) => |
| 155 | hostname === "0.0.0.0" || | 158 | new OAuthDiscoveryError({ |
| 156 | hostname === "::1" || | 159 | message: `${label} must use https: or loopback http:`, |
| 157 | hostname === "[::1]" || | 160 | cause, |
| 158 | hostname.startsWith("127.") | 161 | }), |
| 159 | ); | 162 | }); |
| 160 | } catch { | 163 | |
| 161 | return false; | 164 | const validateAuthorizationServerMetadata = ( |
| 162 | } | 165 | metadata: OAuthAuthorizationServerMetadata, |
| 163 | }; | 166 | policy: OAuthEndpointUrlPolicy = {}, |
| 167 | ): Effect.Effect<void, OAuthDiscoveryError> => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | yield* validateEndpointUrl(metadata.issuer, "issuer", policy); | ||
| 170 | yield* validateEndpointUrl(metadata.authorization_endpoint, "authorization_endpoint", policy); | ||
| 171 | yield* validateEndpointUrl(metadata.token_endpoint, "token_endpoint", policy); | ||
| 172 | if (metadata.registration_endpoint) { | ||
| 173 | yield* validateEndpointUrl(metadata.registration_endpoint, "registration_endpoint", policy); | ||
| 174 | } | ||
| 175 | }); | ||
| 164 | 176 | ||
| 165 | const provideHttpClient = <A, E>( | 177 | const provideHttpClient = <A, E>( |
| 166 | effect: Effect.Effect<A, E, HttpClient.HttpClient>, | 178 | effect: Effect.Effect<A, E, HttpClient.HttpClient>, |
| @@ -325,6 +337,7 @@ export const discoverAuthorizationServerMetadata = ( · 158 unmodified lines | |||
| 325 | OAuthDiscoveryError | 337 | OAuthDiscoveryError |
| 326 | > => | 338 | > => |
| 327 | Effect.gen(function* () { | 339 | Effect.gen(function* () { |
| 340 | yield* validateEndpointUrl(issuer, "issuer", options.endpointUrlPolicy); | ||
| 328 | const issuerUrl = new URL(issuer); | 341 | const issuerUrl = new URL(issuer); |
| 329 | const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`; | 342 | const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`; |
| 330 | const issuerPath = issuerUrl.pathname.replace(/\/+$/, ""); | 343 | const issuerPath = issuerUrl.pathname.replace(/\/+$/, ""); |
| @@ -380,6 +393,7 @@ export const discoverAuthorizationServerMetadata = ( · 49 unmodified lines | |||
| 380 | }), | 393 | }), |
| 381 | ), | 394 | ), |
| 382 | ); | 395 | ); |
| 396 | yield* validateAuthorizationServerMetadata(metadata, options.endpointUrlPolicy); | ||
| 383 | return { metadataUrl, metadata }; | 397 | return { metadataUrl, metadata }; |
| 384 | } | 398 | } |
| 385 | return null; | 399 | return null; |
| @@ -480,12 +494,19 @@ export const registerDynamicClient = ( · 94 unmodified lines | |||
| 480 | options: DiscoveryRequestOptions = {}, | 494 | options: DiscoveryRequestOptions = {}, |
| 481 | ): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> => | 495 | ): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> => |
| 482 | Effect.gen(function* () { | 496 | Effect.gen(function* () { |
| 483 | const url = new URL(input.registrationEndpoint); | 497 | yield* validateEndpointUrl( |
| 484 | if (url.protocol !== "https:" && !isLoopbackHttpUrl(input.registrationEndpoint)) { | 498 | input.registrationEndpoint, |
| 485 | return yield* new DcrTransport({ | 499 | "registration_endpoint", |
| 486 | detail: `registration_endpoint must be HTTPS or a loopback HTTP URL (got ${url.protocol}//${url.host})`, | 500 | options.endpointUrlPolicy, |
| 487 | }); | 501 | ).pipe( |
| 488 | } | 502 | Effect.mapError( |
| 503 | (cause) => | ||
| 504 | new DcrTransport({ | ||
| 505 | detail: "registration_endpoint must use https: or loopback http:", | ||
| 506 | cause, | ||
| 507 | }), | ||
| 508 | ), | ||
| 509 | ); | ||
| 489 | 510 | ||
| 490 | const headers: Record<string, string> = { | 511 | const headers: Record<string, string> = { |
| 491 | "content-type": "application/json", | 512 | "content-type": "application/json", |
| @@ -573,6 +594,30 @@ export const canonicalResourceUrl = (value: string): string => { · 81 unmodified lines | |||
| 573 | return `${scheme}//${host}${path}`; | 594 | return `${scheme}//${host}${path}`; |
| 574 | }; | 595 | }; |
| 575 | 596 | ||
| 597 | const validateResourceIndicator = ( | ||
| 598 | value: string, | ||
| 599 | expected: string, | ||
| 600 | ): Effect.Effect<string, OAuthDiscoveryError> => | ||
| 601 | Effect.try({ | ||
| 602 | try: () => canonicalResourceUrl(value), | ||
| 603 | catch: (cause) => | ||
| 604 | new OAuthDiscoveryError({ | ||
| 605 | message: "Protected resource metadata resource is malformed", | ||
| 606 | cause, | ||
| 607 | }), | ||
| 608 | }).pipe( | ||
| 609 | Effect.flatMap((actual) => | ||
| 610 | actual === expected || expected.startsWith(`${actual}/`) | ||
| 611 | ? Effect.succeed(actual) | ||
| 612 | : Effect.fail( | ||
| 613 | new OAuthDiscoveryError({ | ||
| 614 | message: "Protected resource metadata resource does not match requested endpoint", | ||
| 615 | cause: { expected, actual }, | ||
| 616 | }), | ||
| 617 | ), | ||
| 618 | ), | ||
| 619 | ); | ||
| 620 | |||
| 576 | // --------------------------------------------------------------------------- | 621 | // --------------------------------------------------------------------------- |
| 577 | // Token-endpoint auth method negotiation | 622 | // Token-endpoint auth method negotiation |
| 578 | // | 623 | // |
| @@ -667,6 +712,8 @@ export const beginDynamicAuthorization = ( · 88 unmodified lines | |||
| 667 | : null | 712 | : null |
| 668 | : yield* discoverProtectedResourceMetadata(input.endpoint, options); | 713 | : yield* discoverProtectedResourceMetadata(input.endpoint, options); |
| 669 | 714 | ||
| 715 | const expectedResource = canonicalResourceUrl(input.endpoint); | ||
| 716 | |||
| 670 | // RFC 9728 allows multiple authorization_servers — try each in | 717 | // RFC 9728 allows multiple authorization_servers — try each in |
| 671 | // listed order. Fall back to the endpoint's origin only when no | 718 | // listed order. Fall back to the endpoint's origin only when no |
| 672 | // PRM is advertised. | 719 | // PRM is advertised. |
| @@ -777,7 +824,9 @@ export const beginDynamicAuthorization = ( · 104 unmodified lines | |||
| 777 | ); | 824 | ); |
| 778 | })()); | 825 | })()); |
| 779 | 826 | ||
| 780 | const resourceValue = resource?.metadata.resource ?? canonicalResourceUrl(input.endpoint); | 827 | const resourceValue = resource?.metadata.resource |
| 828 | ? yield* validateResourceIndicator(resource.metadata.resource, expectedResource) | ||
| 829 | : expectedResource; | ||
| 781 | 830 | ||
| 782 | const codeVerifier = createPkceCodeVerifier(); | 831 | const codeVerifier = createPkceCodeVerifier(); |
| 783 | const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); | 832 | const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); |
| @@ -790,6 +839,7 @@ export const beginDynamicAuthorization = ( · 6 unmodified lines | |||
| 790 | state: input.state, | 839 | state: input.state, |
| 791 | codeChallenge, | 840 | codeChallenge, |
| 792 | resource: resourceValue, | 841 | resource: resourceValue, |
| 842 | endpointUrlPolicy: options.endpointUrlPolicy, | ||
| 793 | }); | 843 | }); |
| 794 | 844 | ||
| 795 | return { | 845 | return { |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | |||
| 17 | createPkceCodeChallenge, | 17 | createPkceCodeChallenge, |
| 18 | createPkceCodeVerifier, | 18 | createPkceCodeVerifier, |
| 19 | exchangeAuthorizationCode, | 19 | exchangeAuthorizationCode, |
| 20 | exchangeClientCredentials, | ||
| 20 | refreshAccessToken, | 21 | refreshAccessToken, |
| 21 | shouldRefreshToken, | 22 | shouldRefreshToken, |
| 22 | } from "./oauth-helpers"; | 23 | } from "./oauth-helpers"; |
| @@ -203,6 +204,32 @@ describe("buildAuthorizationUrl", () => { · 180 unmodified lines | |||
| 203 | const url = new URL(buildAuthorizationUrl(baseInput)); | 204 | const url = new URL(buildAuthorizationUrl(baseInput)); |
| 204 | expect(url.searchParams.has("resource")).toBe(false); | 205 | expect(url.searchParams.has("resource")).toBe(false); |
| 205 | }); | 206 | }); |
| 207 | |||
| 208 | it("rejects unsupported authorization URL schemes", () => { | ||
| 209 | expect(() => | ||
| 210 | buildAuthorizationUrl({ | ||
| 211 | ...baseInput, | ||
| 212 | authorizationUrl: "javascript:alert(1)", | ||
| 213 | }), | ||
| 214 | ).toThrow(/Authorization URL must use https: or loopback http:/); | ||
| 215 | expect(() => | ||
| 216 | buildAuthorizationUrl({ | ||
| 217 | ...baseInput, | ||
| 218 | authorizationUrl: "http://example.com/authorize", | ||
| 219 | }), | ||
| 220 | ).toThrow(/Authorization URL must use https: or loopback http:/); | ||
| 221 | }); | ||
| 222 | |||
| 223 | it("allows HTTP authorization URLs when the caller opts into local HTTP", () => { | ||
| 224 | const url = new URL( | ||
| 225 | buildAuthorizationUrl({ | ||
| 226 | ...baseInput, | ||
| 227 | authorizationUrl: "http://example.com/authorize", | ||
| 228 | endpointUrlPolicy: { allowHttp: true }, | ||
| 229 | }), | ||
| 230 | ); | ||
| 231 | expect(url.origin + url.pathname).toBe("http://example.com/authorize"); | ||
| 232 | }); | ||
| 206 | }); | 233 | }); |
| 207 | 234 | ||
| 208 | describe("exchangeAuthorizationCode", () => { | 235 | describe("exchangeAuthorizationCode", () => { |
| @@ -232,6 +259,23 @@ describe("exchangeAuthorizationCode", () => { · 23 unmodified lines | |||
| 232 | ), | 259 | ), |
| 233 | ); | 260 | ); |
| 234 | 261 | ||
| 262 | it.effect("rejects unsupported token URL schemes before exchange", () => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const exit = yield* Effect.exit( | ||
| 265 | exchangeAuthorizationCode({ | ||
| 266 | tokenUrl: "http://example.com/token", | ||
| 267 | clientId: "cid", | ||
| 268 | redirectUrl: "https://app.example.com/cb", | ||
| 269 | codeVerifier: "verifier", | ||
| 270 | code: "abc", | ||
| 271 | }), | ||
| 272 | ); | ||
| 273 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 274 | if (!Exit.isFailure(exit)) return; | ||
| 275 | expect(JSON.stringify(exit.cause)).toContain("Token URL must use https: or loopback http:"); | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | |||
| 235 | it.effect("omits client_secret when none is provided (public clients with PKCE)", () => | 279 | it.effect("omits client_secret when none is provided (public clients with PKCE)", () => |
| 236 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | 280 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => |
| 237 | Effect.gen(function* () { | 281 | Effect.gen(function* () { |
| @@ -549,6 +593,23 @@ describe("exchangeAuthorizationCode", () => { · 311 unmodified lines | |||
| 549 | ); | 593 | ); |
| 550 | }); | 594 | }); |
| 551 | 595 | ||
| 596 | describe("exchangeClientCredentials", () => { | ||
| 597 | it.effect("rejects unsupported token URL schemes before exchange", () => | ||
| 598 | Effect.gen(function* () { | ||
| 599 | const exit = yield* Effect.exit( | ||
| 600 | exchangeClientCredentials({ | ||
| 601 | tokenUrl: "http://example.com/token", | ||
| 602 | clientId: "cid", | ||
| 603 | clientSecret: "secret", | ||
| 604 | }), | ||
| 605 | ); | ||
| 606 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 607 | if (!Exit.isFailure(exit)) return; | ||
| 608 | expect(JSON.stringify(exit.cause)).toContain("Token URL must use https: or loopback http:"); | ||
| 609 | }), | ||
| 610 | ); | ||
| 611 | }); | ||
| 612 | |||
| 552 | describe("refreshAccessToken", () => { | 613 | describe("refreshAccessToken", () => { |
| 553 | it.effect("posts grant_type=refresh_token with the refresh token", () => | 614 | it.effect("posts grant_type=refresh_token with the refresh token", () => |
| 554 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => | 615 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => |
| @@ -57,6 +57,47 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 56 unmodified lines | |||
| 57 | /** Default token-endpoint timeout. */ | 57 | /** Default token-endpoint timeout. */ |
| 58 | export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; | 58 | export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; |
| 59 | 59 | ||
| 60 | export interface OAuthEndpointUrlPolicy { | ||
| 61 | readonly allowHttp?: boolean; | ||
| 62 | } | ||
| 63 | |||
| 64 | const isLoopbackHttpUrl = (value: string): boolean => { | ||
| 65 | if (!URL.canParse(value)) return false; | ||
| 66 | const url = new URL(value); | ||
| 67 | if (url.protocol !== "http:") return false; | ||
| 68 | const hostname = url.hostname.toLowerCase(); | ||
| 69 | return ( | ||
| 70 | hostname === "localhost" || | ||
| 71 | hostname === "0.0.0.0" || | ||
| 72 | hostname === "::1" || | ||
| 73 | hostname === "[::1]" || | ||
| 74 | hostname.startsWith("127.") | ||
| 75 | ); | ||
| 76 | }; | ||
| 77 | |||
| 78 | export const isSupportedOAuthEndpointUrl = ( | ||
| 79 | value: string, | ||
| 80 | policy: OAuthEndpointUrlPolicy = {}, | ||
| 81 | ): boolean => { | ||
| 82 | if (!URL.canParse(value)) return false; | ||
| 83 | const url = new URL(value); | ||
| 84 | return ( | ||
| 85 | url.protocol === "https:" || | ||
| 86 | isLoopbackHttpUrl(value) || | ||
| 87 | (url.protocol === "http:" && policy.allowHttp === true) | ||
| 88 | ); | ||
| 89 | }; | ||
| 90 | |||
| 91 | export const assertSupportedOAuthEndpointUrl = ( | ||
| 92 | value: string, | ||
| 93 | label = "OAuth endpoint URL", | ||
| 94 | policy: OAuthEndpointUrlPolicy = {}, | ||
| 95 | ): string => { | ||
| 96 | if (isSupportedOAuthEndpointUrl(value, policy)) return value; | ||
| 97 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: synchronous assertion helper used by URL constructors and Effect.try wrappers | ||
| 98 | throw new TypeError(`${label} must use https: or loopback http:`); | ||
| 99 | }; | ||
| 100 | |||
| 60 | // --------------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------------- |
| 61 | // PKCE (RFC 7636) — straight delegation to `oauth4webapi` | 102 | // PKCE (RFC 7636) — straight delegation to `oauth4webapi` |
| 62 | // --------------------------------------------------------------------------- | 103 | // --------------------------------------------------------------------------- |
| @@ -86,12 +127,19 @@ export type BuildAuthorizationUrlInput = { · 23 unmodified lines | |||
| 86 | readonly resource?: string; | 127 | readonly resource?: string; |
| 87 | /** Provider-specific extras (e.g. Google's `access_type=offline`). */ | 128 | /** Provider-specific extras (e.g. Google's `access_type=offline`). */ |
| 88 | readonly extraParams?: Readonly<Record<string, string>>; | 129 | readonly extraParams?: Readonly<Record<string, string>>; |
| 130 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 89 | }; | 131 | }; |
| 90 | 132 | ||
| 91 | /** Build an RFC 6749 §4.1.1 authorization URL. Sync; pre-computed | 133 | /** Build an RFC 6749 §4.1.1 authorization URL. Sync; pre-computed |
| 92 | * challenge lets this stay out of the Promise world. */ | 134 | * challenge lets this stay out of the Promise world. */ |
| 93 | export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => { | 135 | export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => { |
| 94 | const url = new URL(input.authorizationUrl); | 136 | const url = new URL( |
| 137 | assertSupportedOAuthEndpointUrl( | ||
| 138 | input.authorizationUrl, | ||
| 139 | "Authorization URL", | ||
| 140 | input.endpointUrlPolicy, | ||
| 141 | ), | ||
| 142 | ); | ||
| 95 | const separator = input.scopeSeparator ?? " "; | 143 | const separator = input.scopeSeparator ?? " "; |
| 96 | url.searchParams.set("client_id", input.clientId); | 144 | url.searchParams.set("client_id", input.clientId); |
| 97 | url.searchParams.set("redirect_uri", input.redirectUrl); | 145 | url.searchParams.set("redirect_uri", input.redirectUrl); |
| @@ -222,7 +270,11 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 124 unmodified lines | |||
| 222 | 270 | ||
| 223 | export type ClientAuthMethod = "body" | "basic"; | 271 | export type ClientAuthMethod = "body" | "basic"; |
| 224 | 272 | ||
| 225 | const asFromTokenUrl = (tokenUrl: string): oauth.AuthorizationServer => { | 273 | const asFromTokenUrl = ( |
| 274 | tokenUrl: string, | ||
| 275 | endpointUrlPolicy: OAuthEndpointUrlPolicy = {}, | ||
| 276 | ): oauth.AuthorizationServer => { | ||
| 277 | assertSupportedOAuthEndpointUrl(tokenUrl, "Token URL", endpointUrlPolicy); | ||
| 226 | const url = new URL(tokenUrl); | 278 | const url = new URL(tokenUrl); |
| 227 | return { | 279 | return { |
| 228 | issuer: `${url.protocol}//${url.host}`, | 280 | issuer: `${url.protocol}//${url.host}`, |
| @@ -235,9 +287,10 @@ const asFromTokenUrlAndIssuer = ( · 6 unmodified lines | |||
| 235 | issuerUrl: string | null | undefined, | 287 | issuerUrl: string | null | undefined, |
| 236 | options: { | 288 | options: { |
| 237 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 289 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 290 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 238 | } = {}, | 291 | } = {}, |
| 239 | ): oauth.AuthorizationServer => { | 292 | ): oauth.AuthorizationServer => { |
| 240 | const as = asFromTokenUrl(tokenUrl); | 293 | const as = asFromTokenUrl(tokenUrl, options.endpointUrlPolicy); |
| 241 | const withIssuer = issuerUrl ? { ...as, issuer: issuerUrl } : as; | 294 | const withIssuer = issuerUrl ? { ...as, issuer: issuerUrl } : as; |
| 242 | return options.idTokenSigningAlgValuesSupported | 295 | return options.idTokenSigningAlgValuesSupported |
| 243 | ? { | 296 | ? { |
| @@ -247,28 +300,20 @@ const asFromTokenUrlAndIssuer = ( · 3 unmodified lines | |||
| 247 | : withIssuer; | 300 | : withIssuer; |
| 248 | }; | 301 | }; |
| 249 | 302 | ||
| 250 | const isLoopbackHttpUrl = (value: string): boolean => { | ||
| 251 | if (!URL.canParse(value)) return false; | ||
| 252 | const url = new URL(value); | ||
| 253 | if (url.protocol !== "http:") return false; | ||
| 254 | const hostname = url.hostname.toLowerCase(); | ||
| 255 | return ( | ||
| 256 | hostname === "localhost" || | ||
| 257 | hostname === "0.0.0.0" || | ||
| 258 | hostname === "::1" || | ||
| 259 | hostname === "[::1]" || | ||
| 260 | hostname.startsWith("127.") | ||
| 261 | ); | ||
| 262 | }; | ||
| 263 | |||
| 264 | const oauth4webapiRequestOptions = ( | 303 | const oauth4webapiRequestOptions = ( |
| 265 | targetUrl: string, | 304 | targetUrl: string, |
| 266 | timeoutMs: number | undefined, | 305 | timeoutMs: number | undefined, |
| 306 | endpointUrlPolicy: OAuthEndpointUrlPolicy = {}, | ||
| 267 | ): Record<string, unknown> => { | 307 | ): Record<string, unknown> => { |
| 268 | const options: Record<string, unknown> = { | 308 | const options: Record<string, unknown> = { |
| 269 | signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), | 309 | signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), |
| 270 | }; | 310 | }; |
| 271 | if (isLoopbackHttpUrl(targetUrl)) { | 311 | if ( |
| 312 | isLoopbackHttpUrl(targetUrl) || | ||
| 313 | (URL.canParse(targetUrl) && | ||
| 314 | new URL(targetUrl).protocol === "http:" && | ||
| 315 | endpointUrlPolicy.allowHttp === true) | ||
| 316 | ) { | ||
| 272 | (options as { [oauth.allowInsecureRequests]?: boolean })[oauth.allowInsecureRequests] = true; | 317 | (options as { [oauth.allowInsecureRequests]?: boolean })[oauth.allowInsecureRequests] = true; |
| 273 | } | 318 | } |
| 274 | return options; | 319 | return options; |
| @@ -344,6 +389,7 @@ export type ExchangeAuthorizationCodeInput = { · 69 unmodified lines | |||
| 344 | * to call. */ | 389 | * to call. */ |
| 345 | readonly resource?: string; | 390 | readonly resource?: string; |
| 346 | readonly timeoutMs?: number; | 391 | readonly timeoutMs?: number; |
| 392 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 347 | }; | 393 | }; |
| 348 | 394 | ||
| 349 | export const exchangeAuthorizationCode = ( | 395 | export const exchangeAuthorizationCode = ( |
| @@ -353,6 +399,7 @@ export const exchangeAuthorizationCode = ( · 3 unmodified lines | |||
| 353 | try: async () => { | 399 | try: async () => { |
| 354 | const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | 400 | const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { |
| 355 | idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | 401 | idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, |
| 402 | endpointUrlPolicy: input.endpointUrlPolicy, | ||
| 356 | }); | 403 | }); |
| 357 | const client: oauth.Client = { client_id: input.clientId }; | 404 | const client: oauth.Client = { client_id: input.clientId }; |
| 358 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | 405 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); |
| @@ -375,7 +422,7 @@ export const exchangeAuthorizationCode = ( · 16 unmodified lines | |||
| 375 | clientAuth, | 422 | clientAuth, |
| 376 | "authorization_code", | 423 | "authorization_code", |
| 377 | params, | 424 | params, |
| 378 | oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | 425 | oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), |
| 379 | ); | 426 | ); |
| 380 | return await processTokenEndpointResponse(as, client, response); | 427 | return await processTokenEndpointResponse(as, client, response); |
| 381 | }, | 428 | }, |
| @@ -394,6 +441,7 @@ export type ExchangeClientCredentialsInput = { · 12 unmodified lines | |||
| 394 | readonly scopeSeparator?: string; | 441 | readonly scopeSeparator?: string; |
| 395 | readonly clientAuth?: ClientAuthMethod; | 442 | readonly clientAuth?: ClientAuthMethod; |
| 396 | readonly timeoutMs?: number; | 443 | readonly timeoutMs?: number; |
| 444 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 397 | }; | 445 | }; |
| 398 | 446 | ||
| 399 | export const exchangeClientCredentials = ( | 447 | export const exchangeClientCredentials = ( |
| @@ -401,7 +449,7 @@ export const exchangeClientCredentials = ( · 1 unmodified line | |||
| 401 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => | 449 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| 402 | Effect.tryPromise({ | 450 | Effect.tryPromise({ |
| 403 | try: async () => { | 451 | try: async () => { |
| 404 | const as = asFromTokenUrl(input.tokenUrl); | 452 | const as = asFromTokenUrl(input.tokenUrl, input.endpointUrlPolicy); |
| 405 | const client: oauth.Client = { client_id: input.clientId }; | 453 | const client: oauth.Client = { client_id: input.clientId }; |
| 406 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | 454 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); |
| 407 | const params = new URLSearchParams(); | 455 | const params = new URLSearchParams(); |
| @@ -413,7 +461,7 @@ export const exchangeClientCredentials = ( · 5 unmodified lines | |||
| 413 | client, | 461 | client, |
| 414 | clientAuth, | 462 | clientAuth, |
| 415 | params, | 463 | params, |
| 416 | oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | 464 | oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), |
| 417 | ); | 465 | ); |
| 418 | const result = await oauth.processClientCredentialsResponse(as, client, response); | 466 | const result = await oauth.processClientCredentialsResponse(as, client, response); |
| 419 | return tokenResponseFrom(result); | 467 | return tokenResponseFrom(result); |
| @@ -440,6 +488,7 @@ export type RefreshAccessTokenInput = { · 20 unmodified lines | |||
| 440 | * the same resource. */ | 488 | * the same resource. */ |
| 441 | readonly resource?: string; | 489 | readonly resource?: string; |
| 442 | readonly timeoutMs?: number; | 490 | readonly timeoutMs?: number; |
| 491 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 443 | }; | 492 | }; |
| 444 | 493 | ||
| 445 | export const refreshAccessToken = ( | 494 | export const refreshAccessToken = ( |
| @@ -449,6 +498,7 @@ export const refreshAccessToken = ( · 3 unmodified lines | |||
| 449 | try: async () => { | 498 | try: async () => { |
| 450 | const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { | 499 | const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { |
| 451 | idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, | 500 | idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, |
| 501 | endpointUrlPolicy: input.endpointUrlPolicy, | ||
| 452 | }); | 502 | }); |
| 453 | const client: oauth.Client = { client_id: input.clientId }; | 503 | const client: oauth.Client = { client_id: input.clientId }; |
| 454 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); | 504 | const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); |
| @@ -467,7 +517,7 @@ export const refreshAccessToken = ( · 12 unmodified lines | |||
| 467 | clientAuth, | 517 | clientAuth, |
| 468 | input.refreshToken, | 518 | input.refreshToken, |
| 469 | { | 519 | { |
| 470 | ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), | 520 | ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), |
| 471 | additionalParameters, | 521 | additionalParameters, |
| 472 | }, | 522 | }, |
| 473 | ); | 523 | ); |
| @@ -85,6 +85,7 @@ import { · 84 unmodified lines | |||
| 85 | exchangeAuthorizationCode, | 85 | exchangeAuthorizationCode, |
| 86 | exchangeClientCredentials, | 86 | exchangeClientCredentials, |
| 87 | type OAuth2Error, | 87 | type OAuth2Error, |
| 88 | type OAuthEndpointUrlPolicy, | ||
| 88 | refreshAccessToken, | 89 | refreshAccessToken, |
| 89 | } from "./oauth-helpers"; | 90 | } from "./oauth-helpers"; |
| 90 | import type { ScopedDBAdapter } from "./scoped-adapter"; | 91 | import type { ScopedDBAdapter } from "./scoped-adapter"; |
| @@ -201,6 +202,7 @@ export interface OAuthServiceDeps { · 110 unmodified lines | |||
| 201 | readonly now?: () => number; | 202 | readonly now?: () => number; |
| 202 | /** Outbound HTTP client used for OAuth metadata/DCR probes. */ | 203 | /** Outbound HTTP client used for OAuth metadata/DCR probes. */ |
| 203 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | 204 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 205 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 204 | } | 206 | } |
| 205 | 207 | ||
| 206 | const defaultSessionId = (): string => { | 208 | const defaultSessionId = (): string => { |
| @@ -244,6 +246,7 @@ export const makeOAuth2Service = ( · 37 unmodified lines | |||
| 244 | const now = deps.now ?? (() => Date.now()); | 246 | const now = deps.now ?? (() => Date.now()); |
| 245 | const newSessionId = deps.newSessionId ?? defaultSessionId; | 247 | const newSessionId = deps.newSessionId ?? defaultSessionId; |
| 246 | const httpClientLayer = deps.httpClientLayer; | 248 | const httpClientLayer = deps.httpClientLayer; |
| 249 | const endpointUrlPolicy = deps.endpointUrlPolicy; | ||
| 247 | const secretsGetResolved = | 250 | const secretsGetResolved = |
| 248 | deps.secretsGetResolved ?? | 251 | deps.secretsGetResolved ?? |
| 249 | ((id: string) => | 252 | ((id: string) => |
| @@ -380,6 +383,7 @@ export const makeOAuth2Service = ( · 130 unmodified lines | |||
| 380 | httpClientLayer, | 383 | httpClientLayer, |
| 381 | resourceHeaders: input.headers, | 384 | resourceHeaders: input.headers, |
| 382 | resourceQueryParams: input.queryParams, | 385 | resourceQueryParams: input.queryParams, |
| 386 | endpointUrlPolicy, | ||
| 383 | }, | 387 | }, |
| 384 | ).pipe( | 388 | ).pipe( |
| 385 | Effect.catchTag("OAuthDiscoveryError", ({ message, error, errorDescription }) => | 389 | Effect.catchTag("OAuthDiscoveryError", ({ message, error, errorDescription }) => |
| @@ -411,6 +415,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines | |||
| 411 | state: sessionId, | 415 | state: sessionId, |
| 412 | codeChallenge, | 416 | codeChallenge, |
| 413 | resource: started.state.resource, | 417 | resource: started.state.resource, |
| 418 | endpointUrlPolicy, | ||
| 414 | }); | 419 | }); |
| 415 | 420 | ||
| 416 | const payload: OAuthSessionPayload = { | 421 | const payload: OAuthSessionPayload = { |
| @@ -480,6 +485,7 @@ export const makeOAuth2Service = ( · 63 unmodified lines | |||
| 480 | codeChallenge, | 485 | codeChallenge, |
| 481 | scopeSeparator: strategy.scopeSeparator, | 486 | scopeSeparator: strategy.scopeSeparator, |
| 482 | extraParams: strategy.extraAuthorizationParams, | 487 | extraParams: strategy.extraAuthorizationParams, |
| 488 | endpointUrlPolicy, | ||
| 483 | }); | 489 | }); |
| 484 | 490 | ||
| 485 | const payload: OAuthSessionPayload = { | 491 | const payload: OAuthSessionPayload = { |
| @@ -534,6 +540,7 @@ export const makeOAuth2Service = ( · 48 unmodified lines | |||
| 534 | scopes: strategy.scopes, | 540 | scopes: strategy.scopes, |
| 535 | scopeSeparator: strategy.scopeSeparator, | 541 | scopeSeparator: strategy.scopeSeparator, |
| 536 | clientAuth: strategy.clientAuth ?? "body", | 542 | clientAuth: strategy.clientAuth ?? "body", |
| 543 | endpointUrlPolicy, | ||
| 537 | }).pipe( | 544 | }).pipe( |
| 538 | Effect.mapError( | 545 | Effect.mapError( |
| 539 | ({ message }: OAuth2Error) => | 546 | ({ message }: OAuth2Error) => |
| @@ -890,6 +897,7 @@ export const makeOAuth2Service = ( · 350 unmodified lines | |||
| 890 | idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported, | 897 | idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported, |
| 891 | clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body", | 898 | clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body", |
| 892 | resource: payload.resource ?? undefined, | 899 | resource: payload.resource ?? undefined, |
| 900 | endpointUrlPolicy, | ||
| 893 | }).pipe( | 901 | }).pipe( |
| 894 | Effect.mapError( | 902 | Effect.mapError( |
| 895 | ({ message, error }: OAuth2Error) => | 903 | ({ message, error }: OAuth2Error) => |
| @@ -943,6 +951,7 @@ export const makeOAuth2Service = ( · 47 unmodified lines | |||
| 943 | codeVerifier: payload.codeVerifier, | 951 | codeVerifier: payload.codeVerifier, |
| 944 | code, | 952 | code, |
| 945 | clientAuth: payload.clientAuth, | 953 | clientAuth: payload.clientAuth, |
| 954 | endpointUrlPolicy, | ||
| 946 | }).pipe( | 955 | }).pipe( |
| 947 | Effect.mapError( | 956 | Effect.mapError( |
| 948 | ({ message, error }: OAuth2Error) => | 957 | ({ message, error }: OAuth2Error) => |
| @@ -1132,6 +1141,7 @@ export const makeOAuth2Service = ( · 183 unmodified lines | |||
| 1132 | scopes: state.scopes, | 1141 | scopes: state.scopes, |
| 1133 | scopeSeparator: state.scopeSeparator, | 1142 | scopeSeparator: state.scopeSeparator, |
| 1134 | clientAuth: state.clientAuth, | 1143 | clientAuth: state.clientAuth, |
| 1144 | endpointUrlPolicy, | ||
| 1135 | }) | 1145 | }) |
| 1136 | : refreshAccessToken({ | 1146 | : refreshAccessToken({ |
| 1137 | tokenUrl: tokenEndpoint, | 1147 | tokenUrl: tokenEndpoint, |
| @@ -1153,6 +1163,7 @@ export const makeOAuth2Service = ( · 15 unmodified lines | |||
| 1153 | clientAuth: state.clientAuth, | 1163 | clientAuth: state.clientAuth, |
| 1154 | idTokenSigningAlgValuesSupported: | 1164 | idTokenSigningAlgValuesSupported: |
| 1155 | state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined, | 1165 | state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined, |
| 1166 | endpointUrlPolicy, | ||
| 1156 | resource: | 1167 | resource: |
| 1157 | state.kind === "dynamic-dcr" || state.kind === "authorization-code" | 1168 | state.kind === "dynamic-dcr" || state.kind === "authorization-code" |
| 1158 | ? (state.resource ?? undefined) | 1169 | ? (state.resource ?? undefined) |
| @@ -0,0 +1,114 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { openOAuthPopup } from "./oauth-popup"; | ||
| 4 | |||
| 5 | type OAuthPopupTestWindow = { | ||
| 6 | readonly screenX: number; | ||
| 7 | readonly screenY: number; | ||
| 8 | readonly outerWidth: number; | ||
| 9 | readonly outerHeight: number; | ||
| 10 | readonly location: { readonly origin: string }; | ||
| 11 | readonly addEventListener: () => void; | ||
| 12 | readonly removeEventListener: () => void; | ||
| 13 | readonly open: ( | ||
| 14 | url: string, | ||
| 15 | name: string, | ||
| 16 | features: string, | ||
| 17 | ) => { readonly closed: boolean; close: () => void }; | ||
| 18 | }; | ||
| 19 | |||
| 20 | describe("openOAuthPopup", () => { | ||
| 21 | it("does not open unsupported OAuth endpoint URLs", async () => { | ||
| 22 | let openFailed = false; | ||
| 23 | const teardown = openOAuthPopup({ | ||
| 24 | url: "javascript:alert(1)", | ||
| 25 | popupName: "oauth", | ||
| 26 | channelName: "oauth-channel", | ||
| 27 | onResult: () => {}, | ||
| 28 | onOpenFailed: () => { | ||
| 29 | openFailed = true; | ||
| 30 | }, | ||
| 31 | }); | ||
| 32 | |||
| 33 | teardown(); | ||
| 34 | await new Promise<void>((resolve) => queueMicrotask(() => resolve())); | ||
| 35 | expect(openFailed).toBe(true); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("opens supported OAuth URLs without opener access", () => { | ||
| 39 | let features = ""; | ||
| 40 | const previousWindow = globalThis.window; | ||
| 41 | const fakeWindow: OAuthPopupTestWindow = { | ||
| 42 | screenX: 0, | ||
| 43 | screenY: 0, | ||
| 44 | outerWidth: 1200, | ||
| 45 | outerHeight: 900, | ||
| 46 | location: { origin: "https://app.example" }, | ||
| 47 | addEventListener: () => {}, | ||
| 48 | removeEventListener: () => {}, | ||
| 49 | open: (_url: string, _name: string, requestedFeatures: string) => { | ||
| 50 | features = requestedFeatures; | ||
| 51 | return { closed: false, close: () => {} }; | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | Object.defineProperty(globalThis, "window", { | ||
| 55 | configurable: true, | ||
| 56 | value: fakeWindow, | ||
| 57 | writable: true, | ||
| 58 | }); | ||
| 59 | |||
| 60 | const teardown = openOAuthPopup({ | ||
| 61 | url: "https://auth.example/authorize", | ||
| 62 | popupName: "oauth", | ||
| 63 | channelName: "oauth-channel", | ||
| 64 | onResult: () => {}, | ||
| 65 | }); | ||
| 66 | |||
| 67 | teardown(); | ||
| 68 | Object.defineProperty(globalThis, "window", { | ||
| 69 | configurable: true, | ||
| 70 | value: previousWindow, | ||
| 71 | writable: true, | ||
| 72 | }); | ||
| 73 | expect(features).toContain("noopener"); | ||
| 74 | expect(features).toContain("noreferrer"); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("opens HTTP authorization URLs returned by local flows", () => { | ||
| 78 | let opened = ""; | ||
| 79 | const previousWindow = globalThis.window; | ||
| 80 | const fakeWindow: OAuthPopupTestWindow = { | ||
| 81 | screenX: 0, | ||
| 82 | screenY: 0, | ||
| 83 | outerWidth: 1200, | ||
| 84 | outerHeight: 900, | ||
| 85 | location: { origin: "http://127.0.0.1:4000" }, | ||
| 86 | addEventListener: () => {}, | ||
| 87 | removeEventListener: () => {}, | ||
| 88 | open: (url: string) => { | ||
| 89 | opened = url; | ||
| 90 | return { closed: false, close: () => {} }; | ||
| 91 | }, | ||
| 92 | }; | ||
| 93 | Object.defineProperty(globalThis, "window", { | ||
| 94 | configurable: true, | ||
| 95 | value: fakeWindow, | ||
| 96 | writable: true, | ||
| 97 | }); | ||
| 98 | |||
| 99 | const teardown = openOAuthPopup({ | ||
| 100 | url: "http://example.com/authorize", | ||
| 101 | popupName: "oauth", | ||
| 102 | channelName: "oauth-channel", | ||
| 103 | onResult: () => {}, | ||
| 104 | }); | ||
| 105 | |||
| 106 | teardown(); | ||
| 107 | Object.defineProperty(globalThis, "window", { | ||
| 108 | configurable: true, | ||
| 109 | value: previousWindow, | ||
| 110 | writable: true, | ||
| 111 | }); | ||
| 112 | expect(opened).toBe("http://example.com/authorize"); | ||
| 113 | }); | ||
| 114 | }); | ||
| @@ -46,6 +46,12 @@ export type OpenOAuthPopupInput<TAuth> = { · 45 unmodified lines | |||
| 46 | readonly closedPollMs?: number; | 46 | readonly closedPollMs?: number; |
| 47 | }; | 47 | }; |
| 48 | 48 | ||
| 49 | const isHttpPopupUrl = (value: string): boolean => { | ||
| 50 | if (!URL.canParse(value)) return false; | ||
| 51 | const url = new URL(value); | ||
| 52 | return url.protocol === "http:" || url.protocol === "https:"; | ||
| 53 | }; | ||
| 54 | |||
| 49 | /** | 55 | /** |
| 50 | * Open a centered popup window at `url` and resolve when the popup posts | 56 | * Open a centered popup window at `url` and resolve when the popup posts |
| 51 | * an `OAuthPopupResult` back to the opener. Returns a teardown function | 57 | * an `OAuthPopupResult` back to the opener. Returns a teardown function |
| @@ -60,6 +66,11 @@ export type OpenOAuthPopupInput<TAuth> = { · 8 unmodified lines | |||
| 60 | * `onOpenFailed` on the next microtask and returns a no-op teardown. | 66 | * `onOpenFailed` on the next microtask and returns a no-op teardown. |
| 61 | */ | 67 | */ |
| 62 | export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => { | 68 | export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => { |
| 69 | if (!isHttpPopupUrl(input.url)) { | ||
| 70 | queueMicrotask(() => input.onOpenFailed?.()); | ||
| 71 | return () => {}; | ||
| 72 | } | ||
| 73 | |||
| 63 | const w = input.width ?? 640; | 74 | const w = input.width ?? 640; |
| 64 | const h = input.height ?? 760; | 75 | const h = input.height ?? 760; |
| 65 | const left = window.screenX + (window.outerWidth - w) / 2; | 76 | const left = window.screenX + (window.outerWidth - w) / 2; |
| @@ -114,7 +125,7 @@ export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => · 48 unmodified lines | |||
| 114 | const popup = window.open( | 125 | const popup = window.open( |
| 115 | input.url, | 126 | input.url, |
| 116 | input.popupName, | 127 | input.popupName, |
| 117 | `width=${w},height=${h},left=${left},top=${top},popup=1`, | 128 | `width=${w},height=${h},left=${left},top=${top},popup=1,noopener,noreferrer`, |
| 118 | ); | 129 | ); |
| 119 | if (!popup) { | 130 | if (!popup) { |
| 120 | if (!settled) { | 131 | if (!settled) { |