import { describe, expect, it } from "@effect/vitest";import { Deferred, Effect, Fiber, Predicate } from "effect";import { withQueryContext } from "@executor-js/fumadb/query"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, ProviderKey, Subject, Tenant, ToolAddress, ToolName,} from "./ids";import { authToolFailure } from "./auth-tool-failure";import { createExecutor } from "./executor";import { decodeOAuthCallbackState } from "./oauth";import { OAuthStartError } from "./oauth-client";import { missingGrantedOAuthScopes } from "./oauth-service";import { definePlugin } from "./plugin";import type { CredentialProvider } from "./provider";import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { ToolResult } from "./tool-result";import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow// and OAuth access-token refresh end to end against the test authorization// server. const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const CLIENT = OAuthClientSlug.make("acme-app"); const oauthPlugin = definePlugin(() => ({ id: "acme" as const, storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), describeAuthMethods: (record) => { const config = record.config as { readonly scopes?: readonly string[] } | null; return [ { id: "oauth", label: "OAuth2", kind: "oauth" as const, template: String(TEMPLATE), oauth: { scopes: config?.scopes ?? [] }, }, ]; }, // Echo the resolved credential value (the OAuth access token) back out. invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), checkHealth: ({ credential }) => Effect.succeed({ status: credential.value === null ? "expired" : "healthy", checkedAt: Date.now(), }), extension: (ctx) => ({ seed: (scopes: readonly string[] = []) => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: { scopes }, }), }),}))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; // Stated explicitly where a test builds a SECOND root database handle by hand:// both handles must carry the same owner-policy context to address one// connection, so the values cannot be left to `makeTestConfig`'s defaults.const SHARED_STORE_TENANT = "test-tenant";const SHARED_STORE_SUBJECT = "test-subject"; /** The URL a `fetch` double was handed, however the caller spelled it. */const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string => typeof input === "string" ? input : input instanceof URL ? input.href : input.url; /** * A `fetch` that holds token-endpoint requests open AFTER the authorization * server has answered them. * * Parking after the response is the point. The refresh token has been rotated * upstream by then, and the in-flight gate entry is still registered, so a peer * arriving during the park has to resolve against an OPEN grant rather than a * settled one. Parking before the response would prove nothing: the grant would * never reach the server, and a peer that went on to run its own grant would * find the stored token still live and succeed. * * Idle until `arm()`, so connection setup (the authorization-code exchange) * runs through untouched. */const makeTokenRequestPark = () => { let armed = false; let onSeen: (() => void) | null = null; const seen = new Promise<void>((resolve) => { onSeen = resolve; }); let onRelease: (() => void) | null = null; const parked = new Promise<void>((resolve) => { onRelease = resolve; }); // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant. const platformFetch: typeof globalThis.fetch = globalThis.fetch; const fetch: typeof globalThis.fetch = async (input, init) => { const response = await platformFetch(input, init); if (armed && new URL(fetchTarget(input)).pathname === "/token") { onSeen?.(); await parked; } return response; }; return { fetch, arm: () => { armed = true; }, /** Resolves once a token request has been answered and is being held. */ seen, release: () => onRelease?.(), };}; /** Every refresh-token grant the authorization server was asked for. */const refreshGrantsIn = ( requests: ReadonlyArray<{ readonly path: string; readonly body: string }>,) => requests.filter( (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"), ); interface TokenEndpointCall { readonly host: string; readonly grantType: string | null;} // Route token-endpoint requests aimed at a *non-loopback* host (the// regional/attacker hosts a multi-site rebind test exercises) back to the// loopback test AS, recording the host + grant each one was sent to. The token// exchange/refresh runs through `oauth4webapi`, which calls the global `fetch`// at request time, so swapping `globalThis.fetch` lets the real// `oauth.complete` / refresh path drive the rebind decision while still hitting// a live authorization server. Loopback traffic (the authorize/login hops)// passes straight through untouched. Returns a restore function.const routeTokenEndpointToLoopback = ( server: { readonly issuerUrl: string }, record: TokenEndpointCall[],): (() => void) => { // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: oauth4webapi reads the global `fetch` at call time, so doubling it is the only seam to observe the token exchange/refresh host. const originalFetch = globalThis.fetch; const loopback = new URL(server.issuerUrl); const patched: typeof fetch = async (input, init) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const target = new URL(requestUrl); if (target.hostname === loopback.hostname) { return originalFetch(input as Parameters<typeof fetch>[0], init); } if (target.pathname === "/token") { const bodyText = init?.body instanceof URLSearchParams ? init.body.toString() : typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : ""; record.push({ host: target.hostname, grantType: new URLSearchParams(bodyText).get("grant_type"), }); } const rerouted = new URL(loopback.origin); rerouted.pathname = target.pathname; rerouted.search = target.search; return input instanceof Request ? originalFetch(new Request(rerouted.href, input)) : originalFetch(rerouted.href, init); }; // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: install the doubled fetch (see above). globalThis.fetch = patched; return () => { // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: restore the original fetch. globalThis.fetch = originalFetch; };}; describe("oauth.start / oauth.complete", () => { it.effect( "createClient → start (redirect) → complete mints a connection + tools, executable", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", resource: server.mcpResourceUrl, }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; // Drive the test AS through the authorization request to obtain the // callback code + echoed state. const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); expect(callback.state).toBe(String(started.state)); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(String(connection.name)).toBe("mainAccount"); expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); expect(connection.expiresAt).toBeGreaterThan(Date.now()); const requests = yield* server.requests; const authorizationRequest = requests.find( (r) => r.path === "/authorize" && r.method === "GET", ); expect(authorizationRequest?.query.resource).toBe(server.mcpResourceUrl); const tokenRequest = requests.find( (r) => r.path === "/token" && r.method === "POST" && r.body.includes("grant_type"), ); expect(tokenRequest?.body).toContain( `resource=${encodeURIComponent(server.mcpResourceUrl)}`, ); // The connection produced its tools. const tools = yield* executor.tools.list(); expect(tools.map((t) => String(t.name))).toEqual(["whoami"]); // Executing the tool resolves the minted access token, which the AS // recognises as one it issued. const out = (yield* executor.execute( ToolAddress.make("tools.acme.org.mainAccount.whoami"), {}, )) as { token: string }; expect(out.token).toMatch(/^at_/); expect(yield* server.acceptsAccessToken(out.token)).toBe(true); }), ), ); it.effect("carries the URL org selector in provider state without changing redirect_uri", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins, oauthCallbackStateOrgSlug: "acme", }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const authorizationUrl = new URL(started.authorizationUrl); const redirectUri = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? ""); const providerState = authorizationUrl.searchParams.get("state") ?? ""; expect(redirectUri.toString()).toBe("http://localhost/oauth/callback"); expect(providerState).not.toBe(String(started.state)); expect(decodeOAuthCallbackState(providerState)).toEqual({ state: String(started.state), orgSlug: "acme", }); const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const callbackState = decodeOAuthCallbackState(callback.state); expect(callbackState).not.toBeNull(); if (callbackState === null) return; const connection = yield* executor.oauth.complete({ state: OAuthState.make(callbackState.state), code: callback.code, }); expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); }), ), ); it.effect("records offline_access when a refresh token proves it was granted", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["offline_access", "read"], omitTokenResponseScopes: ["offline_access"], }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["offline_access", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.oauthScope?.split(/\s+/)).toEqual(["read", "offline_access"]); }), ), ); it.effect("persists connection identity from id_token claims on authorization-code grant", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.identityLabel).toBe("alice@example.com"); }), ), ); it.effect("keeps an explicit OAuth session identity label over id_token claims", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, identityLabel: "Work account", }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.identityLabel).toBe("Work account"); }), ), ); it.effect("newConnection resolves a taken name to the next free suffix", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const runFlow = Effect.gen(function* () { const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, newConnection: true, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return null; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); return yield* executor.oauth.complete({ state: started.state, code: callback.code, }); }); const first = yield* runFlow; const second = yield* runFlow;Lines 1–500 of 2578. Load more
import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Predicate } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, ProviderKey, Subject, Tenant, ToolAddress, ToolName, } from "./ids"; import { authToolFailure } from "./auth-tool-failure"; import { createExecutor } from "./executor"; import { decodeOAuthCallbackState } from "./oauth"; import { OAuthStartError } from "./oauth-client"; import { missingGrantedOAuthScopes } from "./oauth-service"; import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { ToolResult } from "./tool-result"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow // and OAuth access-token refresh end to end against the test authorization // server. const INTEG = IntegrationSlug.make("acme"); const TEMPLATE = AuthTemplateSlug.make("oauth"); const CLIENT = OAuthClientSlug.make("acme-app"); const oauthPlugin = definePlugin(() => ({ id: "acme" as const, storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), describeAuthMethods: (record) => { const config = record.config as { readonly scopes?: readonly string[] } | null; return [ { id: "oauth", label: "OAuth2", kind: "oauth" as const, template: String(TEMPLATE), oauth: { scopes: config?.scopes ?? [] }, }, ]; }, // Echo the resolved credential value (the OAuth access token) back out. invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), checkHealth: ({ credential }) => Effect.succeed({ status: credential.value === null ? "expired" : "healthy", checkedAt: Date.now(), }), extension: (ctx) => ({ seed: (scopes: readonly string[] = []) => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: { scopes }, }), }), }))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; // Stated explicitly where a test builds a SECOND root database handle by hand: // both handles must carry the same owner-policy context to address one // connection, so the values cannot be left to `makeTestConfig`'s defaults. const SHARED_STORE_TENANT = "test-tenant"; const SHARED_STORE_SUBJECT = "test-subject"; /** The URL a `fetch` double was handed, however the caller spelled it. */ const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string => typeof input === "string" ? input : input instanceof URL ? input.href : input.url; /** * A `fetch` that holds token-endpoint requests open AFTER the authorization * server has answered them. * * Parking after the response is the point. The refresh token has been rotated * upstream by then, and the in-flight gate entry is still registered, so a peer * arriving during the park has to resolve against an OPEN grant rather than a * settled one. Parking before the response would prove nothing: the grant would * never reach the server, and a peer that went on to run its own grant would * find the stored token still live and succeed. * * Idle until `arm()`, so connection setup (the authorization-code exchange) * runs through untouched. */ const makeTokenRequestPark = () => { let armed = false; let onSeen: (() => void) | null = null; const seen = new Promise<void>((resolve) => { onSeen = resolve; }); let onRelease: (() => void) | null = null; const parked = new Promise<void>((resolve) => { onRelease = resolve; }); // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant. const platformFetch: typeof globalThis.fetch = globalThis.fetch; const fetch: typeof globalThis.fetch = async (input, init) => { const response = await platformFetch(input, init); if (armed && new URL(fetchTarget(input)).pathname === "/token") { onSeen?.(); await parked; } return response; }; return { fetch, arm: () => { armed = true; }, /** Resolves once a token request has been answered and is being held. */ seen, release: () => onRelease?.(), }; }; /** Every refresh-token grant the authorization server was asked for. */ const refreshGrantsIn = ( requests: ReadonlyArray<{ readonly path: string; readonly body: string }>, ) => requests.filter( (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"), ); interface TokenEndpointCall { readonly host: string; readonly grantType: string | null; } // Route token-endpoint requests aimed at a *non-loopback* host (the // regional/attacker hosts a multi-site rebind test exercises) back to the // loopback test AS, recording the host + grant each one was sent to. The token // exchange/refresh runs through `oauth4webapi`, which calls the global `fetch` // at request time, so swapping `globalThis.fetch` lets the real // `oauth.complete` / refresh path drive the rebind decision while still hitting // a live authorization server. Loopback traffic (the authorize/login hops) // passes straight through untouched. Returns a restore function. const routeTokenEndpointToLoopback = ( server: { readonly issuerUrl: string }, record: TokenEndpointCall[], ): (() => void) => { // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: oauth4webapi reads the global `fetch` at call time, so doubling it is the only seam to observe the token exchange/refresh host. const originalFetch = globalThis.fetch; const loopback = new URL(server.issuerUrl); const patched: typeof fetch = async (input, init) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const target = new URL(requestUrl); if (target.hostname === loopback.hostname) { return originalFetch(input as Parameters<typeof fetch>[0], init); } if (target.pathname === "/token") { const bodyText = init?.body instanceof URLSearchParams ? init.body.toString() : typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : ""; record.push({ host: target.hostname, grantType: new URLSearchParams(bodyText).get("grant_type"), }); } const rerouted = new URL(loopback.origin); rerouted.pathname = target.pathname; rerouted.search = target.search; return input instanceof Request ? originalFetch(new Request(rerouted.href, input)) : originalFetch(rerouted.href, init); }; // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: install the doubled fetch (see above). globalThis.fetch = patched; return () => { // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: restore the original fetch. globalThis.fetch = originalFetch; }; }; describe("oauth.start / oauth.complete", () => { it.effect( "createClient → start (redirect) → complete mints a connection + tools, executable", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", resource: server.mcpResourceUrl, }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; // Drive the test AS through the authorization request to obtain the // callback code + echoed state. const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); expect(callback.state).toBe(String(started.state)); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(String(connection.name)).toBe("mainAccount"); expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); expect(connection.expiresAt).toBeGreaterThan(Date.now()); const requests = yield* server.requests; const authorizationRequest = requests.find( (r) => r.path === "/authorize" && r.method === "GET", ); expect(authorizationRequest?.query.resource).toBe(server.mcpResourceUrl); const tokenRequest = requests.find( (r) => r.path === "/token" && r.method === "POST" && r.body.includes("grant_type"), ); expect(tokenRequest?.body).toContain( `resource=${encodeURIComponent(server.mcpResourceUrl)}`, ); // The connection produced its tools. const tools = yield* executor.tools.list(); expect(tools.map((t) => String(t.name))).toEqual(["whoami"]); // Executing the tool resolves the minted access token, which the AS // recognises as one it issued. const out = (yield* executor.execute( ToolAddress.make("tools.acme.org.mainAccount.whoami"), {}, )) as { token: string }; expect(out.token).toMatch(/^at_/); expect(yield* server.acceptsAccessToken(out.token)).toBe(true); }), ), ); it.effect("carries the URL org selector in provider state without changing redirect_uri", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins, oauthCallbackStateOrgSlug: "acme", }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const authorizationUrl = new URL(started.authorizationUrl); const redirectUri = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? ""); const providerState = authorizationUrl.searchParams.get("state") ?? ""; expect(redirectUri.toString()).toBe("http://localhost/oauth/callback"); expect(providerState).not.toBe(String(started.state)); expect(decodeOAuthCallbackState(providerState)).toEqual({ state: String(started.state), orgSlug: "acme", }); const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const callbackState = decodeOAuthCallbackState(callback.state); expect(callbackState).not.toBeNull(); if (callbackState === null) return; const connection = yield* executor.oauth.complete({ state: OAuthState.make(callbackState.state), code: callback.code, }); expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); }), ), ); it.effect("records offline_access when a refresh token proves it was granted", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["offline_access", "read"], omitTokenResponseScopes: ["offline_access"], }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["offline_access", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.oauthScope?.split(/\s+/)).toEqual(["read", "offline_access"]); }), ), ); it.effect("persists connection identity from id_token claims on authorization-code grant", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.identityLabel).toBe("alice@example.com"); }), ), ); it.effect("keeps an explicit OAuth session identity label over id_token claims", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, identityLabel: "Work account", }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(connection.identityLabel).toBe("Work account"); }), ), ); it.effect("newConnection resolves a taken name to the next free suffix", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["openid", "email", "profile", "read"], idTokenClaims: { email: "alice@example.com", sub: "user-1" }, }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(["openid", "email", "profile", "read"]); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const runFlow = Effect.gen(function* () { const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, newConnection: true, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return null; const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); return yield* executor.oauth.complete({ state: started.state, code: callback.code, }); }); const first = yield* runFlow; const second = yield* runFlow;