History

Fix OAuth popup completion and DCR reuse (#852)

Rhys Sullivan committed 5 months ago

Commit 6d177071 parent bc70f845 files · +201 −12Browse files

Unified · Split

5 files
  1. packages/core/sdk/src/executor.ts +1 −0
  2. packages/core/sdk/src/oauth-service.ts +122 −0
  3. packages/core/sdk/src/testing.test.ts +72 −0
  4. packages/react/src/api/oauth-popup.test.ts +3 −3
  5. packages/react/src/api/oauth-popup.ts +3 −9
packages/core/sdk/src/executor.ts+1−0
@@ -3021,6 +3021,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3020 unmodified lines
3021 ),3021 ),
3022 secretsSet: (input) => secretsSet(input),3022 secretsSet: (input) => secretsSet(input),
3023 connectionsCreate: (input) => connectionsCreate(input),3023 connectionsCreate: (input) => connectionsCreate(input),
3024 connectionsGet: (id) => connectionsGet(id),
3024 httpClientLayer: config.httpClientLayer,3025 httpClientLayer: config.httpClientLayer,
3025 endpointUrlPolicy: config.oauthEndpointUrlPolicy,3026 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
3026 });3027 });
packages/core/sdk/src/oauth-service.ts+122−0
@@ -76,6 +76,10 @@ import { · 75 unmodified lines
76 beginDynamicAuthorization,76 beginDynamicAuthorization,
77 discoverAuthorizationServerMetadata,77 discoverAuthorizationServerMetadata,
78 discoverProtectedResourceMetadata,78 discoverProtectedResourceMetadata,
79 type BeginDynamicAuthorizationInput,
80 type OAuthAuthorizationServerMetadata,
81 type OAuthClientInformation,
82 type OAuthProtectedResourceMetadata,
79} from "./oauth-discovery";83} from "./oauth-discovery";
80import {84import {
81 buildAuthorizationUrl,85 buildAuthorizationUrl,
@@ -111,6 +115,14 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 29 unmodified lines
111 resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))),115 resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))),
112});116});
113 117
118const PendingDynamicDcrSessionRows = Schema.Array(
119 Schema.Struct({
120 payload: Schema.Unknown,
121 expires_at: Schema.Union([Schema.Number, Schema.BigInt, Schema.String]),
122 created_at: Schema.Union([Schema.Date, Schema.String, Schema.Number]),
123 }),
124);
125
114const AuthorizationCodeSessionPayload = Schema.Struct({126const AuthorizationCodeSessionPayload = Schema.Struct({
115 kind: Schema.Literal("authorization-code"),127 kind: Schema.Literal("authorization-code"),
116 identityLabel: Schema.NullOr(Schema.String),128 identityLabel: Schema.NullOr(Schema.String),
@@ -141,14 +153,17 @@ const OAuthSessionPayload = Schema.Union([ · 24 unmodified lines
141 AuthorizationCodeSessionPayload,153 AuthorizationCodeSessionPayload,
142]);154]);
143type OAuthSessionPayload = typeof OAuthSessionPayload.Type;155type OAuthSessionPayload = typeof OAuthSessionPayload.Type;
156type PreviousDynamicAuthorizationState = BeginDynamicAuthorizationInput["previousState"];
144 157
145const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload);158const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload);
146const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload);159const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload);
160const isPendingDynamicDcrSessionRows = Schema.is(PendingDynamicDcrSessionRows);
147 161
148const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);162const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
149const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString);163const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString);
150 164
151const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema);165const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema);
166const decodeProviderStateOption = Schema.decodeUnknownOption(OAuthProviderStateSchema);
152const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema);167const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema);
153 168
154const coerceJson = (value: unknown): unknown => {169const coerceJson = (value: unknown): unknown => {
@@ -187,6 +202,10 @@ export interface OAuthServiceDeps { · 32 unmodified lines
187 readonly connectionsCreate: (202 readonly connectionsCreate: (
188 input: CreateConnectionInput,203 input: CreateConnectionInput,
189 ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>;204 ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>;
205 /** Reads an existing Connection so dynamic-DCR retries can reuse the
206 * registered OAuth client instead of registering a new client every
207 * time the user restarts a browser flow. */
208 readonly connectionsGet?: (id: string) => Effect.Effect<ConnectionRef | null, StorageFailure>;
190 /** Random session id generator. Tests override to make outputs209 /** Random session id generator. Tests override to make outputs
191 * deterministic. */210 * deterministic. */
192 readonly newSessionId?: () => string;211 readonly newSessionId?: () => string;
@@ -239,6 +258,7 @@ export const makeOAuth2Service = ( · 46 unmodified lines
239 const newSessionId = deps.newSessionId ?? defaultSessionId;258 const newSessionId = deps.newSessionId ?? defaultSessionId;
240 const httpClientLayer = deps.httpClientLayer;259 const httpClientLayer = deps.httpClientLayer;
241 const endpointUrlPolicy = deps.endpointUrlPolicy;260 const endpointUrlPolicy = deps.endpointUrlPolicy;
261 const connectionsGet = deps.connectionsGet ?? (() => Effect.succeed(null));
242 const secretsGetResolved =262 const secretsGetResolved =
243 deps.secretsGetResolved ??263 deps.secretsGetResolved ??
244 ((id: string) =>264 ((id: string) =>
@@ -372,17 +392,119 @@ export const makeOAuth2Service = ( · 127 unmodified lines
372 // -------------------------------------------------------------------392 // -------------------------------------------------------------------
373 // start — branches on strategy.kind393 // start — branches on strategy.kind
374 // -------------------------------------------------------------------394 // -------------------------------------------------------------------
395
396 const dynamicClientAuthMethod = (
397 state: Extract<OAuthProviderState, { kind: "dynamic-dcr" }>,
398 ): "none" | "client_secret_basic" | "client_secret_post" =>
399 state.clientSecretSecretId
400 ? state.clientAuth === "basic"
401 ? "client_secret_basic"
402 : "client_secret_post"
403 : "none";
404
405 const timestampMillis = (value: unknown): number => {
406 if (value instanceof Date) return value.getTime();
407 if (typeof value === "string" || typeof value === "number") return new Date(value).getTime();
408 return 0;
409 };
410
411 const previousDynamicStateFromConnection = (
412 connectionId: string,
413 ): Effect.Effect<PreviousDynamicAuthorizationState | undefined, StorageFailure> =>
414 Effect.gen(function* () {
415 const existing = yield* connectionsGet(connectionId);
416 const state = existing?.providerState
417 ? Option.getOrNull(decodeProviderStateOption(coerceJson(existing.providerState)))
418 : null;
419 if (!state || state.kind !== "dynamic-dcr") return undefined;
420
421 const clientSecret =
422 state.clientSecretSecretId !== null
423 ? yield* getSecretFromRecordedScope({
424 secretId: state.clientSecretSecretId,
425 scopeId: state.clientSecretSecretScopeId ?? null,
426 })
427 : null;
428 if (state.clientSecretSecretId !== null && !clientSecret) return undefined;
429
430 return {
431 authorizationServerUrl: state.authorizationServerUrl ?? null,
432 authorizationServerMetadataUrl: state.authorizationServerMetadataUrl,
433 clientInformation: {
434 client_id: state.clientId,
435 token_endpoint_auth_method: dynamicClientAuthMethod(state),
436 ...(clientSecret ? { client_secret: clientSecret } : {}),
437 },
438 };
439 });
440
441 const previousDynamicStateFromPendingSession = (input: {
442 readonly connectionId: string;
443 readonly tokenScope: string;
444 }): Effect.Effect<PreviousDynamicAuthorizationState | undefined, StorageFailure> =>
445 Effect.gen(function* () {
446 const rowsRaw = yield* deps.fuma.use("oauth2_session.findReusableDynamicDcr", (db) =>
447 db.findMany("oauth2_session", {
448 where: (b) =>
449 b.and(
450 b("connection_id", "=", input.connectionId),
451 b("token_scope", "=", input.tokenScope),
452 b("strategy", "=", "dynamic-dcr"),
453 ),
454 }),
455 );
456 const rows = isPendingDynamicDcrSessionRows(rowsRaw) ? rowsRaw : [];
457
458 const reusable = rows
459 .filter((row) => Number(row.expires_at) > now())
460 .sort((a, b) => {
461 const aTime = timestampMillis(a.created_at);
462 const bTime = timestampMillis(b.created_at);
463 return bTime - aTime;
464 });
465
466 for (const row of reusable) {
467 const payload = decodeSessionPayload(row.payload);
468 if (payload.kind !== "dynamic-dcr") continue;
469 return {
470 authorizationServerUrl: payload.authorizationServerUrl,
471 authorizationServerMetadataUrl: payload.authorizationServerMetadataUrl,
472 authorizationServerMetadata:
473 payload.authorizationServerMetadata as OAuthAuthorizationServerMetadata,
474 resourceMetadata: payload.resourceMetadata as OAuthProtectedResourceMetadata | null,
475 resourceMetadataUrl: payload.resourceMetadataUrl,
476 clientInformation: payload.clientInformation as OAuthClientInformation,
477 };
478 }
479 return undefined;
480 });
481
482 const previousDynamicState = (input: {
483 readonly connectionId: string;
484 readonly tokenScope: string;
485 }) =>
486 previousDynamicStateFromPendingSession(input).pipe(
487 Effect.flatMap((pending) =>
488 pending ? Effect.succeed(pending) : previousDynamicStateFromConnection(input.connectionId),
489 ),
490 );
491
375 const startDynamicDcr = (492 const startDynamicDcr = (
376 input: OAuthStartInput,493 input: OAuthStartInput,
377 strategy: OAuthDynamicDcrStrategy,494 strategy: OAuthDynamicDcrStrategy,
378 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>495 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
379 Effect.gen(function* () {496 Effect.gen(function* () {
497 const previousState = yield* previousDynamicState({
498 connectionId: input.connectionId,
499 tokenScope: input.tokenScope,
500 });
380 const started = yield* beginDynamicAuthorization(501 const started = yield* beginDynamicAuthorization(
381 {502 {
382 endpoint: input.endpoint,503 endpoint: input.endpoint,
383 redirectUrl: input.redirectUrl,504 redirectUrl: input.redirectUrl,
384 state: "",505 state: "",
385 scopes: strategy.scopes,506 scopes: strategy.scopes,
507 previousState,
386 },508 },
387 {509 {
388 httpClientLayer,510 httpClientLayer,
packages/core/sdk/src/testing.test.ts+72−0
@@ -115,6 +115,78 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 114 unmodified lines
115 }),115 }),
116 );116 );
117 117
118 it.effect("dynamic client registration is reused across OAuth start retries", () =>
119 Effect.gen(function* () {
120 const workspace = yield* TestWorkspace.current<typeof plugins>();
121 const oauth = yield* OAuthTestServer;
122 const scope = workspace.scopes[0]!;
123 yield* oauth.clearRequests;
124
125 const start = () =>
126 workspace.executor.oauth.start({
127 endpoint: oauth.mcpResourceUrl,
128 connectionId: "test-oauth-dcr-retry",
129 tokenScope: String(scope.id),
130 redirectUrl: "http://127.0.0.1/callback",
131 pluginId: "test",
132 identityLabel: "MCP OAuth Test",
133 strategy: { kind: "dynamic-dcr", scopes: ["read"] },
134 });
135
136 const startedA = yield* start();
137 const startedB = yield* start();
138
139 expect(startedA.authorizationUrl).not.toBeNull();
140 expect(startedB.authorizationUrl).not.toBeNull();
141 expect(
142 (yield* oauth.requests).filter((request) => request.path === "/register"),
143 ).toHaveLength(1);
144 expect(new URL(startedB.authorizationUrl ?? "").searchParams.get("client_id")).toBe(
145 new URL(startedA.authorizationUrl ?? "").searchParams.get("client_id"),
146 );
147 }),
148 );
149
150 it.effect("dynamic client registration is reused after a completed connection reconnect", () =>
151 Effect.gen(function* () {
152 const workspace = yield* TestWorkspace.current<typeof plugins>();
153 const oauth = yield* OAuthTestServer;
154 const scope = workspace.scopes[0]!;
155 yield* oauth.clearRequests;
156
157 const start = () =>
158 workspace.executor.oauth.start({
159 endpoint: oauth.mcpResourceUrl,
160 connectionId: "test-oauth-dcr-reconnect",
161 tokenScope: String(scope.id),
162 redirectUrl: "http://127.0.0.1/callback",
163 pluginId: "test",
164 identityLabel: "MCP OAuth Test",
165 strategy: { kind: "dynamic-dcr", scopes: ["read"] },
166 });
167
168 const startedA = yield* start();
169 const callback = yield* oauth.completeAuthorizationCodeFlow({
170 authorizationUrl: startedA.authorizationUrl ?? "",
171 });
172 yield* workspace.executor.oauth.complete({
173 state: callback.state,
174 code: callback.code,
175 tokenScope: String(scope.id),
176 });
177
178 const startedB = yield* start();
179
180 expect(startedB.authorizationUrl).not.toBeNull();
181 expect(
182 (yield* oauth.requests).filter((request) => request.path === "/register"),
183 ).toHaveLength(1);
184 expect(new URL(startedB.authorizationUrl ?? "").searchParams.get("client_id")).toBe(
185 new URL(startedA.authorizationUrl ?? "").searchParams.get("client_id"),
186 );
187 }),
188 );
189
118 it.effect(190 it.effect(
119 "OAuthTestServer can mint a bearer token through the full authorization-code flow",191 "OAuthTestServer can mint a bearer token through the full authorization-code flow",
120 () =>192 () =>
packages/react/src/api/oauth-popup.test.ts+3−3
@@ -42,7 +42,7 @@ describe("openOAuthPopup", () => { · 41 unmodified lines
42 expect(openFailed).toBe(true);42 expect(openFailed).toBe(true);
43 });43 });
44 44
45 it("opens supported OAuth URLs through a reserved popup", () => {45 it("opens supported OAuth URLs through a reserved popup with opener available", () => {
46 let features = "";46 let features = "";
47 let opened = "";47 let opened = "";
48 const popup: FakePopup = { closed: false, close: () => {}, location: { href: "" } };48 const popup: FakePopup = { closed: false, close: () => {}, location: { href: "" } };
@@ -81,7 +81,7 @@ describe("openOAuthPopup", () => { · 32 unmodified lines
81 writable: true,81 writable: true,
82 });82 });
83 expect(opened).toBe("about:blank");83 expect(opened).toBe("about:blank");
84 expect(popup.opener).toBe(null);84 expect(popup.opener).toBeUndefined();
85 expect(popup.location.href).toBe("https://auth.example/authorize");85 expect(popup.location.href).toBe("https://auth.example/authorize");
86 expect(features).toContain("popup=1");86 expect(features).toContain("popup=1");
87 expect(features).not.toContain("noopener");87 expect(features).not.toContain("noopener");
@@ -128,7 +128,7 @@ describe("openOAuthPopup", () => { · 40 unmodified lines
128 });128 });
129 expect(opened).toBe("about:blank");129 expect(opened).toBe("about:blank");
130 expect(reservedPopup).not.toBeNull();130 expect(reservedPopup).not.toBeNull();
131 expect(popup.opener).toBe(null);131 expect(popup.opener).toBeUndefined();
132 expect(popup.location.href).toBe("https://auth.example/authorize");132 expect(popup.location.href).toBe("https://auth.example/authorize");
133 });133 });
134 134
packages/react/src/api/oauth-popup.ts+3−9
@@ -72,15 +72,9 @@ export const reserveOAuthPopup = (input: { · 71 unmodified lines
72}): ReservedOAuthPopup | null => {72}): ReservedOAuthPopup | null => {
73 const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input));73 const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input));
74 if (!popup) return null;74 if (!popup) return null;
75 // The app keeps a WindowProxy for navigation/closed polling, but the75 // Keep opener available for the same-origin callback page. Browser
76 // provider should not receive opener access after the reserved window76 // BroadcastChannel delivery can be partitioned in popup flows, so the
77 // is navigated cross-origin. The callback uses BroadcastChannel.77 // callback's postMessage path is the primary completion signal.
78 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: popup opener access can throw in browser-specific states
79 try {
80 popup.opener = null;
81 } catch {
82 // Best-effort hardening; the popup handle is still usable for the flow.
83 }
84 return { popup };78 return { popup };
85};79};
86 80