diff --git a/packages/cli/src/commands/_lib/apply/__tests__/map-config.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/map-config.test.ts index 66760addc..36724227d 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/map-config.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/map-config.test.ts @@ -304,6 +304,39 @@ describe("mapProjectToDesiredState", () => { expect(dc?.feeds).toEqual([{ feedKey: "stars", schedule: "0 */6 * * *" }]); }); + test("folds `managedBy` (org only — no url) into the connection config", () => { + const conn = defineConnection({ + slug: "gh-managed", + connector: "github", + config: { existing: true }, + managedBy: { org: "lobu-managed" }, + }); + const state = mapProjectToDesiredState( + defineConfig({ agents: [], connections: [conn] }) + ); + const dc = state.connectors.connections[0]; + // No connection-supplied URL: a connection can never redirect where the + // cloud PAT is sent (it always targets the instance's LOBU_CLOUD_URL). + expect(dc?.config).toEqual({ + existing: true, + managedBy: { org: "lobu-managed" }, + }); + }); + + test("a connection without `managedBy` carries no managedBy in config", () => { + const conn = defineConnection({ + slug: "gh-plain", + connector: "github", + config: { existing: true }, + }); + const state = mapProjectToDesiredState( + defineConfig({ agents: [], connections: [conn] }) + ); + const dc = state.connectors.connections[0]; + expect(dc?.config).toEqual({ existing: true }); + expect(dc?.config?.managedBy).toBeUndefined(); + }); + test("rejects an invalid connection slug", () => { const conn = defineConnection({ slug: "Bad_Slug", connector: "github" }); expect(() => diff --git a/packages/cli/src/commands/_lib/apply/map-config.ts b/packages/cli/src/commands/_lib/apply/map-config.ts index 8078fa4f5..d3a110a18 100644 --- a/packages/cli/src/commands/_lib/apply/map-config.ts +++ b/packages/cli/src/commands/_lib/apply/map-config.ts @@ -642,8 +642,33 @@ function mapConnection(connection: Connection): DesiredConnection { ...(feed.config ? { config: feed.config } : {}), }; }); + // A consent-only connection exists solely to hold an OAuth grant for + // delegation (the cloud grant-holder behind a managed connector); it cannot + // have feeds, so it never syncs. Reject feeds at authoring time too — the + // server enforces the same invariant on feed creation. + if (connection.consentOnly && feeds.length > 0) { + throw new ValidationError( + `connection "${connection.slug}" is consent-only (holds an OAuth grant for delegation) and cannot have feeds` + ); + } const authSlug = authProfileSlug(connection.authProfile); const appAuthSlug = authProfileSlug(connection.appAuthProfile); + // A managed connection's grant lives in a cloud (public) org. Fold the + // `managedBy` descriptor into the persisted connection `config` so the server + // resolver (execution-context.ts) can detect it and fetch the user's token + // from the cloud at runtime — no new column or CRUD field needed. The + // `consent_only` flag is folded the same way: it lives in the trusted + // connection `config` (where `managedBy` lives), never in `auth_data`. + const config = + connection.managedBy || connection.consentOnly + ? { + ...(connection.config ?? {}), + ...(connection.managedBy + ? { managedBy: { ...connection.managedBy } } + : {}), + ...(connection.consentOnly ? { consent_only: true } : {}), + } + : connection.config; return { slug: connection.slug, connector: connectorKey(connection.connector), @@ -652,7 +677,7 @@ function mapConnection(connection: Connection): DesiredConnection { ...(connection.name ? { name: connection.name } : {}), ...(authSlug ? { authProfileSlug: authSlug } : {}), ...(appAuthSlug ? { appAuthProfileSlug: appAuthSlug } : {}), - ...(connection.config ? { config: connection.config } : {}), + ...(config ? { config } : {}), ...(connection.deviceWorkerId ? { deviceWorkerId: connection.deviceWorkerId } : {}), diff --git a/packages/cli/src/config/define.ts b/packages/cli/src/config/define.ts index ce735a180..bc2002540 100644 --- a/packages/cli/src/config/define.ts +++ b/packages/cli/src/config/define.ts @@ -92,6 +92,24 @@ export interface ConnectionFeed { config?: Record; } +/** + * Marks a connection as MANAGED by a cloud (public) org. The OAuth grant lives + * in the cloud: a user joins the public `org`, connects normally (consent + * against the managed app → a connection owned by them), and the local instance + * fetches a fresh access token for its own user's connection at runtime via + * `POST /oauth/connection-token`, authenticating with the instance's cloud PAT + * (`LOBU_CLOUD_PAT`). The managed client secret + refresh token never leave the + * cloud. + * + * The cloud origin is fixed by the instance's `LOBU_CLOUD_URL` — a connection + * CANNOT supply a URL, so a malicious config can never redirect where the cloud + * PAT is sent. + */ +export interface ManagedBy { + /** The cloud (public) org the managed connector lives under. */ + org: string; +} + export interface Connection { readonly kind: "connection"; /** Stable slug — diff key. */ @@ -103,6 +121,20 @@ export interface Connection { /** OAuth-app auth profile (handle or slug). */ appAuthProfile?: AuthProfile | string; config?: Record; + /** + * Mark this connection as managed by a cloud (public) org — the grant lives + * in the cloud and the local instance fetches its token at runtime. See + * {@link ManagedBy}. + */ + managedBy?: ManagedBy; + /** + * Consent-only connections exist solely to hold an OAuth grant for delegation + * (the cloud grant-holder behind a managed connector); they cannot have feeds, + * so they never sync. This is the by-construction guarantee that a managed + * connector's data only ever lives on the local instance — feed creation is + * rejected for a connection whose persisted `config.consent_only === true`. + */ + consentOnly?: boolean; /** UUID pinning syncs/actions to a specific device worker. */ deviceWorkerId?: string; feeds?: ConnectionFeed[]; diff --git a/packages/server/src/__tests__/integration/connectors/connection-token.test.ts b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts new file mode 100644 index 000000000..b709110c0 --- /dev/null +++ b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts @@ -0,0 +1,648 @@ +/** + * Managed-connector connection-token endpoint + local resolver. + * + * The model: a managed connector lives in a PUBLIC org with a managed + * `oauth_app`. A user JOINS the org (a `member` row) and CONNECTS normally — + * consent against the managed app mints a connection OWNED by them + * (`connections.created_by`). The managed client secret + refresh token stay in + * the cloud. The user's LOCAL Lobu fetches a fresh ACCESS token for its own + * user's connection at runtime via `POST /oauth/connection-token`, with the + * instance's cloud PAT. + * + * Proven here without a real external provider: + * + * 1. A public org has a connector (whose oauth method `tokenUrl` points at a + * LOCAL fake provider), a managed `oauth_app` (fake client_id/secret), an + * `oauth_account` profile + `account` row holding an EXPIRING access token + * + a refresh token, and a connection OWNED by a member (`created_by`). + * 2. `POST /oauth/connection-token` is PAT-gated. The owner's PAT → the cloud + * resolves the managed app + connector endpoint, REFRESHES the expiring + * token with its secret, and returns ONLY `{ access_token, expires_at }` — + * never the refresh token or client secret. + * 3. Owner-scope: a DIFFERENT member's PAT for the SAME org cannot fetch the + * owner's connection token (404). A NON-member PAT → 403. No PAT → 401, bad + * PAT → 401, malformed body → 400. + * 4. Local resolver: a `managedBy` connection resolves its access token by + * calling the cloud endpoint (cloud = the in-process server in-test). + */ + +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { connectionTokenRoutes } from "../../../connect/connection-token-route"; +import type { Env } from "../../../index"; +import { createAuthProfile } from "../../../utils/auth-profiles"; +import { resolveExecutionAuth } from "../../../utils/execution-context"; +import { initWorkspaceProvider } from "../../../workspace"; +import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; +import { + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestPAT, + createTestUser, +} from "../../setup/test-fixtures"; + +const TEST_ENV = { + ENVIRONMENT: "test", + DATABASE_URL: process.env.DATABASE_URL, +} as unknown as Env; + +// Canned tokens the fake provider returns on refresh. Distinct from the stored +// (expiring) token so a successful refresh is observable. +const REFRESHED = { + access_token: "refreshed-access-token-123", + refresh_token: "managed-refresh-token-456", + expires_in: 3600, +}; +const STALE_ACCESS_TOKEN = "stale-access-token-000"; +const MANAGED_SECRET = "managed-secret"; + +// Fake OAuth provider token endpoint the public org's connector points at. +let providerServer: ReturnType | null = null; +let providerTokenUrl = ""; +let lastRefreshBody: Record = {}; + +// Cloud app served on a real port so the local resolver's `fetch` reaches it. +let cloudServer: ReturnType | null = null; +let cloudBaseUrl = ""; + +// Saved instance cloud-config env so afterAll can restore it. +let savedCloudUrl: string | undefined; +let savedCloudPat: string | undefined; + +function buildCloudApp(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.route("/", connectionTokenRoutes); + return app; +} + +beforeAll(async () => { + await initWorkspaceProvider(); + + // Fake provider: a refresh_token grant returns canned refreshed tokens. Record + // the form body so we can assert the cloud authed with its own secret. + const providerApp = new Hono(); + providerApp.post("/token", async (c) => { + const text = await c.req.text(); + lastRefreshBody = Object.fromEntries(new URLSearchParams(text)); + return c.json({ + access_token: REFRESHED.access_token, + refresh_token: REFRESHED.refresh_token, + expires_in: REFRESHED.expires_in, + }); + }); + providerServer = await new Promise((resolve) => { + const s = serve( + { fetch: providerApp.fetch, hostname: "127.0.0.1", port: 0 }, + (info) => { + providerTokenUrl = `http://127.0.0.1:${info.port}/token`; + resolve(s); + }, + ); + }); + + // Cloud app on a real port (Env carries DATABASE_URL so handlers hit test DB). + const cloudApp = buildCloudApp(); + cloudServer = await new Promise((resolve) => { + const s = serve( + { + fetch: (req: Request) => cloudApp.fetch(req, TEST_ENV), + hostname: "127.0.0.1", + port: 0, + }, + (info) => { + cloudBaseUrl = `http://127.0.0.1:${info.port}`; + resolve(s); + }, + ); + }); + + savedCloudUrl = process.env.LOBU_CLOUD_URL; + savedCloudPat = process.env.LOBU_CLOUD_PAT; +}); + +afterAll(async () => { + if (savedCloudUrl === undefined) delete process.env.LOBU_CLOUD_URL; + else process.env.LOBU_CLOUD_URL = savedCloudUrl; + if (savedCloudPat === undefined) delete process.env.LOBU_CLOUD_PAT; + else process.env.LOBU_CLOUD_PAT = savedCloudPat; + await new Promise((done) => + providerServer ? providerServer.close(() => done()) : done(), + ); + await new Promise((done) => + cloudServer ? cloudServer.close(() => done()) : done(), + ); +}); + +interface SeededManagedConnection { + orgId: string; + /** The org slug (callers may pass id OR slug as `org`). */ + orgSlug: string; + /** The connection OWNER (created_by). */ + ownerId: string; + /** The owner's PAT — member of the public org, WITH `connections:token`. */ + ownerPat: string; + /** Same owner/org, but WITHOUT `connections:token` — must be rejected (403). */ + ownerPatNoScope: string; + connectorKey: string; + connectionId: number; +} + +/** + * Seed a PUBLIC org with a managed `oauth_app`, an `oauth_account` grant (an + * `account` row with an EXPIRING access token + refresh token), a connector + * whose tokenUrl points at the fake provider, and a connection OWNED by a + * member. The connector endpoints live in the org's OWN metadata — the cloud + * resolves them server-side; the caller never supplies them. + * + * By default the org is PUBLIC and the connection is a consent-only managed + * grant-holder (the only shape the token endpoint will delegate). The `opts` + * let a test seed the rejected shapes — a private org, or a non-consent-only + * connection — to prove they are NOT exported (404). + */ +async function seedManagedConnection( + orgName: string, + opts?: { visibility?: "public" | "private"; consentOnly?: boolean }, +): Promise { + const sql = getTestDb(); + const visibility = opts?.visibility ?? "public"; + const consentOnly = opts?.consentOnly ?? true; + const org = await createTestOrganization({ + name: orgName, + visibility, + }); + const owner = await createTestUser({ name: `${orgName} Owner` }); + await addUserToOrganization(owner.id, org.id, "member"); + + const connectorKey = "demo.oauth"; + await createTestConnectorDefinition({ + key: connectorKey, + name: "Demo OAuth", + organization_id: org.id, + auth_schema: { + methods: [ + { + type: "oauth", + provider: "demo", + requiredScopes: ["read"], + authorizationUrl: "https://demo.example/authorize", + tokenUrl: providerTokenUrl, + tokenEndpointAuthMethod: "client_secret_post", + clientIdKey: "DEMO_CLIENT_ID", + clientSecretKey: "DEMO_CLIENT_SECRET", + }, + ], + }, + feeds_schema: { items: {} }, + }); + + // Managed oauth_app holds the REAL client_id/secret (never leaves the cloud). + const appProfile = await createAuthProfile({ + organizationId: org.id, + connectorKey, + displayName: "Managed Demo App", + profileKind: "oauth_app", + provider: "demo", + authData: { + DEMO_CLIENT_ID: "managed-cid", + DEMO_CLIENT_SECRET: MANAGED_SECRET, + }, + }); + + // The grant: an account row with an EXPIRING access token + a refresh token. + const accountId = `acct_${org.id}`; + const expiringSoon = new Date(Date.now() + 60 * 1000).toISOString(); // < 5min buffer + await sql` + INSERT INTO "account" ( + id, "accountId", "providerId", "userId", + "accessToken", "refreshToken", "accessTokenExpiresAt", + scope, "createdAt", "updatedAt" + ) VALUES ( + ${accountId}, ${accountId}, 'demo', ${owner.id}, + ${STALE_ACCESS_TOKEN}, ${"managed-refresh-token-original"}, ${expiringSoon}, + 'read', NOW(), NOW() + ) + `; + + const accountProfile = await createAuthProfile({ + organizationId: org.id, + connectorKey, + displayName: "Demo Account", + profileKind: "oauth_account", + provider: "demo", + accountId, + }); + + // Connection OWNED by the member (created_by), wiring the grant + managed app. + // Consent-only managed grant-holders carry `config.consent_only = true`. + const connRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + account_id, auth_profile_id, app_auth_profile_id, created_by, config, + created_at, updated_at + ) VALUES ( + ${org.id}, ${connectorKey}, ${`demo-${org.id}`}, 'Demo Connection', 'active', + ${accountId}, ${accountProfile.id}, ${appProfile.id}, ${owner.id}, + ${consentOnly ? sql.json({ consent_only: true }) : null}, + NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + // Happy-path PAT carries the least-privilege `connections:token` scope; a + // sibling PAT for the same owner/org WITHOUT it proves the scope gate. + const ownerPat = await createTestPAT(owner.id, org.id, { + scope: "mcp:read mcp:write connections:token", + }); + const ownerPatNoScope = await createTestPAT(owner.id, org.id, { + scope: "mcp:read mcp:write", + }); + return { + orgId: org.id, + orgSlug: org.slug, + ownerId: owner.id, + ownerPat: ownerPat.token, + ownerPatNoScope: ownerPatNoScope.token, + connectorKey, + connectionId: Number(connRows[0].id), + }; +} + +function tokenRequest( + app: Hono<{ Bindings: Env }>, + opts: { pat?: string; body?: unknown }, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (opts.pat) headers.Authorization = `Bearer ${opts.pat}`; + return app.fetch( + new Request("http://cloud.local/oauth/connection-token", { + method: "POST", + headers, + body: JSON.stringify(opts.body ?? {}), + }), + TEST_ENV, + ); +} + +describe("managed connector — POST /oauth/connection-token", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + lastRefreshBody = {}; + }); + + it("returns a fresh access token to the OWNER, refreshed server-side with the managed secret", async () => { + const { ownerPat, orgId, connectorKey } = + await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: orgId, connector_key: connectorKey }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + // The access token is the REFRESHED one — proves the cloud refreshed via + // its own tokenUrl + secret (not the stored stale token). + expect(body.access_token).toBe(REFRESHED.access_token); + expect(typeof body.expires_at).toBe("string"); + + // The cloud authed the refresh with ITS managed client_id/secret. + expect(lastRefreshBody.client_id).toBe("managed-cid"); + expect(lastRefreshBody.client_secret).toBe(MANAGED_SECRET); + expect(lastRefreshBody.grant_type).toBe("refresh_token"); + + // The response leaks NEITHER the refresh token NOR the client secret. + const serialized = JSON.stringify(body); + expect(serialized).not.toContain(REFRESHED.refresh_token); + expect(serialized).not.toContain("managed-refresh-token-original"); + expect(serialized).not.toContain(MANAGED_SECRET); + expect(body.refresh_token).toBeUndefined(); + }); + + it("rejects a valid owner PAT that LACKS `connections:token` (403)", async () => { + // Right user, right org, owns the connection — but the PAT carries only the + // default `mcp:read mcp:write`. The scope gate (before any lookup) rejects + // it, so a default member PAT can never mint a managed-connection token. + const { ownerPatNoScope, orgId, connectorKey } = + await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + + const res = await tokenRequest(app, { + pat: ownerPatNoScope, + body: { org: orgId, connector_key: connectorKey }, + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body.error).toBe("insufficient_scope"); + // Rejected before any token resolution — the provider was never contacted. + expect(lastRefreshBody).toEqual({}); + }); + + it("resolves `org` passed as a SLUG (200), same as by id", async () => { + const { ownerPat, orgSlug, connectorKey } = + await seedManagedConnection("Public Org Slug"); + const app = buildCloudApp(); + + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: orgSlug, connector_key: connectorKey }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(REFRESHED.access_token); + }); + + it("rejects a DIFFERENT member's PAT for the SAME org (404 — owner-scoped)", async () => { + const seeded = await seedManagedConnection("Public Org"); + + // A second member of the SAME public org — NOT the connection owner. PAT + // carries `connections:token` so the scope gate passes and we exercise the + // downstream owner-scope check. + const other = await createTestUser({ name: "Other Member" }); + await addUserToOrganization(other.id, seeded.orgId, "member"); + const otherPat = await createTestPAT(other.id, seeded.orgId, { + scope: "mcp:read mcp:write connections:token", + }); + + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: otherPat.token, + body: { org: seeded.orgId, connector_key: seeded.connectorKey }, + }); + + // Member of the org, but does NOT own the connection → 404 (owner-scope). + expect(res.status).toBe(404); + const body = (await res.json()) as Record; + expect(body.error).toBe("not_found"); + + // Sanity: the refresh provider was never contacted. + expect(lastRefreshBody).toEqual({}); + }); + + it("rejects a NON-member PAT (403)", async () => { + const seeded = await seedManagedConnection("Public Org"); + + // A user with their OWN private org — NOT a member of the public org. PAT + // carries `connections:token` so the scope gate passes and we exercise the + // downstream membership check. + const outsider = await createTestUser({ name: "Outsider" }); + const outsiderOrg = await createTestOrganization({ name: "Outsider Org" }); + await addUserToOrganization(outsider.id, outsiderOrg.id, "owner"); + const outsiderPat = await createTestPAT(outsider.id, outsiderOrg.id, { + scope: "mcp:read mcp:write connections:token", + }); + + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: outsiderPat.token, + body: { org: seeded.orgId, connector_key: seeded.connectorKey }, + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body.error).toBe("forbidden"); + }); + + it("rejects a malformed body (400) — validated, not cast", async () => { + const { ownerPat } = await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: "", connector_key: 42 }, + }); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe("bad_request"); + }); + + it("rejects no PAT (401)", async () => { + const { orgId, connectorKey } = await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + const res = await tokenRequest(app, { + body: { org: orgId, connector_key: connectorKey }, + }); + expect(res.status).toBe(401); + }); + + it("rejects an invalid PAT (401)", async () => { + const { orgId, connectorKey } = await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: "owl_pat_totally-bogus-token", + body: { org: orgId, connector_key: connectorKey }, + }); + expect(res.status).toBe(401); + }); + + it("404 when the user owns no active connection for the connector in the org", async () => { + const { ownerPat, orgId } = await seedManagedConnection("Public Org"); + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: orgId, connector_key: "demo.nonexistent" }, + }); + expect(res.status).toBe(404); + }); + + it("does NOT export the owner's own NON-consent-only connection (404)", async () => { + // Same owner + public org, but the connection is an ordinary (non + // consent-only) connection — NOT a managed grant-holder. The endpoint must + // refuse to delegate it, so a user's normal connection tokens can't leak. + const { ownerPat, orgId, connectorKey } = await seedManagedConnection( + "Public Org NonConsent", + { consentOnly: false }, + ); + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: orgId, connector_key: connectorKey }, + }); + expect(res.status).toBe(404); + const body = (await res.json()) as Record; + expect(body.error).toBe("not_found"); + // The refresh provider was never contacted — no token resolution at all. + expect(lastRefreshBody).toEqual({}); + }); + + it("does NOT export a consent-only connection in a PRIVATE org (404)", async () => { + // Consent-only, owned by the caller, but the org is PRIVATE — managed + // connectors only live in public orgs, so a private-org connection (even a + // consent-only one) must not be exported. + const { ownerPat, orgId, connectorKey } = await seedManagedConnection( + "Private Org Consent", + { visibility: "private", consentOnly: true }, + ); + const app = buildCloudApp(); + const res = await tokenRequest(app, { + pat: ownerPat, + body: { org: orgId, connector_key: connectorKey }, + }); + expect(res.status).toBe(404); + const body = (await res.json()) as Record; + expect(body.error).toBe("not_found"); + expect(lastRefreshBody).toEqual({}); + }); +}); + +describe("managed connector — local resolver", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + lastRefreshBody = {}; + process.env.LOBU_CLOUD_URL = cloudBaseUrl; + }); + + it("a `managedBy` connection resolves its access token via the cloud endpoint", async () => { + // Cloud side: public org + managed app + grant + owner-owned connection + + // the owner's PAT (the instance's cloud PAT in this single-user case). + const cloud = await seedManagedConnection("Cloud Org"); + // The instance's cloud PAT is the user's OWN credential. + process.env.LOBU_CLOUD_PAT = cloud.ownerPat; + + // Local side: a separate org with a local connection marked `managedBy` + // (config.managedBy points at the cloud org). No local grant. + const sql = getTestDb(); + const localOrg = await createTestOrganization({ name: "Local Org" }); + const localUser = await createTestUser({ name: "Local User" }); + await addUserToOrganization(localUser.id, localOrg.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.oauth", + name: "Demo OAuth Local", + organization_id: localOrg.id, + auth_schema: { + methods: [ + { type: "oauth", provider: "demo", requiredScopes: ["read"] }, + ], + }, + feeds_schema: { items: {} }, + }); + + const localConnRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + config, created_at, updated_at + ) VALUES ( + ${localOrg.id}, 'demo.oauth', 'demo-local', 'Local Demo', 'active', + ${sql.json({ managedBy: { org: cloud.orgId } })}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + // The runtime token-resolution path: detects `managedBy`, fetches the + // access token from the cloud, and returns it as the connection's creds. + const resolved = await resolveExecutionAuth({ + organizationId: localOrg.id, + connectionId: Number(localConnRows[0].id), + authProfileId: null, + appAuthProfileId: null, + credentialDb: sql, + }); + + expect(resolved.credentials?.accessToken).toBe(REFRESHED.access_token); + // No local refresh token / secret ever materialized. + expect(resolved.credentials?.refreshToken).toBeNull(); + expect(resolved.connectionCredentials).toEqual({}); + }); + + it("ignores a connection-supplied `managedBy.url` — the PAT always goes to LOBU_CLOUD_URL", async () => { + // LOBU_CLOUD_URL is the real in-process cloud (set in beforeEach). The + // connection config carries a bogus `url` (a stand-in for an attacker host + // that would steal the PAT). If the resolver honored it, the fetch would + // hit the bogus host and fail; instead it resolves a real token — proving + // the PAT only ever targets the instance-configured cloud origin. + const cloud = await seedManagedConnection("Cloud Org URL Ignored"); + process.env.LOBU_CLOUD_PAT = cloud.ownerPat; + + const sql = getTestDb(); + const localOrg = await createTestOrganization({ name: "Local Org URL" }); + const localUser = await createTestUser({ name: "Local URL User" }); + await addUserToOrganization(localUser.id, localOrg.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.oauth", + name: "Demo OAuth Local URL", + organization_id: localOrg.id, + auth_schema: { + methods: [ + { type: "oauth", provider: "demo", requiredScopes: ["read"] }, + ], + }, + feeds_schema: { items: {} }, + }); + + const localConnRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + config, created_at, updated_at + ) VALUES ( + ${localOrg.id}, 'demo.oauth', 'demo-local-url', 'Local Demo URL', 'active', + ${sql.json({ + managedBy: { org: cloud.orgId, url: "http://attacker.invalid:1" }, + })}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + const resolved = await resolveExecutionAuth({ + organizationId: localOrg.id, + connectionId: Number(localConnRows[0].id), + authProfileId: null, + appAuthProfileId: null, + credentialDb: sql, + }); + + // Token resolved → the fetch hit LOBU_CLOUD_URL, NOT the bogus connection + // URL (which would have failed and yielded null credentials). + expect(resolved.credentials?.accessToken).toBe(REFRESHED.access_token); + }); + + it("a non-managed (local) connection ignores the cloud path entirely", async () => { + // No managedBy on config → resolver must NOT call the cloud (no refresh). + process.env.LOBU_CLOUD_PAT = "owl_pat_unused"; + const sql = getTestDb(); + const localOrg = await createTestOrganization({ name: "Plain Local Org" }); + const localUser = await createTestUser({ name: "Plain Local User" }); + await addUserToOrganization(localUser.id, localOrg.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.plain", + name: "Demo Plain", + organization_id: localOrg.id, + auth_schema: { methods: [{ type: "env_keys", keys: ["DEMO_API_KEY"] }] }, + feeds_schema: { items: {} }, + }); + const envProfile = await createAuthProfile({ + organizationId: localOrg.id, + connectorKey: "demo.plain", + displayName: "Demo Env", + profileKind: "env", + authData: { DEMO_API_KEY: "local-key-123" }, + }); + const connRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + auth_profile_id, created_at, updated_at + ) VALUES ( + ${localOrg.id}, 'demo.plain', 'demo-plain', 'Plain Local', 'active', + ${envProfile.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + const resolved = await resolveExecutionAuth({ + organizationId: localOrg.id, + connectionId: Number(connRows[0].id), + authProfileId: envProfile.id, + appAuthProfileId: null, + credentialDb: sql, + }); + + // Local env credentials resolve unchanged; the cloud was never contacted. + expect(resolved.connectionCredentials.DEMO_API_KEY).toBe("local-key-123"); + expect(lastRefreshBody).toEqual({}); + }); +}); diff --git a/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts new file mode 100644 index 000000000..78b816e39 --- /dev/null +++ b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts @@ -0,0 +1,421 @@ +/** + * Consent-only connections — the by-construction "data stays local" guarantee. + * + * A managed connector's OAuth grant is held by a connection in a PUBLIC cloud + * org. That cloud grant-holder must be CONSENT-ONLY: it exists solely to hold + * the grant for delegation (the local instance fetches a short-lived token from + * it via POST /oauth/connection-token), and it can NEVER have feeds — so the + * cloud worker never syncs, so the data only ever lives on the local instance. + * + * This is enforced by construction, not convention: a connection whose + * persisted `config.consent_only === true` rejects feed creation. These tests + * pin that invariant: + * 1. Creating a feed on a consent-only connection → rejected with a clear error. + * 2. Creating a feed on a normal connection → still works (unchanged). + * 3. A consent-only connection still resolves an access token via + * /oauth/connection-token (consent-only blocks feeds, not auth). + */ + +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { connectionTokenRoutes } from '../../../connect/connection-token-route'; +import type { Env } from '../../../index'; +import { manageConnections } from '../../../tools/admin/manage_connections'; +import { manageFeeds } from '../../../tools/admin/manage_feeds'; +import type { ToolContext } from '../../../tools/registry'; +import { createAuthProfile } from '../../../utils/auth-profiles'; +import { initWorkspaceProvider } from '../../../workspace'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestPAT, + createTestUser, +} from '../../setup/test-fixtures'; + +const TEST_ENV = { + ENVIRONMENT: 'test', + DATABASE_URL: process.env.DATABASE_URL, +} as unknown as Env; + +const REFRESHED = { + access_token: 'consent-only-refreshed-token', + refresh_token: 'consent-only-refresh-token', + expires_in: 3600, +}; +const MANAGED_SECRET = 'consent-only-secret'; + +let providerServer: ReturnType | null = null; +let providerTokenUrl = ''; + +function ctxFor(organizationId: string, userId: string): ToolContext { + return { + organizationId, + userId, + memberRole: 'owner', + agentId: null, + isAuthenticated: true, + clientId: null, + scopes: ['mcp:read', 'mcp:write', 'mcp:admin'], + tokenType: 'oauth', + scopedToOrg: true, + allowCrossOrg: false, + } as ToolContext; +} + +function buildCloudApp(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.route('/', connectionTokenRoutes); + return app; +} + +beforeAll(async () => { + await initWorkspaceProvider(); + + // Fake OAuth provider for the token-resolution test: a refresh_token grant + // returns canned tokens. + const providerApp = new Hono(); + providerApp.post('/token', async (c) => + c.json({ + access_token: REFRESHED.access_token, + refresh_token: REFRESHED.refresh_token, + expires_in: REFRESHED.expires_in, + }) + ); + providerServer = await new Promise((resolve) => { + const s = serve({ fetch: providerApp.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + providerTokenUrl = `http://127.0.0.1:${info.port}/token`; + resolve(s); + }); + }); +}); + +afterAll(async () => { + await new Promise((done) => + providerServer ? providerServer.close(() => done()) : done() + ); +}); + +describe('consent-only connections — feed creation is rejected by construction', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it('rejects creating a feed on a consent-only connection with a clear error', async () => { + const org = await createTestOrganization({ name: 'Consent Only Org' }); + const user = await createTestUser({ name: 'Consent Only User' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const ctx = ctxFor(org.id, user.id); + + await createTestConnectorDefinition({ + key: 'demo.oauth', + name: 'Demo OAuth', + organization_id: org.id, + auth_schema: { + methods: [{ type: 'oauth', provider: 'demo', requiredScopes: ['read'] }], + }, + feeds_schema: { items: {} }, + }); + + const sql = getTestDb(); + // A consent-only connection: holds the grant for delegation, no feeds ever. + const connRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, config, created_by, + created_at, updated_at + ) VALUES ( + ${org.id}, 'demo.oauth', 'demo-consent', 'Consent Only Connection', 'active', + ${sql.json({ consent_only: true })}, ${user.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + const res = await manageFeeds( + { + action: 'create_feed', + connection_id: Number(connRows[0].id), + feed_key: 'items', + display_name: 'Should Not Exist', + }, + TEST_ENV, + ctx + ); + + expect('error' in res).toBe(true); + if ('error' in res) { + expect(res.error).toBe( + 'This connection is consent-only (holds an OAuth grant for delegation) and cannot have feeds.' + ); + } + + // No feed row was created. + const feedRows = await sql` + SELECT id FROM feeds WHERE organization_id = ${org.id} AND connection_id = ${Number(connRows[0].id)} + `; + expect(feedRows).toHaveLength(0); + }); + + it('still allows creating a feed on a normal (non-consent-only) connection', async () => { + const org = await createTestOrganization({ name: 'Normal Org' }); + const user = await createTestUser({ name: 'Normal User' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const ctx = ctxFor(org.id, user.id); + + await createTestConnectorDefinition({ + key: 'demo.oauth', + name: 'Demo OAuth', + organization_id: org.id, + auth_schema: { + methods: [{ type: 'oauth', provider: 'demo', requiredScopes: ['read'] }], + }, + feeds_schema: { items: {} }, + }); + + const sql = getTestDb(); + // A normal connection: no consent_only flag (config carries an unrelated key). + const connRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, config, created_by, + created_at, updated_at + ) VALUES ( + ${org.id}, 'demo.oauth', 'demo-normal', 'Normal Connection', 'active', + ${sql.json({ some_setting: 'x' })}, ${user.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + const res = await manageFeeds( + { + action: 'create_feed', + connection_id: Number(connRows[0].id), + feed_key: 'items', + display_name: 'Normal Feed', + }, + TEST_ENV, + ctx + ); + + expect('error' in res).toBe(false); + if ('feed' in res) { + expect((res.feed as { status: string }).status).toBe('active'); + } + + const feedRows = await sql` + SELECT id FROM feeds WHERE organization_id = ${org.id} AND connection_id = ${Number(connRows[0].id)} AND deleted_at IS NULL + `; + expect(feedRows).toHaveLength(1); + }); + + it('a consent-only connection still resolves an access token via /oauth/connection-token', async () => { + // The consent-only connection holds the grant; consent_only blocks feeds, + // NOT auth delegation. The token endpoint must still mint a fresh token. + const sql = getTestDb(); + const org = await createTestOrganization({ name: 'Consent Token Org', visibility: 'public' }); + const owner = await createTestUser({ name: 'Consent Token Owner' }); + await addUserToOrganization(owner.id, org.id, 'member'); + + const connectorKey = 'demo.oauth'; + await createTestConnectorDefinition({ + key: connectorKey, + name: 'Demo OAuth', + organization_id: org.id, + auth_schema: { + methods: [ + { + type: 'oauth', + provider: 'demo', + requiredScopes: ['read'], + authorizationUrl: 'https://demo.example/authorize', + tokenUrl: providerTokenUrl, + tokenEndpointAuthMethod: 'client_secret_post', + clientIdKey: 'DEMO_CLIENT_ID', + clientSecretKey: 'DEMO_CLIENT_SECRET', + }, + ], + }, + feeds_schema: { items: {} }, + }); + + const appProfile = await createAuthProfile({ + organizationId: org.id, + connectorKey, + displayName: 'Managed Demo App', + profileKind: 'oauth_app', + provider: 'demo', + authData: { DEMO_CLIENT_ID: 'managed-cid', DEMO_CLIENT_SECRET: MANAGED_SECRET }, + }); + + const accountId = `acct_${org.id}`; + const expiringSoon = new Date(Date.now() + 60 * 1000).toISOString(); + await sql` + INSERT INTO "account" ( + id, "accountId", "providerId", "userId", + "accessToken", "refreshToken", "accessTokenExpiresAt", + scope, "createdAt", "updatedAt" + ) VALUES ( + ${accountId}, ${accountId}, 'demo', ${owner.id}, + ${'stale-token'}, ${'refresh-original'}, ${expiringSoon}, + 'read', NOW(), NOW() + ) + `; + const accountProfile = await createAuthProfile({ + organizationId: org.id, + connectorKey, + displayName: 'Demo Account', + profileKind: 'oauth_account', + provider: 'demo', + accountId, + }); + + // The grant-holder connection is CONSENT-ONLY (config.consent_only) AND + // owned by the member — the token endpoint must still serve it. + await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + account_id, auth_profile_id, app_auth_profile_id, created_by, config, + created_at, updated_at + ) VALUES ( + ${org.id}, ${connectorKey}, ${`demo-${org.id}`}, 'Demo Connection', 'active', + ${accountId}, ${accountProfile.id}, ${appProfile.id}, ${owner.id}, + ${sql.json({ consent_only: true })}, NOW(), NOW() + ) + `; + + const ownerPat = await createTestPAT(owner.id, org.id, { + scope: 'mcp:read mcp:write connections:token', + }); + const app = buildCloudApp(); + const res = await app.fetch( + new Request('http://cloud.local/oauth/connection-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ownerPat.token}`, + }, + body: JSON.stringify({ org: org.id, connector_key: connectorKey }), + }), + TEST_ENV + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(REFRESHED.access_token); + // Still never leaks the refresh token or secret. + const serialized = JSON.stringify(body); + expect(serialized).not.toContain(REFRESHED.refresh_token); + expect(serialized).not.toContain(MANAGED_SECRET); + }); +}); + +describe('consent-only connections — making a connection consent-only is bidirectional', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + async function seedNormalConnection(orgName: string): Promise<{ + orgId: string; + userId: string; + ctx: ToolContext; + connectionId: number; + }> { + const org = await createTestOrganization({ name: orgName }); + const user = await createTestUser({ name: `${orgName} User` }); + await addUserToOrganization(user.id, org.id, 'owner'); + const ctx = ctxFor(org.id, user.id); + + await createTestConnectorDefinition({ + key: 'demo.oauth', + name: 'Demo OAuth', + organization_id: org.id, + auth_schema: { + methods: [{ type: 'oauth', provider: 'demo', requiredScopes: ['read'] }], + }, + feeds_schema: { items: {} }, + }); + + const sql = getTestDb(); + const connRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, config, created_by, + created_at, updated_at + ) VALUES ( + ${org.id}, 'demo.oauth', ${`demo-${org.id}`}, 'Demo Connection', 'active', + ${sql.json({})}, ${user.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + return { orgId: org.id, userId: user.id, ctx, connectionId: Number(connRows[0].id) }; + } + + it('rejects making a connection that HAS a feed consent-only; the feed stays untouched', async () => { + const { orgId, ctx, connectionId } = await seedNormalConnection('Has Feed Org'); + + // Give the connection an active feed. + const feedRes = await manageFeeds( + { action: 'create_feed', connection_id: connectionId, feed_key: 'items', display_name: 'A Feed' }, + TEST_ENV, + ctx + ); + expect('error' in feedRes).toBe(false); + + // Attempt to flip it to consent-only → must be rejected. + const updateRes = await manageConnections( + { action: 'update', connection_id: connectionId, config: { consent_only: true } }, + TEST_ENV, + ctx + ); + expect('error' in updateRes).toBe(true); + if ('error' in updateRes) { + expect(updateRes.error).toBe( + 'This connection has feeds; a consent-only connection cannot have feeds. Remove its feeds first.' + ); + } + + const sql = getTestDb(); + // The connection was NOT flipped to consent_only. + const connRows = await sql` + SELECT config FROM connections WHERE id = ${connectionId} AND organization_id = ${orgId} + `; + expect((connRows[0] as { config: Record | null }).config?.consent_only).not.toBe( + true + ); + // The feed is untouched (still present + active). + const feedRows = await sql` + SELECT status FROM feeds WHERE connection_id = ${connectionId} AND deleted_at IS NULL + `; + expect(feedRows).toHaveLength(1); + expect((feedRows[0] as { status: string }).status).toBe('active'); + }); + + it('allows making a connection with NO feeds consent-only', async () => { + const { orgId, ctx, connectionId } = await seedNormalConnection('No Feed Org'); + + const updateRes = await manageConnections( + { action: 'update', connection_id: connectionId, config: { consent_only: true } }, + TEST_ENV, + ctx + ); + expect('error' in updateRes).toBe(false); + + const sql = getTestDb(); + const connRows = await sql` + SELECT config FROM connections WHERE id = ${connectionId} AND organization_id = ${orgId} + `; + expect((connRows[0] as { config: Record | null }).config?.consent_only).toBe(true); + + // And now feed creation on it is rejected too (the other direction). + const feedRes = await manageFeeds( + { action: 'create_feed', connection_id: connectionId, feed_key: 'items', display_name: 'Nope' }, + TEST_ENV, + ctx + ); + expect('error' in feedRes).toBe(true); + if ('error' in feedRes) { + expect(feedRes.error).toBe( + 'This connection is consent-only (holds an OAuth grant for delegation) and cannot have feeds.' + ); + } + }); +}); diff --git a/packages/server/src/__tests__/setup/test-fixtures.ts b/packages/server/src/__tests__/setup/test-fixtures.ts index 5bc4d835d..bfb5bc792 100644 --- a/packages/server/src/__tests__/setup/test-fixtures.ts +++ b/packages/server/src/__tests__/setup/test-fixtures.ts @@ -447,7 +447,11 @@ interface TestPAT { organizationId: string; } -export async function createTestPAT(userId: string, organizationId: string): Promise { +export async function createTestPAT( + userId: string, + organizationId: string, + options?: { scope?: string } +): Promise { const sql = getTestDb(); const token = `owl_pat_${generateSecureToken(24)}`; const tokenHash = hashToken(token); @@ -455,9 +459,9 @@ export async function createTestPAT(userId: string, organizationId: string): Pro await sql` INSERT INTO personal_access_tokens ( - token_hash, token_prefix, user_id, organization_id, name, created_at, updated_at + token_hash, token_prefix, user_id, organization_id, name, scope, created_at, updated_at ) VALUES ( - ${tokenHash}, ${tokenPrefix}, ${userId}, ${organizationId}, 'Test PAT', NOW(), NOW() + ${tokenHash}, ${tokenPrefix}, ${userId}, ${organizationId}, 'Test PAT', ${options?.scope ?? null}, NOW(), NOW() ) `; diff --git a/packages/server/src/auth/__tests__/token-routes.test.ts b/packages/server/src/auth/__tests__/token-routes.test.ts index a1caead4d..ff53e1a17 100644 --- a/packages/server/src/auth/__tests__/token-routes.test.ts +++ b/packages/server/src/auth/__tests__/token-routes.test.ts @@ -48,6 +48,27 @@ describe('org-scoped token creation route', () => { expect(verified?.scopes).toEqual(['mcp:read', 'mcp:write']); }); + it('lets an org owner mint a connections:token PAT (managed-connector token fetch)', async () => { + const org = await createTestOrganization({ slug: 'token-conn-scope' }); + const user = await createTestUser({ email: 'token-conn@test.example.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const client = await createTestOAuthClient(); + const { token: oauthToken } = await createTestAccessToken(user.id, org.id, client.client_id, { + scope: 'mcp:read mcp:write mcp:admin profile:read', + }); + + const response = await post(`/api/${org.slug}/tokens`, { + token: oauthToken, + body: { name: 'cloud-pat', scope: 'connections:token' }, + }); + + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.token.scope).toBe('connections:token'); + const verified = await new PersonalAccessTokenService(getTestDb()).verify(body.token.token); + expect(verified?.scopes).toEqual(['connections:token']); + }); + it('rejects OAuth tokens without mcp:admin scope', async () => { const org = await createTestOrganization({ slug: 'token-no-admin-scope' }); const user = await createTestUser({ email: 'token-no-admin@test.example.com' }); diff --git a/packages/server/src/auth/oauth/scopes.ts b/packages/server/src/auth/oauth/scopes.ts index 91ba5c3e8..77a22ec95 100644 --- a/packages/server/src/auth/oauth/scopes.ts +++ b/packages/server/src/auth/oauth/scopes.ts @@ -12,6 +12,12 @@ export const AVAILABLE_SCOPES = [ 'mcp:admin', 'profile:read', 'device_worker:run', + // Least-privilege scope for the managed-connector runtime token fetch + // (POST /oauth/connection-token). Deliberately NOT in DEFAULT_SCOPES so a + // broad member PAT cannot mint managed-connection access tokens — the local + // instance's LOBU_CLOUD_PAT must be minted explicitly with this scope + // (`lobu token create --scope connections:token`). + 'connections:token', ] as const; /** Default scopes for MCP access */ diff --git a/packages/server/src/auth/pat-auth.ts b/packages/server/src/auth/pat-auth.ts new file mode 100644 index 000000000..3606b6aba --- /dev/null +++ b/packages/server/src/auth/pat-auth.ts @@ -0,0 +1,157 @@ +/** + * Shared Personal Access Token (PAT) authentication. + * + * One implementation of the `owl_pat_*` bearer path used by both the embedded + * Agent API auth bridge (`createLobuAuthBridge`) and the managed-connector + * connection-token router: verify the token, reject null-org / cross-tenant + * PATs, and resolve the authenticated user + org. Keeps the auth gate in a + * single place so the two callers cannot drift. + */ + +import type { DbClient } from "../db/client"; +import type { AuthInfo } from "./oauth/types"; +import { PersonalAccessTokenService } from "./tokens"; + +const PAT_PREFIX = "owl_pat_"; + +export interface PatUserRow { + id: string; + name: string; + email: string; + emailVerified: boolean; +} + +export interface PatAuthSuccess { + ok: true; + userId: string; + organizationId: string; + /** The PAT's granted scopes, so callers can enforce least-privilege gates. */ + scopes: string[]; + /** The resolved user row, so callers can hydrate their session context. */ + user: PatUserRow; + /** Raw verify() output (clientId/expiresAt/scopes) for session hydration. */ + patInfo: AuthInfo; +} + +export interface PatAuthFailure { + ok: false; + status: 401 | 403; + error: string; + error_description: string; +} + +export type PatAuthResult = PatAuthSuccess | PatAuthFailure; + +/** + * Extract a `owl_pat_*` bearer value from an Authorization header, or `null` + * when the header is absent or does not carry a PAT. + * + * The auth scheme token (`Bearer`) is matched case-insensitively per RFC 7235 + * §2.1, and the `owl_pat_` prefix is detected case-insensitively, so a request + * sending `bearer owl_pat_*` is still recognized as a PAT (and validated) + * rather than silently masked behind cookie auth. The token VALUE handed to + * verify() is unchanged — PAT hashes are case-sensitive on the bytes. + */ +export function extractPatBearer( + authHeader: string | null | undefined, +): string | null { + const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; + const bearerValue = bearerMatch ? (bearerMatch[1] ?? "").trim() : null; + if ( + !bearerValue || + bearerValue.slice(0, PAT_PREFIX.length).toLowerCase() !== PAT_PREFIX + ) { + return null; + } + return bearerValue; +} + +/** + * Verify a `owl_pat_*` bearer and resolve the authenticated (user, org). + * + * Returns a discriminated result rather than throwing so callers can map it to + * their own response shape. On any failure the status is the HTTP code the + * caller should return: + * - 401 — invalid/expired/revoked PAT, null org, or owner no longer exists. + * - 403 — owner is no longer a member of the org the PAT is bound to. + */ +export async function authenticatePat( + sql: DbClient, + bearerValue: string, +): Promise { + let patInfo: AuthInfo | null; + try { + patInfo = await new PersonalAccessTokenService(sql).verify(bearerValue); + } catch { + return { + ok: false, + status: 401, + error: "invalid_token", + error_description: "PAT verification failed", + }; + } + + if (!patInfo?.userId) { + return { + ok: false, + status: 401, + error: "invalid_token", + error_description: "PAT is invalid, expired, or revoked", + }; + } + + // Reject PATs with null organization_id: the FK is `ON DELETE SET NULL`, so a + // PAT bound to a since-deleted org would otherwise silently re-resolve to an + // unrelated org via default-org resolution. + if (!patInfo.organizationId) { + return { + ok: false, + status: 401, + error: "invalid_token", + error_description: + "PAT is not scoped to an organization — re-mint via `lobu token`", + }; + } + + const userRows = (await sql` + SELECT id, name, email, "emailVerified" + FROM "user" + WHERE id = ${patInfo.userId} + LIMIT 1 + `) as unknown as PatUserRow[]; + const user = userRows[0]; + if (!user) { + return { + ok: false, + status: 401, + error: "invalid_token", + error_description: "PAT user no longer exists", + }; + } + + // Tenant-membership check — a PAT for org A must still belong to org A. + const memberRows = (await sql` + SELECT 1 + FROM "member" + WHERE "userId" = ${user.id} + AND "organizationId" = ${patInfo.organizationId} + LIMIT 1 + `) as unknown as Array; + if (memberRows.length === 0) { + return { + ok: false, + status: 403, + error: "forbidden", + error_description: "Token owner is not a member of this organization", + }; + } + + return { + ok: true, + userId: user.id, + organizationId: patInfo.organizationId, + scopes: patInfo.scopes, + user, + patInfo, + }; +} diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index fb1cc7a1c..5e01e338e 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -104,7 +104,16 @@ credentialRoutes.get('/agents', requireAuth, async (c) => { // Org-scoped Personal Access Token Routes // ============================================ -const AVAILABLE_PAT_SCOPES = new Set(['mcp:read', 'mcp:write', 'mcp:admin', 'profile:read']); +// `connections:token` lets a PAT call POST /oauth/connection-token to fetch a +// managed connector's access token (the local instance's LOBU_CLOUD_PAT is +// minted with it). Mintable here, but NOT a default scope. +const AVAILABLE_PAT_SCOPES = new Set([ + 'mcp:read', + 'mcp:write', + 'mcp:admin', + 'profile:read', + 'connections:token', +]); const DEFAULT_PAT_SCOPE = 'mcp:read mcp:write'; const MAX_PAT_EXPIRY_DAYS = 3650; diff --git a/packages/server/src/connect/connection-token-route.ts b/packages/server/src/connect/connection-token-route.ts new file mode 100644 index 000000000..0f0cc7615 --- /dev/null +++ b/packages/server/src/connect/connection-token-route.ts @@ -0,0 +1,252 @@ +/** + * Managed-connector connection-token endpoint. + * + * Public-org managed connectors: a managed connector lives in a PUBLIC org + * (`organization.visibility = 'public'`) with a managed `oauth_app`. A user + * JOINS that org (a `member` row) and CONNECTS normally — consent against the + * managed app mints a connection OWNED by them (`connections.created_by`). The + * managed client secret + refresh token stay in the cloud and never leave it. + * + * At RUNTIME the user's LOCAL Lobu instance fetches a fresh ACCESS token for + * its own user's connection via this endpoint, authenticating with that user's + * own cloud PAT (`owl_pat_*`). The token is resolved/refreshed server-side via + * the existing `CredentialService` (`resolveExecutionAuth`) and ONLY the access + * token + expiry are returned — never the refresh token or client secret. + * + * Owner-scoped: the lookup is keyed on `created_by = `, so a user + * can only fetch tokens for connections they own. + * + * Endpoint (PAT-gated): + * - POST /oauth/connection-token { org, connector_key } + */ + +import type { Env } from "@lobu/connector-sdk"; +import { Type } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import { Hono } from "hono"; +import { authenticatePat, extractPatBearer } from "../auth/pat-auth"; +import { getDb } from "../db/client"; +import { resolveExecutionAuth } from "../utils/execution-context"; +import logger from "../utils/logger"; + +type ConnectionTokenEnv = { + Bindings: Env; + Variables: { authedUserId: string }; +}; + +const connectionTokenRoutes = new Hono(); + +/** + * The least-privilege scope a PAT must carry to mint a managed-connection + * access token via this endpoint. Deliberately separate from the default + * `mcp:*` scopes so a broad org-member PAT cannot mint connection tokens — only + * a PAT minted explicitly with `connections:token` is authorized. The local + * instance's `LOBU_CLOUD_PAT` must carry it: `lobu token create --scope + * connections:token`. + */ +const CONNECTIONS_TOKEN_SCOPE = "connections:token"; + +/** + * PAT auth for the connection-token endpoint — the single shared + * `authenticatePat` gate (verifies the token, rejects null-org / cross-tenant + * PATs, resolves the user + org). Org membership ALONE is not enough: the PAT + * must also carry the `connections:token` scope (403 otherwise), so a broad + * member PAT cannot reach the token-minting endpoint. On success the + * authenticated user + org are stashed on the context for the handler's + * owner-scoped lookup. + */ +connectionTokenRoutes.use("/oauth/connection-token", async (c, next) => { + const bearerValue = extractPatBearer(c.req.header("Authorization")); + if (!bearerValue) { + return c.json( + { error: "unauthorized", error_description: "Bearer PAT required" }, + 401, + ); + } + + const result = await authenticatePat(getDb(), bearerValue); + if (!result.ok) { + return c.json( + { error: result.error, error_description: result.error_description }, + result.status, + ); + } + + // Least-privilege: a valid, org-scoped PAT is necessary but not sufficient — + // it must also be granted `connections:token`. A default `mcp:read mcp:write` + // member PAT is rejected here (403) before any org/connection is looked up. + if (!result.scopes.includes(CONNECTIONS_TOKEN_SCOPE)) { + return c.json( + { + error: "insufficient_scope", + error_description: `PAT is missing the '${CONNECTIONS_TOKEN_SCOPE}' scope`, + }, + 403, + ); + } + + c.set("authedUserId", result.userId); + return next(); +}); + +const TokenBody = Type.Object({ + org: Type.String({ minLength: 1 }), + connector_key: Type.String({ minLength: 1 }), +}); +const tokenValidator = TypeCompiler.Compile(TokenBody); + +/** + * POST /oauth/connection-token + * Return a fresh access token for the authed user's OWN active connection to + * `connector_key` in `org`. + * + * The cloud owns the managed grant: it resolves the connection's + * `oauth_account` (token store) + managed `oauth_app` (client_id/secret) and + * runs the EXISTING `resolveExecutionAuth` path, which refreshes via the + * managed secret when the token is expiring. Secrets + the refresh token are + * held server-side and never returned. + * + * Authorization (narrow by design — this delegates ONLY managed grant-holders, + * never a user's ordinary connection tokens): + * - 403 `insufficient_scope` if the PAT lacks `connections:token` (enforced in + * the auth middleware, before any lookup). + * - 403 if the authed user is not a `member` of `org` (`org` matches an + * organization id OR slug). + * - 404 unless the connection is the user's OWN (`created_by`), in a PUBLIC + * org (`organization.visibility = 'public'`), and a consent-only managed + * grant-holder (`config.consent_only = true`). The not-found shape is the + * same regardless of which condition failed (no leak). + */ +connectionTokenRoutes.post("/oauth/connection-token", async (c) => { + const raw = await c.req.json().catch(() => null); + if (!tokenValidator.Check(raw)) { + const detail = [...tokenValidator.Errors(raw)] + .map((e) => `${e.path || "/"} ${e.message}`) + .join("; "); + return c.json( + { + error: "bad_request", + error_description: detail || "Invalid request body", + }, + 400, + ); + } + + const authedUserId = c.get("authedUserId"); + const sql = getDb(); + + // Resolve `org` by EITHER id or slug → the canonical org id, used uniformly + // in the membership + connection queries below. A caller may pass either. + const orgRows = (await sql` + SELECT id + FROM "organization" + WHERE id = ${raw.org} OR slug = ${raw.org} + LIMIT 1 + `) as unknown as Array<{ id: string }>; + const organizationId = orgRows[0]?.id ?? null; + if (!organizationId) { + // Unknown org is indistinguishable from "not a member" → same 403 shape so + // org existence can't be probed. + return c.json( + { + error: "forbidden", + error_description: "Not a member of this organization", + }, + 403, + ); + } + + // Membership check: the authed user must be a member of the target org. A + // PAT's own org binding does NOT imply membership in an ARBITRARY `org` in + // the body — managed connectors live in a separate public org the user has + // joined, so verify membership explicitly. + const memberRows = (await sql` + SELECT 1 + FROM "member" + WHERE "userId" = ${authedUserId} + AND "organizationId" = ${organizationId} + LIMIT 1 + `) as unknown as Array; + if (memberRows.length === 0) { + return c.json( + { + error: "forbidden", + error_description: "Not a member of this organization", + }, + 403, + ); + } + + // Scoped connection lookup. This endpoint exists ONLY to delegate a managed + // grant-holder's token, so the lookup is deliberately narrow — it must NOT be + // usable to export a user's ordinary connection tokens: + // - owner-scoped: the user must OWN the connection (`created_by`); a + // connection owned by another member is indistinguishable from not-found; + // - the org must be a PUBLIC org (`organization.visibility = 'public'`) — + // where managed connectors live — never a user's private org; + // - the connection must be a consent-only managed grant-holder + // (`config.consent_only = true`). + // Any connection that isn't all three → 404 (same not-found shape; we don't + // leak which condition failed). + const rows = (await sql` + SELECT c.id, c.auth_profile_id, c.app_auth_profile_id + FROM connections c + JOIN "organization" o ON o.id = c.organization_id + WHERE c.organization_id = ${organizationId} + AND c.connector_key = ${raw.connector_key} + AND c.created_by = ${authedUserId} + AND c.deleted_at IS NULL + AND c.status = 'active' + AND o.visibility = 'public' + AND c.config->>'consent_only' = 'true' + LIMIT 1 + `) as unknown as Array<{ + id: number; + auth_profile_id: number | null; + app_auth_profile_id: number | null; + }>; + if (rows.length === 0) { + return c.json( + { + error: "not_found", + error_description: "No active managed connection found for this connector", + }, + 404, + ); + } + + const connection = rows[0]; + + const { credentials } = await resolveExecutionAuth({ + organizationId, + connectionId: Number(connection.id), + authProfileId: connection.auth_profile_id, + appAuthProfileId: connection.app_auth_profile_id, + credentialDb: sql, + logContext: { org: organizationId, connector_key: raw.connector_key }, + logMessage: "Failed to resolve managed connection token", + }); + + if (!credentials?.accessToken) { + return c.json( + { + error: "no_token", + error_description: "No access token available for this connection", + }, + 502, + ); + } + + logger.info( + { org: organizationId, connector_key: raw.connector_key, connection_id: Number(connection.id) }, + "Resolved managed connection token", + ); + + // Return ONLY the access token + expiry. Never the refresh token or secret. + return c.json({ + access_token: credentials.accessToken, + expires_at: credentials.expiresAt ?? null, + }); +}); + +export { connectionTokenRoutes }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index eb31fe30d..22a5d54fd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,6 +21,7 @@ import { mcpAuth } from './auth/middleware'; import { oauthRoutes } from './auth/oauth/routes'; import { findExistingPersonalOrg } from './auth/personal-org-provisioning'; import { credentialRoutes } from './auth/routes'; +import { connectionTokenRoutes } from './connect/connection-token-route'; import { connectRoutes } from './connect/routes'; import { getDb } from './db/client'; import * as invalidationEmitter from './events/emitter'; @@ -551,6 +552,16 @@ app.route('/mcp', oauthRoutes); */ app.route('/connect', connectRoutes); +/** + * Managed-connector connection-token route — PAT-gated. A managed connector + * lives in a PUBLIC org with a managed `oauth_app`; a user joins it and + * connects normally (a connection owned by them). Their LOCAL Lobu fetches a + * fresh access token for its OWN user's connection via POST + * /oauth/connection-token, authenticating with the user's cloud PAT. The + * managed client secret + refresh token never leave the cloud. + */ +app.route('/', connectionTokenRoutes); + /** * Logo endpoint for MCP/OAuth client metadata. */ diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index 3b70229d3..03c1f02ef 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -12,7 +12,7 @@ import path from 'node:path'; import type { Hono } from 'hono'; import { Hono as HonoApp } from 'hono'; import { createAuth } from '../auth'; -import { PersonalAccessTokenService } from '../auth/tokens'; +import { authenticatePat, extractPatBearer } from '../auth/pat-auth'; import { ApiPlatform } from '../gateway/api/platform'; import { createGatewayApp } from '../gateway/cli/gateway'; import { ChatInstanceManager } from '../gateway/connections/chat-instance-manager'; @@ -122,10 +122,10 @@ function ensureEmbeddedGatewaySecrets(): void { * * 1. Better Auth session (cookie or bearer session-token) — original path. * 2. Personal Access Token (`Authorization: Bearer owl_pat_*`) — needed so - * `lobu chat` / device-flow PATs reach `/lobu/api/v1/agents/*`. - * 3. Tenant membership check — a PAT for org A must verify the user is still - * a member of org A; the canonical pattern lives at - * `workspace/multi-tenant.ts:425`. + * `lobu chat` / device-flow PATs reach `/lobu/api/v1/agents/*`. Verified + * via the shared `authenticatePat` (also used by the managed-connector + * connection-token router), which enforces the tenant-membership check (a + * PAT for org A must verify the user is still a member of org A). * * PAT validation runs BEFORE Better Auth so a stale/invalid PAT in the * `Authorization` header cannot be silently masked by a still-valid session @@ -139,120 +139,40 @@ export function createLobuAuthBridge() { c.set('user', null); c.set('session', null); - const authHeader = c.req.header('Authorization'); - // RFC 7235 §2.1 — the auth scheme token is case-insensitive. A request - // sending `Authorization: bearer owl_pat_*` with a valid Better Auth - // cookie would otherwise skip PAT validation entirely (lowercase fails - // the `Bearer ` literal match) and fall through to the cookie path, - // silently masking an invalid/revoked PAT (codex round-2 finding). - // Token VALUE comparison stays case-sensitive — PAT hashes are. - const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; - const bearerValue = bearerMatch ? (bearerMatch[1] ?? '').trim() : null; - // PAT prefix detection is case-insensitive so `Bearer OWL_PAT_*` is - // recognized as a PAT and validated, not silently masked behind cookie - // auth (codex round-3 finding). The token VALUE handed to verify() is - // unchanged — PAT hashes are case-sensitive on the bytes. - const isPatBearer = - bearerValue !== null && bearerValue.slice(0, 8).toLowerCase() === 'owl_pat_'; - // 1. PAT path — authoritative when the Authorization header carries // `Bearer owl_pat_*`. Validate first so an invalid PAT cannot fall - // through to a cooked Better Auth cookie (codex finding #2). On any - // failure return 401 immediately rather than masking it with a - // different identity. - if (isPatBearer) { - let patInfo: Awaited> | null = null; - try { - const sql = getDb(); - patInfo = await new PersonalAccessTokenService(sql).verify(bearerValue); - } catch (err) { - logger.warn( - { err: err instanceof Error ? err.message : String(err) }, - '[Lobu] PAT verification failed' - ); - return c.json({ error: 'invalid_token', error_description: 'PAT verification failed' }, 401); - } - - if (!patInfo?.userId) { - return c.json( - { error: 'invalid_token', error_description: 'PAT is invalid, expired, or revoked' }, - 401 - ); - } - - // Reject PATs with null organization_id on the embedded Agent API - // path (codex finding #3). The FK is `ON DELETE SET NULL`, so a PAT - // bound to a since-deleted org would otherwise silently re-resolve to - // an unrelated org via `resolveDefaultOrgId`. - if (!patInfo.organizationId) { - return c.json( - { - error: 'invalid_token', - error_description: - 'PAT is not scoped to an organization — re-mint via `lobu token`', - }, - 401 - ); - } - - const sql = getDb(); - const rows = (await sql` - SELECT id, name, email, "emailVerified" - FROM "user" - WHERE id = ${patInfo.userId} - LIMIT 1 - `) as unknown as Array<{ - id: string; - name: string; - email: string; - emailVerified: boolean; - }>; - const userRow = rows[0]; - if (!userRow) { - return c.json( - { error: 'invalid_token', error_description: 'PAT user no longer exists' }, - 401 - ); - } - - // Enforce tenant membership (codex finding #1). Mirrors the canonical - // check in workspace/multi-tenant.ts: 403 + `forbidden` when the PAT - // owner is no longer a member of the org the PAT is bound to. - const memberRows = (await sql` - SELECT 1 - FROM "member" - WHERE "userId" = ${userRow.id} - AND "organizationId" = ${patInfo.organizationId} - LIMIT 1 - `) as unknown as Array<{ '?column?': number }>; - if (memberRows.length === 0) { + // through to a cooked Better Auth cookie: invalid PAT short-circuits + // here rather than masking the failure with a still-valid session + // cookie. Shared with the connection-token router via `authenticatePat`. + const bearerValue = extractPatBearer(c.req.header('Authorization')); + if (bearerValue) { + const result = await authenticatePat(getDb(), bearerValue); + if (!result.ok) { return c.json( - { - error: 'forbidden', - error_description: 'Token owner is not a member of this organization', - }, - 403 + { error: result.error, error_description: result.error_description }, + result.status ); } + const { user, patInfo, organizationId } = result; const expiresAt = patInfo.expiresAt === Number.MAX_SAFE_INTEGER ? new Date(Date.now() + 86_400_000) : new Date(patInfo.expiresAt * 1000); c.set('user', { - id: userRow.id, - name: userRow.name, - email: userRow.email, - emailVerified: userRow.emailVerified, + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, }); c.set('session', { id: `pat:${patInfo.clientId}`, - userId: userRow.id, + userId: user.id, token: bearerValue, expiresAt, - activeOrganizationId: patInfo.organizationId, + activeOrganizationId: organizationId, }); - c.set('organizationId', patInfo.organizationId); + c.set('organizationId', organizationId); await next(); return; diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index 8c4cc6119..12adfd347 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -424,6 +424,17 @@ export async function resolveConnectionAuthSelection(params: { const browserMethod = getBrowserMethods(params.authSchema)[0] ?? null; const preferredMethodType = getPreferredAuthMethodType(params.authSchema); + // 0. An explicit app profile slug points at an `oauth_app` (local client + // credentials). Resolve it once so it can be honored as the oauth_account + // app profile (step 2). + const explicitAppProfile = params.appAuthProfileSlug + ? await resolveAuthProfileSlugToId({ + organizationId, + slug: params.appAuthProfileSlug, + connectorKey, + }) + : null; + // 1. Resolve explicitly selected auth profile, or auto-select the primary // auth profile for the connector's preferred auth method. const authProfile = @@ -456,15 +467,14 @@ export async function resolveConnectionAuthSelection(params: { return EMPTY_SELECTION({ oauthMethod, envMethod, browserMethod, preferredMethodType }); } - // 2. For OAuth accounts, also resolve the app credentials profile. + // 2. For OAuth accounts, also resolve the app credentials profile. The + // explicit app profile (resolved in step 0) is an `oauth_app` (local + // client credentials); here we accept only `oauth_app`. const needsAppAuth = authProfile.profile_kind === 'oauth_account' || !!params.appAuthProfileSlug; const appAuthProfile = needsAppAuth - ? ((await resolveAuthProfileSlugToId({ - organizationId, - slug: params.appAuthProfileSlug, - expectedKind: 'oauth_app', - connectorKey, - })) ?? + ? ((explicitAppProfile && explicitAppProfile.profile_kind === 'oauth_app' + ? explicitAppProfile + : null) ?? (oauthMethod && authProfile.profile_kind === 'oauth_account' ? await getPrimaryAuthProfileForKind({ organizationId, diff --git a/packages/server/src/tools/admin/manage_auth_profiles.ts b/packages/server/src/tools/admin/manage_auth_profiles.ts index bbc84e450..b1fec0144 100644 --- a/packages/server/src/tools/admin/manage_auth_profiles.ts +++ b/packages/server/src/tools/admin/manage_auth_profiles.ts @@ -706,19 +706,25 @@ async function handleUpdateAuthProfile( error: `You can only update OAuth account profiles you created. Ask an admin if you need to manage another member's profile.`, }; } + } + // The payload that will actually be persisted: an explicit `auth_data` wins, + // else `credentials` (normalized to a string map), else undefined (leave the + // existing auth_data as-is). + const updateAuthDataPayload: Record | undefined = + args.auth_data !== undefined + ? (args.auth_data as Record) + : args.credentials + ? normalizeAuthValues(args.credentials) + : undefined; + let authProfile = await updateAuthProfile({ organizationId: ctx.organizationId, slug: args.auth_profile_slug, displayName: args.display_name, nextSlug: args.slug, - authData: - args.auth_data !== undefined - ? (args.auth_data as Record) - : args.credentials - ? normalizeAuthValues(args.credentials) - : undefined, + authData: updateAuthDataPayload, status: args.status as AuthProfileStatus | undefined, }); diff --git a/packages/server/src/tools/admin/manage_connections.ts b/packages/server/src/tools/admin/manage_connections.ts index 583911f23..f3607e64c 100644 --- a/packages/server/src/tools/admin/manage_connections.ts +++ b/packages/server/src/tools/admin/manage_connections.ts @@ -20,6 +20,7 @@ * - update_connector_auth: Update reusable default auth profiles for an installed org connector */ +import { parseJsonObject } from '@lobu/core'; import { type Static, Type } from '@sinclair/typebox'; import { getDb } from '../../db/client'; import type { Env } from '../../index'; @@ -1623,7 +1624,7 @@ async function handleUpdate( // Verify ownership const existingRows = await sql` - SELECT c.id, c.connector_key, c.auth_profile_id, c.app_auth_profile_id, c.created_by, cd.auth_schema, cd.feeds_schema + SELECT c.id, c.connector_key, c.auth_profile_id, c.app_auth_profile_id, c.created_by, c.config, cd.auth_schema, cd.feeds_schema FROM connections c LEFT JOIN LATERAL ( SELECT auth_schema, feeds_schema @@ -1650,6 +1651,7 @@ async function handleUpdate( auth_profile_id: number | null; app_auth_profile_id: number | null; created_by: string | null; + config: Record | null; }; const hasAuthProfileArg = Object.hasOwn(args, 'auth_profile_slug'); @@ -1847,6 +1849,35 @@ async function handleUpdate( const replaceConfig = args.replace_config === true && args.config !== undefined; const connectionConfigForReplace = splitConfig.connectionConfig ?? {}; + // Consent-only is enforced BIDIRECTIONALLY: the feed-creation guard stops a + // consent-only connection from gaining feeds, and this stops a feed-having + // connection from becoming consent-only. Compute the consent_only flag the + // UPDATE below would land on — replace = exactly the new config; merge = + // existing config overlaid with the incoming keys — and reject the flip when + // the connection still has feeds, so the "data stays local" invariant holds. + const existingConfig = parseJsonObject(existing.config); + const resultingConfig = replaceConfig + ? connectionConfigForReplace + : splitConfig.connectionConfig + ? { ...existingConfig, ...splitConfig.connectionConfig } + : existingConfig; + const willBeConsentOnly = parseJsonObject(resultingConfig).consent_only === true; + if (willBeConsentOnly && existingConfig.consent_only !== true) { + const feedRows = await sql` + SELECT 1 FROM feeds + WHERE connection_id = ${args.connection_id} + AND organization_id = ${organizationId} + AND deleted_at IS NULL + LIMIT 1 + `; + if (feedRows.length > 0) { + return { + error: + 'This connection has feeds; a consent-only connection cannot have feeds. Remove its feeds first.', + }; + } + } + // Slug is only ever changed when the caller passes one explicitly — a // display_name change never touches it (that's the whole point of a stable // identity for `lobu apply`). An explicit slug is validated for format and diff --git a/packages/server/src/tools/admin/manage_feeds.ts b/packages/server/src/tools/admin/manage_feeds.ts index 6a27edd4d..5ee313c9a 100644 --- a/packages/server/src/tools/admin/manage_feeds.ts +++ b/packages/server/src/tools/admin/manage_feeds.ts @@ -12,6 +12,7 @@ * - trigger_feed: Trigger an immediate sync for a feed */ +import { parseJsonObject } from '@lobu/core'; import { type Static, Type } from '@sinclair/typebox'; import { getDb, pgBigintArray } from '../../db/client'; import type { Env } from '../../index'; @@ -284,7 +285,7 @@ async function handleCreateFeed( const { organizationId } = ctx; const connRows = await sql` - SELECT c.id, c.connector_key, c.status, c.auth_profile_id, cd.feeds_schema + SELECT c.id, c.connector_key, c.status, c.auth_profile_id, c.config, cd.feeds_schema FROM connections c LEFT JOIN LATERAL ( SELECT feeds_schema @@ -303,6 +304,18 @@ async function handleCreateFeed( } const conn = connRows[0] as any; + // Consent-only connections exist solely to hold an OAuth grant for delegation + // (the cloud grant-holder behind a managed connector); they cannot have feeds, + // so they never sync. This is the by-construction guarantee that a managed + // connector's data only ever lives on the local instance — a consent-only + // cloud connection can never get a feed, so the cloud worker never syncs it. + const connConfig = parseJsonObject(conn.config); + if (connConfig.consent_only === true) { + return { + error: + 'This connection is consent-only (holds an OAuth grant for delegation) and cannot have feeds.', + }; + } // A `pending_auth` connection is OK — the feed is created `paused` (the // `feeds.status` CHECK only allows active|paused|error). The OAuth/connect // callback un-pauses the connection's feeds when it activates the connection. diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index e541e8997..33d007d79 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -43,6 +43,38 @@ export async function resolveExecutionAuth( let credentials: ExecutionOAuthCredentials | null = null; + // Managed-connector branch: when the LOCAL connection is `managedBy` a cloud + // (public) org, the grant lives in the cloud — fetch a fresh access token for + // THIS user's own cloud connection via POST /oauth/connection-token. A null + // result means the connection uses the local credential path below, which is + // unchanged. The managed-by descriptor comes from the trusted connection + // `config`, never from raw auth_data keys. + const managed = await resolveManagedByForConnection( + params.organizationId, + params.connectionId + ); + if (managed) { + const accessToken = await fetchManagedConnectionToken(managed, { + ...params.logContext, + connection_id: params.connectionId, + }); + if (accessToken) { + credentials = { + provider: appAuthProfile?.provider ?? 'managed', + accessToken: accessToken.access_token, + refreshToken: null, + expiresAt: accessToken.expires_at ?? null, + scope: null, + }; + } + return { + credentials, + connectionCredentials: {}, + sessionState: null, + browserUserDataDir: null, + }; + } + if (authProfile?.profile_kind === 'oauth_account' && authProfile.account_id) { try { const credentialService = new CredentialService(params.credentialDb); @@ -115,6 +147,152 @@ export async function resolveExecutionAuth( }; } +/** + * A managed-connector descriptor resolved from a LOCAL connection's `config`. + * When present, the connection's OAuth grant lives in a cloud (public) org: the + * local instance fetches a fresh access token for THIS user's own cloud + * connection at runtime, authenticating with the instance's cloud PAT. + */ +interface ManagedByDescriptor { + /** The cloud org slug/id the managed connector lives under. */ + org: string; + /** The connector key to fetch the user's connection token for. */ + connectorKey: string; + /** Cloud base URL (no trailing `/oauth/connection-token`). */ + baseUrl: string; + /** The instance's cloud PAT (`owl_pat_*`), the user's own credential. */ + pat: string; +} + +/** + * Resolve the {@link ManagedByDescriptor} for a connection, or `null` when the + * connection is NOT managed (i.e. it uses the local/unchanged credential path). + * + * A connection opts into the managed path by carrying `config.managedBy = { + * org }` (set via `defineConnection({ connector, managedBy })`). The cloud PAT + * AND the cloud base URL are sourced ONLY from the INSTANCE config + * (`LOBU_CLOUD_PAT` / `LOBU_CLOUD_URL`) — a single credential + a single fixed, + * trusted origin for the local instance. The connection config supplies ONLY + * the `org`; it CANNOT influence where the PAT is sent (a connection-controlled + * URL would let a malicious config exfiltrate the cloud PAT). Returns `null` + * (so the connection falls through to the local path) when the descriptor, the + * instance cloud PAT, or the instance cloud URL is missing. + */ +async function resolveManagedByForConnection( + organizationId: string, + connectionId: number +): Promise { + const sql = getDb(); + const rows = (await sql` + SELECT connector_key, config + FROM connections + WHERE id = ${connectionId} + AND organization_id = ${organizationId} + AND deleted_at IS NULL + LIMIT 1 + `) as unknown as Array<{ + connector_key: string; + config: Record | null; + }>; + if (rows.length === 0) return null; + + const config = parseJsonObject(rows[0].config); + const managedByRaw = config.managedBy; + if (!managedByRaw || typeof managedByRaw !== 'object' || Array.isArray(managedByRaw)) { + return null; + } + const managedBy = managedByRaw as Record; + const org = typeof managedBy.org === 'string' ? managedBy.org.trim() : ''; + if (!org) return null; + + const pat = process.env.LOBU_CLOUD_PAT?.trim(); + if (!pat) return null; + + // The PAT is ALWAYS sent to the instance-configured cloud origin only. The + // connection config never supplies a URL, so it cannot redirect the PAT. + const baseUrl = (process.env.LOBU_CLOUD_URL?.trim() ?? '').replace(/\/+$/, ''); + if (!baseUrl) return null; + + return { org, connectorKey: rows[0].connector_key, baseUrl, pat }; +} + +/** + * Per-instance cache of fetched managed access tokens. Keyed by a + * JSON.stringify'd [org, connectorKey, baseUrl] tuple so field boundaries are + * unambiguous and values can't collide across keys; cached until shortly before + * expiry so a burst of runs doesn't re-fetch on every resolution. Pod-local by + * design (a cache miss simply re-fetches), so this holds under N>1 replicas. + */ +const MANAGED_TOKEN_CACHE = new Map< + string, + { accessToken: string; expiresAt: string | null; expiresAtMs: number } +>(); +/** Refresh a cached token this long before its stated expiry. */ +const MANAGED_TOKEN_EXPIRY_BUFFER_MS = 60_000; + +/** + * Fetch a fresh access token for a managed connection from the cloud via POST + * /oauth/connection-token. The cloud holds the managed grant + secret and + * refreshes server-side; we only ever receive `{ access_token, expires_at }`. + * Caches until near expiry. Returns null on any failure so the connection + * resolves without credentials (fail-soft, like the local path). + */ +async function fetchManagedConnectionToken( + managed: ManagedByDescriptor, + logContext: Record +): Promise<{ access_token: string; expires_at: string | null } | null> { + const cacheKey = JSON.stringify([managed.org, managed.connectorKey, managed.baseUrl]); + const cached = MANAGED_TOKEN_CACHE.get(cacheKey); + if (cached && cached.expiresAtMs - MANAGED_TOKEN_EXPIRY_BUFFER_MS > Date.now()) { + return { access_token: cached.accessToken, expires_at: cached.expiresAt }; + } + + let tokenUrl: string; + try { + tokenUrl = new URL(`${managed.baseUrl}/oauth/connection-token`).toString(); + } catch { + logger.warn({ ...logContext }, 'Managed connection cloud URL is not a valid absolute URL'); + return null; + } + + try { + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${managed.pat}`, + }, + body: JSON.stringify({ org: managed.org, connector_key: managed.connectorKey }), + }); + if (!response.ok) { + logger.warn( + { ...logContext, status: response.status }, + 'Managed connection token fetch failed' + ); + return null; + } + const body = (await response.json()) as { + access_token?: string; + expires_at?: string | null; + }; + if (!body.access_token) return null; + const expiresAt = body.expires_at ?? null; + const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.POSITIVE_INFINITY; + MANAGED_TOKEN_CACHE.set(cacheKey, { + accessToken: body.access_token, + expiresAt, + expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : Number.POSITIVE_INFINITY, + }); + return { access_token: body.access_token, expires_at: expiresAt }; + } catch (error) { + logger.warn( + { ...logContext, error: errorMessage(error) }, + 'Managed connection token fetch error' + ); + return null; + } +} + async function resolveExecutionOAuthConfig( organizationId: string, connectionId: number,