| @@ -0,0 +1,422 @@ |
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// End-to-end refresh behaviour for the OpenAPI plugin's oauth2 connection |
| 3 | +// provider. |
| 4 | +// |
| 5 | +// The existing `multi-scope-oauth.test.ts` covers sign-in isolation; this |
| 6 | +// file focuses on RFC 6749 §6 refresh behaviour at the plugin boundary: |
| 7 | +// |
| 8 | +// 1. An expired access_token is refreshed transparently before invoke. |
| 9 | +// 2. Concurrent invokes collapse to a single `grant_type=refresh_token` |
| 10 | +// POST — the SDK's dedup applies to the plugin's provider. |
| 11 | +// 3. `invalid_grant` from the token endpoint surfaces as |
| 12 | +// `ConnectionReauthRequiredError` so the UI can prompt sign-in. |
| 13 | +// --------------------------------------------------------------------------- |
| 14 | + |
| 15 | +import { afterEach } from "vitest"; |
| 16 | +import { expect, layer } from "@effect/vitest"; |
| 17 | +import { Effect, Layer, Schema } from "effect"; |
| 18 | +import { |
| 19 | + HttpApi, |
| 20 | + HttpApiBuilder, |
| 21 | + HttpApiEndpoint, |
| 22 | + HttpApiGroup, |
| 23 | + HttpClient, |
| 24 | + HttpServerRequest, |
| 25 | + OpenApi, |
| 26 | +} from "@effect/platform"; |
| 27 | +import { NodeHttpServer } from "@effect/platform-node"; |
| 28 | + |
| 29 | +import { |
| 30 | + ConnectionId, |
| 31 | + ConnectionReauthRequiredError, |
| 32 | + CreateConnectionInput, |
| 33 | + ScopeId, |
| 34 | + SecretId, |
| 35 | + Scope, |
| 36 | + SetSecretInput, |
| 37 | + TokenMaterial, |
| 38 | + collectSchemas, |
| 39 | + createExecutor, |
| 40 | + definePlugin, |
| 41 | + makeInMemoryBlobStore, |
| 42 | + type InvokeOptions, |
| 43 | + type SecretProvider, |
| 44 | +} from "@executor/sdk"; |
| 45 | +import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; |
| 46 | + |
| 47 | +import { openApiPlugin } from "./plugin"; |
| 48 | +import { OAuth2Auth } from "./types"; |
| 49 | + |
| 50 | +const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| 51 | + |
| 52 | +// --------------------------------------------------------------------------- |
| 53 | +// Test API — one endpoint that echoes the Authorization header so we can |
| 54 | +// prove which access token was in flight at invoke time. |
| 55 | +// --------------------------------------------------------------------------- |
| 56 | + |
| 57 | +class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({ |
| 58 | + authorization: Schema.optional(Schema.String), |
| 59 | +}) {} |
| 60 | + |
| 61 | +const ItemsGroup = HttpApiGroup.make("items").add( |
| 62 | + HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders), |
| 63 | +); |
| 64 | + |
| 65 | +const TestApi = HttpApi.make("testApi").add(ItemsGroup); |
| 66 | +const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); |
| 67 | + |
| 68 | +const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => |
| 69 | + handlers.handle("echoHeaders", () => |
| 70 | + Effect.gen(function* () { |
| 71 | + const req = yield* HttpServerRequest.HttpServerRequest; |
| 72 | + return new EchoHeaders({ |
| 73 | + authorization: req.headers["authorization"], |
| 74 | + }); |
| 75 | + }), |
| 76 | + ), |
| 77 | +); |
| 78 | + |
| 79 | +const ApiLive = HttpApiBuilder.api(TestApi).pipe(Layer.provide(ItemsGroupLive)); |
| 80 | + |
| 81 | +const TestLayer = HttpApiBuilder.serve().pipe( |
| 82 | + Layer.provide(ApiLive), |
| 83 | + Layer.provideMerge(NodeHttpServer.layerTest), |
| 84 | +); |
| 85 | + |
| 86 | +// --------------------------------------------------------------------------- |
| 87 | +// Token-endpoint mock. Callers supply a handler that sees the parsed body |
| 88 | +// (grant_type, refresh_token, ...) and returns either an RFC 6749 success |
| 89 | +// response or an error envelope. `calls` records every hit for assertions. |
| 90 | +// --------------------------------------------------------------------------- |
| 91 | + |
| 92 | +const originalFetch = globalThis.fetch; |
| 93 | + |
| 94 | +type TokenCall = { |
| 95 | + readonly body: URLSearchParams; |
| 96 | +}; |
| 97 | + |
| 98 | +const mockTokenFetch = ( |
| 99 | + handler: (body: URLSearchParams) => Effect.Effect<Response> | Promise<Response>, |
| 100 | +) => { |
| 101 | + const calls: TokenCall[] = []; |
| 102 | + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { |
| 103 | + const bodyText = |
| 104 | + init?.body instanceof URLSearchParams |
| 105 | + ? init.body.toString() |
| 106 | + : typeof init?.body === "string" |
| 107 | + ? init.body |
| 108 | + : ""; |
| 109 | + const body = new URLSearchParams(bodyText); |
| 110 | + calls.push({ body }); |
| 111 | + const out = handler(body); |
| 112 | + if (out && typeof (out as Promise<Response>).then === "function") { |
| 113 | + return await (out as Promise<Response>); |
| 114 | + } |
| 115 | + return await Effect.runPromise(out as Effect.Effect<Response>); |
| 116 | + }) as unknown as typeof fetch; |
| 117 | + return { calls }; |
| 118 | +}; |
| 119 | + |
| 120 | +afterEach(() => { |
| 121 | + globalThis.fetch = originalFetch; |
| 122 | +}); |
| 123 | + |
| 124 | +// --------------------------------------------------------------------------- |
| 125 | +// Fixture builder. Wires up a single-scope executor with an in-memory |
| 126 | +// secrets provider, the openApi plugin pointed at a live HttpClient, and |
| 127 | +// seeds an expired oauth2 Connection + source pointing at that server. |
| 128 | +// --------------------------------------------------------------------------- |
| 129 | + |
| 130 | +const makeExecutor = () => |
| 131 | + Effect.gen(function* () { |
| 132 | + const secretStore = new Map<string, string>(); |
| 133 | + const keyOf = (scope: string, id: string) => `${scope} ${id}`; |
| 134 | + const memoryProvider: SecretProvider = { |
| 135 | + key: "memory", |
| 136 | + writable: true, |
| 137 | + get: (id, scope) => |
| 138 | + Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null), |
| 139 | + set: (id, value, scope) => |
| 140 | + Effect.sync(() => { |
| 141 | + secretStore.set(keyOf(scope, id), value); |
| 142 | + }), |
| 143 | + delete: (id, scope) => |
| 144 | + Effect.sync(() => secretStore.delete(keyOf(scope, id))), |
| 145 | + }; |
| 146 | + const memorySecretsPlugin = definePlugin(() => ({ |
| 147 | + id: "memory-secrets" as const, |
| 148 | + storage: () => ({}), |
| 149 | + secretProviders: [memoryProvider], |
| 150 | + })); |
| 151 | + |
| 152 | + const httpClient = yield* HttpClient.HttpClient; |
| 153 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); |
| 154 | + const plugins = [ |
| 155 | + openApiPlugin({ httpClientLayer: clientLayer }), |
| 156 | + memorySecretsPlugin(), |
| 157 | + ] as const; |
| 158 | + |
| 159 | + const schema = collectSchemas(plugins); |
| 160 | + const adapter = makeMemoryAdapter({ schema }); |
| 161 | + const blobs = makeInMemoryBlobStore(); |
| 162 | + |
| 163 | + const scopeId = ScopeId.make("test-scope"); |
| 164 | + const scope = new Scope({ |
| 165 | + id: scopeId, |
| 166 | + name: "test", |
| 167 | + createdAt: new Date(), |
| 168 | + }); |
| 169 | + const executor = yield* createExecutor({ |
| 170 | + scopes: [scope], |
| 171 | + adapter, |
| 172 | + blobs, |
| 173 | + plugins, |
| 174 | + }); |
| 175 | + |
| 176 | + // Seed client id + secret in the executor scope so the openapi |
| 177 | + // provider's refresh can resolve them. |
| 178 | + yield* executor.secrets.set( |
| 179 | + new SetSecretInput({ |
| 180 | + id: SecretId.make("client_id"), |
| 181 | + scope: scopeId, |
| 182 | + name: "Client ID", |
| 183 | + value: "abc", |
| 184 | + }), |
| 185 | + ); |
| 186 | + yield* executor.secrets.set( |
| 187 | + new SetSecretInput({ |
| 188 | + id: SecretId.make("client_secret"), |
| 189 | + scope: scopeId, |
| 190 | + name: "Client Secret", |
| 191 | + value: "shhh", |
| 192 | + }), |
| 193 | + ); |
| 194 | + |
| 195 | + return { executor, scopeId }; |
| 196 | + }); |
| 197 | + |
| 198 | +type ExecutorValue = Effect.Effect.Success< |
| 199 | + ReturnType<typeof makeExecutor> |
| 200 | +>["executor"]; |
| 201 | + |
| 202 | +// Seed an authorizationCode Connection with an already-expired access |
| 203 | +// token and a stored refresh token. The test's mock token endpoint |
| 204 | +// decides what comes back on `grant_type=refresh_token`. |
| 205 | +const seedExpiredConnection = ( |
| 206 | + executor: ExecutorValue, |
| 207 | + scopeId: ScopeId, |
| 208 | + connectionId: string, |
| 209 | +) => |
| 210 | + Effect.gen(function* () { |
| 211 | + yield* executor.connections.create( |
| 212 | + new CreateConnectionInput({ |
| 213 | + id: ConnectionId.make(connectionId), |
| 214 | + scope: scopeId, |
| 215 | + provider: "openapi:oauth2", |
| 216 | + kind: "user", |
| 217 | + identityLabel: "Alice", |
| 218 | + accessToken: new TokenMaterial({ |
| 219 | + secretId: SecretId.make(`${connectionId}.access_token`), |
| 220 | + name: "Access", |
| 221 | + value: "expired-access-v1", |
| 222 | + }), |
| 223 | + refreshToken: new TokenMaterial({ |
| 224 | + secretId: SecretId.make(`${connectionId}.refresh_token`), |
| 225 | + name: "Refresh", |
| 226 | + value: "refresh-v1", |
| 227 | + }), |
| 228 | + expiresAt: Date.now() - 10_000, |
| 229 | + oauthScope: "read", |
| 230 | + providerState: { |
| 231 | + flow: "authorizationCode", |
| 232 | + tokenUrl: "https://token.example.com/token", |
| 233 | + clientIdSecretId: "client_id", |
| 234 | + clientSecretSecretId: "client_secret", |
| 235 | + scopes: ["read"], |
| 236 | + }, |
| 237 | + }), |
| 238 | + ); |
| 239 | + return new OAuth2Auth({ |
| 240 | + kind: "oauth2", |
| 241 | + connectionId, |
| 242 | + securitySchemeName: "oauth2", |
| 243 | + flow: "authorizationCode", |
| 244 | + tokenUrl: "https://token.example.com/token", |
| 245 | + authorizationUrl: "https://auth.example.com/authorize", |
| 246 | + clientIdSecretId: "client_id", |
| 247 | + clientSecretSecretId: "client_secret", |
| 248 | + scopes: ["read"], |
| 249 | + }); |
| 250 | + }); |
| 251 | + |
| 252 | +// --------------------------------------------------------------------------- |
| 253 | +// Tests |
| 254 | +// --------------------------------------------------------------------------- |
| 255 | + |
| 256 | +layer(TestLayer)("OpenAPI oauth refresh", (it) => { |
| 257 | + it.effect( |
| 258 | + "expired access_token is refreshed via grant_type=refresh_token before invoke", |
| 259 | + () => |
| 260 | + Effect.gen(function* () { |
| 261 | + const { executor, scopeId } = yield* makeExecutor(); |
| 262 | + const { calls } = mockTokenFetch( |
| 263 | + () => |
| 264 | + Effect.succeed( |
| 265 | + new Response( |
| 266 | + JSON.stringify({ |
| 267 | + access_token: "fresh-access-v2", |
| 268 | + token_type: "Bearer", |
| 269 | + refresh_token: "refresh-v2", |
| 270 | + expires_in: 3600, |
| 271 | + }), |
| 272 | + { status: 200, headers: { "content-type": "application/json" } }, |
| 273 | + ), |
| 274 | + ), |
| 275 | + ); |
| 276 | + |
| 277 | + const auth = yield* seedExpiredConnection( |
| 278 | + executor, |
| 279 | + scopeId, |
| 280 | + "conn-refresh-ok", |
| 281 | + ); |
| 282 | + |
| 283 | + yield* executor.openapi.addSpec({ |
| 284 | + spec: specJson, |
| 285 | + scope: scopeId as unknown as string, |
| 286 | + namespace: "petstore", |
| 287 | + baseUrl: "", |
| 288 | + oauth2: auth, |
| 289 | + }); |
| 290 | + |
| 291 | + const result = (yield* executor.tools.invoke( |
| 292 | + "petstore.items.echoHeaders", |
| 293 | + {}, |
| 294 | + autoApprove, |
| 295 | + )) as { data: { authorization?: string } | null; error: unknown }; |
| 296 | + |
| 297 | + expect(result.error).toBeNull(); |
| 298 | + // Proves the refresh landed: invoke carried the fresh token, |
| 299 | + // not the expired one we seeded. |
| 300 | + expect(result.data?.authorization).toBe("Bearer fresh-access-v2"); |
| 301 | + expect(calls).toHaveLength(1); |
| 302 | + expect(calls[0]!.body.get("grant_type")).toBe("refresh_token"); |
| 303 | + expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1"); |
| 304 | + |
| 305 | + // Connection row is patched with the new expiry so the next |
| 306 | + // invoke in-window doesn't trip a second refresh. |
| 307 | + const conn = yield* executor.connections.get("conn-refresh-ok"); |
| 308 | + expect(conn).not.toBeNull(); |
| 309 | + expect(conn!.expiresAt).not.toBeNull(); |
| 310 | + expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000); |
| 311 | + }), |
| 312 | + ); |
| 313 | + |
| 314 | + it.effect( |
| 315 | + "concurrent invokes with an expired token issue exactly one refresh", |
| 316 | + () => |
| 317 | + Effect.gen(function* () { |
| 318 | + const { executor, scopeId } = yield* makeExecutor(); |
| 319 | + const { calls } = mockTokenFetch( |
| 320 | + () => |
| 321 | + Effect.succeed( |
| 322 | + new Response( |
| 323 | + JSON.stringify({ |
| 324 | + access_token: "fresh-access-v2", |
| 325 | + token_type: "Bearer", |
| 326 | + refresh_token: "refresh-v2", |
| 327 | + expires_in: 3600, |
| 328 | + }), |
| 329 | + { status: 200, headers: { "content-type": "application/json" } }, |
| 330 | + ), |
| 331 | + ), |
| 332 | + ); |
| 333 | + |
| 334 | + const auth = yield* seedExpiredConnection( |
| 335 | + executor, |
| 336 | + scopeId, |
| 337 | + "conn-refresh-concurrent", |
| 338 | + ); |
| 339 | + |
| 340 | + yield* executor.openapi.addSpec({ |
| 341 | + spec: specJson, |
| 342 | + scope: scopeId as unknown as string, |
| 343 | + namespace: "petstore", |
| 344 | + baseUrl: "", |
| 345 | + oauth2: auth, |
| 346 | + }); |
| 347 | + |
| 348 | + const invokes = yield* Effect.all( |
| 349 | + [1, 2, 3, 4, 5].map(() => |
| 350 | + executor.tools.invoke( |
| 351 | + "petstore.items.echoHeaders", |
| 352 | + {}, |
| 353 | + autoApprove, |
| 354 | + ), |
| 355 | + ), |
| 356 | + { concurrency: "unbounded" }, |
| 357 | + ); |
| 358 | + |
| 359 | + for (const r of invokes) { |
| 360 | + const res = r as { |
| 361 | + data: { authorization?: string } | null; |
| 362 | + error: unknown; |
| 363 | + }; |
| 364 | + expect(res.error).toBeNull(); |
| 365 | + expect(res.data?.authorization).toBe("Bearer fresh-access-v2"); |
| 366 | + } |
| 367 | + // Critical assertion: the SDK's dedup collapses every parallel |
| 368 | + // invoke into one call to the token endpoint. Anything more |
| 369 | + // means we're hammering the AS under load. |
| 370 | + expect(calls).toHaveLength(1); |
| 371 | + }), |
| 372 | + ); |
| 373 | + |
| 374 | + it.effect( |
| 375 | + "invalid_grant from refresh surfaces as ConnectionReauthRequiredError", |
| 376 | + () => |
| 377 | + Effect.gen(function* () { |
| 378 | + const { executor, scopeId } = yield* makeExecutor(); |
| 379 | + mockTokenFetch( |
| 380 | + () => |
| 381 | + Effect.succeed( |
| 382 | + new Response( |
| 383 | + JSON.stringify({ |
| 384 | + error: "invalid_grant", |
| 385 | + error_description: "Refresh token revoked", |
| 386 | + }), |
| 387 | + { status: 400, headers: { "content-type": "application/json" } }, |
| 388 | + ), |
| 389 | + ), |
| 390 | + ); |
| 391 | + |
| 392 | + const auth = yield* seedExpiredConnection( |
| 393 | + executor, |
| 394 | + scopeId, |
| 395 | + "conn-refresh-dead", |
| 396 | + ); |
| 397 | + |
| 398 | + yield* executor.openapi.addSpec({ |
| 399 | + spec: specJson, |
| 400 | + scope: scopeId as unknown as string, |
| 401 | + namespace: "petstore", |
| 402 | + baseUrl: "", |
| 403 | + oauth2: auth, |
| 404 | + }); |
| 405 | + |
| 406 | + // Tool invocation currently wraps connection errors in a |
| 407 | + // generic Error (see openapi invokeTool), so we assert against |
| 408 | + // the `accessToken` call directly too — that's the surface |
| 409 | + // the UI bridges use to trigger re-auth. |
| 410 | + const flipped = yield* executor.connections |
| 411 | + .accessToken("conn-refresh-dead") |
| 412 | + .pipe(Effect.flip); |
| 413 | + expect(flipped._tag).toBe("ConnectionReauthRequiredError"); |
| 414 | + expect((flipped as ConnectionReauthRequiredError).provider).toBe( |
| 415 | + "openapi:oauth2", |
| 416 | + ); |
| 417 | + expect( |
| 418 | + (flipped as ConnectionReauthRequiredError).message, |
| 419 | + ).toMatch(/invalid_grant|revoked/i); |
| 420 | + }), |
| 421 | + ); |
| 422 | +}); |