164 lines · 6.2 KB · TypeScript
RawDownload
import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import { composePluginApi } from "@executor-js/api/server";import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";import {  AuthTemplateSlug,  ConnectionName,  decodeOAuthCallbackState,  IntegrationSlug,  OAuthClientSlug,} from "@executor-js/sdk/shared";import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario";import { Api, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; const oauthIntegrationSpec = (oauth: {  readonly authorizationEndpoint: string;  readonly tokenEndpoint: string;}) =>  ({    spec: {      kind: "blob" as const,      value: JSON.stringify({        openapi: "3.0.3",        info: { title: "OAuth-protected API", version: "1.0.0" },        paths: {          "/me": {            get: {              operationId: "getMe",              tags: ["default"],              responses: { "200": { description: "the caller" } },            },          },        },      }),    },    baseUrl: "http://127.0.0.1:59999",    authenticationTemplate: [      {        slug: "oauth",        kind: "oauth2" as const,        authorizationUrl: oauth.authorizationEndpoint,        tokenUrl: oauth.tokenEndpoint,        scopes: ["read"],      },    ],  }) as const; // Better Auth email sign-in → session cookie, so the callback (a browser GET// behind the session) can be driven with a plain authenticated fetch. Mirrors// what the API surface does internally; kept local to keep this a black-box HTTP// journey with no browser dependency.const sessionCookie = (baseUrl: string, credentials: { email: string; password: string }) =>  Effect.promise(async () => {    const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), {      method: "POST",      headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },      body: JSON.stringify(credentials),    });    const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");    if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`);    return cookie;  }); // Regression guard for the org-wrapped callback state. Self-host binds every// request to an org slug ("default"), so `oauth.start` wraps the raw session// token in the state it sends the provider. The provider echoes that wrapped// value back on the callback; the shared popup callback must unwrap it to the// raw token before looking up the session. Before the fix it passed the wrapped// value straight to `oauth.complete`, which looks up by the raw token and failed// with "OAuth session expired or not found".scenario(  "OAuth callback · a self-host org-context popup callback completes with the wrapped state",  {},  Effect.gen(function* () {    const target = yield* Target;    const { client: makeApiClient } = yield* Api;    const oauth = yield* serveOAuthTestServer();    const identity = yield* target.newIdentity();    const client = yield* makeApiClient(api, identity);     const integration = IntegrationSlug.make(unique("selfhostorgstate"));    yield* client.openapi.addSpec({      payload: { ...oauthIntegrationSpec(oauth), slug: integration },    });     const clientSlug = OAuthClientSlug.make(unique("selfhostorgstate"));    yield* client.oauth.createClient({      payload: {        owner: "org",        slug: clientSlug,        authorizationUrl: oauth.authorizationEndpoint,        tokenUrl: oauth.tokenEndpoint,        grant: "authorization_code",        clientId: "test-client",        clientSecret: "test-secret",      },    });     const started = yield* client.oauth.start({      payload: {        client: clientSlug,        clientOwner: "org",        owner: "org",        name: ConnectionName.make("main"),        integration,        template: AuthTemplateSlug.make("oauth"),      },    });    expect(started.status, "oauth.start begins at the provider").toBe("redirect");    const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";     // The bug's precondition: the state sent to the provider is NOT the raw    // session token, it is the org-slug-wrapped envelope. If this stops being    // true the callback path below no longer exercises the regression.    const providerState = new URL(authorizationUrl).searchParams.get("state") ?? "";    expect(      decodeOAuthCallbackState(providerState),      "self-host org context wraps the OAuth state with the org slug before redirecting",    ).not.toBeNull();     const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" }));    expect(authorize.status, "the provider asks the user to log in").toBe(302);    const consent = yield* Effect.promise(() =>      fetch(authorize.headers.get("location") ?? "", {        method: "POST",        redirect: "manual",        headers: {          authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,        },      }),    );    expect(consent.status, "provider consent redirects back to Executor").toBe(302);    const callback = new URL(consent.headers.get("location") ?? "");    const callbackPath = `${callback.pathname}${callback.search}`;    expect(      callback.searchParams.get("state"),      "the provider echoes the wrapped state back on the callback",    ).toBe(providerState);     const cookie = yield* sessionCookie(target.baseUrl, identity.credentials!);    const response = yield* Effect.promise(() =>      fetch(new URL(callbackPath, target.baseUrl), { headers: { cookie } }),    );    expect(response.status, "the callback renders its popup HTML").toBe(200);    const html = yield* Effect.promise(() => response.text());     expect(      html,      "the wrapped state is unwrapped to the raw token, so the session is found and completes",    ).toContain("Connected");    expect(      html,      "the raw session token is recovered from the wrapped state (no expired-session error)",    ).not.toContain("OAuth session expired or not found");  }).pipe(Effect.scoped),);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t