427 lines · 14.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// End-to-end refresh behaviour for the OpenAPI plugin's oauth2 connection// provider.//// The existing `multi-scope-oauth.test.ts` covers sign-in isolation; this// file focuses on RFC 6749 §6 refresh behaviour at the plugin boundary:////   1. An expired access_token is refreshed transparently before invoke.//   2. Concurrent invokes collapse to a single `grant_type=refresh_token`//      POST — the SDK's dedup applies to the plugin's provider.//   3. `invalid_grant` from the token endpoint surfaces as//      `ConnectionReauthRequiredError` so the UI can prompt sign-in.// --------------------------------------------------------------------------- import { expect, layer } from "@effect/vitest";import { Effect, Layer, Predicate, Ref, Schema } from "effect";import {  HttpApi,  HttpApiBuilder,  HttpApiEndpoint,  HttpApiGroup,  OpenApi,} from "effect/unstable/httpapi";import {  FetchHttpClient,  HttpRouter,  HttpServer,  HttpServerRequest,  HttpServerResponse,} from "effect/unstable/http";import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import {  ConnectionId,  CreateConnectionInput,  ScopeId,  SecretId,  Scope,  SetSecretInput,  TokenMaterial,  OAUTH2_PROVIDER_KEY,  collectSchemas,  createExecutor,  definePlugin,  makeInMemoryBlobStore,  type InvokeOptions,  type SecretProvider,} from "@executor-js/sdk";import { serveTestHttpApp } from "@executor-js/sdk/testing";import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin";import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; // ---------------------------------------------------------------------------// Test API — one endpoint that echoes the Authorization header so we can// prove which access token was in flight at invoke time.// --------------------------------------------------------------------------- const EchoHeaders = Schema.Struct({  authorization: Schema.optional(Schema.String),});type EchoHeaders = typeof EchoHeaders.Type; const ItemsGroup = HttpApiGroup.make("items").add(  HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }),); const TestApi = HttpApi.make("testApi").add(ItemsGroup);const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>  handlers.handle("echoHeaders", () =>    Effect.gen(function* () {      const req = yield* HttpServerRequest.HttpServerRequest;      return EchoHeaders.make({        authorization: req.headers["authorization"],      });    }),  ),); const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe(  Layer.provideMerge(NodeHttpServer.layerTest),); // ---------------------------------------------------------------------------// Token-endpoint mock. Callers supply a handler that sees the parsed body// (grant_type, refresh_token, ...) and returns either an RFC 6749 success// response or an error envelope. `calls` records every hit for assertions.// --------------------------------------------------------------------------- type TokenCall = {  readonly body: URLSearchParams;}; const serveTokenEndpoint = (  handler: (body: URLSearchParams) => HttpServerResponse.HttpServerResponse,) =>  Effect.gen(function* () {    const calls = yield* Ref.make<readonly TokenCall[]>([]);    const server = yield* serveTestHttpApp((request) =>      Effect.gen(function* () {        const body = new URLSearchParams(yield* request.text);        yield* Ref.update(calls, (all) => [...all, { body }]);        return handler(body);      }).pipe(        Effect.catch(() =>          Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })),        ),      ),    );    return {      tokenUrl: server.url("/token"),      calls: Ref.get(calls),    } as const;  }); // ---------------------------------------------------------------------------// Fixture builder. Wires up a single-scope executor with an in-memory// secrets provider, the openApi plugin pointed at a live HttpClient, and// seeds an expired oauth2 Connection + source pointing at that server.// --------------------------------------------------------------------------- const makeExecutor = () =>  Effect.gen(function* () {    const secretStore = new Map<string, string>();    const keyOf = (scope: string, id: string) => `${scope} ${id}`;    const memoryProvider: SecretProvider = {      key: "memory",      writable: true,      get: (id, scope) => Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),      set: (id, value, scope) =>        Effect.sync(() => {          secretStore.set(keyOf(scope, id), value);        }),      delete: (id, scope) => Effect.sync(() => secretStore.delete(keyOf(scope, id))),    };    const memorySecretsPlugin = definePlugin(() => ({      id: "memory-secrets" as const,      storage: () => ({}),      secretProviders: [memoryProvider],    }));    const clientLayer = FetchHttpClient.layer;    const server = yield* HttpServer.HttpServer;    const address = server.address;    if (!Predicate.isTagged("TcpAddress")(address)) {      // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address      return yield* Effect.die("test server must bind to TCP");    }    const baseUrl = `http://127.0.0.1:${address.port}`;    const plugins = [      openApiPlugin({ httpClientLayer: clientLayer }),      memorySecretsPlugin(),    ] as const;     const schema = collectSchemas(plugins);    const adapter = makeMemoryAdapter({ schema });    const blobs = makeInMemoryBlobStore();     const scopeId = ScopeId.make("test-scope");    const scope = Scope.make({      id: scopeId,      name: "test",      createdAt: new Date(),    });    const executor = yield* createExecutor({      scopes: [scope],      adapter,      blobs,      plugins,      onElicitation: "accept-all",    });     // Seed client id + secret in the executor scope so the openapi    // provider's refresh can resolve them.    yield* executor.secrets.set(      SetSecretInput.make({        id: SecretId.make("client_id"),        scope: scopeId,        name: "Client ID",        value: "abc",      }),    );    yield* executor.secrets.set(      SetSecretInput.make({        id: SecretId.make("client_secret"),        scope: scopeId,        name: "Client Secret",        value: "shhh",      }),    );     return { executor, scopeId, baseUrl };  }); type EffectSuccess<T> = T extends Effect.Effect<infer A, unknown, unknown> ? A : never; type ExecutorValue = EffectSuccess<ReturnType<typeof makeExecutor>>["executor"]; // Seed an authorizationCode Connection with an already-expired access// token and a stored refresh token. The test's mock token endpoint// decides what comes back on `grant_type=refresh_token`.const seedExpiredConnection = (  executor: ExecutorValue,  scopeId: ScopeId,  connectionId: string,  tokenUrl: string,) =>  Effect.gen(function* () {    yield* executor.connections.create(      CreateConnectionInput.make({        id: ConnectionId.make(connectionId),        scope: scopeId,        provider: OAUTH2_PROVIDER_KEY,        identityLabel: "Alice",        accessToken: TokenMaterial.make({          secretId: SecretId.make(`${connectionId}.access_token`),          name: "Access",          value: "expired-access-v1",        }),        refreshToken: TokenMaterial.make({          secretId: SecretId.make(`${connectionId}.refresh_token`),          name: "Refresh",          value: "refresh-v1",        }),        expiresAt: Date.now() - 10_000,        oauthScope: "read",        providerState: {          kind: "authorization-code",          tokenEndpoint: tokenUrl,          issuerUrl: null,          clientIdSecretId: "client_id",          clientSecretSecretId: "client_secret",          clientAuth: "body",          scopes: ["read"],          scope: "read",        },      }),    );    return OAuth2SourceConfig.make({      kind: "oauth2",      securitySchemeName: "oauth2",      flow: "authorizationCode",      tokenUrl,      authorizationUrl: "https://auth.example.com/authorize",      clientIdSlot: "oauth2:oauth2:client-id",      clientSecretSlot: "oauth2:oauth2:client-secret",      connectionSlot: "oauth2:oauth2:connection",      scopes: ["read"],    });  }); const bindOAuthConnection = (  executor: ExecutorValue,  scopeId: ScopeId,  connectionId: string,  oauth2: OAuth2SourceConfig,) =>  executor.openapi.setSourceBinding(    OpenApiSourceBindingInput.make({      sourceId: "petstore",      sourceScope: scopeId,      scope: scopeId,      slot: oauth2.connectionSlot,      value: { kind: "connection", connectionId: ConnectionId.make(connectionId) },    }),  ); // ---------------------------------------------------------------------------// Tests// --------------------------------------------------------------------------- layer(TestLayer)("OpenAPI oauth refresh", (it) => {  it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () =>    Effect.gen(function* () {      const { executor, scopeId, baseUrl } = yield* makeExecutor();      const tokenEndpoint = yield* serveTokenEndpoint(() =>        HttpServerResponse.jsonUnsafe({          access_token: "fresh-access-v2",          token_type: "Bearer",          refresh_token: "refresh-v2",          expires_in: 3600,        }),      );       const auth = yield* seedExpiredConnection(        executor,        scopeId,        "conn-refresh-ok",        tokenEndpoint.tokenUrl,      );       yield* executor.openapi.addSpec({        spec: specJson,        scope: String(scopeId),        namespace: "petstore",        baseUrl,        oauth2: auth,      });      yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth);       const result = (yield* executor.tools.invoke(        "petstore.items.echoHeaders",        {},        autoApprove,      )) as { data: { authorization?: string } | null; error: unknown };       expect(result.error).toBeNull();      // Proves the refresh landed: invoke carried the fresh token,      // not the expired one we seeded.      expect(result.data?.authorization).toBe("Bearer fresh-access-v2");      const calls = yield* tokenEndpoint.calls;      expect(calls).toHaveLength(1);      expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");      expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");       // Connection row is patched with the new expiry so the next      // invoke in-window doesn't trip a second refresh.      const conn = yield* executor.connections.get("conn-refresh-ok");      expect(conn).not.toBeNull();      expect(conn!.expiresAt).not.toBeNull();      expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);    }),  );   it.effect("concurrent invokes with an expired token issue exactly one refresh", () =>    Effect.gen(function* () {      const { executor, scopeId, baseUrl } = yield* makeExecutor();      const tokenEndpoint = yield* serveTokenEndpoint(() =>        HttpServerResponse.jsonUnsafe({          access_token: "fresh-access-v2",          token_type: "Bearer",          refresh_token: "refresh-v2",          expires_in: 3600,        }),      );       const auth = yield* seedExpiredConnection(        executor,        scopeId,        "conn-refresh-concurrent",        tokenEndpoint.tokenUrl,      );       yield* executor.openapi.addSpec({        spec: specJson,        scope: String(scopeId),        namespace: "petstore",        baseUrl,        oauth2: auth,      });      yield* bindOAuthConnection(executor, scopeId, "conn-refresh-concurrent", auth);       const invokes = yield* Effect.all(        [1, 2, 3, 4, 5].map(() =>          executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),        ),        { concurrency: "unbounded" },      );       for (const r of invokes) {        const res = r as {          data: { authorization?: string } | null;          error: unknown;        };        expect(res.error).toBeNull();        expect(res.data?.authorization).toBe("Bearer fresh-access-v2");      }      // Critical assertion: the SDK's dedup collapses every parallel      // invoke into one call to the token endpoint. Anything more      // means we're hammering the AS under load.      const calls = yield* tokenEndpoint.calls;      expect(calls).toHaveLength(1);    }),  );   it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () =>    Effect.gen(function* () {      const { executor, scopeId, baseUrl } = yield* makeExecutor();      const tokenEndpoint = yield* serveTokenEndpoint(() =>        HttpServerResponse.jsonUnsafe(          {            error: "invalid_grant",            error_description: "Refresh token revoked",          },          { status: 400 },        ),      );       const auth = yield* seedExpiredConnection(        executor,        scopeId,        "conn-refresh-dead",        tokenEndpoint.tokenUrl,      );       yield* executor.openapi.addSpec({        spec: specJson,        scope: String(scopeId),        namespace: "petstore",        baseUrl,        oauth2: auth,      });      yield* bindOAuthConnection(executor, scopeId, "conn-refresh-dead", auth);       // Tool invocation currently wraps connection errors in a      // generic Error (see openapi invokeTool), so we assert against      // the `accessToken` call directly too — that's the surface      // the UI bridges use to trigger re-auth.      const flipped = yield* executor.connections.accessToken("conn-refresh-dead").pipe(        Effect.flip,        Effect.flatMap((error) =>          Predicate.isTagged("ConnectionReauthRequiredError")(error)            ? Effect.succeed(error)            : Effect.fail(error),        ),      );      expect(flipped.provider).toBe(OAUTH2_PROVIDER_KEY);      expect(flipped.message).toMatch(/OAuth refresh failed: .*revoked/i);    }),  );});