History

Merge pull request #711 from RhysSullivan/codex/oauth-endpoint-validation

Validate OAuth endpoint URLs

Rhys Sullivan committed 6 months ago

Commit 545cb0f2 parents b4721cf 45a5f0f11 files · +414 −57Browse files

Unified · Split

11 files
  1. apps/local/src/server/executor.ts +1 −0
  2. apps/local/src/server/mcp-oauth.test.ts +1 −0
  3. packages/core/sdk/src/executor.ts +3 −0
  4. packages/core/sdk/src/index.ts +2 −0
  5. packages/core/sdk/src/oauth-discovery.test.ts +63 −10
  6. packages/core/sdk/src/oauth-discovery.ts +74 −24
  7. packages/core/sdk/src/oauth-helpers.test.ts +61 −0
  8. packages/core/sdk/src/oauth-helpers.ts +72 −22
  9. packages/core/sdk/src/oauth-service.ts +11 −0
  10. packages/react/src/api/oauth-popup.test.ts +114 −0
  11. packages/react/src/api/oauth-popup.ts +12 −1
apps/local/src/server/executor.ts+1−0
@@ -195,6 +195,7 @@ const createLocalExecutorLayer = () => { · 194 unmodified lines
195195 blobs,
196196 plugins,
197197 onElicitation: "accept-all",
198+ oauthEndpointUrlPolicy: { allowHttp: true },
198199 });
199200
200201 // Sync sources from executor.jsonc (idempotent — plugins upsert).
apps/local/src/server/mcp-oauth.test.ts+1−0
@@ -259,6 +259,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => { · 258 unmodified lines
259259 blobs,
260260 plugins,
261261 onElicitation: "accept-all",
262+ oauthEndpointUrlPolicy: { allowHttp: true },
262263 }),
263264 );
264265
packages/core/sdk/src/executor.ts+3−0
@@ -1,5 +1,6 @@
11 import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";
22 import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
3+import type { OAuthEndpointUrlPolicy } from "./oauth-helpers";
34 import { generateKeyBetween } from "fractional-indexing";
45 import {
56 StorageError,
@@ -343,6 +344,7 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 337 unmodified lines
343344 */
344345 readonly onElicitation: OnElicitation;
345346 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
347+ readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;
346348 }
347349
348350 // ---------------------------------------------------------------------------
@@ -2517,6 +2519,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2168 unmodified lines
25172519 secretsSet: (input) => secretsSet(input),
25182520 connectionsCreate: (input) => connectionsCreate(input),
25192521 httpClientLayer: config.httpClientLayer,
2522+ endpointUrlPolicy: config.oauthEndpointUrlPolicy,
25202523 });
25212524 connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider);
25222525
packages/core/sdk/src/index.ts+2−0
@@ -215,11 +215,13 @@ export { · 214 unmodified lines
215215 OAuth2Error,
216216 OAUTH2_DEFAULT_TIMEOUT_MS,
217217 OAUTH2_REFRESH_SKEW_MS,
218+ assertSupportedOAuthEndpointUrl,
218219 buildAuthorizationUrl,
219220 createPkceCodeChallenge,
220221 createPkceCodeVerifier,
221222 exchangeAuthorizationCode,
222223 exchangeClientCredentials,
224+ isSupportedOAuthEndpointUrl,
223225 refreshAccessToken,
224226 shouldRefreshToken,
225227 type OAuth2TokenResponse,
packages/core/sdk/src/oauth-discovery.test.ts+63−10
@@ -180,6 +180,52 @@ describe("discoverAuthorizationServerMetadata", () => { · 179 unmodified lines
180180 }),
181181 ),
182182 );
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+ );
183229 });
184230
185231 describe("registerDynamicClient", () => {
@@ -480,7 +526,7 @@ describe("beginDynamicAuthorization", () => { · 294 unmodified lines
480526 ),
481527 );
482528
483− it.effect("includes RFC 8707 resource parameter on the authorization URL", () =>
529+ it.effect("rejects protected-resource metadata for a different resource", () =>
484530 withOAuthFixture(
485531 (request, baseUrl) => {
486532 if (request.url === "/.well-known/oauth-protected-resource/v1/mcp") {
@@ -513,15 +559,22 @@ describe("beginDynamicAuthorization", () => { · 26 unmodified lines
513559 },
514560 ({ baseUrl }) =>
515561 Effect.gen(function* () {
516− const result = yield* beginDynamicAuthorization({
517− endpoint: `${baseUrl}/v1/mcp`,
518− redirectUrl: "https://app/cb",
519− state: "s",
520− });
521−
522− const authUrl = new URL(result.authorizationUrl);
523− expect(authUrl.searchParams.get("resource")).toBe(`${baseUrl}/canonical-id`);
524− expect(result.state.resource).toBe(`${baseUrl}/canonical-id`);
562+ const exit = yield* Effect.exit(
563+ beginDynamicAuthorization({
564+ endpoint: `${baseUrl}/v1/mcp`,
565+ redirectUrl: "https://app/cb",
566+ state: "s",
567+ }),
568+ );
569+ expect(Exit.isFailure(exit)).toBe(true);
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+ );
525578 }),
526579 ),
527580 );
packages/core/sdk/src/oauth-discovery.ts+74−24
@@ -23,9 +23,11 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ · 22 unmodified lines
2323
2424 import {
2525 OAUTH2_DEFAULT_TIMEOUT_MS,
26+ assertSupportedOAuthEndpointUrl,
2627 buildAuthorizationUrl,
2728 createPkceCodeChallenge,
2829 createPkceCodeVerifier,
30+ type OAuthEndpointUrlPolicy,
2931 } from "./oauth-helpers";
3032
3133 // ---------------------------------------------------------------------------
@@ -140,27 +142,37 @@ export interface DiscoveryRequestOptions { · 108 unmodified lines
140142 * authorization-server metadata, DCR, authorization, or token calls. */
141143 readonly resourceHeaders?: Readonly<Record<string, string>>;
142144 readonly resourceQueryParams?: Readonly<Record<string, string>>;
145+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
143146 }
144147
145148 const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
146149
147−const isLoopbackHttpUrl = (value: string): boolean => {
148− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform parser; invalid URLs are not loopback HTTP
149− try {
150− const url = new URL(value);
151− if (url.protocol !== "http:") return false;
152− const hostname = url.hostname.toLowerCase();
153− return (
154− hostname === "localhost" ||
155− hostname === "0.0.0.0" ||
156− hostname === "::1" ||
157− hostname === "[::1]" ||
158− hostname.startsWith("127.")
159− );
160− } catch {
161− return false;
162− }
163−};
150+const validateEndpointUrl = (
151+ value: string,
152+ label: string,
153+ policy: OAuthEndpointUrlPolicy = {},
154+): Effect.Effect<string, OAuthDiscoveryError> =>
155+ Effect.try({
156+ try: () => assertSupportedOAuthEndpointUrl(value, label, policy),
157+ catch: (cause) =>
158+ new OAuthDiscoveryError({
159+ message: `${label} must use https: or loopback http:`,
160+ cause,
161+ }),
162+ });
163+
164+const validateAuthorizationServerMetadata = (
165+ metadata: OAuthAuthorizationServerMetadata,
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+ });
164176
165177 const provideHttpClient = <A, E>(
166178 effect: Effect.Effect<A, E, HttpClient.HttpClient>,
@@ -325,6 +337,7 @@ export const discoverAuthorizationServerMetadata = ( · 158 unmodified lines
325337 OAuthDiscoveryError
326338 > =>
327339 Effect.gen(function* () {
340+ yield* validateEndpointUrl(issuer, "issuer", options.endpointUrlPolicy);
328341 const issuerUrl = new URL(issuer);
329342 const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`;
330343 const issuerPath = issuerUrl.pathname.replace(/\/+$/, "");
@@ -380,6 +393,7 @@ export const discoverAuthorizationServerMetadata = ( · 49 unmodified lines
380393 }),
381394 ),
382395 );
396+ yield* validateAuthorizationServerMetadata(metadata, options.endpointUrlPolicy);
383397 return { metadataUrl, metadata };
384398 }
385399 return null;
@@ -480,12 +494,19 @@ export const registerDynamicClient = ( · 94 unmodified lines
480494 options: DiscoveryRequestOptions = {},
481495 ): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> =>
482496 Effect.gen(function* () {
483− const url = new URL(input.registrationEndpoint);
484− if (url.protocol !== "https:" && !isLoopbackHttpUrl(input.registrationEndpoint)) {
485− return yield* new DcrTransport({
486− detail: `registration_endpoint must be HTTPS or a loopback HTTP URL (got ${url.protocol}//${url.host})`,
487− });
488− }
497+ yield* validateEndpointUrl(
498+ input.registrationEndpoint,
499+ "registration_endpoint",
500+ options.endpointUrlPolicy,
501+ ).pipe(
502+ Effect.mapError(
503+ (cause) =>
504+ new DcrTransport({
505+ detail: "registration_endpoint must use https: or loopback http:",
506+ cause,
507+ }),
508+ ),
509+ );
489510
490511 const headers: Record<string, string> = {
491512 "content-type": "application/json",
@@ -573,6 +594,30 @@ export const canonicalResourceUrl = (value: string): string => { · 81 unmodified lines
573594 return `${scheme}//${host}${path}`;
574595 };
575596
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+
576621 // ---------------------------------------------------------------------------
577622 // Token-endpoint auth method negotiation
578623 //
@@ -667,6 +712,8 @@ export const beginDynamicAuthorization = ( · 88 unmodified lines
667712 : null
668713 : yield* discoverProtectedResourceMetadata(input.endpoint, options);
669714
715+ const expectedResource = canonicalResourceUrl(input.endpoint);
716+
670717 // RFC 9728 allows multiple authorization_servers — try each in
671718 // listed order. Fall back to the endpoint's origin only when no
672719 // PRM is advertised.
@@ -777,7 +824,9 @@ export const beginDynamicAuthorization = ( · 104 unmodified lines
777824 );
778825 })());
779826
780− const resourceValue = resource?.metadata.resource ?? canonicalResourceUrl(input.endpoint);
827+ const resourceValue = resource?.metadata.resource
828+ ? yield* validateResourceIndicator(resource.metadata.resource, expectedResource)
829+ : expectedResource;
781830
782831 const codeVerifier = createPkceCodeVerifier();
783832 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
@@ -790,6 +839,7 @@ export const beginDynamicAuthorization = ( · 6 unmodified lines
790839 state: input.state,
791840 codeChallenge,
792841 resource: resourceValue,
842+ endpointUrlPolicy: options.endpointUrlPolicy,
793843 });
794844
795845 return {
packages/core/sdk/src/oauth-helpers.test.ts+61−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 createPkceCodeChallenge,
1818 createPkceCodeVerifier,
1919 exchangeAuthorizationCode,
20+ exchangeClientCredentials,
2021 refreshAccessToken,
2122 shouldRefreshToken,
2223 } from "./oauth-helpers";
@@ -203,6 +204,32 @@ describe("buildAuthorizationUrl", () => { · 180 unmodified lines
203204 const url = new URL(buildAuthorizationUrl(baseInput));
204205 expect(url.searchParams.has("resource")).toBe(false);
205206 });
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+ });
206233 });
207234
208235 describe("exchangeAuthorizationCode", () => {
@@ -232,6 +259,23 @@ describe("exchangeAuthorizationCode", () => { · 23 unmodified lines
232259 ),
233260 );
234261
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+
235279 it.effect("omits client_secret when none is provided (public clients with PKCE)", () =>
236280 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
237281 Effect.gen(function* () {
@@ -549,6 +593,23 @@ describe("exchangeAuthorizationCode", () => { · 311 unmodified lines
549593 );
550594 });
551595
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+
552613 describe("refreshAccessToken", () => {
553614 it.effect("posts grant_type=refresh_token with the refresh token", () =>
554615 withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
packages/core/sdk/src/oauth-helpers.ts+72−22
@@ -57,6 +57,47 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 56 unmodified lines
5757 /** Default token-endpoint timeout. */
5858 export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
5959
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+
60101 // ---------------------------------------------------------------------------
61102 // PKCE (RFC 7636) — straight delegation to `oauth4webapi`
62103 // ---------------------------------------------------------------------------
@@ -86,12 +127,19 @@ export type BuildAuthorizationUrlInput = { · 23 unmodified lines
86127 readonly resource?: string;
87128 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
88129 readonly extraParams?: Readonly<Record<string, string>>;
130+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
89131 };
90132
91133 /** Build an RFC 6749 §4.1.1 authorization URL. Sync; pre-computed
92134 * challenge lets this stay out of the Promise world. */
93135 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+ );
95143 const separator = input.scopeSeparator ?? " ";
96144 url.searchParams.set("client_id", input.clientId);
97145 url.searchParams.set("redirect_uri", input.redirectUrl);
@@ -222,7 +270,11 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 124 unmodified lines
222270
223271 export type ClientAuthMethod = "body" | "basic";
224272
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);
226278 const url = new URL(tokenUrl);
227279 return {
228280 issuer: `${url.protocol}//${url.host}`,
@@ -235,9 +287,10 @@ const asFromTokenUrlAndIssuer = ( · 6 unmodified lines
235287 issuerUrl: string | null | undefined,
236288 options: {
237289 readonly idTokenSigningAlgValuesSupported?: readonly string[];
290+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
238291 } = {},
239292 ): oauth.AuthorizationServer => {
240− const as = asFromTokenUrl(tokenUrl);
293+ const as = asFromTokenUrl(tokenUrl, options.endpointUrlPolicy);
241294 const withIssuer = issuerUrl ? { ...as, issuer: issuerUrl } : as;
242295 return options.idTokenSigningAlgValuesSupported
243296 ? {
@@ -247,28 +300,20 @@ const asFromTokenUrlAndIssuer = ( · 3 unmodified lines
247300 : withIssuer;
248301 };
249302
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−
264303 const oauth4webapiRequestOptions = (
265304 targetUrl: string,
266305 timeoutMs: number | undefined,
306+ endpointUrlPolicy: OAuthEndpointUrlPolicy = {},
267307 ): Record<string, unknown> => {
268308 const options: Record<string, unknown> = {
269309 signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
270310 };
271− if (isLoopbackHttpUrl(targetUrl)) {
311+ if (
312+ isLoopbackHttpUrl(targetUrl) ||
313+ (URL.canParse(targetUrl) &&
314+ new URL(targetUrl).protocol === "http:" &&
315+ endpointUrlPolicy.allowHttp === true)
316+ ) {
272317 (options as { [oauth.allowInsecureRequests]?: boolean })[oauth.allowInsecureRequests] = true;
273318 }
274319 return options;
@@ -344,6 +389,7 @@ export type ExchangeAuthorizationCodeInput = { · 69 unmodified lines
344389 * to call. */
345390 readonly resource?: string;
346391 readonly timeoutMs?: number;
392+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
347393 };
348394
349395 export const exchangeAuthorizationCode = (
@@ -353,6 +399,7 @@ export const exchangeAuthorizationCode = ( · 3 unmodified lines
353399 try: async () => {
354400 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
355401 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
402+ endpointUrlPolicy: input.endpointUrlPolicy,
356403 });
357404 const client: oauth.Client = { client_id: input.clientId };
358405 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
@@ -375,7 +422,7 @@ export const exchangeAuthorizationCode = ( · 16 unmodified lines
375422 clientAuth,
376423 "authorization_code",
377424 params,
378− oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
425+ oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
379426 );
380427 return await processTokenEndpointResponse(as, client, response);
381428 },
@@ -394,6 +441,7 @@ export type ExchangeClientCredentialsInput = { · 12 unmodified lines
394441 readonly scopeSeparator?: string;
395442 readonly clientAuth?: ClientAuthMethod;
396443 readonly timeoutMs?: number;
444+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
397445 };
398446
399447 export const exchangeClientCredentials = (
@@ -401,7 +449,7 @@ export const exchangeClientCredentials = ( · 1 unmodified line
401449 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
402450 Effect.tryPromise({
403451 try: async () => {
404− const as = asFromTokenUrl(input.tokenUrl);
452+ const as = asFromTokenUrl(input.tokenUrl, input.endpointUrlPolicy);
405453 const client: oauth.Client = { client_id: input.clientId };
406454 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
407455 const params = new URLSearchParams();
@@ -413,7 +461,7 @@ export const exchangeClientCredentials = ( · 5 unmodified lines
413461 client,
414462 clientAuth,
415463 params,
416− oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
464+ oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
417465 );
418466 const result = await oauth.processClientCredentialsResponse(as, client, response);
419467 return tokenResponseFrom(result);
@@ -440,6 +488,7 @@ export type RefreshAccessTokenInput = { · 20 unmodified lines
440488 * the same resource. */
441489 readonly resource?: string;
442490 readonly timeoutMs?: number;
491+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
443492 };
444493
445494 export const refreshAccessToken = (
@@ -449,6 +498,7 @@ export const refreshAccessToken = ( · 3 unmodified lines
449498 try: async () => {
450499 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
451500 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
501+ endpointUrlPolicy: input.endpointUrlPolicy,
452502 });
453503 const client: oauth.Client = { client_id: input.clientId };
454504 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
@@ -467,7 +517,7 @@ export const refreshAccessToken = ( · 12 unmodified lines
467517 clientAuth,
468518 input.refreshToken,
469519 {
470− ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
520+ ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
471521 additionalParameters,
472522 },
473523 );
packages/core/sdk/src/oauth-service.ts+11−0
@@ -85,6 +85,7 @@ import { · 84 unmodified lines
8585 exchangeAuthorizationCode,
8686 exchangeClientCredentials,
8787 type OAuth2Error,
88+ type OAuthEndpointUrlPolicy,
8889 refreshAccessToken,
8990 } from "./oauth-helpers";
9091 import type { ScopedDBAdapter } from "./scoped-adapter";
@@ -201,6 +202,7 @@ export interface OAuthServiceDeps { · 110 unmodified lines
201202 readonly now?: () => number;
202203 /** Outbound HTTP client used for OAuth metadata/DCR probes. */
203204 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
205+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
204206 }
205207
206208 const defaultSessionId = (): string => {
@@ -244,6 +246,7 @@ export const makeOAuth2Service = ( · 37 unmodified lines
244246 const now = deps.now ?? (() => Date.now());
245247 const newSessionId = deps.newSessionId ?? defaultSessionId;
246248 const httpClientLayer = deps.httpClientLayer;
249+ const endpointUrlPolicy = deps.endpointUrlPolicy;
247250 const secretsGetResolved =
248251 deps.secretsGetResolved ??
249252 ((id: string) =>
@@ -380,6 +383,7 @@ export const makeOAuth2Service = ( · 130 unmodified lines
380383 httpClientLayer,
381384 resourceHeaders: input.headers,
382385 resourceQueryParams: input.queryParams,
386+ endpointUrlPolicy,
383387 },
384388 ).pipe(
385389 Effect.catchTag("OAuthDiscoveryError", ({ message, error, errorDescription }) =>
@@ -411,6 +415,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines
411415 state: sessionId,
412416 codeChallenge,
413417 resource: started.state.resource,
418+ endpointUrlPolicy,
414419 });
415420
416421 const payload: OAuthSessionPayload = {
@@ -480,6 +485,7 @@ export const makeOAuth2Service = ( · 63 unmodified lines
480485 codeChallenge,
481486 scopeSeparator: strategy.scopeSeparator,
482487 extraParams: strategy.extraAuthorizationParams,
488+ endpointUrlPolicy,
483489 });
484490
485491 const payload: OAuthSessionPayload = {
@@ -534,6 +540,7 @@ export const makeOAuth2Service = ( · 48 unmodified lines
534540 scopes: strategy.scopes,
535541 scopeSeparator: strategy.scopeSeparator,
536542 clientAuth: strategy.clientAuth ?? "body",
543+ endpointUrlPolicy,
537544 }).pipe(
538545 Effect.mapError(
539546 ({ message }: OAuth2Error) =>
@@ -890,6 +897,7 @@ export const makeOAuth2Service = ( · 350 unmodified lines
890897 idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,
891898 clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",
892899 resource: payload.resource ?? undefined,
900+ endpointUrlPolicy,
893901 }).pipe(
894902 Effect.mapError(
895903 ({ message, error }: OAuth2Error) =>
@@ -943,6 +951,7 @@ export const makeOAuth2Service = ( · 47 unmodified lines
943951 codeVerifier: payload.codeVerifier,
944952 code,
945953 clientAuth: payload.clientAuth,
954+ endpointUrlPolicy,
946955 }).pipe(
947956 Effect.mapError(
948957 ({ message, error }: OAuth2Error) =>
@@ -1132,6 +1141,7 @@ export const makeOAuth2Service = ( · 183 unmodified lines
11321141 scopes: state.scopes,
11331142 scopeSeparator: state.scopeSeparator,
11341143 clientAuth: state.clientAuth,
1144+ endpointUrlPolicy,
11351145 })
11361146 : refreshAccessToken({
11371147 tokenUrl: tokenEndpoint,
@@ -1153,6 +1163,7 @@ export const makeOAuth2Service = ( · 15 unmodified lines
11531163 clientAuth: state.clientAuth,
11541164 idTokenSigningAlgValuesSupported:
11551165 state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,
1166+ endpointUrlPolicy,
11561167 resource:
11571168 state.kind === "dynamic-dcr" || state.kind === "authorization-code"
11581169 ? (state.resource ?? undefined)
packages/react/src/api/oauth-popup.test.ts+114−0
@@ -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+});
packages/react/src/api/oauth-popup.ts+12−1
@@ -46,6 +46,12 @@ export type OpenOAuthPopupInput<TAuth> = { · 45 unmodified lines
4646 readonly closedPollMs?: number;
4747 };
4848
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+
4955 /**
5056 * Open a centered popup window at `url` and resolve when the popup posts
5157 * an `OAuthPopupResult` back to the opener. Returns a teardown function
@@ -60,6 +66,11 @@ export type OpenOAuthPopupInput<TAuth> = { · 8 unmodified lines
6066 * `onOpenFailed` on the next microtask and returns a no-op teardown.
6167 */
6268 export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {
69+ if (!isHttpPopupUrl(input.url)) {
70+ queueMicrotask(() => input.onOpenFailed?.());
71+ return () => {};
72+ }
73+
6374 const w = input.width ?? 640;
6475 const h = input.height ?? 760;
6576 const left = window.screenX + (window.outerWidth - w) / 2;
@@ -114,7 +125,7 @@ export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => · 48 unmodified lines
114125 const popup = window.open(
115126 input.url,
116127 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`,
118129 );
119130 if (!popup) {
120131 if (!settled) {