Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ import {
createConnectionRoutes,
createMcpOAuthRoutes,
createMcpServerRoutes,
createOAuthConnectRoutes,
createTenantConnectCredential,
createWorkflowConnectionRoutes,
DEFAULT_RETURN_PATH_ALLOWLIST,
listMcpServerConnections,
} from "@workbench/connections";
import { CONNECTOR_REGISTRY } from "@workbench/connections/registry";
Expand Down Expand Up @@ -1753,6 +1756,36 @@ export async function createHub(config: HubConfig) {
listConnectedProviders(db, tenantId),
}),
);
// Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes`
// (`@workbench/connections`) was exported but never mounted here — every
// provider whose descriptor sets `oauth` (OpenRouter, Hugging Face, and
// the GitHub App path) needs this to complete a one-click connect from
// the settings surface above. Follows #115's `mcp-servers/oauth` mount
// just below: state-param CSRF (real `state()` + exact-match callback
// validation) lives entirely inside the factory; this mount only wires
// the tenant already resolved by the platform's tenant middleware
// through to `createTenantConnectCredential`.
app.route(
`${TENANT_PREFIX}/connections/oauth`,
createOAuthConnectRoutes({
hubUrl: config.baseUrl,
log: (line) => log.info`${line}`,
credentialCipher,
// Same env bag `GET .../connections/oauth-configured` reads above.
oauthEnv: {
huggingfaceClientId: config.huggingfaceOAuthClientId,
githubAppClientId: config.githubAppClientId,
githubAppClientSecret: config.githubAppClientSecret,
},
connectCredential: createTenantConnectCredential({
hubUrl: config.baseUrl,
log: (line) => log.info`${line}`,
providerHealth: providerHealthStore,
}),
defaultReturnPath: "/settings/connections",
returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins"],
}),
);
// GitHub connect card (CL-6344): the code-review template's inline
// room card reads its live state and starts reviews through here.
// Connecting the PAT itself stays on `connections` above (`github` is
Expand Down Expand Up @@ -2990,6 +3023,10 @@ export async function createHub(config: HubConfig) {
onboardingDeps.seedModel = config.seedModel;
if (config.huggingfaceOAuthClientId !== undefined)
onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId;
if (config.githubAppClientId !== undefined)
onboardingDeps.githubAppClientId = config.githubAppClientId;
if (config.githubAppClientSecret !== undefined)
onboardingDeps.githubAppClientSecret = config.githubAppClientSecret;

app.route("/api/onboarding", createOnboardingRoutes(onboardingDeps));

Expand Down
4 changes: 4 additions & 0 deletions packages/connections/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export {
type CreateOAuthConnectRoutesDeps,
type OAuthStoreOutcome,
} from "./oauth-routes";
export {
createTenantConnectCredential,
type CreateTenantConnectCredentialDeps,
} from "./oauth-tenant-connect";
export {
CONNECT_STATE_TTL_MS as OPENROUTER_CONNECT_STATE_TTL_MS,
exchangeCodeForKey,
Expand Down
8 changes: 8 additions & 0 deletions packages/connections/src/oauth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export type CreateOAuthConnectRoutesDeps = {
* Required: without a caller-supplied store step, a successful
* exchange would have nowhere to land. */
readonly connectCredential: (args: {
/** Given so a tenant-scoped caller (mounted inside the platform's
* tenant middleware, same as `afterConnected` below) can read
* `c.get("tenant")`/`c.get("principal")` directly instead of
* re-deriving them — see `createTenantConnectCredential` in
* `./oauth-tenant-connect.ts`. A caller with no tenant middleware
* (`packages/onboarding`'s own mount) is free to ignore it. */
c: Context;
connectorId: string;
userId: string;
userEmail: string;
Expand Down Expand Up @@ -518,6 +525,7 @@ export function createOAuthConnectRoutes(
const connectCredentialArgs: Parameters<
typeof deps.connectCredential
>[0] = {
c,
connectorId,
userId: user.id,
userEmail: user.email,
Expand Down
234 changes: 234 additions & 0 deletions packages/connections/src/oauth-tenant-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// Proves `createTenantConnectCredential` end to end through the actual
// `createOAuthConnectRoutes` mount apps/hub uses (CL-6389): a full
// authorize -> callback -> credential-stored round trip against a fake
// provider (mirroring `oauth-routes.test.ts`'s `fakeDescriptor`), and a
// mismatched-state callback that must never reach persistence at all.
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import type { MiddlewareHandler } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import { createNoopCredentialCipher } from "@intx/crypto";
import type { ConnectorDescriptor } from "./descriptor";
import { createOAuthConnectRoutes } from "./oauth-routes";
import { createTenantConnectCredential } from "./oauth-tenant-connect";
import { createProviderHealthStore } from "./provider-health";

const TENANT = {
id: "tnt_1",
name: "Acme",
slug: "acme",
domain: "acme.example",
parentId: null,
config: null,
createdAt: new Date(),
updatedAt: new Date(),
};

const PRINCIPAL = {
id: "prn_alice",
tenantId: TENANT.id,
kind: "user" as const,
refId: "prn_alice",
status: "active" as const,
createdAt: new Date(),
updatedAt: new Date(),
};

const WIDGET_CONNECTOR: ConnectorDescriptor = {
id: "widget",
displayName: "Widget",
authKind: "oauth-pkce",
docsUrl: "https://widget.example.com",
credentialPlugin: "http",
feedsTools: [],
oauth: {
authorizeUrl: "https://widget.example.com/authorize",
usesPKCE: true,
echoesState: false,
deploysDefaultWorkflows: false,
buildAuthorizeUrl: ({ callbackUrl, codeChallenge }) => {
const url = new URL("https://widget.example.com/authorize");
url.searchParams.set("redirect_uri", callbackUrl);
if (codeChallenge !== undefined) {
url.searchParams.set("code_challenge", codeChallenge);
}
return url;
},
// Stands in for the fake provider's own token endpoint.
exchange: async ({ code }) => ({ ok: true, apiKey: `key-for-${code}` }),
},
};

function mountTenantScoped(
overrides: Parameters<typeof createTenantConnectCredential>[0] = {
hubUrl: "https://bench.example.com",
log: () => undefined,
},
): {
app: Hono<TenantEnv>;
providers: { tenantId: string; name: string; plugin: string }[];
credentials: { tenantId: string; providerId: string; secret: string }[];
} {
const providers: { tenantId: string; name: string; plugin: string }[] = [];
const credentials: {
tenantId: string;
providerId: string;
secret: string;
}[] = [];

const connectCredential = createTenantConnectCredential({
...overrides,
registry: { widget: WIDGET_CONNECTOR },
ensureProviderFn: async (_api, _cookies, args) => {
providers.push({
tenantId: args.tenantId,
name: args.name,
plugin: args.plugin,
});
return `prv_${args.name}`;
},
ensureCredentialFn: async (_api, _cookies, args) => {
credentials.push({
tenantId: args.tenantId,
providerId: args.providerId,
secret: args.secret,
});
return `cred_${args.providerId}`;
},
});

const routes = createOAuthConnectRoutes({
hubUrl: "https://bench.example.com",
log: () => undefined,
credentialCipher: createNoopCredentialCipher(),
registry: { widget: WIDGET_CONNECTOR },
connectCredential,
});

const asTenant: MiddlewareHandler<TenantEnv> = async (c, next) => {
c.set("user", {
id: "user_1",
email: "user_1@example.com",
} as never);
c.set("tenant", TENANT);
c.set("principal", PRINCIPAL);
await next();
};
const app = new Hono<TenantEnv>();
app.use("*", asTenant);
app.route("/api/tenants/tnt_1/connections/oauth", routes);
return { app, providers, credentials };
}

function cookieHeaderFrom(response: Response): string {
return response.headers
.getSetCookie()
.map((sc) => sc.split(";")[0])
.join("; ");
}

describe("createTenantConnectCredential, mounted through createOAuthConnectRoutes", () => {
test("full authorize -> callback -> credential-stored round trip", async () => {
const { app, providers, credentials } = mountTenantScoped();

const started = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/start",
);
expect(started.status).toBe(302);
const authorizeUrl = new URL(started.headers.get("location") ?? "");
expect(authorizeUrl.origin).toBe("https://widget.example.com");
const cookie = cookieHeaderFrom(started);

const callback = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/callback?code=abc123",
{ headers: { cookie } },
);
expect(callback.status).toBe(302);
const redirect = new URL(
callback.headers.get("location") ?? "",
"https://x",
);
expect(redirect.searchParams.get("outcome")).toBe("connected");
expect(redirect.searchParams.get("tenantSlug")).toBe(TENANT.slug);

expect(providers).toEqual([
{ tenantId: TENANT.id, name: "widget", plugin: "http" },
]);
expect(credentials).toEqual([
{
tenantId: TENANT.id,
providerId: "prv_widget",
secret: "key-for-abc123",
},
]);
});

test("a callback with no matching state never persists a credential", async () => {
const { app, providers, credentials } = mountTenantScoped();

const callback = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/callback?code=abc123&state=forged",
);
expect(callback.status).toBe(302);
const redirect = new URL(
callback.headers.get("location") ?? "",
"https://x",
);
expect(redirect.searchParams.get("outcome")).toBe("error");
expect(redirect.searchParams.get("code")).toBe("state_expired");
expect(providers).toEqual([]);
expect(credentials).toEqual([]);
});

test("a tampered state cookie is rejected before anything is persisted", async () => {
const { app, providers, credentials } = mountTenantScoped();

const started = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/start",
);
// Same cookie *name*, garbage value: fails the sealed-state cipher
// check the same way a forged or replayed cookie would, matching
// the factory's own cross-user regression coverage in
// oauth-routes.test.ts.
const cookie = cookieHeaderFrom(started).replace(
/workbench_widget_connect=[^;]+/,
"workbench_widget_connect=not-a-real-sealed-state",
);

const callback = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/callback?code=abc123",
{ headers: { cookie } },
);
expect(callback.status).toBe(302);
const redirect = new URL(
callback.headers.get("location") ?? "",
"https://x",
);
expect(redirect.searchParams.get("code")).toBe("state_expired");
expect(providers).toEqual([]);
expect(credentials).toEqual([]);
});

test("provider-health clears on a successful connect", async () => {
const providerHealth = createProviderHealthStore();
providerHealth.report(TENANT.id, "widget", "credential_failure");
expect(providerHealth.listForTenant(TENANT.id)["widget"]).toBeDefined();

const { app } = mountTenantScoped({
hubUrl: "https://bench.example.com",
log: () => undefined,
providerHealth,
});

const started = await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/start",
);
const cookie = cookieHeaderFrom(started);
await app.request(
"/api/tenants/tnt_1/connections/oauth/widget/callback?code=abc123",
{ headers: { cookie } },
);

expect(providerHealth.listForTenant(TENANT.id)["widget"]).toBeUndefined();
});
});
Loading
Loading