// ---------------------------------------------------------------------------// End-to-end shape test for multi-scope OAuth on the OpenAPI plugin.//// Models the production scenario: an org-level admin uploads the shared// client credentials, each member of the org runs their own OAuth flow,// and each member's access token lives on a per-user Connection. The// Connections primitive owns every secret — they're filtered out of the// user-facing `secrets.list()` automatically.// --------------------------------------------------------------------------- import { expect, layer } from "@effect/vitest";import { Data, 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 { collectSchemas, ConnectionId, createExecutor, definePlugin, makeInMemoryBlobStore, Scope, ScopeId, SecretId, SetSecretInput, 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" }; class TestInvariantError extends Data.TaggedError("TestInvariantError")<{ readonly message: string;}> {} const makeOauth2SourceConfig = (params: { readonly flow: "authorizationCode" | "clientCredentials"; readonly tokenUrl: string; readonly authorizationUrl: string | null; readonly scopes: readonly string[];}): OAuth2SourceConfig => new OAuth2SourceConfig({ kind: "oauth2", securitySchemeName: "oauth2", flow: params.flow, tokenUrl: params.tokenUrl, authorizationUrl: params.authorizationUrl, clientIdSlot: "oauth2:oauth2:client-id", clientSecretSlot: "oauth2:oauth2:client-secret", connectionSlot: "oauth2:oauth2:connection", scopes: [...params.scopes], }); // ---------------------------------------------------------------------------// Test API — a single endpoint that echoes the Authorization header so the// test can assert which user's token got injected.// --------------------------------------------------------------------------- class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({ authorization: Schema.optional(Schema.String),}) {} 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 new EchoHeaders({ 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),); const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => HttpServerResponse.jsonUnsafe(body, { status }); const serveTokenEndpoint = ( handle: (params: URLSearchParams) => HttpServerResponse.HttpServerResponse,) => Effect.gen(function* () { const clientIds = yield* Ref.make<readonly string[]>([]); const server = yield* serveTestHttpApp((request) => Effect.gen(function* () { const params = new URLSearchParams(yield* request.text); const clientId = params.get("client_id"); if (clientId) { yield* Ref.update(clientIds, (all) => [...all, clientId]); } return handle(params); }).pipe( Effect.catch(() => Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })), ), ), ); return { tokenUrl: server.url("/token"), clientIds: Ref.get(clientIds), } as const; }); // ---------------------------------------------------------------------------// Tests// --------------------------------------------------------------------------- layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { it.effect("per-user Connections coexist with a shared org-level client credential", () => Effect.gen(function* () { const secretStore = new Map<string, string>(); const key = (scope: string, id: string) => `${scope} ${id}`; const memoryProvider: SecretProvider = { key: "memory", writable: true, get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), set: (id, value, scope) => Effect.sync(() => { secretStore.set(key(scope, id), value); }), delete: (id, scope) => Effect.sync(() => secretStore.delete(key(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(address, "TcpAddress")) { return yield* new TestInvariantError({ message: "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 now = new Date(); const orgScope = new Scope({ id: ScopeId.make("org"), name: "acme-org", createdAt: now, }); const aliceScope = new Scope({ id: ScopeId.make("user-alice"), name: "alice", createdAt: now, }); const bobScope = new Scope({ id: ScopeId.make("user-bob"), name: "bob", createdAt: now, }); const adminExec = yield* createExecutor({ scopes: [orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const aliceExec = yield* createExecutor({ scopes: [aliceScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const bobExec = yield* createExecutor({ scopes: [bobScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); // ------------------------------------------------------------- // 1. Admin seeds the org-level client credentials. // ------------------------------------------------------------- yield* adminExec.secrets.set( new SetSecretInput({ id: SecretId.make("petstore_client_id"), scope: orgScope.id, name: "Petstore Client ID", value: "client-abc", }), ); yield* adminExec.secrets.set( new SetSecretInput({ id: SecretId.make("petstore_client_secret"), scope: orgScope.id, name: "Petstore Client Secret", value: "secret-xyz", }), ); // ------------------------------------------------------------- // 2. Each user runs startOAuth + centralized OAuth completion to mint a // per-user Connection. // ------------------------------------------------------------- const tokenEndpoint = yield* serveTokenEndpoint((params) => { const code = params.get("code") ?? ""; const tokenByCode: Record<string, string> = { "code-alice": "alice-token", "code-bob": "bob-token", }; const token = tokenByCode[code]; if (!token) { return json(400, { error: "invalid_grant", code }); } return json(200, { access_token: token, token_type: "Bearer", refresh_token: `${token}-refresh`, }); }); const startInputFor = (user: string, scope: ScopeId) => ({ sourceId: "petstore", displayName: `Petstore (${user})`, securitySchemeName: "oauth2", authorizationUrl: "https://auth.example.com/authorize", tokenUrl: tokenEndpoint.tokenUrl, redirectUrl: "https://app.example.com/oauth/callback", clientIdSecretId: "petstore_client_id", clientSecretSecretId: "petstore_client_secret", scopes: ["read"], tokenScope: String(scope), }); const startAuthorizationCode = ( exec: typeof aliceExec, input: ReturnType<typeof startInputFor>, ) => exec.oauth.start({ endpoint: input.authorizationUrl, redirectUrl: input.redirectUrl, connectionId: `openapi-oauth2-user-${input.sourceId}`, tokenScope: input.tokenScope, pluginId: "openapi", identityLabel: `${input.displayName} OAuth`, strategy: { kind: "authorization-code", authorizationEndpoint: input.authorizationUrl, tokenEndpoint: input.tokenUrl, issuerUrl: null, clientIdSecretId: input.clientIdSecretId, clientSecretSecretId: input.clientSecretSecretId, scopes: input.scopes, }, }); const aliceStart = yield* startAuthorizationCode( aliceExec, startInputFor("alice", aliceScope.id), ); const bobStart = yield* startAuthorizationCode(bobExec, startInputFor("bob", bobScope.id)); if (aliceStart.authorizationUrl === null) { return yield* new TestInvariantError({ message: "expected authorizationCode flow for alice", }); } if (bobStart.authorizationUrl === null) { return yield* new TestInvariantError({ message: "expected authorizationCode flow for bob", }); } const aliceAuth = yield* aliceExec.oauth.complete({ state: aliceStart.sessionId, code: "code-alice", }); const bobAuth = yield* bobExec.oauth.complete({ state: bobStart.sessionId, code: "code-bob", }); // With the stable-id fix both users derive the same row id // string from `sourceId`, but the rows live at different user // scopes (ids are only unique within a scope). The assertion // below that `adminConnectionIds` doesn't include either one // proves admin's stack can't reach either user's row. expect(aliceAuth.connectionId).toBe(bobAuth.connectionId); const oauth2 = makeOauth2SourceConfig({ flow: "authorizationCode", tokenUrl: tokenEndpoint.tokenUrl, authorizationUrl: "https://auth.example.com/authorize", scopes: ["read"], }); // ------------------------------------------------------------- // 3. Each user adds the spec with source-owned OAuth structure, // then binds their own connection into the configured slot. // ------------------------------------------------------------- yield* aliceExec.openapi.addSpec({ spec: specJson, scope: String(aliceScope.id), namespace: "petstore", baseUrl, oauth2, }); yield* aliceExec.openapi.setSourceBinding( new OpenApiSourceBindingInput({ sourceId: "petstore", sourceScope: aliceScope.id, scope: aliceScope.id, slot: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, }), ); yield* bobExec.openapi.addSpec({ spec: specJson, scope: String(bobScope.id), namespace: "petstore", baseUrl, oauth2, }); yield* bobExec.openapi.setSourceBinding( new OpenApiSourceBindingInput({ sourceId: "petstore", sourceScope: bobScope.id, scope: bobScope.id, slot: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, }), ); // ------------------------------------------------------------- // 4. Invoke through each exec — Authorization must carry that // user's token. // ------------------------------------------------------------- const aliceResult = (yield* aliceExec.tools.invoke( "petstore.items.echoHeaders", {}, autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(aliceResult.error).toBeNull(); expect(aliceResult.data?.authorization).toBe("Bearer alice-token"); const bobResult = (yield* bobExec.tools.invoke( "petstore.items.echoHeaders", {}, autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(bobResult.error).toBeNull(); expect(bobResult.data?.authorization).toBe("Bearer bob-token"); // ------------------------------------------------------------- // 5. Each user's Connection is scoped to them; admin sees none. // ------------------------------------------------------------- const aliceConnections = yield* aliceExec.connections.list(); const aliceConn = aliceConnections.find((c) => c.id === aliceAuth.connectionId); expect(String(aliceConn?.scopeId)).toBe("user-alice"); const bobConnections = yield* bobExec.connections.list(); const bobConn = bobConnections.find((c) => c.id === bobAuth.connectionId); expect(String(bobConn?.scopeId)).toBe("user-bob"); const adminConnectionIds = new Set( (yield* adminExec.connections.list()).map((c) => String(c.id)), ); expect(adminConnectionIds).not.toContain(String(aliceAuth.connectionId)); expect(adminConnectionIds).not.toContain(String(bobAuth.connectionId)); // ------------------------------------------------------------- // 6. Connection-owned secrets are filtered from secrets.list(). // Alice only sees the org client creds; her access / refresh // tokens are hidden behind the Connection primitive. // ------------------------------------------------------------- const aliceSecretIds = new Set((yield* aliceExec.secrets.list()).map((s) => String(s.id))); expect(aliceSecretIds).toContain("petstore_client_id"); expect(aliceSecretIds).toContain("petstore_client_secret"); expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.access_token`); expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.refresh_token`); }), ); // ------------------------------------------------------------------------- // Regression: repeated `clientCredentials` sign-ins used to mint a fresh // random UUID per call AND rewrite `source.oauth2.connectionId` to that // new id, which meant whichever user signed in last owned the pointer // and everyone else's invocations broke (their scope stack couldn't // find the previous signer's row). Fix: the Connection id is now a // stable `openapi-oauth2-app-${sourceId}` *name* — the same string // across callers — written at the innermost (per-user) scope. Each // user's stack resolves that one name to their own physical row via // `findInnermostConnectionRow`, so shared source + per-user credentials // (secrets shadowed at user scope) keeps producing per-user tokens // without clobbering each other. // ------------------------------------------------------------------------- it.effect("clientCredentials sign-in is per-user with a stable shared connection name", () => Effect.gen(function* () { const secretStore = new Map<string, string>(); const key = (scope: string, id: string) => `${scope} ${id}`; const memoryProvider: SecretProvider = { key: "memory", writable: true, get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), set: (id, value, scope) => Effect.sync(() => { secretStore.set(key(scope, id), value); }), delete: (id, scope) => Effect.sync(() => secretStore.delete(key(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(address, "TcpAddress")) { return yield* new TestInvariantError({ message: "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 now = new Date(); const orgScope = new Scope({ id: ScopeId.make("org"), name: "acme-org", createdAt: now, }); const aliceScope = new Scope({ id: ScopeId.make("user-alice"), name: "alice", createdAt: now, }); const bobScope = new Scope({ id: ScopeId.make("user-bob"), name: "bob", createdAt: now, }); const adminExec = yield* createExecutor({ scopes: [orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const aliceExec = yield* createExecutor({ scopes: [aliceScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const bobExec = yield* createExecutor({ scopes: [bobScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); // Org-wide default client_id at org scope. Alice then shadowsLines 1–500 of 705. Load more
// --------------------------------------------------------------------------- // End-to-end shape test for multi-scope OAuth on the OpenAPI plugin. // // Models the production scenario: an org-level admin uploads the shared // client credentials, each member of the org runs their own OAuth flow, // and each member's access token lives on a per-user Connection. The // Connections primitive owns every secret — they're filtered out of the // user-facing `secrets.list()` automatically. // --------------------------------------------------------------------------- import { expect, layer } from "@effect/vitest"; import { Data, 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 { collectSchemas, ConnectionId, createExecutor, definePlugin, makeInMemoryBlobStore, Scope, ScopeId, SecretId, SetSecretInput, 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" }; class TestInvariantError extends Data.TaggedError("TestInvariantError")<{ readonly message: string; }> {} const makeOauth2SourceConfig = (params: { readonly flow: "authorizationCode" | "clientCredentials"; readonly tokenUrl: string; readonly authorizationUrl: string | null; readonly scopes: readonly string[]; }): OAuth2SourceConfig => new OAuth2SourceConfig({ kind: "oauth2", securitySchemeName: "oauth2", flow: params.flow, tokenUrl: params.tokenUrl, authorizationUrl: params.authorizationUrl, clientIdSlot: "oauth2:oauth2:client-id", clientSecretSlot: "oauth2:oauth2:client-secret", connectionSlot: "oauth2:oauth2:connection", scopes: [...params.scopes], }); // --------------------------------------------------------------------------- // Test API — a single endpoint that echoes the Authorization header so the // test can assert which user's token got injected. // --------------------------------------------------------------------------- class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({ authorization: Schema.optional(Schema.String), }) {} 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 new EchoHeaders({ 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), ); const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => HttpServerResponse.jsonUnsafe(body, { status }); const serveTokenEndpoint = ( handle: (params: URLSearchParams) => HttpServerResponse.HttpServerResponse, ) => Effect.gen(function* () { const clientIds = yield* Ref.make<readonly string[]>([]); const server = yield* serveTestHttpApp((request) => Effect.gen(function* () { const params = new URLSearchParams(yield* request.text); const clientId = params.get("client_id"); if (clientId) { yield* Ref.update(clientIds, (all) => [...all, clientId]); } return handle(params); }).pipe( Effect.catch(() => Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })), ), ), ); return { tokenUrl: server.url("/token"), clientIds: Ref.get(clientIds), } as const; }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { it.effect("per-user Connections coexist with a shared org-level client credential", () => Effect.gen(function* () { const secretStore = new Map<string, string>(); const key = (scope: string, id: string) => `${scope} ${id}`; const memoryProvider: SecretProvider = { key: "memory", writable: true, get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), set: (id, value, scope) => Effect.sync(() => { secretStore.set(key(scope, id), value); }), delete: (id, scope) => Effect.sync(() => secretStore.delete(key(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(address, "TcpAddress")) { return yield* new TestInvariantError({ message: "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 now = new Date(); const orgScope = new Scope({ id: ScopeId.make("org"), name: "acme-org", createdAt: now, }); const aliceScope = new Scope({ id: ScopeId.make("user-alice"), name: "alice", createdAt: now, }); const bobScope = new Scope({ id: ScopeId.make("user-bob"), name: "bob", createdAt: now, }); const adminExec = yield* createExecutor({ scopes: [orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const aliceExec = yield* createExecutor({ scopes: [aliceScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const bobExec = yield* createExecutor({ scopes: [bobScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); // ------------------------------------------------------------- // 1. Admin seeds the org-level client credentials. // ------------------------------------------------------------- yield* adminExec.secrets.set( new SetSecretInput({ id: SecretId.make("petstore_client_id"), scope: orgScope.id, name: "Petstore Client ID", value: "client-abc", }), ); yield* adminExec.secrets.set( new SetSecretInput({ id: SecretId.make("petstore_client_secret"), scope: orgScope.id, name: "Petstore Client Secret", value: "secret-xyz", }), ); // ------------------------------------------------------------- // 2. Each user runs startOAuth + centralized OAuth completion to mint a // per-user Connection. // ------------------------------------------------------------- const tokenEndpoint = yield* serveTokenEndpoint((params) => { const code = params.get("code") ?? ""; const tokenByCode: Record<string, string> = { "code-alice": "alice-token", "code-bob": "bob-token", }; const token = tokenByCode[code]; if (!token) { return json(400, { error: "invalid_grant", code }); } return json(200, { access_token: token, token_type: "Bearer", refresh_token: `${token}-refresh`, }); }); const startInputFor = (user: string, scope: ScopeId) => ({ sourceId: "petstore", displayName: `Petstore (${user})`, securitySchemeName: "oauth2", authorizationUrl: "https://auth.example.com/authorize", tokenUrl: tokenEndpoint.tokenUrl, redirectUrl: "https://app.example.com/oauth/callback", clientIdSecretId: "petstore_client_id", clientSecretSecretId: "petstore_client_secret", scopes: ["read"], tokenScope: String(scope), }); const startAuthorizationCode = ( exec: typeof aliceExec, input: ReturnType<typeof startInputFor>, ) => exec.oauth.start({ endpoint: input.authorizationUrl, redirectUrl: input.redirectUrl, connectionId: `openapi-oauth2-user-${input.sourceId}`, tokenScope: input.tokenScope, pluginId: "openapi", identityLabel: `${input.displayName} OAuth`, strategy: { kind: "authorization-code", authorizationEndpoint: input.authorizationUrl, tokenEndpoint: input.tokenUrl, issuerUrl: null, clientIdSecretId: input.clientIdSecretId, clientSecretSecretId: input.clientSecretSecretId, scopes: input.scopes, }, }); const aliceStart = yield* startAuthorizationCode( aliceExec, startInputFor("alice", aliceScope.id), ); const bobStart = yield* startAuthorizationCode(bobExec, startInputFor("bob", bobScope.id)); if (aliceStart.authorizationUrl === null) { return yield* new TestInvariantError({ message: "expected authorizationCode flow for alice", }); } if (bobStart.authorizationUrl === null) { return yield* new TestInvariantError({ message: "expected authorizationCode flow for bob", }); } const aliceAuth = yield* aliceExec.oauth.complete({ state: aliceStart.sessionId, code: "code-alice", }); const bobAuth = yield* bobExec.oauth.complete({ state: bobStart.sessionId, code: "code-bob", }); // With the stable-id fix both users derive the same row id // string from `sourceId`, but the rows live at different user // scopes (ids are only unique within a scope). The assertion // below that `adminConnectionIds` doesn't include either one // proves admin's stack can't reach either user's row. expect(aliceAuth.connectionId).toBe(bobAuth.connectionId); const oauth2 = makeOauth2SourceConfig({ flow: "authorizationCode", tokenUrl: tokenEndpoint.tokenUrl, authorizationUrl: "https://auth.example.com/authorize", scopes: ["read"], }); // ------------------------------------------------------------- // 3. Each user adds the spec with source-owned OAuth structure, // then binds their own connection into the configured slot. // ------------------------------------------------------------- yield* aliceExec.openapi.addSpec({ spec: specJson, scope: String(aliceScope.id), namespace: "petstore", baseUrl, oauth2, }); yield* aliceExec.openapi.setSourceBinding( new OpenApiSourceBindingInput({ sourceId: "petstore", sourceScope: aliceScope.id, scope: aliceScope.id, slot: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, }), ); yield* bobExec.openapi.addSpec({ spec: specJson, scope: String(bobScope.id), namespace: "petstore", baseUrl, oauth2, }); yield* bobExec.openapi.setSourceBinding( new OpenApiSourceBindingInput({ sourceId: "petstore", sourceScope: bobScope.id, scope: bobScope.id, slot: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, }), ); // ------------------------------------------------------------- // 4. Invoke through each exec — Authorization must carry that // user's token. // ------------------------------------------------------------- const aliceResult = (yield* aliceExec.tools.invoke( "petstore.items.echoHeaders", {}, autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(aliceResult.error).toBeNull(); expect(aliceResult.data?.authorization).toBe("Bearer alice-token"); const bobResult = (yield* bobExec.tools.invoke( "petstore.items.echoHeaders", {}, autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(bobResult.error).toBeNull(); expect(bobResult.data?.authorization).toBe("Bearer bob-token"); // ------------------------------------------------------------- // 5. Each user's Connection is scoped to them; admin sees none. // ------------------------------------------------------------- const aliceConnections = yield* aliceExec.connections.list(); const aliceConn = aliceConnections.find((c) => c.id === aliceAuth.connectionId); expect(String(aliceConn?.scopeId)).toBe("user-alice"); const bobConnections = yield* bobExec.connections.list(); const bobConn = bobConnections.find((c) => c.id === bobAuth.connectionId); expect(String(bobConn?.scopeId)).toBe("user-bob"); const adminConnectionIds = new Set( (yield* adminExec.connections.list()).map((c) => String(c.id)), ); expect(adminConnectionIds).not.toContain(String(aliceAuth.connectionId)); expect(adminConnectionIds).not.toContain(String(bobAuth.connectionId)); // ------------------------------------------------------------- // 6. Connection-owned secrets are filtered from secrets.list(). // Alice only sees the org client creds; her access / refresh // tokens are hidden behind the Connection primitive. // ------------------------------------------------------------- const aliceSecretIds = new Set((yield* aliceExec.secrets.list()).map((s) => String(s.id))); expect(aliceSecretIds).toContain("petstore_client_id"); expect(aliceSecretIds).toContain("petstore_client_secret"); expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.access_token`); expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.refresh_token`); }), ); // ------------------------------------------------------------------------- // Regression: repeated `clientCredentials` sign-ins used to mint a fresh // random UUID per call AND rewrite `source.oauth2.connectionId` to that // new id, which meant whichever user signed in last owned the pointer // and everyone else's invocations broke (their scope stack couldn't // find the previous signer's row). Fix: the Connection id is now a // stable `openapi-oauth2-app-${sourceId}` *name* — the same string // across callers — written at the innermost (per-user) scope. Each // user's stack resolves that one name to their own physical row via // `findInnermostConnectionRow`, so shared source + per-user credentials // (secrets shadowed at user scope) keeps producing per-user tokens // without clobbering each other. // ------------------------------------------------------------------------- it.effect("clientCredentials sign-in is per-user with a stable shared connection name", () => Effect.gen(function* () { const secretStore = new Map<string, string>(); const key = (scope: string, id: string) => `${scope} ${id}`; const memoryProvider: SecretProvider = { key: "memory", writable: true, get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), set: (id, value, scope) => Effect.sync(() => { secretStore.set(key(scope, id), value); }), delete: (id, scope) => Effect.sync(() => secretStore.delete(key(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(address, "TcpAddress")) { return yield* new TestInvariantError({ message: "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 now = new Date(); const orgScope = new Scope({ id: ScopeId.make("org"), name: "acme-org", createdAt: now, }); const aliceScope = new Scope({ id: ScopeId.make("user-alice"), name: "alice", createdAt: now, }); const bobScope = new Scope({ id: ScopeId.make("user-bob"), name: "bob", createdAt: now, }); const adminExec = yield* createExecutor({ scopes: [orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const aliceExec = yield* createExecutor({ scopes: [aliceScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); const bobExec = yield* createExecutor({ scopes: [bobScope, orgScope], adapter, blobs, plugins, onElicitation: "accept-all", }); // Org-wide default client_id at org scope. Alice then shadows