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
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).
apps/local/src/server/mcp-oauth.test.ts+1−0
@@ -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
packages/core/sdk/src/executor.ts+3−0
@@ -1,5 +1,6 @@
1import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";1import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";
2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
3import type { OAuthEndpointUrlPolicy } from "./oauth-helpers";
3import { generateKeyBetween } from "fractional-indexing";4import { generateKeyBetween } from "fractional-indexing";
4import {5import {
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
packages/core/sdk/src/index.ts+2−0
@@ -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,
packages/core/sdk/src/oauth-discovery.test.ts+63−10
@@ -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
185describe("registerDynamicClient", () => {231describe("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 );
packages/core/sdk/src/oauth-discovery.ts+74−24
@@ -23,9 +23,11 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ · 22 unmodified lines
23 23
24import {24import {
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
145const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";148const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
146 149
147const isLoopbackHttpUrl = (value: string): boolean => {150const validateEndpointUrl = (
148 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform parser; invalid URLs are not loopback HTTP151 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;164const 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
165const provideHttpClient = <A, E>(177const 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 OAuthDiscoveryError337 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
597const 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 negotiation622// Token-endpoint auth method negotiation
578//623//
@@ -667,6 +712,8 @@ export const beginDynamicAuthorization = ( · 88 unmodified lines
667 : null712 : 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 in717 // RFC 9728 allows multiple authorization_servers — try each in
671 // listed order. Fall back to the endpoint's origin only when no718 // 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 {
packages/core/sdk/src/oauth-helpers.test.ts+61−0
@@ -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
208describe("exchangeAuthorizationCode", () => {235describe("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
596describe("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
552describe("refreshAccessToken", () => {613describe("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 }) =>
packages/core/sdk/src/oauth-helpers.ts+72−22
@@ -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. */
58export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;58export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
59 59
60export interface OAuthEndpointUrlPolicy {
61 readonly allowHttp?: boolean;
62}
63
64const 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
78export 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
91export 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-computed133/** 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. */
93export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => {135export 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
223export type ClientAuthMethod = "body" | "basic";271export type ClientAuthMethod = "body" | "basic";
224 272
225const asFromTokenUrl = (tokenUrl: string): oauth.AuthorizationServer => {273const 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.idTokenSigningAlgValuesSupported295 return options.idTokenSigningAlgValuesSupported
243 ? {296 ? {
@@ -247,28 +300,20 @@ const asFromTokenUrlAndIssuer = ( · 3 unmodified lines
247 : withIssuer;300 : withIssuer;
248};301};
249 302
250const 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
264const oauth4webapiRequestOptions = (303const 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
349export const exchangeAuthorizationCode = (395export 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
399export const exchangeClientCredentials = (447export 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
445export const refreshAccessToken = (494export 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 );
packages/core/sdk/src/oauth-service.ts+11−0
@@ -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";
90import type { ScopedDBAdapter } from "./scoped-adapter";91import 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
206const defaultSessionId = (): string => {208const 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)
packages/react/src/api/oauth-popup.test.ts+114−0
@@ -0,0 +1,114 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { openOAuthPopup } from "./oauth-popup";
4
5type 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
20describe("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
46 readonly closedPollMs?: number;46 readonly closedPollMs?: number;
47};47};
48 48
49const 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 posts56 * Open a centered popup window at `url` and resolve when the popup posts
51 * an `OAuthPopupResult` back to the opener. Returns a teardown function57 * 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 */
62export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {68export 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) {