History

Update OAuth refresh reauth assertion

Rhys Sullivan committed 6 months ago

Commit 418b0961 parent 70d01de1 file · +173 −193Browse files

Unified · Split

packages/plugins/openapi/src/sdk/oauth-refresh.test.ts+173−193
@@ -13,14 +13,19 @@ · 12 unmodified lines
13// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
14 14
15import { afterEach, expect, layer } from "@effect/vitest";15import { afterEach, expect, layer } from "@effect/vitest";
16import { Effect, Layer, Schema } from "effect";16import { Effect, Layer, Predicate, Schema } from "effect";
17import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";17import {
18 HttpApi,
19 HttpApiBuilder,
20 HttpApiEndpoint,
21 HttpApiGroup,
22 OpenApi,
23} from "effect/unstable/httpapi";
18import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";24import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";
19import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";25import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
20 26
21import {27import {
22 ConnectionId,28 ConnectionId,
23 ConnectionReauthRequiredError,
24 CreateConnectionInput,29 CreateConnectionInput,
25 ScopeId,30 ScopeId,
26 SecretId,31 SecretId,
@@ -90,23 +95,26 @@ const mockTokenFetch = ( · 63 unmodified lines
90 handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>,95 handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>,
91) => {96) => {
92 const calls: TokenCall[] = [];97 const calls: TokenCall[] = [];
93 globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => {98 globalThis.fetch = Object.assign(
94 const url = typeof _input === "string" ? _input : _input.toString();99 async (_input: RequestInfo | URL, init?: RequestInit) => {
95 if (!url.includes("token.example.com")) {100 const url = typeof _input === "string" ? _input : _input.toString();
96 return originalFetch(_input, init);101 if (!url.includes("token.example.com")) {
97 }102 return originalFetch(_input, init);
98 const bodyText =103 }
99 init?.body instanceof URLSearchParams104 const bodyText =
100 ? init.body.toString()105 init?.body instanceof URLSearchParams
101 : typeof init?.body === "string"106 ? init.body.toString()
102 ? init.body107 : typeof init?.body === "string"
103 : "";108 ? init.body
104 const body = new URLSearchParams(bodyText);109 : "";
105 calls.push({ body });110 const body = new URLSearchParams(bodyText);
106 const out = handler(body);111 calls.push({ body });
107 if (Effect.isEffect(out)) return await Effect.runPromise(out);112 const out = handler(body);
108 return await out;113 if (Effect.isEffect(out)) return await Effect.runPromise(out);
109 }, { preconnect: originalFetch.preconnect });114 return await out;
115 },
116 { preconnect: originalFetch.preconnect },
117 );
110 return { calls };118 return { calls };
111};119};
112 120
@@ -127,25 +135,26 @@ const makeExecutor = () => · 14 unmodified lines
127 const memoryProvider: SecretProvider = {135 const memoryProvider: SecretProvider = {
128 key: "memory",136 key: "memory",
129 writable: true,137 writable: true,
130 get: (id, scope) =>138 get: (id, scope) => Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
131 Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
132 set: (id, value, scope) =>139 set: (id, value, scope) =>
133 Effect.sync(() => {140 Effect.sync(() => {
134 secretStore.set(keyOf(scope, id), value);141 secretStore.set(keyOf(scope, id), value);
135 }),142 }),
136 delete: (id, scope) =>143 delete: (id, scope) => Effect.sync(() => secretStore.delete(keyOf(scope, id))),
137 Effect.sync(() => secretStore.delete(keyOf(scope, id))),
138 };144 };
139 const memorySecretsPlugin = definePlugin(() => ({145 const memorySecretsPlugin = definePlugin(() => ({
140 id: "memory-secrets" as const,146 id: "memory-secrets" as const,
141 storage: () => ({}),147 storage: () => ({}),
142 secretProviders: [memoryProvider],148 secretProviders: [memoryProvider],
143 }));149 }));
144 const clientLayer = FetchHttpClient.layer;150 const clientLayer = FetchHttpClient.layer;
145 const server = yield* HttpServer.HttpServer;151 const server = yield* HttpServer.HttpServer;
146 const address = server.address;152 const address = server.address;
147 if (address._tag !== "TcpAddress") return yield* Effect.die("test server must bind to TCP");153 if (!Predicate.isTagged("TcpAddress")(address)) {
148 const baseUrl = `http://127.0.0.1:${address.port}`;154 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address
155 return yield* Effect.die("test server must bind to TCP");
156 }
157 const baseUrl = `http://127.0.0.1:${address.port}`;
149 const plugins = [158 const plugins = [
150 openApiPlugin({ httpClientLayer: clientLayer }),159 openApiPlugin({ httpClientLayer: clientLayer }),
151 memorySecretsPlugin(),160 memorySecretsPlugin(),
@@ -198,11 +207,7 @@ type ExecutorValue = EffectSuccess<ReturnType<typeof makeExecutor>>["executor"]; · 46 unmodified lines
198// Seed an authorizationCode Connection with an already-expired access207// Seed an authorizationCode Connection with an already-expired access
199// token and a stored refresh token. The test's mock token endpoint208// token and a stored refresh token. The test's mock token endpoint
200// decides what comes back on `grant_type=refresh_token`.209// decides what comes back on `grant_type=refresh_token`.
201const seedExpiredConnection = (210const seedExpiredConnection = (executor: ExecutorValue, scopeId: ScopeId, connectionId: string) =>
202 executor: ExecutorValue,
203 scopeId: ScopeId,
204 connectionId: string,
205) =>
206 Effect.gen(function* () {211 Effect.gen(function* () {
207 yield* executor.connections.create(212 yield* executor.connections.create(
208 new CreateConnectionInput({213 new CreateConnectionInput({
@@ -249,169 +254,144 @@ const seedExpiredConnection = ( · 40 unmodified lines
249// ---------------------------------------------------------------------------254// ---------------------------------------------------------------------------
250 255
251layer(TestLayer)("OpenAPI oauth refresh", (it) => {256layer(TestLayer)("OpenAPI oauth refresh", (it) => {
252 it.effect(257 it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () =>
253 "expired access_token is refreshed via grant_type=refresh_token before invoke",258 Effect.gen(function* () {
254 () =>259 const { executor, scopeId, baseUrl } = yield* makeExecutor();
255 Effect.gen(function* () {260 const { calls } = mockTokenFetch(() =>
256 const { executor, scopeId, baseUrl } = yield* makeExecutor();261 Effect.succeed(
257 const { calls } = mockTokenFetch(262 new Response(
258 () =>263 JSON.stringify({
259 Effect.succeed(264 access_token: "fresh-access-v2",
260 new Response(265 token_type: "Bearer",
261 JSON.stringify({266 refresh_token: "refresh-v2",
262 access_token: "fresh-access-v2",267 expires_in: 3600,
263 token_type: "Bearer",268 }),
264 refresh_token: "refresh-v2",269 { status: 200, headers: { "content-type": "application/json" } },
265 expires_in: 3600,270 ),
266 }),271 ),
267 { status: 200, headers: { "content-type": "application/json" } },272 );
268 ),273
269 ),274 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-ok");
270 );275
271 276 yield* executor.openapi.addSpec({
272 const auth = yield* seedExpiredConnection(277 spec: specJson,
273 executor,278 scope: String(scopeId),
274 scopeId,279 namespace: "petstore",
275 "conn-refresh-ok",280 baseUrl,
276 );281 oauth2: auth,
277 282 });
278 yield* executor.openapi.addSpec({283
279 spec: specJson,284 const result = (yield* executor.tools.invoke(
280 scope: String(scopeId),285 "petstore.items.echoHeaders",
281 namespace: "petstore",286 {},
282 baseUrl,287 autoApprove,
283 oauth2: auth,288 )) as { data: { authorization?: string } | null; error: unknown };
284 });289
285 290 expect(result.error).toBeNull();
286 const result = (yield* executor.tools.invoke(291 // Proves the refresh landed: invoke carried the fresh token,
287 "petstore.items.echoHeaders",292 // not the expired one we seeded.
288 {},293 expect(result.data?.authorization).toBe("Bearer fresh-access-v2");
289 autoApprove,294 expect(calls).toHaveLength(1);
290 )) as { data: { authorization?: string } | null; error: unknown };295 expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");
291 296 expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");
292 expect(result.error).toBeNull();297
293 // Proves the refresh landed: invoke carried the fresh token,298 // Connection row is patched with the new expiry so the next
294 // not the expired one we seeded.299 // invoke in-window doesn't trip a second refresh.
295 expect(result.data?.authorization).toBe("Bearer fresh-access-v2");300 const conn = yield* executor.connections.get("conn-refresh-ok");
296 expect(calls).toHaveLength(1);301 expect(conn).not.toBeNull();
297 expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");302 expect(conn!.expiresAt).not.toBeNull();
298 expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");303 expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
299 304 }),
300 // Connection row is patched with the new expiry so the next
301 // invoke in-window doesn't trip a second refresh.
302 const conn = yield* executor.connections.get("conn-refresh-ok");
303 expect(conn).not.toBeNull();
304 expect(conn!.expiresAt).not.toBeNull();
305 expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
306 }),
307 );305 );
308 306
309 it.effect(307 it.effect("concurrent invokes with an expired token issue exactly one refresh", () =>
310 "concurrent invokes with an expired token issue exactly one refresh",308 Effect.gen(function* () {
311 () =>309 const { executor, scopeId, baseUrl } = yield* makeExecutor();
312 Effect.gen(function* () {310 const { calls } = mockTokenFetch(() =>
313 const { executor, scopeId, baseUrl } = yield* makeExecutor();311 Effect.succeed(
314 const { calls } = mockTokenFetch(312 new Response(
315 () =>313 JSON.stringify({
316 Effect.succeed(314 access_token: "fresh-access-v2",
317 new Response(315 token_type: "Bearer",
318 JSON.stringify({316 refresh_token: "refresh-v2",
319 access_token: "fresh-access-v2",317 expires_in: 3600,
320 token_type: "Bearer",318 }),
321 refresh_token: "refresh-v2",319 { status: 200, headers: { "content-type": "application/json" } },
322 expires_in: 3600,
323 }),
324 { status: 200, headers: { "content-type": "application/json" } },
325 ),
326 ),
327 );
328
329 const auth = yield* seedExpiredConnection(
330 executor,
331 scopeId,
332 "conn-refresh-concurrent",
333 );
334
335 yield* executor.openapi.addSpec({
336 spec: specJson,
337 scope: String(scopeId),
338 namespace: "petstore",
339 baseUrl,
340 oauth2: auth,
341 });
342
343 const invokes = yield* Effect.all(
344 [1, 2, 3, 4, 5].map(() =>
345 executor.tools.invoke(
346 "petstore.items.echoHeaders",
347 {},
348 autoApprove,
349 ),
350 ),320 ),
351 { concurrency: "unbounded" },321 ),
352 );322 );
353 323
354 for (const r of invokes) {324 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-concurrent");
355 const res = r as {325
356 data: { authorization?: string } | null;326 yield* executor.openapi.addSpec({
357 error: unknown;327 spec: specJson,
358 };328 scope: String(scopeId),
359 expect(res.error).toBeNull();329 namespace: "petstore",
360 expect(res.data?.authorization).toBe("Bearer fresh-access-v2");330 baseUrl,
361 }331 oauth2: auth,
362 // Critical assertion: the SDK's dedup collapses every parallel332 });
363 // invoke into one call to the token endpoint. Anything more333
364 // means we're hammering the AS under load.334 const invokes = yield* Effect.all(
365 expect(calls).toHaveLength(1);335 [1, 2, 3, 4, 5].map(() =>
366 }),336 executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
337 ),
338 { concurrency: "unbounded" },
339 );
340
341 for (const r of invokes) {
342 const res = r as {
343 data: { authorization?: string } | null;
344 error: unknown;
345 };
346 expect(res.error).toBeNull();
347 expect(res.data?.authorization).toBe("Bearer fresh-access-v2");
348 }
349 // Critical assertion: the SDK's dedup collapses every parallel
350 // invoke into one call to the token endpoint. Anything more
351 // means we're hammering the AS under load.
352 expect(calls).toHaveLength(1);
353 }),
367 );354 );
368 355
369 it.effect(356 it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () =>
370 "invalid_grant from refresh surfaces as ConnectionReauthRequiredError",357 Effect.gen(function* () {
371 () =>358 const { executor, scopeId, baseUrl } = yield* makeExecutor();
372 Effect.gen(function* () {359 mockTokenFetch(() =>
373 const { executor, scopeId, baseUrl } = yield* makeExecutor();360 Effect.succeed(
374 mockTokenFetch(361 new Response(
375 () =>362 JSON.stringify({
376 Effect.succeed(363 error: "invalid_grant",
377 new Response(364 error_description: "Refresh token revoked",
378 JSON.stringify({365 }),
379 error: "invalid_grant",366 { status: 400, headers: { "content-type": "application/json" } },
380 error_description: "Refresh token revoked",367 ),
381 }),368 ),
382 { status: 400, headers: { "content-type": "application/json" } },369 );
383 ),370
384 ),371 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-dead");
385 );372
386 373 yield* executor.openapi.addSpec({
387 const auth = yield* seedExpiredConnection(374 spec: specJson,
388 executor,375 scope: String(scopeId),
389 scopeId,376 namespace: "petstore",
390 "conn-refresh-dead",377 baseUrl,
391 );378 oauth2: auth,
392 379 });
393 yield* executor.openapi.addSpec({380
394 spec: specJson,381 // Tool invocation currently wraps connection errors in a
395 scope: String(scopeId),382 // generic Error (see openapi invokeTool), so we assert against
396 namespace: "petstore",383 // the `accessToken` call directly too — that's the surface
397 baseUrl,384 // the UI bridges use to trigger re-auth.
398 oauth2: auth,385 const flipped = yield* executor.connections.accessToken("conn-refresh-dead").pipe(
399 });386 Effect.flip,
400 387 Effect.flatMap((error) =>
401 // Tool invocation currently wraps connection errors in a388 Predicate.isTagged("ConnectionReauthRequiredError")(error)
402 // generic Error (see openapi invokeTool), so we assert against389 ? Effect.succeed(error)
403 // the `accessToken` call directly too — that's the surface390 : Effect.fail(error),
404 // the UI bridges use to trigger re-auth.391 ),
405 const flipped = yield* executor.connections392 );
406 .accessToken("conn-refresh-dead")393 expect(flipped.provider).toBe("openapi:oauth2");
407 .pipe(Effect.flip);394 expect(flipped.message).toBe("OAuth refresh failed");
408 expect(flipped._tag).toBe("ConnectionReauthRequiredError");395 }),
409 expect((flipped as ConnectionReauthRequiredError).provider).toBe(
410 "openapi:oauth2",
411 );
412 expect(
413 (flipped as ConnectionReauthRequiredError).message,
414 ).toMatch(/invalid_grant|revoked/i);
415 }),
416 );396 );
417});397});