From 2dae73f735c0880e1fe5f28a468401062e96c80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 04:12:45 +0100 Subject: [PATCH 01/15] feat(connect): broker-delegated oauth_app profiles (spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nango-style managed OAuth, Lobu-native. An oauth_app profile can encode a `__broker` reference ({url, org, pat}) instead of client_id/secret. When set, the local instance delegates the secret-requiring OAuth steps to a remote Lobu "broker" over HTTP, authenticating with a Lobu PAT (Bearer). The broker holds the real provider app credentials, performs build-authorize-url / exchange / refresh on the caller's behalf, and returns ONLY the user's tokens — the client_secret never leaves the broker. - getBrokerRef() helper + __broker preservation in normalizeAuthData (no DB migration; reuses profile_kind=oauth_app) - POST /broker/oauth/{authorize-url,exchange,refresh} — PAT-gated router - local delegation hooks in the connect start + callback paths SPIKE — not for merge. --- .../connectors/oauth-broker.test.ts | 348 ++++++++++++++++++ packages/server/src/connect/broker-routes.ts | 300 +++++++++++++++ packages/server/src/connect/routes.ts | 167 ++++++++- packages/server/src/http/spa-route-filter.ts | 1 + packages/server/src/index.ts | 9 + packages/server/src/utils/auth-profiles.ts | 56 ++- 6 files changed, 864 insertions(+), 17 deletions(-) create mode 100644 packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts create mode 100644 packages/server/src/connect/broker-routes.ts diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts new file mode 100644 index 000000000..ae7f432f7 --- /dev/null +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -0,0 +1,348 @@ +/** + * SPIKE: broker-delegated oauth_app profiles. + * + * Proves the local↔broker AUTH handshake for Nango-style managed OAuth without + * a real external provider: + * + * 1. A "broker" org holds a managed `oauth_app` (fake client_id/secret) for a + * test provider whose tokenUrl points at a LOCAL fake provider server that + * returns canned tokens. + * 2. The broker's `POST /broker/oauth/exchange` is PAT-gated. A valid PAT for + * the broker org → the broker resolves ITS managed app, uses the secret, + * and returns ONLY the user's tokens. No/invalid PAT → 401. + * 3. End-to-end: a LOCAL connect token whose oauth_app profile is a + * `__broker` ref drives `GET /connect/oauth/callback`; the callback + * delegates the code exchange to the broker (broker.url = self) and stores + * the broker-returned tokens on the `account` row — never touching a local + * client_secret. + */ + +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { brokerRoutes } from "../../../connect/broker-routes"; +import { connectRoutes } from "../../../connect/routes"; +import type { Env } from "../../../index"; +import { createAuthProfile } from "../../../utils/auth-profiles"; +import { createConnectToken } from "../../../utils/connect-tokens"; +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 CANNED = { + access_token: "canned-access-token-123", + refresh_token: "canned-refresh-token-456", + expires_in: 3600, + scope: "read write", + token_type: "Bearer", +}; + +// Fake OAuth provider: the tokenUrl the broker's managed app points at. +let providerServer: ReturnType | null = null; +let providerTokenUrl = ""; + +// Broker app served on a real port so the local callback's `fetch` reaches it. +let brokerServer: ReturnType | null = null; +let brokerBaseUrl = ""; + +function buildBrokerApp(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.route("/broker", brokerRoutes); + return app; +} + +beforeAll(async () => { + await initWorkspaceProvider(); + + // Fake provider token endpoint — returns canned tokens for any code. + const providerApp = new Hono(); + providerApp.post("/token", (c) => c.json(CANNED)); + 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); + }, + ); + }); + + // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). + const brokerApp = buildBrokerApp(); + brokerServer = await new Promise((resolve) => { + const s = serve( + { + fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), + hostname: "127.0.0.1", + port: 0, + }, + (info) => { + brokerBaseUrl = `http://127.0.0.1:${info.port}`; + resolve(s); + }, + ); + }); +}); + +afterAll(async () => { + await new Promise((done) => + providerServer ? providerServer.close(() => done()) : done(), + ); + await new Promise((done) => + brokerServer ? brokerServer.close(() => done()) : done(), + ); +}); + +/** Seed a broker org with a managed oauth_app (fake creds) for `demo`. */ +async function seedBrokerOrg() { + const org = await createTestOrganization({ name: "Broker Org" }); + const user = await createTestUser({ name: "Broker Admin" }); + await addUserToOrganization(user.id, org.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.oauth", + name: "Demo OAuth", + organization_id: org.id, + auth_schema: { + methods: [{ type: "oauth", provider: "demo", requiredScopes: ["read"] }], + }, + feeds_schema: { items: {} }, + }); + // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). + await createAuthProfile({ + organizationId: org.id, + connectorKey: "demo.oauth", + displayName: "Managed Demo App", + slug: "managed-demo", + profileKind: "oauth_app", + provider: "demo", + authData: { + DEMO_CLIENT_ID: "broker-cid", + DEMO_CLIENT_SECRET: "broker-secret", + }, + }); + const pat = await createTestPAT(user.id, org.id); + return { org, user, pat }; +} + +describe("SPIKE: broker-delegated oauth_app — exchange handshake", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it("PAT-gated exchange resolves the broker managed app and returns user tokens", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "auth-code-abc", + redirect_uri: "http://local.example/connect/oauth/callback", + token_url: providerTokenUrl, + }), + }), + TEST_ENV, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(CANNED.access_token); + expect(body.refresh_token).toBe(CANNED.refresh_token); + expect(body.expires_in).toBe(CANNED.expires_in); + expect(body.scope).toBe(CANNED.scope); + // The broker never leaks the client_secret back to the caller. + expect(JSON.stringify(body)).not.toContain("broker-secret"); + }); + + it("rejects exchange with no PAT (401)", async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "x", + redirect_uri: "http://local.example/cb", + token_url: providerTokenUrl, + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(401); + }); + + it("rejects exchange with an invalid PAT (401)", async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer owl_pat_totally-bogus-token", + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "x", + redirect_uri: "http://local.example/cb", + token_url: providerTokenUrl, + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(401); + }); + + it("authorize-url builds the provider consent URL with the broker client_id", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/authorize-url", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + redirect_uri: "http://local.example/connect/oauth/callback", + scopes: ["read"], + state: "state-xyz", + authorization_url: "https://demo.example/authorize", + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { authorization_url: string }; + const url = new URL(body.authorization_url); + expect(url.searchParams.get("client_id")).toBe("broker-cid"); + expect(url.searchParams.get("state")).toBe("state-xyz"); + }); +}); + +describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it("local callback delegates code exchange to the broker and stores broker tokens", async () => { + // Broker side: managed app + PAT (broker.url = the in-process broker server). + const { pat } = await seedBrokerOrg(); + + // Local side: a separate org whose oauth_app profile is a broker-ref (no + // local client_id/secret) plus a connect token bound to it. + 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 brokerProfile = await createAuthProfile({ + organizationId: localOrg.id, + connectorKey: "demo.oauth", + displayName: "Broker-backed Demo App", + slug: "local-broker-app", + profileKind: "oauth_app", + provider: "demo", + authData: { + __broker: { url: brokerBaseUrl, org: "broker-org", pat: pat.token }, + }, + }); + + // The broker-ref must survive normalization (no client_id/secret keys). + const sql = getTestDb(); + const stored = (await sql` + SELECT auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} + `) as unknown as Array<{ auth_data: Record }>; + expect( + (stored[0].auth_data as { __broker?: unknown }).__broker, + ).toBeTruthy(); + expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); + + // Connect token (oauth) bound to the local broker-ref app profile. created_by + // set so the callback skips session lookup. + const tokenRow = await createConnectToken({ + organizationId: localOrg.id, + connectorKey: "demo.oauth", + authType: "oauth", + authConfig: { + provider: "demo", + scopes: ["read"], + tokenUrl: providerTokenUrl, + authorizationUrl: "https://demo.example/authorize", + redirectUri: "http://local.example/connect/oauth/callback", + clientIdKey: "DEMO_CLIENT_ID", + clientSecretKey: "DEMO_CLIENT_SECRET", + }, + createdBy: localUser.id, + }); + + // Drive the stable callback. The local instance has no client_secret; it + // POSTs the broker /exchange (which uses the broker's secret) and stores + // the broker-returned tokens. + const app = new Hono<{ Bindings: Env }>(); + app.route("/connect", connectRoutes); + const res = await app.fetch( + new Request( + `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, + { method: "GET", redirect: "manual" }, + ), + TEST_ENV, + ); + // Callback ends in a redirect (302) once tokens are stored. + expect(res.status).toBe(302); + + // The account row must carry the broker-returned canned tokens — proof the + // exchange was delegated and no local secret was needed. + const accounts = (await sql` + SELECT "accessToken", "refreshToken", "providerId" + FROM "account" + WHERE "userId" = ${localUser.id} AND "providerId" = 'demo' + `) as unknown as Array<{ + accessToken: string; + refreshToken: string; + providerId: string; + }>; + expect(accounts).toHaveLength(1); + expect(accounts[0].accessToken).toBe(CANNED.access_token); + expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); + + // Connect token consumed. + const tk = (await sql` + SELECT status FROM connect_tokens WHERE token = ${tokenRow.token} + `) as unknown as Array<{ status: string }>; + expect(tk[0].status).toBe("completed"); + }); +}); diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts new file mode 100644 index 000000000..304e69ab6 --- /dev/null +++ b/packages/server/src/connect/broker-routes.ts @@ -0,0 +1,300 @@ +/** + * OAuth Broker Routes (SPIKE) + * + * Nango-style managed OAuth, Lobu-native. A LOCAL Lobu instance whose + * `oauth_app` profile is a broker-ref (see `getBrokerRef`) does not hold the + * provider's client_id/secret. Instead it delegates the secret-requiring OAuth + * steps to a REMOTE Lobu instance — the "broker" — over these HTTP endpoints. + * + * Auth handshake: the local instance calls with `Authorization: Bearer + * `. The broker verifies the PAT (resolving the authenticated org + + * tenant membership, exactly as the embedded Agent API does in + * `createLobuAuthBridge`) and uses THAT org's managed `oauth_app` profile to + * read the real client_id/secret. The broker performs the build/exchange/ + * refresh and returns ONLY the user's tokens — the client_secret never leaves + * the broker. + * + * Endpoints (all POST, all PAT-gated): + * - /broker/oauth/authorize-url → buildAuthorizationUrl with broker's client_id + * - /broker/oauth/exchange → exchangeCodeForTokens with broker's secret + * - /broker/oauth/refresh → CredentialService.refreshTokenGeneric + */ + +import type { Env } from '@lobu/connector-sdk'; +import { Hono } from 'hono'; +import { CredentialService } from '../auth/credentials'; +import { PersonalAccessTokenService } from '../auth/tokens'; +import { getDb } from '../db/client'; +import { getPrimaryAuthProfileForKind, normalizeAuthValues } from '../utils/auth-profiles'; +import logger from '../utils/logger'; +import { buildAuthorizationUrl, exchangeCodeForTokens } from './oauth-providers'; + +type BrokerEnv = { + Bindings: Env; + Variables: { brokerOrgId: string }; +}; + +const brokerRoutes = new Hono(); + +/** + * PAT auth for broker calls. Mirrors the authoritative PAT path in + * `createLobuAuthBridge` (gateway.ts): verify the `owl_pat_*` bearer, reject + * null-org or non-member tokens, and stash the resolved org on the context. + * This IS the auth gate — no/invalid/cross-tenant PAT short-circuits 401/403. + */ +brokerRoutes.use('/oauth/*', async (c, next) => { + const authHeader = c.req.header('Authorization'); + const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; + const bearerValue = bearerMatch ? (bearerMatch[1] ?? '').trim() : null; + + if (!bearerValue || bearerValue.slice(0, 8).toLowerCase() !== 'owl_pat_') { + return c.json({ error: 'unauthorized', error_description: 'Bearer PAT required' }, 401); + } + + const sql = getDb(); + const patInfo = await new PersonalAccessTokenService(sql).verify(bearerValue); + if (!patInfo?.userId) { + return c.json({ error: 'invalid_token', error_description: 'PAT invalid/expired/revoked' }, 401); + } + if (!patInfo.organizationId) { + return c.json( + { error: 'invalid_token', error_description: 'PAT not scoped to an organization' }, + 401 + ); + } + + // Tenant-membership check — a PAT for org A must still belong to org A. + const memberRows = (await sql` + SELECT 1 FROM "member" + WHERE "userId" = ${patInfo.userId} + AND "organizationId" = ${patInfo.organizationId} + LIMIT 1 + `) as unknown as Array; + if (memberRows.length === 0) { + return c.json( + { error: 'forbidden', error_description: 'Token owner is not a member of this organization' }, + 403 + ); + } + + c.set('brokerOrgId', patInfo.organizationId); + return next(); +}); + +/** + * Resolve the broker org's managed `oauth_app` client credentials for a + * provider/connector. The broker reads its OWN org's profile — the local + * caller never sees these values. + */ +async function resolveBrokerClientCredentials(params: { + organizationId: string; + provider: string; + connectorKey: string; + clientIdKey?: string; + clientSecretKey?: string; +}): Promise<{ clientId: string | null; clientSecret: string | null }> { + const providerUpper = params.provider.toUpperCase(); + const clientIdKey = params.clientIdKey || `${providerUpper}_CLIENT_ID`; + const clientSecretKey = params.clientSecretKey || `${providerUpper}_CLIENT_SECRET`; + + const appProfile = await getPrimaryAuthProfileForKind({ + organizationId: params.organizationId, + connectorKey: params.connectorKey, + profileKind: 'oauth_app', + provider: params.provider, + }); + + const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); + return { + clientId: authValues[clientIdKey] ?? null, + clientSecret: authValues[clientSecretKey] ?? null, + }; +} + +interface AuthorizeUrlBody { + connector_key?: string; + provider?: string; + redirect_uri?: string; + scopes?: string[]; + state?: string; + code_challenge?: string; + authorization_url?: string; + auth_params?: Record; + client_id_key?: string; +} + +/** + * POST /broker/oauth/authorize-url + * Build the provider authorization URL using the broker org's managed client_id. + */ +brokerRoutes.post('/oauth/authorize-url', async (c) => { + const orgId = c.get('brokerOrgId'); + const body = await c.req.json().catch(() => null); + if (!body?.provider || !body.connector_key || !body.redirect_uri || !body.state) { + return c.json( + { + error: 'bad_request', + error_description: 'provider, connector_key, redirect_uri, state required', + }, + 400 + ); + } + + const { clientId } = await resolveBrokerClientCredentials({ + organizationId: orgId, + provider: body.provider, + connectorKey: body.connector_key, + clientIdKey: body.client_id_key, + }); + if (!clientId) { + return c.json( + { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, + 400 + ); + } + + const authorizationUrl = buildAuthorizationUrl({ + provider: body.provider, + clientId, + redirectUri: body.redirect_uri, + scopes: body.scopes ?? [], + state: body.state, + authorizationUrl: body.authorization_url, + authParams: body.auth_params, + codeChallenge: body.code_challenge, + }); + if (!authorizationUrl) { + return c.json({ error: 'unsupported_provider', error_description: body.provider }, 400); + } + + return c.json({ authorization_url: authorizationUrl }); +}); + +interface ExchangeBody { + connector_key?: string; + provider?: string; + code?: string; + redirect_uri?: string; + code_verifier?: string; + token_url?: string; + token_endpoint_auth_method?: 'client_secret_post' | 'client_secret_basic' | 'none'; + client_id_key?: string; + client_secret_key?: string; +} + +/** + * POST /broker/oauth/exchange + * Exchange an authorization code for tokens using the broker org's managed + * client_id/secret. Returns ONLY the user's tokens — never the client_secret. + */ +brokerRoutes.post('/oauth/exchange', async (c) => { + const orgId = c.get('brokerOrgId'); + const body = await c.req.json().catch(() => null); + if (!body?.provider || !body.connector_key || !body.code || !body.redirect_uri) { + return c.json( + { + error: 'bad_request', + error_description: 'provider, connector_key, code, redirect_uri required', + }, + 400 + ); + } + + const { clientId, clientSecret } = await resolveBrokerClientCredentials({ + organizationId: orgId, + provider: body.provider, + connectorKey: body.connector_key, + clientIdKey: body.client_id_key, + clientSecretKey: body.client_secret_key, + }); + if (!clientId) { + return c.json( + { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, + 400 + ); + } + + const tokens = await exchangeCodeForTokens({ + provider: body.provider, + code: body.code, + clientId, + clientSecret, + redirectUri: body.redirect_uri, + tokenUrl: body.token_url, + tokenEndpointAuthMethod: body.token_endpoint_auth_method, + codeVerifier: body.code_verifier, + }); + if (!tokens) { + return c.json({ error: 'exchange_failed', error_description: 'Token exchange failed' }, 502); + } + + return c.json({ + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + expires_in: tokens.expiresIn, + scope: tokens.scope, + token_type: tokens.tokenType, + }); +}); + +interface RefreshBody { + connector_key?: string; + provider?: string; + refresh_token?: string; + token_url?: string; + token_endpoint_auth_method?: 'client_secret_post' | 'client_secret_basic' | 'none'; + client_id_key?: string; + client_secret_key?: string; +} + +/** + * POST /broker/oauth/refresh + * Refresh an access token using the broker org's managed client_id/secret. + */ +brokerRoutes.post('/oauth/refresh', async (c) => { + const orgId = c.get('brokerOrgId'); + const body = await c.req.json().catch(() => null); + if (!body?.provider || !body.connector_key || !body.refresh_token || !body.token_url) { + return c.json( + { + error: 'bad_request', + error_description: 'provider, connector_key, refresh_token, token_url required', + }, + 400 + ); + } + + const { clientId, clientSecret } = await resolveBrokerClientCredentials({ + organizationId: orgId, + provider: body.provider, + connectorKey: body.connector_key, + clientIdKey: body.client_id_key, + clientSecretKey: body.client_secret_key, + }); + if (!clientId) { + return c.json( + { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, + 400 + ); + } + + const refreshed = await new CredentialService(getDb()).refreshTokenGeneric({ + tokenUrl: body.token_url, + clientId, + clientSecret: clientSecret ?? undefined, + refreshToken: body.refresh_token, + authMethod: body.token_endpoint_auth_method, + }); + if (!refreshed) { + return c.json({ error: 'refresh_failed', error_description: 'Token refresh failed' }, 502); + } + + logger.info({ provider: body.provider, organizationId: orgId }, 'Broker refreshed token'); + return c.json({ + access_token: refreshed.accessToken, + refresh_token: refreshed.refreshToken ?? null, + expires_in: Math.max(0, Math.round((refreshed.expiresAt.getTime() - Date.now()) / 1000)), + }); +}); + +export { brokerRoutes }; diff --git a/packages/server/src/connect/routes.ts b/packages/server/src/connect/routes.ts index aa7565791..dd1b1392e 100644 --- a/packages/server/src/connect/routes.ts +++ b/packages/server/src/connect/routes.ts @@ -21,8 +21,10 @@ import { createAuth } from '../auth'; import { getDb } from '../db/client'; import type { Env } from '../index'; import { + type BrokerRef, ensureUniqueAuthProfileSlug, getAuthProfileById, + getBrokerRef, getPrimaryAuthProfileForKind, normalizeAuthProfileSlug, normalizeAuthValues, @@ -541,6 +543,51 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { return c.json({ error: 'OAuth provider not configured for this connector' }, 400); } + // Broker delegation: when the backing oauth_app profile is a broker-ref, the + // broker holds the client_id, so build the authorization URL remotely. + const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); + if (brokerRef) { + const baseUrl = getBaseUrl(c); + const redirectUri = `${baseUrl}/connect/oauth/callback`; + const pkceCodeVerifier = authConfig.usePkce ? buildPkceVerifier() : undefined; + + const needsUpdate = + authConfig.redirectUri !== redirectUri || + (authConfig.usePkce && !authConfig.pkceCodeVerifier); + if (needsUpdate) { + const sql = getDb(); + await sql` + UPDATE connect_tokens + SET auth_config = ${sql.json({ + ...authConfig, + redirectUri, + ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), + })} + WHERE token = ${token} + `; + } + + const brokerResult = await callBroker<{ authorization_url: string }>( + brokerRef, + 'authorize-url', + { + connector_key: tokenRow.connector_key, + provider: authConfig.provider, + redirect_uri: redirectUri, + scopes: authConfig.scopes ?? [], + state: token, + authorization_url: authConfig.authorizationUrl, + auth_params: authConfig.authParams, + client_id_key: authConfig.clientIdKey, + ...(pkceCodeVerifier ? { code_challenge: buildPkceChallenge(pkceCodeVerifier) } : {}), + } + ); + if (!brokerResult?.authorization_url) { + return c.json({ error: 'Broker failed to build authorization URL' }, 502); + } + return c.redirect(brokerResult.authorization_url); + } + const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); if (!clientId) { @@ -701,24 +748,57 @@ async function handleOAuthCallback( } const sql = getDb(); - const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); - - if (!clientId) { - return c.json({ error: 'OAuth client credentials not found' }, 500); - } - const baseUrl = getBaseUrl(c); - const tokens = await exchangeCodeForTokens({ - provider: authConfig.provider, - code, - clientId, - clientSecret, - redirectUri, - tokenUrl: authConfig.tokenUrl, - tokenEndpointAuthMethod: authConfig.tokenEndpointAuthMethod, - codeVerifier: authConfig.pkceCodeVerifier, - }); + // Broker delegation: when the backing oauth_app profile is a broker-ref, the + // broker holds the client_secret, so exchange the code remotely and use the + // returned user tokens. Storage below is identical to the local path. + const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); + + let tokens: Awaited> = null; + if (brokerRef) { + const exchanged = await callBroker<{ + access_token: string; + refresh_token: string | null; + expires_in: number | null; + scope: string | null; + token_type?: string; + }>(brokerRef, 'exchange', { + connector_key: tokenRow.connector_key, + provider: authConfig.provider, + code, + redirect_uri: redirectUri, + token_url: authConfig.tokenUrl, + token_endpoint_auth_method: authConfig.tokenEndpointAuthMethod, + client_id_key: authConfig.clientIdKey, + client_secret_key: authConfig.clientSecretKey, + ...(authConfig.pkceCodeVerifier ? { code_verifier: authConfig.pkceCodeVerifier } : {}), + }); + if (exchanged?.access_token) { + tokens = { + accessToken: exchanged.access_token, + refreshToken: exchanged.refresh_token ?? null, + expiresIn: exchanged.expires_in ?? null, + scope: exchanged.scope ?? null, + tokenType: exchanged.token_type ?? 'Bearer', + }; + } + } else { + const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); + if (!clientId) { + return c.json({ error: 'OAuth client credentials not found' }, 500); + } + tokens = await exchangeCodeForTokens({ + provider: authConfig.provider, + code, + clientId, + clientSecret, + redirectUri, + tokenUrl: authConfig.tokenUrl, + tokenEndpointAuthMethod: authConfig.tokenEndpointAuthMethod, + codeVerifier: authConfig.pkceCodeVerifier, + }); + } if (!tokens) { return c.redirect(`${baseUrl}/connect/${token}?error=token_exchange_failed`); @@ -924,6 +1004,61 @@ async function resolveOAuthCredentialsForToken( ); } +/** + * Resolve the broker reference (if any) for the oauth_app profile that would + * back this token. When non-null, the secret-requiring OAuth steps are + * delegated to that remote broker instead of using local client credentials. + * Resolution mirrors resolveOAuthClientCredentials: selected app profile first, + * then the org's primary managed oauth_app for the connector/provider. + */ +async function resolveBrokerRefForToken( + tokenRow: { connection_id: number | null; organization_id: string; connector_key: string }, + authConfig: OAuthAuthConfig +): Promise { + const appAuthProfileId = await fetchAppAuthProfileId( + tokenRow.connection_id, + tokenRow.organization_id + ); + const appProfile = + (appAuthProfileId + ? await getAuthProfileById(tokenRow.organization_id, appAuthProfileId) + : null) ?? + (await getPrimaryAuthProfileForKind({ + organizationId: tokenRow.organization_id, + connectorKey: tokenRow.connector_key, + profileKind: 'oauth_app', + provider: authConfig.provider, + })); + return getBrokerRef(appProfile?.auth_data ?? null); +} + +/** POST a JSON body to a broker endpoint with the broker PAT. */ +async function callBroker( + broker: BrokerRef, + endpoint: string, + body: Record +): Promise { + try { + const response = await fetch(`${broker.url}/broker/oauth/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${broker.pat}`, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text(); + logger.error({ endpoint, status: response.status, body: text }, 'Broker OAuth call failed'); + return null; + } + return (await response.json()) as T; + } catch (error) { + logger.error({ endpoint, error }, 'Broker OAuth call error'); + return null; + } +} + async function fetchAppAuthProfileId( connectionId: number | null, organizationId: string diff --git a/packages/server/src/http/spa-route-filter.ts b/packages/server/src/http/spa-route-filter.ts index 9aa9b3dd7..d27df6d61 100644 --- a/packages/server/src/http/spa-route-filter.ts +++ b/packages/server/src/http/spa-route-filter.ts @@ -5,6 +5,7 @@ const SPA_ALLOWED_PATHS = new Set(['/oauth/consent', '/oauth/device']); const SPA_EXCLUDED_PREFIXES = [ '/.well-known', '/api', + '/broker', '/connect', '/health', '/legal', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index eb31fe30d..81c7be704 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 { brokerRoutes } from './connect/broker-routes'; import { connectRoutes } from './connect/routes'; import { getDb } from './db/client'; import * as invalidationEmitter from './events/emitter'; @@ -551,6 +552,14 @@ app.route('/mcp', oauthRoutes); */ app.route('/connect', connectRoutes); +/** + * OAuth Broker routes (SPIKE) — PAT-gated. A remote Lobu instance acting as a + * broker performs the secret-requiring OAuth steps (authorize-url / exchange / + * refresh) on behalf of a local instance whose oauth_app profile is a + * broker-ref. The client_secret never leaves the broker. + */ +app.route('/broker', brokerRoutes); + /** * Logo endpoint for MCP/OAuth client metadata. */ diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index a6e01f252..78533e19d 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -47,6 +47,52 @@ interface BrowserSessionReadiness extends BrowserSessionSummary { resolved_cdp_url: string | null; } +/** + * Reference to a remote Lobu "broker" instance that holds the real provider + * OAuth app credentials. Encoded inside an `oauth_app` profile's `auth_data` + * under the `__broker` key (instead of client_id/secret). The local instance + * delegates the secret-requiring OAuth steps (authorize-url build, code + * exchange, refresh) to the broker over HTTP, authenticating with `pat`. + */ +export interface BrokerRef { + /** Broker base URL, e.g. `https://broker.lobu.ai` (no `/broker` suffix). */ + url: string; + /** Broker org slug the managed oauth_app lives under (informational). */ + org: string; + /** Lobu Personal Access Token (`owl_pat_*`) the broker authenticates. */ + pat: string; +} + +/** + * Extract a {@link BrokerRef} from an `oauth_app` profile's `auth_data`, or + * `null` when the profile carries local client credentials instead. A + * broker-ref profile has a `__broker` object with `url`/`org`/`pat` and NO + * client_id/secret keys. + */ +export function getBrokerRef(authData: unknown): BrokerRef | null { + if (typeof authData === 'string') { + try { + return getBrokerRef(JSON.parse(authData)); + } catch { + return null; + } + } + if (!authData || typeof authData !== 'object' || Array.isArray(authData)) return null; + const broker = (authData as Record).__broker; + if (!broker || typeof broker !== 'object' || Array.isArray(broker)) return null; + const { url, org, pat } = broker as Record; + if ( + typeof url !== 'string' || + typeof org !== 'string' || + typeof pat !== 'string' || + url.trim().length === 0 || + pat.trim().length === 0 + ) { + return null; + } + return { url: url.trim().replace(/\/+$/, ''), org: org.trim(), pat: pat.trim() }; +} + export function normalizeAuthValues(raw: unknown): Record { if (typeof raw === 'string') { try { @@ -73,7 +119,15 @@ function normalizeAuthData( profileKind: AuthProfileKind, raw: unknown ): Record { - if (profileKind === 'env' || profileKind === 'oauth_app') { + if (profileKind === 'oauth_app') { + // A broker-ref oauth_app carries a `__broker` object instead of string + // client credentials. normalizeAuthValues() would strip the object, so + // preserve it explicitly while still normalizing any sibling string keys. + const broker = getBrokerRef(raw); + const normalized = normalizeAuthValues(raw); + return broker ? { ...normalized, __broker: broker } : normalized; + } + if (profileKind === 'env') { return normalizeAuthValues(raw); } From 2d8b0628926bf8fbcf8106278e0fd24e978aad57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 04:24:25 +0100 Subject: [PATCH 02/15] fix(connect): broker resolves provider endpoints server-side; pkce verifier reuse Security blocker: the broker exchange/refresh used caller-supplied token_url / authorization_url, so any valid PAT could redirect the broker's client_secret to an attacker endpoint. Now the broker resolves authorizationUrl / tokenUrl / tokenEndpointAuthMethod (+ client id/secret key names) SERVER-SIDE from its OWN org's connector_definitions auth_schema for the given connector_key/provider, and rejects (400) connectors it doesn't manage. token_url / authorization_url removed from all three endpoint request bodies; local delegation caller updated to match. PKCE: /connect/:token/oauth/start regenerated the verifier on every call, so a repeat start built the challenge from a verifier the callback never sees (the persisted one is unchanged when needsUpdate is false). Reuse the persisted verifier (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) so start/callback stay consistent; fixed in both the broker branch and the pre-existing local path. Test: + caller-supplied token_url is ignored (attacker endpoint gets 0 hits) + unknown-connector 400; all prior assertions kept. 7 passed. --- .../connectors/oauth-broker.test.ts | 643 ++++++++++-------- packages/server/src/connect/broker-routes.ts | 164 ++++- packages/server/src/connect/routes.ts | 30 +- 3 files changed, 511 insertions(+), 326 deletions(-) diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index ae7f432f7..73d4a0d81 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -5,344 +5,419 @@ * a real external provider: * * 1. A "broker" org holds a managed `oauth_app` (fake client_id/secret) for a - * test provider whose tokenUrl points at a LOCAL fake provider server that - * returns canned tokens. + * test connector whose oauth method's `tokenUrl`/`authorizationUrl` point + * at a LOCAL fake provider server that returns canned tokens. Those + * endpoints live in the broker org's OWN connector metadata — the broker + * resolves them SERVER-SIDE; the caller never supplies them. * 2. The broker's `POST /broker/oauth/exchange` is PAT-gated. A valid PAT for - * the broker org → the broker resolves ITS managed app, uses the secret, - * and returns ONLY the user's tokens. No/invalid PAT → 401. - * 3. End-to-end: a LOCAL connect token whose oauth_app profile is a + * the broker org → the broker resolves ITS managed app + ITS connector + * endpoints, uses the secret, and returns ONLY the user's tokens. No / + * invalid PAT → 401. + * 3. A caller-supplied `token_url` in the body is IGNORED (the broker hits + * its own connector's tokenUrl), so the client_secret can never be + * redirected to an attacker. + * 4. End-to-end: a LOCAL connect token whose oauth_app profile is a * `__broker` ref drives `GET /connect/oauth/callback`; the callback * delegates the code exchange to the broker (broker.url = self) and stores * the broker-returned tokens on the `account` row — never touching a local * client_secret. */ -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { brokerRoutes } from "../../../connect/broker-routes"; -import { connectRoutes } from "../../../connect/routes"; -import type { Env } from "../../../index"; -import { createAuthProfile } from "../../../utils/auth-profiles"; -import { createConnectToken } from "../../../utils/connect-tokens"; -import { initWorkspaceProvider } from "../../../workspace"; -import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { brokerRoutes } from '../../../connect/broker-routes'; +import { connectRoutes } from '../../../connect/routes'; +import type { Env } from '../../../index'; +import { createAuthProfile } from '../../../utils/auth-profiles'; +import { createConnectToken } from '../../../utils/connect-tokens'; +import { initWorkspaceProvider } from '../../../workspace'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; import { - addUserToOrganization, - createTestConnectorDefinition, - createTestOrganization, - createTestPAT, - createTestUser, -} from "../../setup/test-fixtures"; + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestPAT, + createTestUser, +} from '../../setup/test-fixtures'; const TEST_ENV = { - ENVIRONMENT: "test", - DATABASE_URL: process.env.DATABASE_URL, + ENVIRONMENT: 'test', + DATABASE_URL: process.env.DATABASE_URL, } as unknown as Env; const CANNED = { - access_token: "canned-access-token-123", - refresh_token: "canned-refresh-token-456", - expires_in: 3600, - scope: "read write", - token_type: "Bearer", + access_token: 'canned-access-token-123', + refresh_token: 'canned-refresh-token-456', + expires_in: 3600, + scope: 'read write', + token_type: 'Bearer', }; -// Fake OAuth provider: the tokenUrl the broker's managed app points at. +// Fake OAuth provider: the tokenUrl the broker org's connector points at. A +// SECOND fake server stands in for an "attacker" endpoint — if the broker ever +// honored a caller-supplied token_url it would POST the client_secret here. let providerServer: ReturnType | null = null; -let providerTokenUrl = ""; +let providerTokenUrl = ''; +let attackerServer: ReturnType | null = null; +let attackerTokenUrl = ''; +let attackerHits = 0; // Broker app served on a real port so the local callback's `fetch` reaches it. let brokerServer: ReturnType | null = null; -let brokerBaseUrl = ""; +let brokerBaseUrl = ''; function buildBrokerApp(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); - app.route("/broker", brokerRoutes); - return app; + const app = new Hono<{ Bindings: Env }>(); + app.route('/broker', brokerRoutes); + return app; } beforeAll(async () => { - await initWorkspaceProvider(); + await initWorkspaceProvider(); - // Fake provider token endpoint — returns canned tokens for any code. - const providerApp = new Hono(); - providerApp.post("/token", (c) => c.json(CANNED)); - 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); - }, - ); - }); + // Fake provider token endpoint — returns canned tokens for any code. + const providerApp = new Hono(); + providerApp.post('/token', (c) => c.json(CANNED)); + 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); + }); + }); - // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). - const brokerApp = buildBrokerApp(); - brokerServer = await new Promise((resolve) => { - const s = serve( - { - fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), - hostname: "127.0.0.1", - port: 0, - }, - (info) => { - brokerBaseUrl = `http://127.0.0.1:${info.port}`; - resolve(s); - }, - ); - }); + // "Attacker" endpoint — records any hit. Must stay at 0 hits. + const attackerApp = new Hono(); + attackerApp.post('/token', (c) => { + attackerHits += 1; + return c.json(CANNED); + }); + attackerServer = await new Promise((resolve) => { + const s = serve({ fetch: attackerApp.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + attackerTokenUrl = `http://127.0.0.1:${info.port}/token`; + resolve(s); + }); + }); + + // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). + const brokerApp = buildBrokerApp(); + brokerServer = await new Promise((resolve) => { + const s = serve( + { + fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), + hostname: '127.0.0.1', + port: 0, + }, + (info) => { + brokerBaseUrl = `http://127.0.0.1:${info.port}`; + resolve(s); + } + ); + }); }); afterAll(async () => { - await new Promise((done) => - providerServer ? providerServer.close(() => done()) : done(), - ); - await new Promise((done) => - brokerServer ? brokerServer.close(() => done()) : done(), - ); + await new Promise((done) => (providerServer ? providerServer.close(() => done()) : done())); + await new Promise((done) => (attackerServer ? attackerServer.close(() => done()) : done())); + await new Promise((done) => (brokerServer ? brokerServer.close(() => done()) : done())); }); -/** Seed a broker org with a managed oauth_app (fake creds) for `demo`. */ +/** + * Seed a broker org with a managed oauth_app (fake creds) for `demo`. The + * connector's auth_schema carries the OAuth endpoints — the broker resolves + * `tokenUrl`/`authorizationUrl` from HERE, not from the request. + */ async function seedBrokerOrg() { - const org = await createTestOrganization({ name: "Broker Org" }); - const user = await createTestUser({ name: "Broker Admin" }); - await addUserToOrganization(user.id, org.id, "owner"); - await createTestConnectorDefinition({ - key: "demo.oauth", - name: "Demo OAuth", - organization_id: org.id, - auth_schema: { - methods: [{ type: "oauth", provider: "demo", requiredScopes: ["read"] }], - }, - feeds_schema: { items: {} }, - }); - // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). - await createAuthProfile({ - organizationId: org.id, - connectorKey: "demo.oauth", - displayName: "Managed Demo App", - slug: "managed-demo", - profileKind: "oauth_app", - provider: "demo", - authData: { - DEMO_CLIENT_ID: "broker-cid", - DEMO_CLIENT_SECRET: "broker-secret", - }, - }); - const pat = await createTestPAT(user.id, org.id); - return { org, user, pat }; + const org = await createTestOrganization({ name: 'Broker Org' }); + const user = await createTestUser({ name: 'Broker Admin' }); + await addUserToOrganization(user.id, org.id, 'owner'); + await createTestConnectorDefinition({ + key: 'demo.oauth', + name: 'Demo OAuth', + organization_id: org.id, + auth_schema: { + methods: [ + { + type: 'oauth', + provider: 'demo', + requiredScopes: ['read'], + authorizationUrl: 'https://demo.example/authorize', + tokenUrl: providerTokenUrl, + clientIdKey: 'DEMO_CLIENT_ID', + clientSecretKey: 'DEMO_CLIENT_SECRET', + }, + ], + }, + feeds_schema: { items: {} }, + }); + // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). + await createAuthProfile({ + organizationId: org.id, + connectorKey: 'demo.oauth', + displayName: 'Managed Demo App', + slug: 'managed-demo', + profileKind: 'oauth_app', + provider: 'demo', + authData: { DEMO_CLIENT_ID: 'broker-cid', DEMO_CLIENT_SECRET: 'broker-secret' }, + }); + const pat = await createTestPAT(user.id, org.id); + return { org, user, pat }; } -describe("SPIKE: broker-delegated oauth_app — exchange handshake", () => { - beforeEach(async () => { - await cleanupTestDatabase(); - }); +describe('SPIKE: broker-delegated oauth_app — exchange handshake', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + attackerHits = 0; + }); + + it('PAT-gated exchange resolves the broker managed app + connector endpoint and returns user tokens', async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: 'demo.oauth', + provider: 'demo', + code: 'auth-code-abc', + redirect_uri: 'http://local.example/connect/oauth/callback', + }), + }), + TEST_ENV + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(CANNED.access_token); + expect(body.refresh_token).toBe(CANNED.refresh_token); + expect(body.expires_in).toBe(CANNED.expires_in); + expect(body.scope).toBe(CANNED.scope); + // The broker never leaks the client_secret back to the caller. + expect(JSON.stringify(body)).not.toContain('broker-secret'); + }); + + it('ignores a caller-supplied token_url — secret can NOT be redirected to an attacker', async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); - it("PAT-gated exchange resolves the broker managed app and returns user tokens", async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: 'demo.oauth', + provider: 'demo', + code: 'auth-code-abc', + redirect_uri: 'http://local.example/connect/oauth/callback', + // Malicious: try to redirect the secret-bearing exchange elsewhere. + token_url: attackerTokenUrl, + token_endpoint_auth_method: 'client_secret_basic', + client_secret_key: 'DEMO_CLIENT_SECRET', + }), + }), + TEST_ENV + ); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "auth-code-abc", - redirect_uri: "http://local.example/connect/oauth/callback", - token_url: providerTokenUrl, - }), - }), - TEST_ENV, - ); + // The broker hits ITS OWN connector's tokenUrl and returns canned tokens; + // the attacker endpoint is never touched. + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(CANNED.access_token); + expect(attackerHits).toBe(0); + }); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body.access_token).toBe(CANNED.access_token); - expect(body.refresh_token).toBe(CANNED.refresh_token); - expect(body.expires_in).toBe(CANNED.expires_in); - expect(body.scope).toBe(CANNED.scope); - // The broker never leaks the client_secret back to the caller. - expect(JSON.stringify(body)).not.toContain("broker-secret"); - }); + it('rejects exchange for a connector the broker org does not manage (400)', async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: 'not.a.real.connector', + provider: 'demo', + code: 'x', + redirect_uri: 'http://local.example/cb', + }), + }), + TEST_ENV + ); + expect(res.status).toBe(400); + expect(attackerHits).toBe(0); + }); - it("rejects exchange with no PAT (401)", async () => { - await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "x", - redirect_uri: "http://local.example/cb", - token_url: providerTokenUrl, - }), - }), - TEST_ENV, - ); - expect(res.status).toBe(401); - }); + it('rejects exchange with no PAT (401)', async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + connector_key: 'demo.oauth', + provider: 'demo', + code: 'x', + redirect_uri: 'http://local.example/cb', + }), + }), + TEST_ENV + ); + expect(res.status).toBe(401); + }); - it("rejects exchange with an invalid PAT (401)", async () => { - await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer owl_pat_totally-bogus-token", - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "x", - redirect_uri: "http://local.example/cb", - token_url: providerTokenUrl, - }), - }), - TEST_ENV, - ); - expect(res.status).toBe(401); - }); + it('rejects exchange with an invalid PAT (401)', async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer owl_pat_totally-bogus-token', + }, + body: JSON.stringify({ + connector_key: 'demo.oauth', + provider: 'demo', + code: 'x', + redirect_uri: 'http://local.example/cb', + }), + }), + TEST_ENV + ); + expect(res.status).toBe(401); + }); - it("authorize-url builds the provider consent URL with the broker client_id", async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/authorize-url", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - redirect_uri: "http://local.example/connect/oauth/callback", - scopes: ["read"], - state: "state-xyz", - authorization_url: "https://demo.example/authorize", - }), - }), - TEST_ENV, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { authorization_url: string }; - const url = new URL(body.authorization_url); - expect(url.searchParams.get("client_id")).toBe("broker-cid"); - expect(url.searchParams.get("state")).toBe("state-xyz"); - }); + it('authorize-url builds the provider consent URL with the broker client_id + server-resolved endpoint', async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request('http://broker.local/broker/oauth/authorize-url', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: 'demo.oauth', + provider: 'demo', + redirect_uri: 'http://local.example/connect/oauth/callback', + scopes: ['read'], + state: 'state-xyz', + }), + }), + TEST_ENV + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { authorization_url: string }; + const url = new URL(body.authorization_url); + // Endpoint comes from the broker org's connector metadata, not the request. + expect(`${url.origin}${url.pathname}`).toBe('https://demo.example/authorize'); + expect(url.searchParams.get('client_id')).toBe('broker-cid'); + expect(url.searchParams.get('state')).toBe('state-xyz'); + }); }); -describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () => { - beforeEach(async () => { - await cleanupTestDatabase(); - }); +describe('SPIKE: broker-delegated oauth_app — end-to-end local delegation', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + attackerHits = 0; + }); - it("local callback delegates code exchange to the broker and stores broker tokens", async () => { - // Broker side: managed app + PAT (broker.url = the in-process broker server). - const { pat } = await seedBrokerOrg(); + it('local callback delegates code exchange to the broker and stores broker tokens', async () => { + // Broker side: managed app + connector endpoints + PAT (broker.url = the + // in-process broker server). + const { pat } = await seedBrokerOrg(); - // Local side: a separate org whose oauth_app profile is a broker-ref (no - // local client_id/secret) plus a connect token bound to it. - 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 brokerProfile = await createAuthProfile({ - organizationId: localOrg.id, - connectorKey: "demo.oauth", - displayName: "Broker-backed Demo App", - slug: "local-broker-app", - profileKind: "oauth_app", - provider: "demo", - authData: { - __broker: { url: brokerBaseUrl, org: "broker-org", pat: pat.token }, - }, - }); + // Local side: a separate org whose oauth_app profile is a broker-ref (no + // local client_id/secret) plus a connect token bound to it. + 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 brokerProfile = await createAuthProfile({ + organizationId: localOrg.id, + connectorKey: 'demo.oauth', + displayName: 'Broker-backed Demo App', + slug: 'local-broker-app', + profileKind: 'oauth_app', + provider: 'demo', + authData: { + __broker: { url: brokerBaseUrl, org: 'broker-org', pat: pat.token }, + }, + }); - // The broker-ref must survive normalization (no client_id/secret keys). - const sql = getTestDb(); - const stored = (await sql` + // The broker-ref must survive normalization (no client_id/secret keys). + const sql = getTestDb(); + const stored = (await sql` SELECT auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} `) as unknown as Array<{ auth_data: Record }>; - expect( - (stored[0].auth_data as { __broker?: unknown }).__broker, - ).toBeTruthy(); - expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); + expect((stored[0].auth_data as { __broker?: unknown }).__broker).toBeTruthy(); + expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); - // Connect token (oauth) bound to the local broker-ref app profile. created_by - // set so the callback skips session lookup. - const tokenRow = await createConnectToken({ - organizationId: localOrg.id, - connectorKey: "demo.oauth", - authType: "oauth", - authConfig: { - provider: "demo", - scopes: ["read"], - tokenUrl: providerTokenUrl, - authorizationUrl: "https://demo.example/authorize", - redirectUri: "http://local.example/connect/oauth/callback", - clientIdKey: "DEMO_CLIENT_ID", - clientSecretKey: "DEMO_CLIENT_SECRET", - }, - createdBy: localUser.id, - }); + // Connect token (oauth) bound to the local broker-ref app profile. The + // local instance has NO tokenUrl/secret — the broker resolves the endpoint + // server-side. created_by set so the callback skips session lookup. + const tokenRow = await createConnectToken({ + organizationId: localOrg.id, + connectorKey: 'demo.oauth', + authType: 'oauth', + authConfig: { + provider: 'demo', + scopes: ['read'], + redirectUri: 'http://local.example/connect/oauth/callback', + clientIdKey: 'DEMO_CLIENT_ID', + clientSecretKey: 'DEMO_CLIENT_SECRET', + }, + createdBy: localUser.id, + }); - // Drive the stable callback. The local instance has no client_secret; it - // POSTs the broker /exchange (which uses the broker's secret) and stores - // the broker-returned tokens. - const app = new Hono<{ Bindings: Env }>(); - app.route("/connect", connectRoutes); - const res = await app.fetch( - new Request( - `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, - { method: "GET", redirect: "manual" }, - ), - TEST_ENV, - ); - // Callback ends in a redirect (302) once tokens are stored. - expect(res.status).toBe(302); + // Drive the stable callback. The local instance has no client_secret; it + // POSTs the broker /exchange (which uses the broker's secret + endpoint) + // and stores the broker-returned tokens. + const app = new Hono<{ Bindings: Env }>(); + app.route('/connect', connectRoutes); + const res = await app.fetch( + new Request( + `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, + { method: 'GET', redirect: 'manual' } + ), + TEST_ENV + ); + // Callback ends in a redirect (302) once tokens are stored. + expect(res.status).toBe(302); - // The account row must carry the broker-returned canned tokens — proof the - // exchange was delegated and no local secret was needed. - const accounts = (await sql` + // The account row must carry the broker-returned canned tokens — proof the + // exchange was delegated and no local secret was needed. + const accounts = (await sql` SELECT "accessToken", "refreshToken", "providerId" FROM "account" WHERE "userId" = ${localUser.id} AND "providerId" = 'demo' - `) as unknown as Array<{ - accessToken: string; - refreshToken: string; - providerId: string; - }>; - expect(accounts).toHaveLength(1); - expect(accounts[0].accessToken).toBe(CANNED.access_token); - expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); + `) as unknown as Array<{ accessToken: string; refreshToken: string; providerId: string }>; + expect(accounts).toHaveLength(1); + expect(accounts[0].accessToken).toBe(CANNED.access_token); + expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); - // Connect token consumed. - const tk = (await sql` + // Connect token consumed. + const tk = (await sql` SELECT status FROM connect_tokens WHERE token = ${tokenRow.token} `) as unknown as Array<{ status: string }>; - expect(tk[0].status).toBe("completed"); - }); + expect(tk[0].status).toBe('completed'); + }); }); diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts index 304e69ab6..dc8041bad 100644 --- a/packages/server/src/connect/broker-routes.ts +++ b/packages/server/src/connect/broker-routes.ts @@ -26,14 +26,34 @@ import { CredentialService } from '../auth/credentials'; import { PersonalAccessTokenService } from '../auth/tokens'; import { getDb } from '../db/client'; import { getPrimaryAuthProfileForKind, normalizeAuthValues } from '../utils/auth-profiles'; +import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from '../utils/connector-auth'; import logger from '../utils/logger'; import { buildAuthorizationUrl, exchangeCodeForTokens } from './oauth-providers'; +type OAuthTokenEndpointAuthMethod = 'client_secret_post' | 'client_secret_basic' | 'none'; + type BrokerEnv = { Bindings: Env; Variables: { brokerOrgId: string }; }; +/** + * The OAuth endpoints + auth-method the broker will hit, resolved SERVER-SIDE + * from the broker org's own connector metadata. The caller never supplies + * these — that is the security premise of the broker (otherwise a caller with + * any valid PAT could redirect the broker's client_secret to an attacker). + */ +interface BrokerProviderConfig { + provider: string; + authorizationUrl?: string; + tokenUrl?: string; + userinfoUrl?: string; + authParams?: Record; + tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; + clientIdKey?: string; + clientSecretKey?: string; +} + const brokerRoutes = new Hono(); /** @@ -82,9 +102,55 @@ brokerRoutes.use('/oauth/*', async (c, next) => { }); /** - * Resolve the broker org's managed `oauth_app` client credentials for a - * provider/connector. The broker reads its OWN org's profile — the local - * caller never sees these values. + * Resolve the OAuth provider config (endpoints, auth method, credential keys) + * SERVER-SIDE from the broker org's `connector_definitions` row for + * `connectorKey`, matching the oauth method by `provider`. Returns `null` when + * the connector or a matching oauth method isn't found in the broker org — the + * broker refuses to act on a connector it doesn't manage. The caller cannot + * influence these endpoints (no token_url/authorization_url in the request). + */ +async function resolveBrokerProviderConfig(params: { + organizationId: string; + connectorKey: string; + provider: string; +}): Promise { + const sql = getDb(); + const rows = await sql` + SELECT auth_schema + FROM connector_definitions + WHERE key = ${params.connectorKey} + AND status = 'active' + AND (organization_id = ${params.organizationId} OR organization_id IS NULL) + ORDER BY CASE WHEN organization_id = ${params.organizationId} THEN 0 ELSE 1 END + LIMIT 1 + `; + if (rows.length === 0) return null; + + const authSchema = normalizeConnectorAuthSchema( + (rows[0] as { auth_schema: unknown }).auth_schema + ); + const method = getOAuthAuthMethods(authSchema).find( + (m) => m.provider.toLowerCase() === params.provider.toLowerCase() + ); + if (!method) return null; + + return { + provider: method.provider, + authorizationUrl: method.authorizationUrl, + tokenUrl: method.tokenUrl, + userinfoUrl: method.userinfoUrl, + authParams: method.authParams, + tokenEndpointAuthMethod: method.tokenEndpointAuthMethod, + clientIdKey: method.clientIdKey, + clientSecretKey: method.clientSecretKey, + }; +} + +/** + * Resolve the broker org's managed `oauth_app` client credentials. The broker + * reads its OWN org's profile — the local caller never sees these values. The + * credential KEY NAMES come from the server-resolved connector config (not the + * request), with the provider-uppercase default as a fallback. */ async function resolveBrokerClientCredentials(params: { organizationId: string; @@ -118,14 +184,12 @@ interface AuthorizeUrlBody { scopes?: string[]; state?: string; code_challenge?: string; - authorization_url?: string; - auth_params?: Record; - client_id_key?: string; } /** * POST /broker/oauth/authorize-url - * Build the provider authorization URL using the broker org's managed client_id. + * Build the provider authorization URL using the broker org's managed client_id + * and SERVER-RESOLVED authorization endpoint (never caller-supplied). */ brokerRoutes.post('/oauth/authorize-url', async (c) => { const orgId = c.get('brokerOrgId'); @@ -140,11 +204,26 @@ brokerRoutes.post('/oauth/authorize-url', async (c) => { ); } + const providerConfig = await resolveBrokerProviderConfig({ + organizationId: orgId, + connectorKey: body.connector_key, + provider: body.provider, + }); + if (!providerConfig) { + return c.json( + { + error: 'unknown_connector', + error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, + }, + 400 + ); + } + const { clientId } = await resolveBrokerClientCredentials({ organizationId: orgId, provider: body.provider, connectorKey: body.connector_key, - clientIdKey: body.client_id_key, + clientIdKey: providerConfig.clientIdKey, }); if (!clientId) { return c.json( @@ -159,8 +238,8 @@ brokerRoutes.post('/oauth/authorize-url', async (c) => { redirectUri: body.redirect_uri, scopes: body.scopes ?? [], state: body.state, - authorizationUrl: body.authorization_url, - authParams: body.auth_params, + authorizationUrl: providerConfig.authorizationUrl, + authParams: providerConfig.authParams, codeChallenge: body.code_challenge, }); if (!authorizationUrl) { @@ -176,16 +255,14 @@ interface ExchangeBody { code?: string; redirect_uri?: string; code_verifier?: string; - token_url?: string; - token_endpoint_auth_method?: 'client_secret_post' | 'client_secret_basic' | 'none'; - client_id_key?: string; - client_secret_key?: string; } /** * POST /broker/oauth/exchange * Exchange an authorization code for tokens using the broker org's managed - * client_id/secret. Returns ONLY the user's tokens — never the client_secret. + * client_id/secret and SERVER-RESOLVED token endpoint (never caller-supplied — + * otherwise a caller could redirect the client_secret to an attacker). Returns + * ONLY the user's tokens — never the client_secret. */ brokerRoutes.post('/oauth/exchange', async (c) => { const orgId = c.get('brokerOrgId'); @@ -200,12 +277,27 @@ brokerRoutes.post('/oauth/exchange', async (c) => { ); } + const providerConfig = await resolveBrokerProviderConfig({ + organizationId: orgId, + connectorKey: body.connector_key, + provider: body.provider, + }); + if (!providerConfig) { + return c.json( + { + error: 'unknown_connector', + error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, + }, + 400 + ); + } + const { clientId, clientSecret } = await resolveBrokerClientCredentials({ organizationId: orgId, provider: body.provider, connectorKey: body.connector_key, - clientIdKey: body.client_id_key, - clientSecretKey: body.client_secret_key, + clientIdKey: providerConfig.clientIdKey, + clientSecretKey: providerConfig.clientSecretKey, }); if (!clientId) { return c.json( @@ -220,8 +312,8 @@ brokerRoutes.post('/oauth/exchange', async (c) => { clientId, clientSecret, redirectUri: body.redirect_uri, - tokenUrl: body.token_url, - tokenEndpointAuthMethod: body.token_endpoint_auth_method, + tokenUrl: providerConfig.tokenUrl, + tokenEndpointAuthMethod: providerConfig.tokenEndpointAuthMethod, codeVerifier: body.code_verifier, }); if (!tokens) { @@ -241,24 +333,36 @@ interface RefreshBody { connector_key?: string; provider?: string; refresh_token?: string; - token_url?: string; - token_endpoint_auth_method?: 'client_secret_post' | 'client_secret_basic' | 'none'; - client_id_key?: string; - client_secret_key?: string; } /** * POST /broker/oauth/refresh - * Refresh an access token using the broker org's managed client_id/secret. + * Refresh an access token using the broker org's managed client_id/secret and + * SERVER-RESOLVED token endpoint (never caller-supplied). */ brokerRoutes.post('/oauth/refresh', async (c) => { const orgId = c.get('brokerOrgId'); const body = await c.req.json().catch(() => null); - if (!body?.provider || !body.connector_key || !body.refresh_token || !body.token_url) { + if (!body?.provider || !body.connector_key || !body.refresh_token) { return c.json( { error: 'bad_request', - error_description: 'provider, connector_key, refresh_token, token_url required', + error_description: 'provider, connector_key, refresh_token required', + }, + 400 + ); + } + + const providerConfig = await resolveBrokerProviderConfig({ + organizationId: orgId, + connectorKey: body.connector_key, + provider: body.provider, + }); + if (!providerConfig?.tokenUrl) { + return c.json( + { + error: 'unknown_connector', + error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}' (no token endpoint)`, }, 400 ); @@ -268,8 +372,8 @@ brokerRoutes.post('/oauth/refresh', async (c) => { organizationId: orgId, provider: body.provider, connectorKey: body.connector_key, - clientIdKey: body.client_id_key, - clientSecretKey: body.client_secret_key, + clientIdKey: providerConfig.clientIdKey, + clientSecretKey: providerConfig.clientSecretKey, }); if (!clientId) { return c.json( @@ -279,11 +383,11 @@ brokerRoutes.post('/oauth/refresh', async (c) => { } const refreshed = await new CredentialService(getDb()).refreshTokenGeneric({ - tokenUrl: body.token_url, + tokenUrl: providerConfig.tokenUrl, clientId, clientSecret: clientSecret ?? undefined, refreshToken: body.refresh_token, - authMethod: body.token_endpoint_auth_method, + authMethod: providerConfig.tokenEndpointAuthMethod, }); if (!refreshed) { return c.json({ error: 'refresh_failed', error_description: 'Token refresh failed' }, 502); diff --git a/packages/server/src/connect/routes.ts b/packages/server/src/connect/routes.ts index dd1b1392e..71ccf049b 100644 --- a/packages/server/src/connect/routes.ts +++ b/packages/server/src/connect/routes.ts @@ -544,12 +544,18 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { } // Broker delegation: when the backing oauth_app profile is a broker-ref, the - // broker holds the client_id, so build the authorization URL remotely. + // broker holds the client_id + resolves the provider endpoints server-side, + // so build the authorization URL remotely. const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); if (brokerRef) { const baseUrl = getBaseUrl(c); const redirectUri = `${baseUrl}/connect/oauth/callback`; - const pkceCodeVerifier = authConfig.usePkce ? buildPkceVerifier() : undefined; + // Reuse the persisted verifier if one exists; only generate (and persist) + // when missing. The challenge MUST derive from the SAME verifier that the + // callback will send, so the start/callback pair stays consistent. + const pkceCodeVerifier = authConfig.usePkce + ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) + : undefined; const needsUpdate = authConfig.redirectUri !== redirectUri || @@ -576,9 +582,6 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { redirect_uri: redirectUri, scopes: authConfig.scopes ?? [], state: token, - authorization_url: authConfig.authorizationUrl, - auth_params: authConfig.authParams, - client_id_key: authConfig.clientIdKey, ...(pkceCodeVerifier ? { code_challenge: buildPkceChallenge(pkceCodeVerifier) } : {}), } ); @@ -614,7 +617,13 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { const baseUrl = getBaseUrl(c); const redirectUri = `${baseUrl}/connect/oauth/callback`; - const pkceCodeVerifier = authConfig.usePkce ? buildPkceVerifier() : undefined; + // Reuse the persisted verifier if one exists; only generate (and persist) + // when missing. Regenerating on a repeat /oauth/start would build the + // challenge from a verifier the callback never sees (the persisted one is + // unchanged when needsUpdate is false), breaking the PKCE handshake. + const pkceCodeVerifier = authConfig.usePkce + ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) + : undefined; const needsUpdate = authConfig.redirectUri !== redirectUri || (authConfig.usePkce && !authConfig.pkceCodeVerifier); @@ -751,8 +760,9 @@ async function handleOAuthCallback( const baseUrl = getBaseUrl(c); // Broker delegation: when the backing oauth_app profile is a broker-ref, the - // broker holds the client_secret, so exchange the code remotely and use the - // returned user tokens. Storage below is identical to the local path. + // broker holds the client_secret + resolves the token endpoint server-side, + // so exchange the code remotely and use the returned user tokens. Storage + // below is identical to the local path. const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); let tokens: Awaited> = null; @@ -768,10 +778,6 @@ async function handleOAuthCallback( provider: authConfig.provider, code, redirect_uri: redirectUri, - token_url: authConfig.tokenUrl, - token_endpoint_auth_method: authConfig.tokenEndpointAuthMethod, - client_id_key: authConfig.clientIdKey, - client_secret_key: authConfig.clientSecretKey, ...(authConfig.pkceCodeVerifier ? { code_verifier: authConfig.pkceCodeVerifier } : {}), }); if (exchanged?.access_token) { From fd735aa96da819f376af583ec923097f2e8c729c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 12:57:13 +0100 Subject: [PATCH 03/15] refactor(connect): consolidate broker delegation Lean the OAuth broker delegation (behavior-preserving; grant stays local): 1. Dedup PAT auth: extract shared authenticatePat + extractPatBearer into auth/pat-auth.ts; broker router AND createLobuAuthBridge now use the one implementation (gateway.ts inline PAT block ~120 -> ~33 lines). 2. CredentialSource seam: replace scattered getBrokerRef branches in the connect start + callback paths with one resolveCredentialSource(...) returning {kind:'local'|'broker'}; each path branches once on source.kind. 3. Dedup provider/credential resolution: factor resolveConnectorOAuthMethod + resolveOAuthClientCredentials into connect/oauth-resolution.ts; broker reuses the existing connector-auth helpers instead of a parallel query, and routes.ts drops its private copy. 4. Runtime validation: broker endpoints validate bodies via @sinclair/typebox TypeCompiler (matching repo pattern) and return 400 on malformed/missing fields instead of casting; added a malformed-body -> 400 test (now 8 tests). Server-side endpoint resolution preserved (no caller-supplied token_url). broker-routes.ts 404 -> 315 lines; all 8 broker tests + 14 gateway-auth tests green. --- .../connectors/oauth-broker.test.ts | 752 ++++++++++-------- packages/server/src/auth/pat-auth.ts | 154 ++++ packages/server/src/connect/broker-routes.ts | 593 ++++++-------- .../server/src/connect/oauth-resolution.ts | 101 +++ packages/server/src/connect/routes.ts | 197 ++--- packages/server/src/lobu/gateway.ts | 124 +-- 6 files changed, 1000 insertions(+), 921 deletions(-) create mode 100644 packages/server/src/auth/pat-auth.ts create mode 100644 packages/server/src/connect/oauth-resolution.ts diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 73d4a0d81..847da1614 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -23,103 +23,115 @@ * client_secret. */ -import { serve } from '@hono/node-server'; -import { Hono } from 'hono'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { brokerRoutes } from '../../../connect/broker-routes'; -import { connectRoutes } from '../../../connect/routes'; -import type { Env } from '../../../index'; -import { createAuthProfile } from '../../../utils/auth-profiles'; -import { createConnectToken } from '../../../utils/connect-tokens'; -import { initWorkspaceProvider } from '../../../workspace'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { brokerRoutes } from "../../../connect/broker-routes"; +import { connectRoutes } from "../../../connect/routes"; +import type { Env } from "../../../index"; +import { createAuthProfile } from "../../../utils/auth-profiles"; +import { createConnectToken } from "../../../utils/connect-tokens"; +import { initWorkspaceProvider } from "../../../workspace"; +import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; import { - addUserToOrganization, - createTestConnectorDefinition, - createTestOrganization, - createTestPAT, - createTestUser, -} from '../../setup/test-fixtures'; + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestPAT, + createTestUser, +} from "../../setup/test-fixtures"; const TEST_ENV = { - ENVIRONMENT: 'test', - DATABASE_URL: process.env.DATABASE_URL, + ENVIRONMENT: "test", + DATABASE_URL: process.env.DATABASE_URL, } as unknown as Env; const CANNED = { - access_token: 'canned-access-token-123', - refresh_token: 'canned-refresh-token-456', - expires_in: 3600, - scope: 'read write', - token_type: 'Bearer', + access_token: "canned-access-token-123", + refresh_token: "canned-refresh-token-456", + expires_in: 3600, + scope: "read write", + token_type: "Bearer", }; // Fake OAuth provider: the tokenUrl the broker org's connector points at. A // SECOND fake server stands in for an "attacker" endpoint — if the broker ever // honored a caller-supplied token_url it would POST the client_secret here. let providerServer: ReturnType | null = null; -let providerTokenUrl = ''; +let providerTokenUrl = ""; let attackerServer: ReturnType | null = null; -let attackerTokenUrl = ''; +let attackerTokenUrl = ""; let attackerHits = 0; // Broker app served on a real port so the local callback's `fetch` reaches it. let brokerServer: ReturnType | null = null; -let brokerBaseUrl = ''; +let brokerBaseUrl = ""; function buildBrokerApp(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); - app.route('/broker', brokerRoutes); - return app; + const app = new Hono<{ Bindings: Env }>(); + app.route("/broker", brokerRoutes); + return app; } beforeAll(async () => { - await initWorkspaceProvider(); - - // Fake provider token endpoint — returns canned tokens for any code. - const providerApp = new Hono(); - providerApp.post('/token', (c) => c.json(CANNED)); - 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); - }); - }); - - // "Attacker" endpoint — records any hit. Must stay at 0 hits. - const attackerApp = new Hono(); - attackerApp.post('/token', (c) => { - attackerHits += 1; - return c.json(CANNED); - }); - attackerServer = await new Promise((resolve) => { - const s = serve({ fetch: attackerApp.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { - attackerTokenUrl = `http://127.0.0.1:${info.port}/token`; - resolve(s); - }); - }); - - // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). - const brokerApp = buildBrokerApp(); - brokerServer = await new Promise((resolve) => { - const s = serve( - { - fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), - hostname: '127.0.0.1', - port: 0, - }, - (info) => { - brokerBaseUrl = `http://127.0.0.1:${info.port}`; - resolve(s); - } - ); - }); + await initWorkspaceProvider(); + + // Fake provider token endpoint — returns canned tokens for any code. + const providerApp = new Hono(); + providerApp.post("/token", (c) => c.json(CANNED)); + 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); + }, + ); + }); + + // "Attacker" endpoint — records any hit. Must stay at 0 hits. + const attackerApp = new Hono(); + attackerApp.post("/token", (c) => { + attackerHits += 1; + return c.json(CANNED); + }); + attackerServer = await new Promise((resolve) => { + const s = serve( + { fetch: attackerApp.fetch, hostname: "127.0.0.1", port: 0 }, + (info) => { + attackerTokenUrl = `http://127.0.0.1:${info.port}/token`; + resolve(s); + }, + ); + }); + + // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). + const brokerApp = buildBrokerApp(); + brokerServer = await new Promise((resolve) => { + const s = serve( + { + fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), + hostname: "127.0.0.1", + port: 0, + }, + (info) => { + brokerBaseUrl = `http://127.0.0.1:${info.port}`; + resolve(s); + }, + ); + }); }); afterAll(async () => { - await new Promise((done) => (providerServer ? providerServer.close(() => done()) : done())); - await new Promise((done) => (attackerServer ? attackerServer.close(() => done()) : done())); - await new Promise((done) => (brokerServer ? brokerServer.close(() => done()) : done())); + await new Promise((done) => + providerServer ? providerServer.close(() => done()) : done(), + ); + await new Promise((done) => + attackerServer ? attackerServer.close(() => done()) : done(), + ); + await new Promise((done) => + brokerServer ? brokerServer.close(() => done()) : done(), + ); }); /** @@ -128,296 +140,332 @@ afterAll(async () => { * `tokenUrl`/`authorizationUrl` from HERE, not from the request. */ async function seedBrokerOrg() { - const org = await createTestOrganization({ name: 'Broker Org' }); - const user = await createTestUser({ name: 'Broker Admin' }); - await addUserToOrganization(user.id, org.id, 'owner'); - await createTestConnectorDefinition({ - key: 'demo.oauth', - name: 'Demo OAuth', - organization_id: org.id, - auth_schema: { - methods: [ - { - type: 'oauth', - provider: 'demo', - requiredScopes: ['read'], - authorizationUrl: 'https://demo.example/authorize', - tokenUrl: providerTokenUrl, - clientIdKey: 'DEMO_CLIENT_ID', - clientSecretKey: 'DEMO_CLIENT_SECRET', - }, - ], - }, - feeds_schema: { items: {} }, - }); - // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). - await createAuthProfile({ - organizationId: org.id, - connectorKey: 'demo.oauth', - displayName: 'Managed Demo App', - slug: 'managed-demo', - profileKind: 'oauth_app', - provider: 'demo', - authData: { DEMO_CLIENT_ID: 'broker-cid', DEMO_CLIENT_SECRET: 'broker-secret' }, - }); - const pat = await createTestPAT(user.id, org.id); - return { org, user, pat }; + const org = await createTestOrganization({ name: "Broker Org" }); + const user = await createTestUser({ name: "Broker Admin" }); + await addUserToOrganization(user.id, org.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.oauth", + name: "Demo OAuth", + organization_id: org.id, + auth_schema: { + methods: [ + { + type: "oauth", + provider: "demo", + requiredScopes: ["read"], + authorizationUrl: "https://demo.example/authorize", + tokenUrl: providerTokenUrl, + clientIdKey: "DEMO_CLIENT_ID", + clientSecretKey: "DEMO_CLIENT_SECRET", + }, + ], + }, + feeds_schema: { items: {} }, + }); + // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). + await createAuthProfile({ + organizationId: org.id, + connectorKey: "demo.oauth", + displayName: "Managed Demo App", + slug: "managed-demo", + profileKind: "oauth_app", + provider: "demo", + authData: { + DEMO_CLIENT_ID: "broker-cid", + DEMO_CLIENT_SECRET: "broker-secret", + }, + }); + const pat = await createTestPAT(user.id, org.id); + return { org, user, pat }; } -describe('SPIKE: broker-delegated oauth_app — exchange handshake', () => { - beforeEach(async () => { - await cleanupTestDatabase(); - attackerHits = 0; - }); - - it('PAT-gated exchange resolves the broker managed app + connector endpoint and returns user tokens', async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/exchange', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: 'demo.oauth', - provider: 'demo', - code: 'auth-code-abc', - redirect_uri: 'http://local.example/connect/oauth/callback', - }), - }), - TEST_ENV - ); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body.access_token).toBe(CANNED.access_token); - expect(body.refresh_token).toBe(CANNED.refresh_token); - expect(body.expires_in).toBe(CANNED.expires_in); - expect(body.scope).toBe(CANNED.scope); - // The broker never leaks the client_secret back to the caller. - expect(JSON.stringify(body)).not.toContain('broker-secret'); - }); - - it('ignores a caller-supplied token_url — secret can NOT be redirected to an attacker', async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/exchange', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: 'demo.oauth', - provider: 'demo', - code: 'auth-code-abc', - redirect_uri: 'http://local.example/connect/oauth/callback', - // Malicious: try to redirect the secret-bearing exchange elsewhere. - token_url: attackerTokenUrl, - token_endpoint_auth_method: 'client_secret_basic', - client_secret_key: 'DEMO_CLIENT_SECRET', - }), - }), - TEST_ENV - ); - - // The broker hits ITS OWN connector's tokenUrl and returns canned tokens; - // the attacker endpoint is never touched. - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body.access_token).toBe(CANNED.access_token); - expect(attackerHits).toBe(0); - }); - - it('rejects exchange for a connector the broker org does not manage (400)', async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/exchange', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: 'not.a.real.connector', - provider: 'demo', - code: 'x', - redirect_uri: 'http://local.example/cb', - }), - }), - TEST_ENV - ); - expect(res.status).toBe(400); - expect(attackerHits).toBe(0); - }); - - it('rejects exchange with no PAT (401)', async () => { - await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/exchange', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - connector_key: 'demo.oauth', - provider: 'demo', - code: 'x', - redirect_uri: 'http://local.example/cb', - }), - }), - TEST_ENV - ); - expect(res.status).toBe(401); - }); - - it('rejects exchange with an invalid PAT (401)', async () => { - await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/exchange', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer owl_pat_totally-bogus-token', - }, - body: JSON.stringify({ - connector_key: 'demo.oauth', - provider: 'demo', - code: 'x', - redirect_uri: 'http://local.example/cb', - }), - }), - TEST_ENV - ); - expect(res.status).toBe(401); - }); - - it('authorize-url builds the provider consent URL with the broker client_id + server-resolved endpoint', async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request('http://broker.local/broker/oauth/authorize-url', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: 'demo.oauth', - provider: 'demo', - redirect_uri: 'http://local.example/connect/oauth/callback', - scopes: ['read'], - state: 'state-xyz', - }), - }), - TEST_ENV - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { authorization_url: string }; - const url = new URL(body.authorization_url); - // Endpoint comes from the broker org's connector metadata, not the request. - expect(`${url.origin}${url.pathname}`).toBe('https://demo.example/authorize'); - expect(url.searchParams.get('client_id')).toBe('broker-cid'); - expect(url.searchParams.get('state')).toBe('state-xyz'); - }); +describe("SPIKE: broker-delegated oauth_app — exchange handshake", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + attackerHits = 0; + }); + + it("PAT-gated exchange resolves the broker managed app + connector endpoint and returns user tokens", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "auth-code-abc", + redirect_uri: "http://local.example/connect/oauth/callback", + }), + }), + TEST_ENV, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(CANNED.access_token); + expect(body.refresh_token).toBe(CANNED.refresh_token); + expect(body.expires_in).toBe(CANNED.expires_in); + expect(body.scope).toBe(CANNED.scope); + // The broker never leaks the client_secret back to the caller. + expect(JSON.stringify(body)).not.toContain("broker-secret"); + }); + + it("ignores a caller-supplied token_url — secret can NOT be redirected to an attacker", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "auth-code-abc", + redirect_uri: "http://local.example/connect/oauth/callback", + // Malicious: try to redirect the secret-bearing exchange elsewhere. + token_url: attackerTokenUrl, + token_endpoint_auth_method: "client_secret_basic", + client_secret_key: "DEMO_CLIENT_SECRET", + }), + }), + TEST_ENV, + ); + + // The broker hits ITS OWN connector's tokenUrl and returns canned tokens; + // the attacker endpoint is never touched. + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.access_token).toBe(CANNED.access_token); + expect(attackerHits).toBe(0); + }); + + it("rejects exchange for a connector the broker org does not manage (400)", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "not.a.real.connector", + provider: "demo", + code: "x", + redirect_uri: "http://local.example/cb", + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(400); + expect(attackerHits).toBe(0); + }); + + it("rejects a malformed body — missing required fields — with 400 (validated, not cast)", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + // Missing `code` and `redirect_uri` — must 400 before any resolution. + body: JSON.stringify({ connector_key: "demo.oauth", provider: "demo" }), + }), + TEST_ENV, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe("bad_request"); + expect(attackerHits).toBe(0); + }); + + it("rejects exchange with no PAT (401)", async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "x", + redirect_uri: "http://local.example/cb", + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(401); + }); + + it("rejects exchange with an invalid PAT (401)", async () => { + await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/exchange", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer owl_pat_totally-bogus-token", + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + code: "x", + redirect_uri: "http://local.example/cb", + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(401); + }); + + it("authorize-url builds the provider consent URL with the broker client_id + server-resolved endpoint", async () => { + const { pat } = await seedBrokerOrg(); + const app = buildBrokerApp(); + const res = await app.fetch( + new Request("http://broker.local/broker/oauth/authorize-url", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${pat.token}`, + }, + body: JSON.stringify({ + connector_key: "demo.oauth", + provider: "demo", + redirect_uri: "http://local.example/connect/oauth/callback", + scopes: ["read"], + state: "state-xyz", + }), + }), + TEST_ENV, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { authorization_url: string }; + const url = new URL(body.authorization_url); + // Endpoint comes from the broker org's connector metadata, not the request. + expect(`${url.origin}${url.pathname}`).toBe( + "https://demo.example/authorize", + ); + expect(url.searchParams.get("client_id")).toBe("broker-cid"); + expect(url.searchParams.get("state")).toBe("state-xyz"); + }); }); -describe('SPIKE: broker-delegated oauth_app — end-to-end local delegation', () => { - beforeEach(async () => { - await cleanupTestDatabase(); - attackerHits = 0; - }); - - it('local callback delegates code exchange to the broker and stores broker tokens', async () => { - // Broker side: managed app + connector endpoints + PAT (broker.url = the - // in-process broker server). - const { pat } = await seedBrokerOrg(); - - // Local side: a separate org whose oauth_app profile is a broker-ref (no - // local client_id/secret) plus a connect token bound to it. - 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 brokerProfile = await createAuthProfile({ - organizationId: localOrg.id, - connectorKey: 'demo.oauth', - displayName: 'Broker-backed Demo App', - slug: 'local-broker-app', - profileKind: 'oauth_app', - provider: 'demo', - authData: { - __broker: { url: brokerBaseUrl, org: 'broker-org', pat: pat.token }, - }, - }); - - // The broker-ref must survive normalization (no client_id/secret keys). - const sql = getTestDb(); - const stored = (await sql` +describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + attackerHits = 0; + }); + + it("local callback delegates code exchange to the broker and stores broker tokens", async () => { + // Broker side: managed app + connector endpoints + PAT (broker.url = the + // in-process broker server). + const { pat } = await seedBrokerOrg(); + + // Local side: a separate org whose oauth_app profile is a broker-ref (no + // local client_id/secret) plus a connect token bound to it. + 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 brokerProfile = await createAuthProfile({ + organizationId: localOrg.id, + connectorKey: "demo.oauth", + displayName: "Broker-backed Demo App", + slug: "local-broker-app", + profileKind: "oauth_app", + provider: "demo", + authData: { + __broker: { url: brokerBaseUrl, org: "broker-org", pat: pat.token }, + }, + }); + + // The broker-ref must survive normalization (no client_id/secret keys). + const sql = getTestDb(); + const stored = (await sql` SELECT auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} `) as unknown as Array<{ auth_data: Record }>; - expect((stored[0].auth_data as { __broker?: unknown }).__broker).toBeTruthy(); - expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); - - // Connect token (oauth) bound to the local broker-ref app profile. The - // local instance has NO tokenUrl/secret — the broker resolves the endpoint - // server-side. created_by set so the callback skips session lookup. - const tokenRow = await createConnectToken({ - organizationId: localOrg.id, - connectorKey: 'demo.oauth', - authType: 'oauth', - authConfig: { - provider: 'demo', - scopes: ['read'], - redirectUri: 'http://local.example/connect/oauth/callback', - clientIdKey: 'DEMO_CLIENT_ID', - clientSecretKey: 'DEMO_CLIENT_SECRET', - }, - createdBy: localUser.id, - }); - - // Drive the stable callback. The local instance has no client_secret; it - // POSTs the broker /exchange (which uses the broker's secret + endpoint) - // and stores the broker-returned tokens. - const app = new Hono<{ Bindings: Env }>(); - app.route('/connect', connectRoutes); - const res = await app.fetch( - new Request( - `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, - { method: 'GET', redirect: 'manual' } - ), - TEST_ENV - ); - // Callback ends in a redirect (302) once tokens are stored. - expect(res.status).toBe(302); - - // The account row must carry the broker-returned canned tokens — proof the - // exchange was delegated and no local secret was needed. - const accounts = (await sql` + expect( + (stored[0].auth_data as { __broker?: unknown }).__broker, + ).toBeTruthy(); + expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); + + // Connect token (oauth) bound to the local broker-ref app profile. The + // local instance has NO tokenUrl/secret — the broker resolves the endpoint + // server-side. created_by set so the callback skips session lookup. + const tokenRow = await createConnectToken({ + organizationId: localOrg.id, + connectorKey: "demo.oauth", + authType: "oauth", + authConfig: { + provider: "demo", + scopes: ["read"], + redirectUri: "http://local.example/connect/oauth/callback", + clientIdKey: "DEMO_CLIENT_ID", + clientSecretKey: "DEMO_CLIENT_SECRET", + }, + createdBy: localUser.id, + }); + + // Drive the stable callback. The local instance has no client_secret; it + // POSTs the broker /exchange (which uses the broker's secret + endpoint) + // and stores the broker-returned tokens. + const app = new Hono<{ Bindings: Env }>(); + app.route("/connect", connectRoutes); + const res = await app.fetch( + new Request( + `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, + { method: "GET", redirect: "manual" }, + ), + TEST_ENV, + ); + // Callback ends in a redirect (302) once tokens are stored. + expect(res.status).toBe(302); + + // The account row must carry the broker-returned canned tokens — proof the + // exchange was delegated and no local secret was needed. + const accounts = (await sql` SELECT "accessToken", "refreshToken", "providerId" FROM "account" WHERE "userId" = ${localUser.id} AND "providerId" = 'demo' - `) as unknown as Array<{ accessToken: string; refreshToken: string; providerId: string }>; - expect(accounts).toHaveLength(1); - expect(accounts[0].accessToken).toBe(CANNED.access_token); - expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); + `) as unknown as Array<{ + accessToken: string; + refreshToken: string; + providerId: string; + }>; + expect(accounts).toHaveLength(1); + expect(accounts[0].accessToken).toBe(CANNED.access_token); + expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); - // Connect token consumed. - const tk = (await sql` + // Connect token consumed. + const tk = (await sql` SELECT status FROM connect_tokens WHERE token = ${tokenRow.token} `) as unknown as Array<{ status: string }>; - expect(tk[0].status).toBe('completed'); - }); + expect(tk[0].status).toBe("completed"); + }); }); diff --git a/packages/server/src/auth/pat-auth.ts b/packages/server/src/auth/pat-auth.ts new file mode 100644 index 000000000..391728bd1 --- /dev/null +++ b/packages/server/src/auth/pat-auth.ts @@ -0,0 +1,154 @@ +/** + * 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 OAuth broker 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 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, + user, + patInfo, + }; +} diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts index dc8041bad..524640e53 100644 --- a/packages/server/src/connect/broker-routes.ts +++ b/packages/server/src/connect/broker-routes.ts @@ -8,254 +8,212 @@ * * Auth handshake: the local instance calls with `Authorization: Bearer * `. The broker verifies the PAT (resolving the authenticated org + - * tenant membership, exactly as the embedded Agent API does in - * `createLobuAuthBridge`) and uses THAT org's managed `oauth_app` profile to - * read the real client_id/secret. The broker performs the build/exchange/ - * refresh and returns ONLY the user's tokens — the client_secret never leaves - * the broker. + * tenant membership via the shared `authenticatePat`, exactly as the embedded + * Agent API does) and uses THAT org's managed `oauth_app` profile to read the + * real client_id/secret. The broker performs the build/exchange/refresh and + * returns ONLY the user's tokens — the client_secret never leaves the broker. * * Endpoints (all POST, all PAT-gated): * - /broker/oauth/authorize-url → buildAuthorizationUrl with broker's client_id * - /broker/oauth/exchange → exchangeCodeForTokens with broker's secret * - /broker/oauth/refresh → CredentialService.refreshTokenGeneric + * + * The OAuth endpoints + credential key names are resolved SERVER-SIDE from the + * broker org's own connector metadata (`resolveConnectorOAuthMethod`). The + * caller never supplies them — that is the security premise (otherwise a caller + * with any valid PAT could redirect the broker's client_secret to an attacker). */ -import type { Env } from '@lobu/connector-sdk'; -import { Hono } from 'hono'; -import { CredentialService } from '../auth/credentials'; -import { PersonalAccessTokenService } from '../auth/tokens'; -import { getDb } from '../db/client'; -import { getPrimaryAuthProfileForKind, normalizeAuthValues } from '../utils/auth-profiles'; -import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from '../utils/connector-auth'; -import logger from '../utils/logger'; -import { buildAuthorizationUrl, exchangeCodeForTokens } from './oauth-providers'; - -type OAuthTokenEndpointAuthMethod = 'client_secret_post' | 'client_secret_basic' | 'none'; +import type { Env } from "@lobu/connector-sdk"; +import { type Static, type TObject, Type } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import { type Context, Hono } from "hono"; +import { CredentialService } from "../auth/credentials"; +import { authenticatePat, extractPatBearer } from "../auth/pat-auth"; +import { getDb } from "../db/client"; +import type { ConnectorAuthOAuthMethod } from "../utils/connector-auth"; +import logger from "../utils/logger"; +import { + buildAuthorizationUrl, + exchangeCodeForTokens, +} from "./oauth-providers"; +import { + resolveConnectorOAuthMethod, + resolveOAuthClientCredentials, +} from "./oauth-resolution"; type BrokerEnv = { - Bindings: Env; - Variables: { brokerOrgId: string }; + Bindings: Env; + Variables: { brokerOrgId: string }; }; -/** - * The OAuth endpoints + auth-method the broker will hit, resolved SERVER-SIDE - * from the broker org's own connector metadata. The caller never supplies - * these — that is the security premise of the broker (otherwise a caller with - * any valid PAT could redirect the broker's client_secret to an attacker). - */ -interface BrokerProviderConfig { - provider: string; - authorizationUrl?: string; - tokenUrl?: string; - userinfoUrl?: string; - authParams?: Record; - tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; - clientIdKey?: string; - clientSecretKey?: string; -} - const brokerRoutes = new Hono(); /** - * PAT auth for broker calls. Mirrors the authoritative PAT path in - * `createLobuAuthBridge` (gateway.ts): verify the `owl_pat_*` bearer, reject - * null-org or non-member tokens, and stash the resolved org on the context. - * This IS the auth gate — no/invalid/cross-tenant PAT short-circuits 401/403. + * PAT auth for broker calls — the single shared `authenticatePat` gate. No / + * invalid / null-org / cross-tenant PAT short-circuits 401/403; on success the + * resolved org is stashed on the context. */ -brokerRoutes.use('/oauth/*', async (c, next) => { - const authHeader = c.req.header('Authorization'); - const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; - const bearerValue = bearerMatch ? (bearerMatch[1] ?? '').trim() : null; - - if (!bearerValue || bearerValue.slice(0, 8).toLowerCase() !== 'owl_pat_') { - return c.json({ error: 'unauthorized', error_description: 'Bearer PAT required' }, 401); - } - - const sql = getDb(); - const patInfo = await new PersonalAccessTokenService(sql).verify(bearerValue); - if (!patInfo?.userId) { - return c.json({ error: 'invalid_token', error_description: 'PAT invalid/expired/revoked' }, 401); - } - if (!patInfo.organizationId) { - return c.json( - { error: 'invalid_token', error_description: 'PAT not scoped to an organization' }, - 401 - ); - } - - // Tenant-membership check — a PAT for org A must still belong to org A. - const memberRows = (await sql` - SELECT 1 FROM "member" - WHERE "userId" = ${patInfo.userId} - AND "organizationId" = ${patInfo.organizationId} - LIMIT 1 - `) as unknown as Array; - if (memberRows.length === 0) { - return c.json( - { error: 'forbidden', error_description: 'Token owner is not a member of this organization' }, - 403 - ); - } - - c.set('brokerOrgId', patInfo.organizationId); - return next(); +brokerRoutes.use("/oauth/*", 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, + ); + } + + c.set("brokerOrgId", result.organizationId); + return next(); }); /** - * Resolve the OAuth provider config (endpoints, auth method, credential keys) - * SERVER-SIDE from the broker org's `connector_definitions` row for - * `connectorKey`, matching the oauth method by `provider`. Returns `null` when - * the connector or a matching oauth method isn't found in the broker org — the - * broker refuses to act on a connector it doesn't manage. The caller cannot - * influence these endpoints (no token_url/authorization_url in the request). + * Parse + validate a JSON request body against a typebox schema. Returns the + * typed value, or sends a 400 with the validation errors. The endpoints supply + * the schema, so malformed/missing fields are rejected (not cast). */ -async function resolveBrokerProviderConfig(params: { - organizationId: string; - connectorKey: string; - provider: string; -}): Promise { - const sql = getDb(); - const rows = await sql` - SELECT auth_schema - FROM connector_definitions - WHERE key = ${params.connectorKey} - AND status = 'active' - AND (organization_id = ${params.organizationId} OR organization_id IS NULL) - ORDER BY CASE WHEN organization_id = ${params.organizationId} THEN 0 ELSE 1 END - LIMIT 1 - `; - if (rows.length === 0) return null; - - const authSchema = normalizeConnectorAuthSchema( - (rows[0] as { auth_schema: unknown }).auth_schema - ); - const method = getOAuthAuthMethods(authSchema).find( - (m) => m.provider.toLowerCase() === params.provider.toLowerCase() - ); - if (!method) return null; +async function parseBody( + c: Context, + validator: ReturnType>, +): Promise | { _error: Response }> { + const raw = await c.req.json().catch(() => null); + if (!validator.Check(raw)) { + const detail = [...validator.Errors(raw)] + .map((e) => `${e.path || "/"} ${e.message}`) + .join("; "); + return { + _error: c.json( + { + error: "bad_request", + error_description: detail || "Invalid request body", + }, + 400, + ), + }; + } + return raw as Static; +} - return { - provider: method.provider, - authorizationUrl: method.authorizationUrl, - tokenUrl: method.tokenUrl, - userinfoUrl: method.userinfoUrl, - authParams: method.authParams, - tokenEndpointAuthMethod: method.tokenEndpointAuthMethod, - clientIdKey: method.clientIdKey, - clientSecretKey: method.clientSecretKey, - }; +function isBodyError( + parsed: T | { _error: Response }, +): parsed is { _error: Response } { + return typeof parsed === "object" && parsed !== null && "_error" in parsed; } /** - * Resolve the broker org's managed `oauth_app` client credentials. The broker - * reads its OWN org's profile — the local caller never sees these values. The - * credential KEY NAMES come from the server-resolved connector config (not the - * request), with the provider-uppercase default as a fallback. + * Resolve the broker org's OAuth method + managed client credentials in one + * step. Returns an error Response when the connector/provider is unmanaged or + * no managed app exists, so each endpoint can early-return uniformly. */ -async function resolveBrokerClientCredentials(params: { - organizationId: string; - provider: string; - connectorKey: string; - clientIdKey?: string; - clientSecretKey?: string; -}): Promise<{ clientId: string | null; clientSecret: string | null }> { - const providerUpper = params.provider.toUpperCase(); - const clientIdKey = params.clientIdKey || `${providerUpper}_CLIENT_ID`; - const clientSecretKey = params.clientSecretKey || `${providerUpper}_CLIENT_SECRET`; - - const appProfile = await getPrimaryAuthProfileForKind({ - organizationId: params.organizationId, - connectorKey: params.connectorKey, - profileKind: 'oauth_app', - provider: params.provider, - }); - - const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); - return { - clientId: authValues[clientIdKey] ?? null, - clientSecret: authValues[clientSecretKey] ?? null, - }; +async function resolveBrokerConfig( + c: Context, + body: { connector_key: string; provider: string }, +): Promise< + | { + method: ConnectorAuthOAuthMethod; + clientId: string; + clientSecret: string | null; + } + | { _error: Response } +> { + const organizationId = c.get("brokerOrgId"); + const method = await resolveConnectorOAuthMethod({ + organizationId, + connectorKey: body.connector_key, + provider: body.provider, + }); + if (!method) { + return { + _error: c.json( + { + error: "unknown_connector", + error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, + }, + 400, + ), + }; + } + + const { clientId, clientSecret } = await resolveOAuthClientCredentials({ + organizationId, + connectorKey: body.connector_key, + provider: body.provider, + clientIdKey: method.clientIdKey, + clientSecretKey: method.clientSecretKey, + }); + if (!clientId) { + return { + _error: c.json( + { + error: "no_managed_app", + error_description: `No managed oauth_app for ${body.provider}`, + }, + 400, + ), + }; + } + + return { method, clientId, clientSecret }; } -interface AuthorizeUrlBody { - connector_key?: string; - provider?: string; - redirect_uri?: string; - scopes?: string[]; - state?: string; - code_challenge?: string; -} +const AuthorizeUrlBody = Type.Object({ + connector_key: Type.String({ minLength: 1 }), + provider: Type.String({ minLength: 1 }), + redirect_uri: Type.String({ minLength: 1 }), + state: Type.String({ minLength: 1 }), + scopes: Type.Optional(Type.Array(Type.String())), + code_challenge: Type.Optional(Type.String()), +}); +const authorizeUrlValidator = TypeCompiler.Compile(AuthorizeUrlBody); /** * POST /broker/oauth/authorize-url * Build the provider authorization URL using the broker org's managed client_id * and SERVER-RESOLVED authorization endpoint (never caller-supplied). */ -brokerRoutes.post('/oauth/authorize-url', async (c) => { - const orgId = c.get('brokerOrgId'); - const body = await c.req.json().catch(() => null); - if (!body?.provider || !body.connector_key || !body.redirect_uri || !body.state) { - return c.json( - { - error: 'bad_request', - error_description: 'provider, connector_key, redirect_uri, state required', - }, - 400 - ); - } - - const providerConfig = await resolveBrokerProviderConfig({ - organizationId: orgId, - connectorKey: body.connector_key, - provider: body.provider, - }); - if (!providerConfig) { - return c.json( - { - error: 'unknown_connector', - error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, - }, - 400 - ); - } - - const { clientId } = await resolveBrokerClientCredentials({ - organizationId: orgId, - provider: body.provider, - connectorKey: body.connector_key, - clientIdKey: providerConfig.clientIdKey, - }); - if (!clientId) { - return c.json( - { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, - 400 - ); - } - - const authorizationUrl = buildAuthorizationUrl({ - provider: body.provider, - clientId, - redirectUri: body.redirect_uri, - scopes: body.scopes ?? [], - state: body.state, - authorizationUrl: providerConfig.authorizationUrl, - authParams: providerConfig.authParams, - codeChallenge: body.code_challenge, - }); - if (!authorizationUrl) { - return c.json({ error: 'unsupported_provider', error_description: body.provider }, 400); - } - - return c.json({ authorization_url: authorizationUrl }); +brokerRoutes.post("/oauth/authorize-url", async (c) => { + const body = await parseBody(c, authorizeUrlValidator); + if (isBodyError(body)) return body._error; + + const resolved = await resolveBrokerConfig(c, body); + if (isBodyError(resolved)) return resolved._error; + + const authorizationUrl = buildAuthorizationUrl({ + provider: body.provider, + clientId: resolved.clientId, + redirectUri: body.redirect_uri, + scopes: body.scopes ?? [], + state: body.state, + authorizationUrl: resolved.method.authorizationUrl, + authParams: resolved.method.authParams, + codeChallenge: body.code_challenge, + }); + if (!authorizationUrl) { + return c.json( + { error: "unsupported_provider", error_description: body.provider }, + 400, + ); + } + + return c.json({ authorization_url: authorizationUrl }); }); -interface ExchangeBody { - connector_key?: string; - provider?: string; - code?: string; - redirect_uri?: string; - code_verifier?: string; -} +const ExchangeBody = Type.Object({ + connector_key: Type.String({ minLength: 1 }), + provider: Type.String({ minLength: 1 }), + code: Type.String({ minLength: 1 }), + redirect_uri: Type.String({ minLength: 1 }), + code_verifier: Type.Optional(Type.String()), +}); +const exchangeValidator = TypeCompiler.Compile(ExchangeBody); /** * POST /broker/oauth/exchange @@ -264,141 +222,94 @@ interface ExchangeBody { * otherwise a caller could redirect the client_secret to an attacker). Returns * ONLY the user's tokens — never the client_secret. */ -brokerRoutes.post('/oauth/exchange', async (c) => { - const orgId = c.get('brokerOrgId'); - const body = await c.req.json().catch(() => null); - if (!body?.provider || !body.connector_key || !body.code || !body.redirect_uri) { - return c.json( - { - error: 'bad_request', - error_description: 'provider, connector_key, code, redirect_uri required', - }, - 400 - ); - } - - const providerConfig = await resolveBrokerProviderConfig({ - organizationId: orgId, - connectorKey: body.connector_key, - provider: body.provider, - }); - if (!providerConfig) { - return c.json( - { - error: 'unknown_connector', - error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, - }, - 400 - ); - } - - const { clientId, clientSecret } = await resolveBrokerClientCredentials({ - organizationId: orgId, - provider: body.provider, - connectorKey: body.connector_key, - clientIdKey: providerConfig.clientIdKey, - clientSecretKey: providerConfig.clientSecretKey, - }); - if (!clientId) { - return c.json( - { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, - 400 - ); - } - - const tokens = await exchangeCodeForTokens({ - provider: body.provider, - code: body.code, - clientId, - clientSecret, - redirectUri: body.redirect_uri, - tokenUrl: providerConfig.tokenUrl, - tokenEndpointAuthMethod: providerConfig.tokenEndpointAuthMethod, - codeVerifier: body.code_verifier, - }); - if (!tokens) { - return c.json({ error: 'exchange_failed', error_description: 'Token exchange failed' }, 502); - } - - return c.json({ - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken, - expires_in: tokens.expiresIn, - scope: tokens.scope, - token_type: tokens.tokenType, - }); +brokerRoutes.post("/oauth/exchange", async (c) => { + const body = await parseBody(c, exchangeValidator); + if (isBodyError(body)) return body._error; + + const resolved = await resolveBrokerConfig(c, body); + if (isBodyError(resolved)) return resolved._error; + + const tokens = await exchangeCodeForTokens({ + provider: body.provider, + code: body.code, + clientId: resolved.clientId, + clientSecret: resolved.clientSecret, + redirectUri: body.redirect_uri, + tokenUrl: resolved.method.tokenUrl, + tokenEndpointAuthMethod: resolved.method.tokenEndpointAuthMethod, + codeVerifier: body.code_verifier, + }); + if (!tokens) { + return c.json( + { error: "exchange_failed", error_description: "Token exchange failed" }, + 502, + ); + } + + return c.json({ + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + expires_in: tokens.expiresIn, + scope: tokens.scope, + token_type: tokens.tokenType, + }); }); -interface RefreshBody { - connector_key?: string; - provider?: string; - refresh_token?: string; -} +const RefreshBody = Type.Object({ + connector_key: Type.String({ minLength: 1 }), + provider: Type.String({ minLength: 1 }), + refresh_token: Type.String({ minLength: 1 }), +}); +const refreshValidator = TypeCompiler.Compile(RefreshBody); /** * POST /broker/oauth/refresh * Refresh an access token using the broker org's managed client_id/secret and * SERVER-RESOLVED token endpoint (never caller-supplied). */ -brokerRoutes.post('/oauth/refresh', async (c) => { - const orgId = c.get('brokerOrgId'); - const body = await c.req.json().catch(() => null); - if (!body?.provider || !body.connector_key || !body.refresh_token) { - return c.json( - { - error: 'bad_request', - error_description: 'provider, connector_key, refresh_token required', - }, - 400 - ); - } - - const providerConfig = await resolveBrokerProviderConfig({ - organizationId: orgId, - connectorKey: body.connector_key, - provider: body.provider, - }); - if (!providerConfig?.tokenUrl) { - return c.json( - { - error: 'unknown_connector', - error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}' (no token endpoint)`, - }, - 400 - ); - } - - const { clientId, clientSecret } = await resolveBrokerClientCredentials({ - organizationId: orgId, - provider: body.provider, - connectorKey: body.connector_key, - clientIdKey: providerConfig.clientIdKey, - clientSecretKey: providerConfig.clientSecretKey, - }); - if (!clientId) { - return c.json( - { error: 'no_managed_app', error_description: `No managed oauth_app for ${body.provider}` }, - 400 - ); - } - - const refreshed = await new CredentialService(getDb()).refreshTokenGeneric({ - tokenUrl: providerConfig.tokenUrl, - clientId, - clientSecret: clientSecret ?? undefined, - refreshToken: body.refresh_token, - authMethod: providerConfig.tokenEndpointAuthMethod, - }); - if (!refreshed) { - return c.json({ error: 'refresh_failed', error_description: 'Token refresh failed' }, 502); - } - - logger.info({ provider: body.provider, organizationId: orgId }, 'Broker refreshed token'); - return c.json({ - access_token: refreshed.accessToken, - refresh_token: refreshed.refreshToken ?? null, - expires_in: Math.max(0, Math.round((refreshed.expiresAt.getTime() - Date.now()) / 1000)), - }); +brokerRoutes.post("/oauth/refresh", async (c) => { + const body = await parseBody(c, refreshValidator); + if (isBodyError(body)) return body._error; + + const resolved = await resolveBrokerConfig(c, body); + if (isBodyError(resolved)) return resolved._error; + + if (!resolved.method.tokenUrl) { + return c.json( + { + error: "unknown_connector", + error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}' (no token endpoint)`, + }, + 400, + ); + } + + const refreshed = await new CredentialService(getDb()).refreshTokenGeneric({ + tokenUrl: resolved.method.tokenUrl, + clientId: resolved.clientId, + clientSecret: resolved.clientSecret ?? undefined, + refreshToken: body.refresh_token, + authMethod: resolved.method.tokenEndpointAuthMethod, + }); + if (!refreshed) { + return c.json( + { error: "refresh_failed", error_description: "Token refresh failed" }, + 502, + ); + } + + logger.info( + { provider: body.provider, organizationId: c.get("brokerOrgId") }, + "Broker refreshed token", + ); + return c.json({ + access_token: refreshed.accessToken, + refresh_token: refreshed.refreshToken ?? null, + expires_in: Math.max( + 0, + Math.round((refreshed.expiresAt.getTime() - Date.now()) / 1000), + ), + }); }); export { brokerRoutes }; diff --git a/packages/server/src/connect/oauth-resolution.ts b/packages/server/src/connect/oauth-resolution.ts new file mode 100644 index 000000000..3f265c8f3 --- /dev/null +++ b/packages/server/src/connect/oauth-resolution.ts @@ -0,0 +1,101 @@ +/** + * Shared OAuth resolution helpers for the Connect flow and the OAuth broker. + * + * Two pieces of resolution both surfaces need: + * 1. The provider's OAuth method (endpoints + auth method + credential key + * names) for a connector — resolved SERVER-SIDE from the org's own + * `connector_definitions` row, never from a request body. + * 2. The org's managed `oauth_app` client_id/secret for that provider. + * + * The broker reuses these against ITS own org (so the caller can never + * influence the endpoints or leak the secret); the local Connect flow uses + * them against the local org. One implementation each, no parallel queries. + */ + +import { getDb } from "../db/client"; +import { + getAuthProfileById, + getPrimaryAuthProfileForKind, + normalizeAuthValues, +} from "../utils/auth-profiles"; +import { + type ConnectorAuthOAuthMethod, + getOAuthAuthMethods, + normalizeConnectorAuthSchema, +} from "../utils/connector-auth"; + +/** + * Resolve the OAuth method (endpoints, auth method, credential key names) for a + * connector + provider from the org's `connector_definitions`. Prefers the + * org's own row over a global (null-org) one. Returns `null` when the connector + * or a matching oauth method isn't found — the caller refuses to act on a + * connector/provider the org doesn't manage. Endpoints come from HERE, never + * from a request body. + */ +export async function resolveConnectorOAuthMethod(params: { + organizationId: string; + connectorKey: string; + provider: string; +}): Promise { + const sql = getDb(); + const rows = await sql` + SELECT auth_schema + FROM connector_definitions + WHERE key = ${params.connectorKey} + AND status = 'active' + AND (organization_id = ${params.organizationId} OR organization_id IS NULL) + ORDER BY CASE WHEN organization_id = ${params.organizationId} THEN 0 ELSE 1 END + LIMIT 1 + `; + if (rows.length === 0) return null; + + const authSchema = normalizeConnectorAuthSchema( + (rows[0] as { auth_schema: unknown }).auth_schema, + ); + return ( + getOAuthAuthMethods(authSchema).find( + (m) => m.provider.toLowerCase() === params.provider.toLowerCase(), + ) ?? null + ); +} + +/** + * Resolve OAuth client_id/secret from the org's managed `oauth_app` profile. + * + * Resolution order: + * 1. A specific selected app profile (by id), when provided. + * 2. The org's primary managed `oauth_app` for the connector/provider. + * + * The credential KEY NAMES come from the (server-resolved) connector config + * when known, with the provider-uppercase default as a fallback. + */ +export async function resolveOAuthClientCredentials(params: { + organizationId: string; + connectorKey: string; + provider: string; + appAuthProfileId?: number | null; + clientIdKey?: string; + clientSecretKey?: string; +}): Promise<{ clientId: string | null; clientSecret: string | null }> { + const providerUpper = params.provider.toUpperCase(); + const clientIdKey = params.clientIdKey || `${providerUpper}_CLIENT_ID`; + const clientSecretKey = + params.clientSecretKey || `${providerUpper}_CLIENT_SECRET`; + + const appProfile = + (params.appAuthProfileId + ? await getAuthProfileById(params.organizationId, params.appAuthProfileId) + : null) ?? + (await getPrimaryAuthProfileForKind({ + organizationId: params.organizationId, + connectorKey: params.connectorKey, + profileKind: "oauth_app", + provider: params.provider, + })); + + const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); + return { + clientId: authValues[clientIdKey] ?? null, + clientSecret: authValues[clientSecretKey] ?? null, + }; +} diff --git a/packages/server/src/connect/routes.ts b/packages/server/src/connect/routes.ts index 71ccf049b..dafce4991 100644 --- a/packages/server/src/connect/routes.ts +++ b/packages/server/src/connect/routes.ts @@ -36,6 +36,7 @@ import { mergeOAuthScopeAuthData, normalizeScopeList } from '../auth/oauth/scope import { createSyncRun } from '../utils/queue-helpers'; import { ACTIVE_RUN_STATUSES, runStatusLiteral } from '../utils/run-statuses'; import { buildConnectionsUrl, getOrganizationSlug, getPublicWebUrl } from '../utils/url-builder'; +import { resolveOAuthClientCredentials } from './oauth-resolution'; import { buildAuthorizationUrl, exchangeCodeForTokens, @@ -543,38 +544,39 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { return c.json({ error: 'OAuth provider not configured for this connector' }, 400); } + const source = await resolveCredentialSource(tokenRow, authConfig); + + const baseUrl = getBaseUrl(c); + const redirectUri = `${baseUrl}/connect/oauth/callback`; + // Reuse the persisted verifier if one exists; only generate (and persist) + // when missing. Regenerating on a repeat /oauth/start would build the + // challenge from a verifier the callback never sees (the persisted one is + // unchanged when needsUpdate is false), breaking the PKCE handshake. + const pkceCodeVerifier = authConfig.usePkce + ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) + : undefined; + + const needsUpdate = + authConfig.redirectUri !== redirectUri || (authConfig.usePkce && !authConfig.pkceCodeVerifier); + if (needsUpdate) { + const sql = getDb(); + await sql` + UPDATE connect_tokens + SET auth_config = ${sql.json({ + ...authConfig, + redirectUri, + ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), + })} + WHERE token = ${token} + `; + } + // Broker delegation: when the backing oauth_app profile is a broker-ref, the // broker holds the client_id + resolves the provider endpoints server-side, - // so build the authorization URL remotely. - const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); - if (brokerRef) { - const baseUrl = getBaseUrl(c); - const redirectUri = `${baseUrl}/connect/oauth/callback`; - // Reuse the persisted verifier if one exists; only generate (and persist) - // when missing. The challenge MUST derive from the SAME verifier that the - // callback will send, so the start/callback pair stays consistent. - const pkceCodeVerifier = authConfig.usePkce - ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) - : undefined; - - const needsUpdate = - authConfig.redirectUri !== redirectUri || - (authConfig.usePkce && !authConfig.pkceCodeVerifier); - if (needsUpdate) { - const sql = getDb(); - await sql` - UPDATE connect_tokens - SET auth_config = ${sql.json({ - ...authConfig, - redirectUri, - ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), - })} - WHERE token = ${token} - `; - } - + // so build the authorization URL remotely. Otherwise use local credentials. + if (source.kind === 'broker') { const brokerResult = await callBroker<{ authorization_url: string }>( - brokerRef, + source.broker, 'authorize-url', { connector_key: tokenRow.connector_key, @@ -591,9 +593,7 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { return c.redirect(brokerResult.authorization_url); } - const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); - - if (!clientId) { + if (!source.clientId) { return c.json( { error: @@ -604,7 +604,7 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { ); } - if (authConfig.tokenEndpointAuthMethod !== 'none' && !clientSecret) { + if (authConfig.tokenEndpointAuthMethod !== 'none' && !source.clientSecret) { return c.json( { error: @@ -615,36 +615,9 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { ); } - const baseUrl = getBaseUrl(c); - const redirectUri = `${baseUrl}/connect/oauth/callback`; - // Reuse the persisted verifier if one exists; only generate (and persist) - // when missing. Regenerating on a repeat /oauth/start would build the - // challenge from a verifier the callback never sees (the persisted one is - // unchanged when needsUpdate is false), breaking the PKCE handshake. - const pkceCodeVerifier = authConfig.usePkce - ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) - : undefined; - - const needsUpdate = - authConfig.redirectUri !== redirectUri || (authConfig.usePkce && !authConfig.pkceCodeVerifier); - - if (needsUpdate) { - const effectiveAuthConfig = { - ...authConfig, - redirectUri, - ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), - }; - const sql = getDb(); - await sql` - UPDATE connect_tokens - SET auth_config = ${sql.json(effectiveAuthConfig)} - WHERE token = ${token} - `; - } - const authUrl = buildAuthorizationUrl({ provider: authConfig.provider, - clientId, + clientId: source.clientId, redirectUri, scopes: authConfig.scopes ?? [], state: token, @@ -763,17 +736,17 @@ async function handleOAuthCallback( // broker holds the client_secret + resolves the token endpoint server-side, // so exchange the code remotely and use the returned user tokens. Storage // below is identical to the local path. - const brokerRef = await resolveBrokerRefForToken(tokenRow, authConfig); + const source = await resolveCredentialSource(tokenRow, authConfig); let tokens: Awaited> = null; - if (brokerRef) { + if (source.kind === 'broker') { const exchanged = await callBroker<{ access_token: string; refresh_token: string | null; expires_in: number | null; scope: string | null; token_type?: string; - }>(brokerRef, 'exchange', { + }>(source.broker, 'exchange', { connector_key: tokenRow.connector_key, provider: authConfig.provider, code, @@ -790,15 +763,14 @@ async function handleOAuthCallback( }; } } else { - const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); - if (!clientId) { + if (!source.clientId) { return c.json({ error: 'OAuth client credentials not found' }, 500); } tokens = await exchangeCodeForTokens({ provider: authConfig.provider, code, - clientId, - clientSecret, + clientId: source.clientId, + clientSecret: source.clientSecret, redirectUri, tokenUrl: authConfig.tokenUrl, tokenEndpointAuthMethod: authConfig.tokenEndpointAuthMethod, @@ -992,35 +964,29 @@ async function handleOAuthCallback( return c.redirect(`${baseUrl}`); } -async function resolveOAuthCredentialsForToken( - tokenRow: { connection_id: number | null; organization_id: string; connector_key: string }, - authConfig: OAuthAuthConfig -): Promise<{ clientId: string | null; clientSecret: string | null }> { - const appAuthProfileId = await fetchAppAuthProfileId( - tokenRow.connection_id, - tokenRow.organization_id - ); - return resolveOAuthClientCredentials( - authConfig.provider, - tokenRow.connector_key, - tokenRow.organization_id, - appAuthProfileId, - authConfig.clientIdKey, - authConfig.clientSecretKey - ); -} +/** + * The credential source backing a connect token's OAuth flow — the single seam + * the start + callback paths branch on: + * - `local` → the org holds the client_id/secret directly. + * - `broker` → the backing oauth_app profile is a broker-ref; the + * secret-requiring steps are delegated to that remote broker, which holds + * the client_id/secret and resolves the provider endpoints server-side. + */ +type CredentialSource = + | { kind: 'local'; clientId: string | null; clientSecret: string | null } + | { kind: 'broker'; broker: BrokerRef }; /** - * Resolve the broker reference (if any) for the oauth_app profile that would - * back this token. When non-null, the secret-requiring OAuth steps are - * delegated to that remote broker instead of using local client credentials. - * Resolution mirrors resolveOAuthClientCredentials: selected app profile first, - * then the org's primary managed oauth_app for the connector/provider. + * Resolve the {@link CredentialSource} for a connect token in ONE lookup of the + * backing oauth_app profile (selected app profile first, then the org's primary + * managed oauth_app for the connector/provider). A `__broker` ref short-circuits + * to broker delegation; otherwise the profile's local client credentials are + * returned. This replaces the previously scattered broker-ref checks. */ -async function resolveBrokerRefForToken( +async function resolveCredentialSource( tokenRow: { connection_id: number | null; organization_id: string; connector_key: string }, authConfig: OAuthAuthConfig -): Promise { +): Promise { const appAuthProfileId = await fetchAppAuthProfileId( tokenRow.connection_id, tokenRow.organization_id @@ -1035,7 +1001,19 @@ async function resolveBrokerRefForToken( profileKind: 'oauth_app', provider: authConfig.provider, })); - return getBrokerRef(appProfile?.auth_data ?? null); + + const broker = getBrokerRef(appProfile?.auth_data ?? null); + if (broker) return { kind: 'broker', broker }; + + const { clientId, clientSecret } = await resolveOAuthClientCredentials({ + organizationId: tokenRow.organization_id, + connectorKey: tokenRow.connector_key, + provider: authConfig.provider, + appAuthProfileId, + clientIdKey: authConfig.clientIdKey, + clientSecretKey: authConfig.clientSecretKey, + }); + return { kind: 'local', clientId, clientSecret }; } /** POST a JSON body to a broker endpoint with the broker PAT. */ @@ -1083,37 +1061,4 @@ async function fetchAppAuthProfileId( : null; } -/** - * Resolve OAuth client ID/secret from: - * 1. Selected OAuth app auth profile - * 2. Primary org-level OAuth app auth profile for the connector/provider - */ -async function resolveOAuthClientCredentials( - provider: string, - connectorKey: string, - organizationId: string, - appAuthProfileId?: number | null, - clientIdKey?: string, - clientSecretKey?: string -): Promise<{ clientId: string | null; clientSecret: string | null }> { - const providerUpper = provider.toUpperCase(); - const resolvedClientIdKey = clientIdKey || `${providerUpper}_CLIENT_ID`; - const resolvedClientSecretKey = clientSecretKey || `${providerUpper}_CLIENT_SECRET`; - - const appProfile = - (appAuthProfileId ? await getAuthProfileById(organizationId, appAuthProfileId) : null) ?? - (await getPrimaryAuthProfileForKind({ - organizationId, - connectorKey, - profileKind: 'oauth_app', - provider, - })); - - const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); - const clientId = authValues[resolvedClientIdKey] ?? null; - const clientSecret = authValues[resolvedClientSecretKey] ?? null; - - return { clientId, clientSecret }; -} - export { connectRoutes }; diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index 3b70229d3..f1bd6c80d 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 OAuth broker 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 broker 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; From 3a4cfe259a5801cb94cf145beaa9935922bc1906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 13:33:41 +0100 Subject: [PATCH 04/15] refactor(connect)!: grant-on-broker model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the OAuth broker spike from grant-local (the local instance ran the flow and delegated authorize-url/exchange/refresh to the broker per-step) to grant-on-broker: the broker OWNS the grant (managed app creds + oauth_account) and runs consent/refresh through its EXISTING /connect flow + CredentialService. The local instance only fetches a fresh access token at runtime. - broker-routes.ts: replace 3 delegation endpoints (authorize-url/exchange/ refresh) with one PAT-gated POST /broker/oauth/token; org-scoped (cross-org connection -> 403); returns only { access_token, expires_at } via the existing resolveExecutionAuth path; never the refresh token or client secret. - execution-context.ts: runtime hook — a broker-backed connection (oauth_app auth_data has __broker) fetches its access token from the broker; the local (non-broker) path is unchanged. - routes.ts: restored to main (broker branch in /oauth/start + callback removed). - oauth-resolution.ts: deleted (now unused). - auth-profiles.ts: BrokerRef gains connection_id (the broker-side connection). - pat-auth.ts + gateway.ts: shared authenticatePat retained (used by both). Production insertions vs main: 826 -> 484 (~41% smaller); broker-routes 315->160. --- .../connectors/oauth-broker.test.ts | 496 ++++++++---------- packages/server/src/connect/broker-routes.ts | 319 +++-------- .../server/src/connect/oauth-resolution.ts | 101 ---- packages/server/src/connect/routes.ts | 252 +++------ packages/server/src/index.ts | 9 +- packages/server/src/utils/auth-profiles.ts | 32 +- .../server/src/utils/execution-context.ts | 71 ++- 7 files changed, 475 insertions(+), 805 deletions(-) delete mode 100644 packages/server/src/connect/oauth-resolution.ts diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 847da1614..981f761cd 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -1,36 +1,36 @@ /** - * SPIKE: broker-delegated oauth_app profiles. + * SPIKE: grant-on-broker managed OAuth. * - * Proves the local↔broker AUTH handshake for Nango-style managed OAuth without - * a real external provider: + * The grant lives on the BROKER. The broker holds the managed app creds + the + * user's grant (oauth_account → account row) and runs consent/refresh through + * its OWN existing flow + CredentialService. The LOCAL instance only fetches a + * fresh ACCESS token at runtime via `POST /broker/oauth/token`. * - * 1. A "broker" org holds a managed `oauth_app` (fake client_id/secret) for a - * test connector whose oauth method's `tokenUrl`/`authorizationUrl` point - * at a LOCAL fake provider server that returns canned tokens. Those - * endpoints live in the broker org's OWN connector metadata — the broker - * resolves them SERVER-SIDE; the caller never supplies them. - * 2. The broker's `POST /broker/oauth/exchange` is PAT-gated. A valid PAT for - * the broker org → the broker resolves ITS managed app + ITS connector - * endpoints, uses the secret, and returns ONLY the user's tokens. No / - * invalid PAT → 401. - * 3. A caller-supplied `token_url` in the body is IGNORED (the broker hits - * its own connector's tokenUrl), so the client_secret can never be - * redirected to an attacker. - * 4. End-to-end: a LOCAL connect token whose oauth_app profile is a - * `__broker` ref drives `GET /connect/oauth/callback`; the callback - * delegates the code exchange to the broker (broker.url = self) and stores - * the broker-returned tokens on the `account` row — never touching a local - * client_secret. + * Proven here without a real external provider: + * + * 1. A "broker" 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 + * and a refresh token, and a connection wiring them together. + * 2. `POST /broker/oauth/token` is PAT-gated. A valid PAT for the broker org + + * the broker connection id → the broker resolves its managed app + connector + * endpoint, REFRESHES the expiring token with its secret (against its own + * tokenUrl), and returns ONLY `{ access_token, expires_at }`. The refresh + * token + client secret never appear in the response. + * 3. Scope: a PAT for org B cannot fetch org A's connection token (403). No + * PAT → 401, bad PAT → 401, malformed body → 400. + * 4. End-to-end runtime hook: a LOCAL broker-backed connection (oauth_app = + * `__broker` ref, broker.url = the in-process broker server) resolves its + * access token through the broker via `resolveExecutionAuth`. */ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { brokerRoutes } from "../../../connect/broker-routes"; -import { connectRoutes } from "../../../connect/routes"; import type { Env } from "../../../index"; import { createAuthProfile } from "../../../utils/auth-profiles"; -import { createConnectToken } from "../../../utils/connect-tokens"; +import { resolveExecutionAuth } from "../../../utils/execution-context"; import { initWorkspaceProvider } from "../../../workspace"; import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; import { @@ -46,24 +46,22 @@ const TEST_ENV = { DATABASE_URL: process.env.DATABASE_URL, } as unknown as Env; -const CANNED = { - access_token: "canned-access-token-123", - refresh_token: "canned-refresh-token-456", +// 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: "broker-refresh-token-456", expires_in: 3600, - scope: "read write", - token_type: "Bearer", }; +const STALE_ACCESS_TOKEN = "stale-access-token-000"; +const BROKER_SECRET = "broker-secret"; -// Fake OAuth provider: the tokenUrl the broker org's connector points at. A -// SECOND fake server stands in for an "attacker" endpoint — if the broker ever -// honored a caller-supplied token_url it would POST the client_secret here. +// Fake OAuth provider token endpoint the broker org's connector points at. let providerServer: ReturnType | null = null; let providerTokenUrl = ""; -let attackerServer: ReturnType | null = null; -let attackerTokenUrl = ""; -let attackerHits = 0; +let lastRefreshBody: Record = {}; -// Broker app served on a real port so the local callback's `fetch` reaches it. +// Broker app served on a real port so the local runtime hook's `fetch` reaches it. let brokerServer: ReturnType | null = null; let brokerBaseUrl = ""; @@ -76,9 +74,18 @@ function buildBrokerApp(): Hono<{ Bindings: Env }> { beforeAll(async () => { await initWorkspaceProvider(); - // Fake provider token endpoint — returns canned tokens for any code. + // Fake provider: a refresh_token grant returns canned refreshed tokens. Record + // the form body so we can assert the broker authed with its own secret. const providerApp = new Hono(); - providerApp.post("/token", (c) => c.json(CANNED)); + 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 }, @@ -89,22 +96,6 @@ beforeAll(async () => { ); }); - // "Attacker" endpoint — records any hit. Must stay at 0 hits. - const attackerApp = new Hono(); - attackerApp.post("/token", (c) => { - attackerHits += 1; - return c.json(CANNED); - }); - attackerServer = await new Promise((resolve) => { - const s = serve( - { fetch: attackerApp.fetch, hostname: "127.0.0.1", port: 0 }, - (info) => { - attackerTokenUrl = `http://127.0.0.1:${info.port}/token`; - resolve(s); - }, - ); - }); - // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). const brokerApp = buildBrokerApp(); brokerServer = await new Promise((resolve) => { @@ -126,23 +117,33 @@ afterAll(async () => { await new Promise((done) => providerServer ? providerServer.close(() => done()) : done(), ); - await new Promise((done) => - attackerServer ? attackerServer.close(() => done()) : done(), - ); await new Promise((done) => brokerServer ? brokerServer.close(() => done()) : done(), ); }); +interface SeededConnection { + orgId: string; + userId: string; + pat: string; + connectionId: number; +} + /** - * Seed a broker org with a managed oauth_app (fake creds) for `demo`. The - * connector's auth_schema carries the OAuth endpoints — the broker resolves - * `tokenUrl`/`authorizationUrl` from HERE, not from the request. + * Seed a broker 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 wiring them. The + * connector endpoints live in the org's OWN metadata — the broker resolves them + * server-side; the caller never supplies them. */ -async function seedBrokerOrg() { - const org = await createTestOrganization({ name: "Broker Org" }); - const user = await createTestUser({ name: "Broker Admin" }); +async function seedBrokerConnection( + orgName: string, +): Promise { + const sql = getTestDb(); + const org = await createTestOrganization({ name: orgName }); + const user = await createTestUser({ name: `${orgName} Admin` }); await addUserToOrganization(user.id, org.id, "owner"); + await createTestConnectorDefinition({ key: "demo.oauth", name: "Demo OAuth", @@ -155,6 +156,7 @@ async function seedBrokerOrg() { requiredScopes: ["read"], authorizationUrl: "https://demo.example/authorize", tokenUrl: providerTokenUrl, + tokenEndpointAuthMethod: "client_secret_post", clientIdKey: "DEMO_CLIENT_ID", clientSecretKey: "DEMO_CLIENT_SECRET", }, @@ -162,223 +164,178 @@ async function seedBrokerOrg() { }, feeds_schema: { items: {} }, }); + // Managed oauth_app holds the REAL client_id/secret (never leaves the broker). - await createAuthProfile({ + const appProfile = await createAuthProfile({ organizationId: org.id, connectorKey: "demo.oauth", displayName: "Managed Demo App", - slug: "managed-demo", profileKind: "oauth_app", provider: "demo", authData: { DEMO_CLIENT_ID: "broker-cid", - DEMO_CLIENT_SECRET: "broker-secret", + DEMO_CLIENT_SECRET: BROKER_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', ${user.id}, + ${STALE_ACCESS_TOKEN}, ${"broker-refresh-token-original"}, ${expiringSoon}, + 'read', NOW(), NOW() + ) + `; + + // oauth_account profile pointing at the grant. + const accountProfile = await createAuthProfile({ + organizationId: org.id, + connectorKey: "demo.oauth", + displayName: "Demo Account", + profileKind: "oauth_account", + provider: "demo", + accountId, + }); + + // Connection wiring the grant (auth_profile_id) + managed app (app_auth_profile_id). + 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_at, updated_at + ) VALUES ( + ${org.id}, 'demo.oauth', ${`demo-${org.id}`}, 'Demo Connection', 'active', + ${accountId}, ${accountProfile.id}, ${appProfile.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + const pat = await createTestPAT(user.id, org.id); - return { org, user, pat }; + return { + orgId: org.id, + userId: user.id, + pat: pat.token, + 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://broker.local/broker/oauth/token", { + method: "POST", + headers, + body: JSON.stringify(opts.body ?? {}), + }), + TEST_ENV, + ); } -describe("SPIKE: broker-delegated oauth_app — exchange handshake", () => { +describe("SPIKE: grant-on-broker — POST /broker/oauth/token", () => { beforeEach(async () => { await cleanupTestDatabase(); - attackerHits = 0; + lastRefreshBody = {}; }); - it("PAT-gated exchange resolves the broker managed app + connector endpoint and returns user tokens", async () => { - const { pat } = await seedBrokerOrg(); + it("returns a fresh access token, refreshed server-side with the broker secret", async () => { + const { pat, connectionId } = await seedBrokerConnection("Broker Org"); const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "auth-code-abc", - redirect_uri: "http://local.example/connect/oauth/callback", - }), - }), - TEST_ENV, - ); + const res = await tokenRequest(app, { + pat, + body: { connection_id: connectionId }, + }); expect(res.status).toBe(200); const body = (await res.json()) as Record; - expect(body.access_token).toBe(CANNED.access_token); - expect(body.refresh_token).toBe(CANNED.refresh_token); - expect(body.expires_in).toBe(CANNED.expires_in); - expect(body.scope).toBe(CANNED.scope); - // The broker never leaks the client_secret back to the caller. - expect(JSON.stringify(body)).not.toContain("broker-secret"); + // The access token is the REFRESHED one — proves the broker 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 broker authed the refresh with ITS managed client_id/secret. + expect(lastRefreshBody.client_id).toBe("broker-cid"); + expect(lastRefreshBody.client_secret).toBe(BROKER_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("broker-refresh-token-original"); + expect(serialized).not.toContain(BROKER_SECRET); + expect(body.refresh_token).toBeUndefined(); }); - it("ignores a caller-supplied token_url — secret can NOT be redirected to an attacker", async () => { - const { pat } = await seedBrokerOrg(); + it("rejects a cross-org connection (403) — a PAT for org B cannot fetch org A's token", async () => { + const orgA = await seedBrokerConnection("Org A"); + const orgB = await seedBrokerConnection("Org B"); const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "auth-code-abc", - redirect_uri: "http://local.example/connect/oauth/callback", - // Malicious: try to redirect the secret-bearing exchange elsewhere. - token_url: attackerTokenUrl, - token_endpoint_auth_method: "client_secret_basic", - client_secret_key: "DEMO_CLIENT_SECRET", - }), - }), - TEST_ENV, - ); + // Org B's PAT asking for Org A's connection id. + const res = await tokenRequest(app, { + pat: orgB.pat, + body: { connection_id: orgA.connectionId }, + }); - // The broker hits ITS OWN connector's tokenUrl and returns canned tokens; - // the attacker endpoint is never touched. - expect(res.status).toBe(200); + expect(res.status).toBe(403); const body = (await res.json()) as Record; - expect(body.access_token).toBe(CANNED.access_token); - expect(attackerHits).toBe(0); + expect(body.error).toBe("forbidden"); }); - it("rejects exchange for a connector the broker org does not manage (400)", async () => { - const { pat } = await seedBrokerOrg(); + it("rejects a malformed body (400) — validated, not cast", async () => { + const { pat } = await seedBrokerConnection("Broker Org"); const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "not.a.real.connector", - provider: "demo", - code: "x", - redirect_uri: "http://local.example/cb", - }), - }), - TEST_ENV, - ); - expect(res.status).toBe(400); - expect(attackerHits).toBe(0); - }); - it("rejects a malformed body — missing required fields — with 400 (validated, not cast)", async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - // Missing `code` and `redirect_uri` — must 400 before any resolution. - body: JSON.stringify({ connector_key: "demo.oauth", provider: "demo" }), - }), - TEST_ENV, - ); + const res = await tokenRequest(app, { + pat, + body: { connection_id: "not-a-number" }, + }); expect(res.status).toBe(400); const body = (await res.json()) as Record; expect(body.error).toBe("bad_request"); - expect(attackerHits).toBe(0); }); - it("rejects exchange with no PAT (401)", async () => { - await seedBrokerOrg(); + it("rejects no PAT (401)", async () => { + const { connectionId } = await seedBrokerConnection("Broker Org"); const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "x", - redirect_uri: "http://local.example/cb", - }), - }), - TEST_ENV, - ); + const res = await tokenRequest(app, { body: { connection_id: connectionId } }); expect(res.status).toBe(401); }); - it("rejects exchange with an invalid PAT (401)", async () => { - await seedBrokerOrg(); + it("rejects an invalid PAT (401)", async () => { + const { connectionId } = await seedBrokerConnection("Broker Org"); const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer owl_pat_totally-bogus-token", - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - code: "x", - redirect_uri: "http://local.example/cb", - }), - }), - TEST_ENV, - ); + const res = await tokenRequest(app, { + pat: "owl_pat_totally-bogus-token", + body: { connection_id: connectionId }, + }); expect(res.status).toBe(401); }); - - it("authorize-url builds the provider consent URL with the broker client_id + server-resolved endpoint", async () => { - const { pat } = await seedBrokerOrg(); - const app = buildBrokerApp(); - const res = await app.fetch( - new Request("http://broker.local/broker/oauth/authorize-url", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pat.token}`, - }, - body: JSON.stringify({ - connector_key: "demo.oauth", - provider: "demo", - redirect_uri: "http://local.example/connect/oauth/callback", - scopes: ["read"], - state: "state-xyz", - }), - }), - TEST_ENV, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { authorization_url: string }; - const url = new URL(body.authorization_url); - // Endpoint comes from the broker org's connector metadata, not the request. - expect(`${url.origin}${url.pathname}`).toBe( - "https://demo.example/authorize", - ); - expect(url.searchParams.get("client_id")).toBe("broker-cid"); - expect(url.searchParams.get("state")).toBe("state-xyz"); - }); }); -describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () => { +describe("SPIKE: grant-on-broker — local runtime hook", () => { beforeEach(async () => { await cleanupTestDatabase(); - attackerHits = 0; + lastRefreshBody = {}; }); - it("local callback delegates code exchange to the broker and stores broker tokens", async () => { - // Broker side: managed app + connector endpoints + PAT (broker.url = the + it("a broker-backed local connection resolves its access token via the broker", async () => { + // Broker side: managed app + grant + connection + PAT (broker.url = the // in-process broker server). - const { pat } = await seedBrokerOrg(); + const broker = await seedBrokerConnection("Broker Org"); // Local side: a separate org whose oauth_app profile is a broker-ref (no - // local client_id/secret) plus a connect token bound to it. + // local client_id/secret) pointing at the broker connection. + const sql = getTestDb(); const localOrg = await createTestOrganization({ name: "Local Org" }); const localUser = await createTestUser({ name: "Local User" }); await addUserToOrganization(localUser.id, localOrg.id, "owner"); @@ -387,9 +344,7 @@ describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () name: "Demo OAuth Local", organization_id: localOrg.id, auth_schema: { - methods: [ - { type: "oauth", provider: "demo", requiredScopes: ["read"] }, - ], + methods: [{ type: "oauth", provider: "demo", requiredScopes: ["read"] }], }, feeds_schema: { items: {} }, }); @@ -397,75 +352,50 @@ describe("SPIKE: broker-delegated oauth_app — end-to-end local delegation", () organizationId: localOrg.id, connectorKey: "demo.oauth", displayName: "Broker-backed Demo App", - slug: "local-broker-app", profileKind: "oauth_app", provider: "demo", authData: { - __broker: { url: brokerBaseUrl, org: "broker-org", pat: pat.token }, + __broker: { + url: brokerBaseUrl, + org: "broker-org", + pat: broker.pat, + connection_id: broker.connectionId, + }, }, }); // The broker-ref must survive normalization (no client_id/secret keys). - const sql = getTestDb(); const stored = (await sql` SELECT auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} `) as unknown as Array<{ auth_data: Record }>; - expect( - (stored[0].auth_data as { __broker?: unknown }).__broker, - ).toBeTruthy(); + expect((stored[0].auth_data as { __broker?: unknown }).__broker).toBeTruthy(); expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); - // Connect token (oauth) bound to the local broker-ref app profile. The - // local instance has NO tokenUrl/secret — the broker resolves the endpoint - // server-side. created_by set so the callback skips session lookup. - const tokenRow = await createConnectToken({ + // A local connection backed by the broker-ref app profile (no grant locally). + const localConnRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + app_auth_profile_id, created_at, updated_at + ) VALUES ( + ${localOrg.id}, 'demo.oauth', 'demo-local', 'Local Demo', 'active', + ${brokerProfile.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + // The runtime token-resolution path: detects the broker-ref, fetches the + // access token from the broker, and returns it as the connection's creds. + const resolved = await resolveExecutionAuth({ organizationId: localOrg.id, - connectorKey: "demo.oauth", - authType: "oauth", - authConfig: { - provider: "demo", - scopes: ["read"], - redirectUri: "http://local.example/connect/oauth/callback", - clientIdKey: "DEMO_CLIENT_ID", - clientSecretKey: "DEMO_CLIENT_SECRET", - }, - createdBy: localUser.id, + connectionId: Number(localConnRows[0].id), + authProfileId: null, + appAuthProfileId: brokerProfile.id, + credentialDb: sql, }); - // Drive the stable callback. The local instance has no client_secret; it - // POSTs the broker /exchange (which uses the broker's secret + endpoint) - // and stores the broker-returned tokens. - const app = new Hono<{ Bindings: Env }>(); - app.route("/connect", connectRoutes); - const res = await app.fetch( - new Request( - `http://local.example/connect/oauth/callback?state=${tokenRow.token}&code=auth-code-e2e`, - { method: "GET", redirect: "manual" }, - ), - TEST_ENV, - ); - // Callback ends in a redirect (302) once tokens are stored. - expect(res.status).toBe(302); - - // The account row must carry the broker-returned canned tokens — proof the - // exchange was delegated and no local secret was needed. - const accounts = (await sql` - SELECT "accessToken", "refreshToken", "providerId" - FROM "account" - WHERE "userId" = ${localUser.id} AND "providerId" = 'demo' - `) as unknown as Array<{ - accessToken: string; - refreshToken: string; - providerId: string; - }>; - expect(accounts).toHaveLength(1); - expect(accounts[0].accessToken).toBe(CANNED.access_token); - expect(accounts[0].refreshToken).toBe(CANNED.refresh_token); - - // Connect token consumed. - const tk = (await sql` - SELECT status FROM connect_tokens WHERE token = ${tokenRow.token} - `) as unknown as Array<{ status: string }>; - expect(tk[0].status).toBe("completed"); + expect(resolved.credentials?.accessToken).toBe(REFRESHED.access_token); + // No local refresh token / secret ever materialized. + expect(resolved.credentials?.refreshToken).toBeNull(); + expect(resolved.connectionCredentials).toEqual({}); }); }); diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts index 524640e53..ffa93fe1a 100644 --- a/packages/server/src/connect/broker-routes.ts +++ b/packages/server/src/connect/broker-routes.ts @@ -1,46 +1,34 @@ /** - * OAuth Broker Routes (SPIKE) + * OAuth Broker Route (SPIKE) — grant-on-broker model. * - * Nango-style managed OAuth, Lobu-native. A LOCAL Lobu instance whose - * `oauth_app` profile is a broker-ref (see `getBrokerRef`) does not hold the - * provider's client_id/secret. Instead it delegates the secret-requiring OAuth - * steps to a REMOTE Lobu instance — the "broker" — over these HTTP endpoints. + * Nango-style managed OAuth, Lobu-native. The grant lives on the BROKER: the + * broker runs consent through its OWN existing `/connect/...` flow against its + * managed `oauth_app` (client_id/secret), and stores the resulting grant + * (`oauth_account` → `account` row) — the local instance never sees the client + * secret or the refresh token. At RUNTIME the local instance asks the broker + * for a fresh access token for a specific broker-side connection. * * Auth handshake: the local instance calls with `Authorization: Bearer * `. The broker verifies the PAT (resolving the authenticated org + * tenant membership via the shared `authenticatePat`, exactly as the embedded - * Agent API does) and uses THAT org's managed `oauth_app` profile to read the - * real client_id/secret. The broker performs the build/exchange/refresh and - * returns ONLY the user's tokens — the client_secret never leaves the broker. + * Agent API does) and scopes the lookup to THAT org — a PAT for org A cannot + * fetch org B's tokens. * - * Endpoints (all POST, all PAT-gated): - * - /broker/oauth/authorize-url → buildAuthorizationUrl with broker's client_id - * - /broker/oauth/exchange → exchangeCodeForTokens with broker's secret - * - /broker/oauth/refresh → CredentialService.refreshTokenGeneric - * - * The OAuth endpoints + credential key names are resolved SERVER-SIDE from the - * broker org's own connector metadata (`resolveConnectorOAuthMethod`). The - * caller never supplies them — that is the security premise (otherwise a caller - * with any valid PAT could redirect the broker's client_secret to an attacker). + * Endpoint (PAT-gated): + * - POST /broker/oauth/token → fresh access token for a broker connection, + * refreshed server-side with the broker's secret via the EXISTING + * CredentialService. Returns ONLY `{ access_token, expires_at }` — never the + * refresh token or client secret. */ import type { Env } from "@lobu/connector-sdk"; -import { type Static, type TObject, Type } from "@sinclair/typebox"; +import { Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; -import { type Context, Hono } from "hono"; -import { CredentialService } from "../auth/credentials"; +import { Hono } from "hono"; import { authenticatePat, extractPatBearer } from "../auth/pat-auth"; import { getDb } from "../db/client"; -import type { ConnectorAuthOAuthMethod } from "../utils/connector-auth"; +import { resolveExecutionAuth } from "../utils/execution-context"; import logger from "../utils/logger"; -import { - buildAuthorizationUrl, - exchangeCodeForTokens, -} from "./oauth-providers"; -import { - resolveConnectorOAuthMethod, - resolveOAuthClientCredentials, -} from "./oauth-resolution"; type BrokerEnv = { Bindings: Env; @@ -75,240 +63,97 @@ brokerRoutes.use("/oauth/*", async (c, next) => { return next(); }); +const TokenBody = Type.Object({ + connection_id: Type.Integer({ minimum: 1 }), +}); +const tokenValidator = TypeCompiler.Compile(TokenBody); + /** - * Parse + validate a JSON request body against a typebox schema. Returns the - * typed value, or sends a 400 with the validation errors. The endpoints supply - * the schema, so malformed/missing fields are rejected (not cast). + * POST /broker/oauth/token + * Return a fresh access token for a broker-side connection. + * + * The broker owns the 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 broker's secret when the + * token is expiring. The body carries ONLY the broker connection id; endpoints, + * secrets and the refresh token are resolved/held server-side and never leave + * the broker. The connection MUST belong to the PAT's org (else 403) so a PAT + * for org A cannot fetch org B's tokens. */ -async function parseBody( - c: Context, - validator: ReturnType>, -): Promise | { _error: Response }> { +brokerRoutes.post("/oauth/token", async (c) => { const raw = await c.req.json().catch(() => null); - if (!validator.Check(raw)) { - const detail = [...validator.Errors(raw)] + if (!tokenValidator.Check(raw)) { + const detail = [...tokenValidator.Errors(raw)] .map((e) => `${e.path || "/"} ${e.message}`) .join("; "); - return { - _error: c.json( - { - error: "bad_request", - error_description: detail || "Invalid request body", - }, - 400, - ), - }; - } - return raw as Static; -} - -function isBodyError( - parsed: T | { _error: Response }, -): parsed is { _error: Response } { - return typeof parsed === "object" && parsed !== null && "_error" in parsed; -} - -/** - * Resolve the broker org's OAuth method + managed client credentials in one - * step. Returns an error Response when the connector/provider is unmanaged or - * no managed app exists, so each endpoint can early-return uniformly. - */ -async function resolveBrokerConfig( - c: Context, - body: { connector_key: string; provider: string }, -): Promise< - | { - method: ConnectorAuthOAuthMethod; - clientId: string; - clientSecret: string | null; - } - | { _error: Response } -> { - const organizationId = c.get("brokerOrgId"); - const method = await resolveConnectorOAuthMethod({ - organizationId, - connectorKey: body.connector_key, - provider: body.provider, - }); - if (!method) { - return { - _error: c.json( - { - error: "unknown_connector", - error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}'`, - }, - 400, - ), - }; - } - - const { clientId, clientSecret } = await resolveOAuthClientCredentials({ - organizationId, - connectorKey: body.connector_key, - provider: body.provider, - clientIdKey: method.clientIdKey, - clientSecretKey: method.clientSecretKey, - }); - if (!clientId) { - return { - _error: c.json( - { - error: "no_managed_app", - error_description: `No managed oauth_app for ${body.provider}`, - }, - 400, - ), - }; - } - - return { method, clientId, clientSecret }; -} - -const AuthorizeUrlBody = Type.Object({ - connector_key: Type.String({ minLength: 1 }), - provider: Type.String({ minLength: 1 }), - redirect_uri: Type.String({ minLength: 1 }), - state: Type.String({ minLength: 1 }), - scopes: Type.Optional(Type.Array(Type.String())), - code_challenge: Type.Optional(Type.String()), -}); -const authorizeUrlValidator = TypeCompiler.Compile(AuthorizeUrlBody); - -/** - * POST /broker/oauth/authorize-url - * Build the provider authorization URL using the broker org's managed client_id - * and SERVER-RESOLVED authorization endpoint (never caller-supplied). - */ -brokerRoutes.post("/oauth/authorize-url", async (c) => { - const body = await parseBody(c, authorizeUrlValidator); - if (isBodyError(body)) return body._error; - - const resolved = await resolveBrokerConfig(c, body); - if (isBodyError(resolved)) return resolved._error; - - const authorizationUrl = buildAuthorizationUrl({ - provider: body.provider, - clientId: resolved.clientId, - redirectUri: body.redirect_uri, - scopes: body.scopes ?? [], - state: body.state, - authorizationUrl: resolved.method.authorizationUrl, - authParams: resolved.method.authParams, - codeChallenge: body.code_challenge, - }); - if (!authorizationUrl) { return c.json( - { error: "unsupported_provider", error_description: body.provider }, + { + error: "bad_request", + error_description: detail || "Invalid request body", + }, 400, ); } - return c.json({ authorization_url: authorizationUrl }); -}); - -const ExchangeBody = Type.Object({ - connector_key: Type.String({ minLength: 1 }), - provider: Type.String({ minLength: 1 }), - code: Type.String({ minLength: 1 }), - redirect_uri: Type.String({ minLength: 1 }), - code_verifier: Type.Optional(Type.String()), -}); -const exchangeValidator = TypeCompiler.Compile(ExchangeBody); - -/** - * POST /broker/oauth/exchange - * Exchange an authorization code for tokens using the broker org's managed - * client_id/secret and SERVER-RESOLVED token endpoint (never caller-supplied — - * otherwise a caller could redirect the client_secret to an attacker). Returns - * ONLY the user's tokens — never the client_secret. - */ -brokerRoutes.post("/oauth/exchange", async (c) => { - const body = await parseBody(c, exchangeValidator); - if (isBodyError(body)) return body._error; - - const resolved = await resolveBrokerConfig(c, body); - if (isBodyError(resolved)) return resolved._error; - - const tokens = await exchangeCodeForTokens({ - provider: body.provider, - code: body.code, - clientId: resolved.clientId, - clientSecret: resolved.clientSecret, - redirectUri: body.redirect_uri, - tokenUrl: resolved.method.tokenUrl, - tokenEndpointAuthMethod: resolved.method.tokenEndpointAuthMethod, - codeVerifier: body.code_verifier, - }); - if (!tokens) { + const organizationId = c.get("brokerOrgId"); + const sql = getDb(); + + // Scope check: the connection MUST belong to the PAT's org. A connection in + // another org (or a non-existent one) is indistinguishable from "not found" + // to the caller — return 403 either way so org membership can't be probed. + const rows = await sql` + SELECT id, auth_profile_id, app_auth_profile_id + FROM connections + WHERE id = ${raw.connection_id} + AND organization_id = ${organizationId} + AND deleted_at IS NULL + LIMIT 1 + `; + if (rows.length === 0) { return c.json( - { error: "exchange_failed", error_description: "Token exchange failed" }, - 502, + { + error: "forbidden", + error_description: "Connection not found in this organization", + }, + 403, ); } - return c.json({ - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken, - expires_in: tokens.expiresIn, - scope: tokens.scope, - token_type: tokens.tokenType, - }); -}); + const connection = rows[0] as { + id: number; + auth_profile_id: number | null; + app_auth_profile_id: number | null; + }; -const RefreshBody = Type.Object({ - connector_key: Type.String({ minLength: 1 }), - provider: Type.String({ minLength: 1 }), - refresh_token: Type.String({ minLength: 1 }), -}); -const refreshValidator = TypeCompiler.Compile(RefreshBody); - -/** - * POST /broker/oauth/refresh - * Refresh an access token using the broker org's managed client_id/secret and - * SERVER-RESOLVED token endpoint (never caller-supplied). - */ -brokerRoutes.post("/oauth/refresh", async (c) => { - const body = await parseBody(c, refreshValidator); - if (isBodyError(body)) return body._error; - - const resolved = await resolveBrokerConfig(c, body); - if (isBodyError(resolved)) return resolved._error; + const { credentials } = await resolveExecutionAuth({ + organizationId, + connectionId: Number(connection.id), + authProfileId: connection.auth_profile_id, + appAuthProfileId: connection.app_auth_profile_id, + credentialDb: sql, + logContext: { broker_org_id: organizationId }, + logMessage: "Broker failed to resolve connection token", + }); - if (!resolved.method.tokenUrl) { + if (!credentials?.accessToken) { return c.json( { - error: "unknown_connector", - error_description: `Broker org does not manage connector '${body.connector_key}' / provider '${body.provider}' (no token endpoint)`, + error: "no_token", + error_description: "No access token available for this connection", }, - 400, - ); - } - - const refreshed = await new CredentialService(getDb()).refreshTokenGeneric({ - tokenUrl: resolved.method.tokenUrl, - clientId: resolved.clientId, - clientSecret: resolved.clientSecret ?? undefined, - refreshToken: body.refresh_token, - authMethod: resolved.method.tokenEndpointAuthMethod, - }); - if (!refreshed) { - return c.json( - { error: "refresh_failed", error_description: "Token refresh failed" }, 502, ); } logger.info( - { provider: body.provider, organizationId: c.get("brokerOrgId") }, - "Broker refreshed token", + { organizationId, connection_id: Number(connection.id) }, + "Broker resolved connection token", ); + + // Return ONLY the access token + expiry. Never the refresh token or secret. return c.json({ - access_token: refreshed.accessToken, - refresh_token: refreshed.refreshToken ?? null, - expires_in: Math.max( - 0, - Math.round((refreshed.expiresAt.getTime() - Date.now()) / 1000), - ), + access_token: credentials.accessToken, + expires_at: credentials.expiresAt ?? null, }); }); diff --git a/packages/server/src/connect/oauth-resolution.ts b/packages/server/src/connect/oauth-resolution.ts deleted file mode 100644 index 3f265c8f3..000000000 --- a/packages/server/src/connect/oauth-resolution.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Shared OAuth resolution helpers for the Connect flow and the OAuth broker. - * - * Two pieces of resolution both surfaces need: - * 1. The provider's OAuth method (endpoints + auth method + credential key - * names) for a connector — resolved SERVER-SIDE from the org's own - * `connector_definitions` row, never from a request body. - * 2. The org's managed `oauth_app` client_id/secret for that provider. - * - * The broker reuses these against ITS own org (so the caller can never - * influence the endpoints or leak the secret); the local Connect flow uses - * them against the local org. One implementation each, no parallel queries. - */ - -import { getDb } from "../db/client"; -import { - getAuthProfileById, - getPrimaryAuthProfileForKind, - normalizeAuthValues, -} from "../utils/auth-profiles"; -import { - type ConnectorAuthOAuthMethod, - getOAuthAuthMethods, - normalizeConnectorAuthSchema, -} from "../utils/connector-auth"; - -/** - * Resolve the OAuth method (endpoints, auth method, credential key names) for a - * connector + provider from the org's `connector_definitions`. Prefers the - * org's own row over a global (null-org) one. Returns `null` when the connector - * or a matching oauth method isn't found — the caller refuses to act on a - * connector/provider the org doesn't manage. Endpoints come from HERE, never - * from a request body. - */ -export async function resolveConnectorOAuthMethod(params: { - organizationId: string; - connectorKey: string; - provider: string; -}): Promise { - const sql = getDb(); - const rows = await sql` - SELECT auth_schema - FROM connector_definitions - WHERE key = ${params.connectorKey} - AND status = 'active' - AND (organization_id = ${params.organizationId} OR organization_id IS NULL) - ORDER BY CASE WHEN organization_id = ${params.organizationId} THEN 0 ELSE 1 END - LIMIT 1 - `; - if (rows.length === 0) return null; - - const authSchema = normalizeConnectorAuthSchema( - (rows[0] as { auth_schema: unknown }).auth_schema, - ); - return ( - getOAuthAuthMethods(authSchema).find( - (m) => m.provider.toLowerCase() === params.provider.toLowerCase(), - ) ?? null - ); -} - -/** - * Resolve OAuth client_id/secret from the org's managed `oauth_app` profile. - * - * Resolution order: - * 1. A specific selected app profile (by id), when provided. - * 2. The org's primary managed `oauth_app` for the connector/provider. - * - * The credential KEY NAMES come from the (server-resolved) connector config - * when known, with the provider-uppercase default as a fallback. - */ -export async function resolveOAuthClientCredentials(params: { - organizationId: string; - connectorKey: string; - provider: string; - appAuthProfileId?: number | null; - clientIdKey?: string; - clientSecretKey?: string; -}): Promise<{ clientId: string | null; clientSecret: string | null }> { - const providerUpper = params.provider.toUpperCase(); - const clientIdKey = params.clientIdKey || `${providerUpper}_CLIENT_ID`; - const clientSecretKey = - params.clientSecretKey || `${providerUpper}_CLIENT_SECRET`; - - const appProfile = - (params.appAuthProfileId - ? await getAuthProfileById(params.organizationId, params.appAuthProfileId) - : null) ?? - (await getPrimaryAuthProfileForKind({ - organizationId: params.organizationId, - connectorKey: params.connectorKey, - profileKind: "oauth_app", - provider: params.provider, - })); - - const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); - return { - clientId: authValues[clientIdKey] ?? null, - clientSecret: authValues[clientSecretKey] ?? null, - }; -} diff --git a/packages/server/src/connect/routes.ts b/packages/server/src/connect/routes.ts index dafce4991..aa7565791 100644 --- a/packages/server/src/connect/routes.ts +++ b/packages/server/src/connect/routes.ts @@ -21,10 +21,8 @@ import { createAuth } from '../auth'; import { getDb } from '../db/client'; import type { Env } from '../index'; import { - type BrokerRef, ensureUniqueAuthProfileSlug, getAuthProfileById, - getBrokerRef, getPrimaryAuthProfileForKind, normalizeAuthProfileSlug, normalizeAuthValues, @@ -36,7 +34,6 @@ import { mergeOAuthScopeAuthData, normalizeScopeList } from '../auth/oauth/scope import { createSyncRun } from '../utils/queue-helpers'; import { ACTIVE_RUN_STATUSES, runStatusLiteral } from '../utils/run-statuses'; import { buildConnectionsUrl, getOrganizationSlug, getPublicWebUrl } from '../utils/url-builder'; -import { resolveOAuthClientCredentials } from './oauth-resolution'; import { buildAuthorizationUrl, exchangeCodeForTokens, @@ -544,56 +541,9 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { return c.json({ error: 'OAuth provider not configured for this connector' }, 400); } - const source = await resolveCredentialSource(tokenRow, authConfig); + const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); - const baseUrl = getBaseUrl(c); - const redirectUri = `${baseUrl}/connect/oauth/callback`; - // Reuse the persisted verifier if one exists; only generate (and persist) - // when missing. Regenerating on a repeat /oauth/start would build the - // challenge from a verifier the callback never sees (the persisted one is - // unchanged when needsUpdate is false), breaking the PKCE handshake. - const pkceCodeVerifier = authConfig.usePkce - ? (authConfig.pkceCodeVerifier ?? buildPkceVerifier()) - : undefined; - - const needsUpdate = - authConfig.redirectUri !== redirectUri || (authConfig.usePkce && !authConfig.pkceCodeVerifier); - if (needsUpdate) { - const sql = getDb(); - await sql` - UPDATE connect_tokens - SET auth_config = ${sql.json({ - ...authConfig, - redirectUri, - ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), - })} - WHERE token = ${token} - `; - } - - // Broker delegation: when the backing oauth_app profile is a broker-ref, the - // broker holds the client_id + resolves the provider endpoints server-side, - // so build the authorization URL remotely. Otherwise use local credentials. - if (source.kind === 'broker') { - const brokerResult = await callBroker<{ authorization_url: string }>( - source.broker, - 'authorize-url', - { - connector_key: tokenRow.connector_key, - provider: authConfig.provider, - redirect_uri: redirectUri, - scopes: authConfig.scopes ?? [], - state: token, - ...(pkceCodeVerifier ? { code_challenge: buildPkceChallenge(pkceCodeVerifier) } : {}), - } - ); - if (!brokerResult?.authorization_url) { - return c.json({ error: 'Broker failed to build authorization URL' }, 502); - } - return c.redirect(brokerResult.authorization_url); - } - - if (!source.clientId) { + if (!clientId) { return c.json( { error: @@ -604,7 +554,7 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { ); } - if (authConfig.tokenEndpointAuthMethod !== 'none' && !source.clientSecret) { + if (authConfig.tokenEndpointAuthMethod !== 'none' && !clientSecret) { return c.json( { error: @@ -615,9 +565,30 @@ connectRoutes.get('/:token/oauth/start', requireConnectToken, async (c) => { ); } + const baseUrl = getBaseUrl(c); + const redirectUri = `${baseUrl}/connect/oauth/callback`; + const pkceCodeVerifier = authConfig.usePkce ? buildPkceVerifier() : undefined; + + const needsUpdate = + authConfig.redirectUri !== redirectUri || (authConfig.usePkce && !authConfig.pkceCodeVerifier); + + if (needsUpdate) { + const effectiveAuthConfig = { + ...authConfig, + redirectUri, + ...(pkceCodeVerifier ? { pkceCodeVerifier } : {}), + }; + const sql = getDb(); + await sql` + UPDATE connect_tokens + SET auth_config = ${sql.json(effectiveAuthConfig)} + WHERE token = ${token} + `; + } + const authUrl = buildAuthorizationUrl({ provider: authConfig.provider, - clientId: source.clientId, + clientId, redirectUri, scopes: authConfig.scopes ?? [], state: token, @@ -730,54 +701,25 @@ async function handleOAuthCallback( } const sql = getDb(); - const baseUrl = getBaseUrl(c); + const { clientId, clientSecret } = await resolveOAuthCredentialsForToken(tokenRow, authConfig); - // Broker delegation: when the backing oauth_app profile is a broker-ref, the - // broker holds the client_secret + resolves the token endpoint server-side, - // so exchange the code remotely and use the returned user tokens. Storage - // below is identical to the local path. - const source = await resolveCredentialSource(tokenRow, authConfig); - - let tokens: Awaited> = null; - if (source.kind === 'broker') { - const exchanged = await callBroker<{ - access_token: string; - refresh_token: string | null; - expires_in: number | null; - scope: string | null; - token_type?: string; - }>(source.broker, 'exchange', { - connector_key: tokenRow.connector_key, - provider: authConfig.provider, - code, - redirect_uri: redirectUri, - ...(authConfig.pkceCodeVerifier ? { code_verifier: authConfig.pkceCodeVerifier } : {}), - }); - if (exchanged?.access_token) { - tokens = { - accessToken: exchanged.access_token, - refreshToken: exchanged.refresh_token ?? null, - expiresIn: exchanged.expires_in ?? null, - scope: exchanged.scope ?? null, - tokenType: exchanged.token_type ?? 'Bearer', - }; - } - } else { - if (!source.clientId) { - return c.json({ error: 'OAuth client credentials not found' }, 500); - } - tokens = await exchangeCodeForTokens({ - provider: authConfig.provider, - code, - clientId: source.clientId, - clientSecret: source.clientSecret, - redirectUri, - tokenUrl: authConfig.tokenUrl, - tokenEndpointAuthMethod: authConfig.tokenEndpointAuthMethod, - codeVerifier: authConfig.pkceCodeVerifier, - }); + if (!clientId) { + return c.json({ error: 'OAuth client credentials not found' }, 500); } + const baseUrl = getBaseUrl(c); + + const tokens = await exchangeCodeForTokens({ + provider: authConfig.provider, + code, + clientId, + clientSecret, + redirectUri, + tokenUrl: authConfig.tokenUrl, + tokenEndpointAuthMethod: authConfig.tokenEndpointAuthMethod, + codeVerifier: authConfig.pkceCodeVerifier, + }); + if (!tokens) { return c.redirect(`${baseUrl}/connect/${token}?error=token_exchange_failed`); } @@ -964,83 +906,22 @@ async function handleOAuthCallback( return c.redirect(`${baseUrl}`); } -/** - * The credential source backing a connect token's OAuth flow — the single seam - * the start + callback paths branch on: - * - `local` → the org holds the client_id/secret directly. - * - `broker` → the backing oauth_app profile is a broker-ref; the - * secret-requiring steps are delegated to that remote broker, which holds - * the client_id/secret and resolves the provider endpoints server-side. - */ -type CredentialSource = - | { kind: 'local'; clientId: string | null; clientSecret: string | null } - | { kind: 'broker'; broker: BrokerRef }; - -/** - * Resolve the {@link CredentialSource} for a connect token in ONE lookup of the - * backing oauth_app profile (selected app profile first, then the org's primary - * managed oauth_app for the connector/provider). A `__broker` ref short-circuits - * to broker delegation; otherwise the profile's local client credentials are - * returned. This replaces the previously scattered broker-ref checks. - */ -async function resolveCredentialSource( +async function resolveOAuthCredentialsForToken( tokenRow: { connection_id: number | null; organization_id: string; connector_key: string }, authConfig: OAuthAuthConfig -): Promise { +): Promise<{ clientId: string | null; clientSecret: string | null }> { const appAuthProfileId = await fetchAppAuthProfileId( tokenRow.connection_id, tokenRow.organization_id ); - const appProfile = - (appAuthProfileId - ? await getAuthProfileById(tokenRow.organization_id, appAuthProfileId) - : null) ?? - (await getPrimaryAuthProfileForKind({ - organizationId: tokenRow.organization_id, - connectorKey: tokenRow.connector_key, - profileKind: 'oauth_app', - provider: authConfig.provider, - })); - - const broker = getBrokerRef(appProfile?.auth_data ?? null); - if (broker) return { kind: 'broker', broker }; - - const { clientId, clientSecret } = await resolveOAuthClientCredentials({ - organizationId: tokenRow.organization_id, - connectorKey: tokenRow.connector_key, - provider: authConfig.provider, + return resolveOAuthClientCredentials( + authConfig.provider, + tokenRow.connector_key, + tokenRow.organization_id, appAuthProfileId, - clientIdKey: authConfig.clientIdKey, - clientSecretKey: authConfig.clientSecretKey, - }); - return { kind: 'local', clientId, clientSecret }; -} - -/** POST a JSON body to a broker endpoint with the broker PAT. */ -async function callBroker( - broker: BrokerRef, - endpoint: string, - body: Record -): Promise { - try { - const response = await fetch(`${broker.url}/broker/oauth/${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${broker.pat}`, - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const text = await response.text(); - logger.error({ endpoint, status: response.status, body: text }, 'Broker OAuth call failed'); - return null; - } - return (await response.json()) as T; - } catch (error) { - logger.error({ endpoint, error }, 'Broker OAuth call error'); - return null; - } + authConfig.clientIdKey, + authConfig.clientSecretKey + ); } async function fetchAppAuthProfileId( @@ -1061,4 +942,37 @@ async function fetchAppAuthProfileId( : null; } +/** + * Resolve OAuth client ID/secret from: + * 1. Selected OAuth app auth profile + * 2. Primary org-level OAuth app auth profile for the connector/provider + */ +async function resolveOAuthClientCredentials( + provider: string, + connectorKey: string, + organizationId: string, + appAuthProfileId?: number | null, + clientIdKey?: string, + clientSecretKey?: string +): Promise<{ clientId: string | null; clientSecret: string | null }> { + const providerUpper = provider.toUpperCase(); + const resolvedClientIdKey = clientIdKey || `${providerUpper}_CLIENT_ID`; + const resolvedClientSecretKey = clientSecretKey || `${providerUpper}_CLIENT_SECRET`; + + const appProfile = + (appAuthProfileId ? await getAuthProfileById(organizationId, appAuthProfileId) : null) ?? + (await getPrimaryAuthProfileForKind({ + organizationId, + connectorKey, + profileKind: 'oauth_app', + provider, + })); + + const authValues = normalizeAuthValues(appProfile?.auth_data ?? {}); + const clientId = authValues[resolvedClientIdKey] ?? null; + const clientSecret = authValues[resolvedClientSecretKey] ?? null; + + return { clientId, clientSecret }; +} + export { connectRoutes }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 81c7be704..7eafd9cbf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -553,10 +553,11 @@ app.route('/mcp', oauthRoutes); app.route('/connect', connectRoutes); /** - * OAuth Broker routes (SPIKE) — PAT-gated. A remote Lobu instance acting as a - * broker performs the secret-requiring OAuth steps (authorize-url / exchange / - * refresh) on behalf of a local instance whose oauth_app profile is a - * broker-ref. The client_secret never leaves the broker. + * OAuth Broker route (SPIKE) — PAT-gated, grant-on-broker. A remote Lobu + * instance acting as a broker OWNS the OAuth grant + managed client secret; a + * local instance whose oauth_app profile is a broker-ref fetches a fresh access + * token at runtime via POST /broker/oauth/token. The client_secret + refresh + * token never leave the broker. */ app.route('/broker', brokerRoutes); diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index 78533e19d..837891559 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -48,11 +48,13 @@ interface BrowserSessionReadiness extends BrowserSessionSummary { } /** - * Reference to a remote Lobu "broker" instance that holds the real provider - * OAuth app credentials. Encoded inside an `oauth_app` profile's `auth_data` - * under the `__broker` key (instead of client_id/secret). The local instance - * delegates the secret-requiring OAuth steps (authorize-url build, code - * exchange, refresh) to the broker over HTTP, authenticating with `pat`. + * Reference to a remote Lobu "broker" instance that OWNS the OAuth grant for a + * connection. Encoded inside an `oauth_app` profile's `auth_data` under the + * `__broker` key (instead of client_id/secret). The broker holds the managed + * client_id/secret AND the user's grant (oauth_account); the local instance + * fetches a fresh access token at runtime from the broker over HTTP (POST + * `/broker/oauth/token`), authenticating with `pat`. `connection_id` is the + * broker-side connection whose grant backs this local connection. */ export interface BrokerRef { /** Broker base URL, e.g. `https://broker.lobu.ai` (no `/broker` suffix). */ @@ -61,13 +63,15 @@ export interface BrokerRef { org: string; /** Lobu Personal Access Token (`owl_pat_*`) the broker authenticates. */ pat: string; + /** The broker-side connection id whose stored grant backs this profile. */ + connection_id: number; } /** * Extract a {@link BrokerRef} from an `oauth_app` profile's `auth_data`, or * `null` when the profile carries local client credentials instead. A - * broker-ref profile has a `__broker` object with `url`/`org`/`pat` and NO - * client_id/secret keys. + * broker-ref profile has a `__broker` object with `url`/`org`/`pat`/ + * `connection_id` and NO client_id/secret keys. */ export function getBrokerRef(authData: unknown): BrokerRef | null { if (typeof authData === 'string') { @@ -80,17 +84,25 @@ export function getBrokerRef(authData: unknown): BrokerRef | null { if (!authData || typeof authData !== 'object' || Array.isArray(authData)) return null; const broker = (authData as Record).__broker; if (!broker || typeof broker !== 'object' || Array.isArray(broker)) return null; - const { url, org, pat } = broker as Record; + const { url, org, pat, connection_id } = broker as Record; + const connectionId = typeof connection_id === 'number' ? connection_id : Number(connection_id); if ( typeof url !== 'string' || typeof org !== 'string' || typeof pat !== 'string' || url.trim().length === 0 || - pat.trim().length === 0 + pat.trim().length === 0 || + !Number.isInteger(connectionId) || + connectionId <= 0 ) { return null; } - return { url: url.trim().replace(/\/+$/, ''), org: org.trim(), pat: pat.trim() }; + return { + url: url.trim().replace(/\/+$/, ''), + org: org.trim(), + pat: pat.trim(), + connection_id: connectionId, + }; } export function normalizeAuthValues(raw: unknown): Record { diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index e541e8997..9215f2b54 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -1,7 +1,7 @@ import { CredentialService } from '../auth/credentials'; import { getBuiltinProviderConfig } from '../connect/oauth-providers'; import { type DbClient, getDb } from '../db/client'; -import { getAuthProfileById, normalizeAuthValues } from './auth-profiles'; +import { type BrokerRef, getAuthProfileById, getBrokerRef, normalizeAuthValues } from './auth-profiles'; import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from './connector-auth'; import { parseJsonObject } from '@lobu/core'; import { errorMessage } from './errors'; @@ -43,6 +43,34 @@ export async function resolveExecutionAuth( let credentials: ExecutionOAuthCredentials | null = null; + // Broker-backed connection: the grant lives on a REMOTE Lobu "broker", which + // holds the managed client_id/secret + the user's refresh token. Fetch a + // fresh access token from the broker at runtime instead of reading/refreshing + // a local grant. ONLY this branch changes for broker-backed connections; the + // local (non-broker) path below is unchanged. + const broker = getBrokerRef(appAuthProfile?.auth_data ?? null); + if (broker) { + const accessToken = await fetchBrokerAccessToken(broker, { + ...params.logContext, + connection_id: params.connectionId, + }); + if (accessToken) { + credentials = { + provider: appAuthProfile?.provider ?? 'broker', + 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 +143,47 @@ export async function resolveExecutionAuth( }; } +/** + * Fetch a fresh access token for a broker-backed connection from the remote + * broker. The broker holds the grant + client secret and refreshes server-side; + * we only ever receive `{ access_token, expires_at }`. No caller-supplied URLs: + * the broker base URL + PAT + connection id come from the trusted broker-ref + * stored on the org's own oauth_app profile. Returns null on any failure so the + * connection simply resolves without credentials (fail-soft, like the local + * path). + */ +async function fetchBrokerAccessToken( + broker: BrokerRef, + logContext: Record +): Promise<{ access_token: string; expires_at: string | null } | null> { + try { + const response = await fetch(`${broker.url}/broker/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${broker.pat}`, + }, + body: JSON.stringify({ connection_id: broker.connection_id }), + }); + if (!response.ok) { + logger.warn( + { ...logContext, status: response.status }, + 'Broker token fetch failed' + ); + return null; + } + const body = (await response.json()) as { + access_token?: string; + expires_at?: string | null; + }; + if (!body.access_token) return null; + return { access_token: body.access_token, expires_at: body.expires_at ?? null }; + } catch (error) { + logger.warn({ ...logContext, error: errorMessage(error) }, 'Broker token fetch error'); + return null; + } +} + async function resolveExecutionOAuthConfig( organizationId: string, connectionId: number, From 75017983723cb2f74e190b1122c47ba823a99ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 14:17:48 +0100 Subject: [PATCH 05/15] fix(connect): require broker:token scope on /broker/oauth/token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org membership alone authorized any member PAT to fetch raw OAuth access tokens for any connection in the org (token exfiltration by a low-privilege member). Enforce least-privilege via a dedicated PAT scope instead of an admin role (the runtime fetch happens in automated/worker runs, so an admin *user* would be wrong). - scopes.ts: add `broker:token` to AVAILABLE_SCOPES (NOT in DEFAULT_SCOPES, so a broad mcp:* PAT never carries it). - pat-auth.ts: authenticatePat now also returns the PAT's `scopes`. - broker-routes.ts: the /oauth/* gate rejects (403, insufficient_scope) any PAT lacking `broker:token` — in ADDITION to the existing org-scope 403. - execution-context.ts: document that the __broker.pat must be minted with `broker:token` (lobu token create --scope broker:token). - test-fixtures.ts: createTestPAT accepts an optional scope. - oauth-broker.test.ts: happy paths mint the PAT with broker:token; new case — a valid same-org PAT WITHOUT broker:token -> 403. gateway.ts path unchanged (it ignores the new scopes field). --- .../connectors/oauth-broker.test.ts | 26 ++++++++++++++++++- .../src/__tests__/setup/test-fixtures.ts | 10 ++++--- packages/server/src/auth/oauth/scopes.ts | 5 ++++ packages/server/src/auth/pat-auth.ts | 3 +++ packages/server/src/connect/broker-routes.ts | 26 ++++++++++++++++++- .../server/src/utils/execution-context.ts | 4 +++ 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 981f761cd..186841079 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -125,7 +125,10 @@ afterAll(async () => { interface SeededConnection { orgId: string; userId: string; + /** PAT minted WITH the `broker:token` scope — the authorized happy-path PAT. */ pat: string; + /** PAT for the SAME org/user but WITHOUT `broker:token` — must be rejected (403). */ + patNoScope: string; connectionId: number; } @@ -215,11 +218,18 @@ async function seedBrokerConnection( RETURNING id `) as unknown as Array<{ id: number }>; - const pat = await createTestPAT(user.id, org.id); + // The broker-ref's PAT carries the least-privilege `broker:token` scope; a + // sibling PAT for the same org/user WITHOUT it proves org membership alone is + // insufficient. + const pat = await createTestPAT(user.id, org.id, { scope: "broker:token" }); + const patNoScope = await createTestPAT(user.id, org.id, { + scope: "mcp:read mcp:write", + }); return { orgId: org.id, userId: user.id, pat: pat.token, + patNoScope: patNoScope.token, connectionId: Number(connRows[0].id), }; } @@ -320,6 +330,20 @@ describe("SPIKE: grant-on-broker — POST /broker/oauth/token", () => { }); expect(res.status).toBe(401); }); + + it("rejects a valid org-member PAT WITHOUT the broker:token scope (403)", async () => { + // Same org + same connection as the happy path, but the PAT carries only + // `mcp:read mcp:write`. Org membership is not enough — least-privilege. + const { patNoScope, connectionId } = await seedBrokerConnection("Broker Org"); + const app = buildBrokerApp(); + const res = await tokenRequest(app, { + pat: patNoScope, + body: { connection_id: connectionId }, + }); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body.error).toBe("insufficient_scope"); + }); }); describe("SPIKE: grant-on-broker — local runtime hook", () => { 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/oauth/scopes.ts b/packages/server/src/auth/oauth/scopes.ts index 91ba5c3e8..98482c7f2 100644 --- a/packages/server/src/auth/oauth/scopes.ts +++ b/packages/server/src/auth/oauth/scopes.ts @@ -12,6 +12,11 @@ export const AVAILABLE_SCOPES = [ 'mcp:admin', 'profile:read', 'device_worker:run', + // Least-privilege scope for the OAuth broker runtime token fetch + // (POST /broker/oauth/token). Deliberately NOT in DEFAULT_SCOPES so a broad + // member PAT cannot exfiltrate connection access tokens — a broker-ref's PAT + // must be minted explicitly with this scope. + 'broker: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 index 391728bd1..eac92eb81 100644 --- a/packages/server/src/auth/pat-auth.ts +++ b/packages/server/src/auth/pat-auth.ts @@ -25,6 +25,8 @@ 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. */ @@ -148,6 +150,7 @@ export async function authenticatePat( ok: true, userId: user.id, organizationId: patInfo.organizationId, + scopes: patInfo.scopes, user, patInfo, }; diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts index ffa93fe1a..8f56edf50 100644 --- a/packages/server/src/connect/broker-routes.ts +++ b/packages/server/src/connect/broker-routes.ts @@ -37,10 +37,21 @@ type BrokerEnv = { const brokerRoutes = new Hono(); +/** + * The least-privilege scope a PAT must carry to fetch connection access tokens + * from the broker. Deliberately separate from the default `mcp:*` scopes so a + * broad org-member PAT cannot exfiltrate every connection's tokens — only a PAT + * minted explicitly with `broker:token` (the scope a broker-ref's PAT is + * expected to carry; see execution-context.ts) is authorized. + */ +const BROKER_TOKEN_SCOPE = "broker:token"; + /** * PAT auth for broker calls — the single shared `authenticatePat` gate. No / * invalid / null-org / cross-tenant PAT short-circuits 401/403; on success the - * resolved org is stashed on the context. + * resolved org is stashed on the context. Org membership ALONE is not enough: + * the PAT must also carry the `broker:token` scope (403 otherwise), so a broad + * member PAT cannot reach the connection-token endpoint. */ brokerRoutes.use("/oauth/*", async (c, next) => { const bearerValue = extractPatBearer(c.req.header("Authorization")); @@ -59,6 +70,19 @@ brokerRoutes.use("/oauth/*", async (c, next) => { ); } + // Least-privilege: a valid, org-scoped PAT is necessary but not sufficient — + // it must also be granted `broker:token`. A `mcp:read mcp:write` member PAT + // is rejected here (403) before any connection is looked up. + if (!result.scopes.includes(BROKER_TOKEN_SCOPE)) { + return c.json( + { + error: "insufficient_scope", + error_description: `PAT is missing the '${BROKER_TOKEN_SCOPE}' scope`, + }, + 403, + ); + } + c.set("brokerOrgId", result.organizationId); return next(); }); diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 9215f2b54..90497a822 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -151,6 +151,10 @@ export async function resolveExecutionAuth( * stored on the org's own oauth_app profile. Returns null on any failure so the * connection simply resolves without credentials (fail-soft, like the local * path). + * + * The `__broker.pat` MUST be minted with the `broker:token` scope — the broker's + * `/broker/oauth/token` gate rejects (403) any PAT lacking it, so a broad member + * PAT cannot be used here. Mint with `lobu token create --scope broker:token`. */ async function fetchBrokerAccessToken( broker: BrokerRef, From c8d34da2ee62ffea92d711c3743a3aa01f1b5197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 14:49:35 +0100 Subject: [PATCH 06/15] refactor(connect): first-class oauth_broker profile kind Replace the __broker magic-key hack (a reserved __-prefixed object smuggled into an oauth_app profile's flat credential blob) with a real profile_kind = 'oauth_broker' in auth_profiles. The whole profile IS the broker descriptor; its auth_data holds typed, named fields (broker_url / broker_org / broker_connection_id) plus the PAT (broker_pat). No __-prefixed keys anywhere. - Migration adds 'oauth_broker' to the auth_profiles.profile_kind CHECK (DROP + re-ADD, with a reversible down). Applies up + down clean on a fresh DB. - Typed CredentialSource + resolveCredentialSource accessor: loads the connection's app_auth_profile and branches on profile_kind (oauth_broker -> broker, oauth_app -> local). Readers never sniff keys. - execution-context resolves broker creds via parseBrokerCredential gated on profile_kind === 'oauth_broker'; the non-broker token path is unchanged. - manage_auth_profiles validates broker fields on create + update for oauth_broker (rejects missing broker_url/org/pat/connection_id). - Connection linkage reuses the existing app_auth_profile_id FK: a broker-backed connection points it at an oauth_broker profile. - Deleted getBrokerRef / BrokerRef and every __broker code path. Security properties intact: PAT auth + broker:token scope check + org-scope 403 on /broker/oauth/token; runtime hook only affects broker-backed connections; no secret leaves the broker. --- ...120000_auth_profiles_oauth_broker_kind.sql | 44 ++++++ .../connectors/oauth-broker.test.ts | 149 ++++++++++++++++-- .../src/sandbox/namespaces/auth-profiles.ts | 3 +- .../tools/admin/helpers/connection-helpers.ts | 20 ++- .../src/tools/admin/manage_auth_profiles.ts | 50 ++++++ packages/server/src/utils/auth-profiles.ts | 129 +++++++++++---- .../server/src/utils/execution-context.ts | 37 +++-- 7 files changed, 370 insertions(+), 62 deletions(-) create mode 100644 db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql diff --git a/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql b/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql new file mode 100644 index 000000000..5fd27b19a --- /dev/null +++ b/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql @@ -0,0 +1,44 @@ +-- migrate:up + +-- Add `oauth_broker` as a first-class auth_profiles.profile_kind. An +-- oauth_broker profile IS a broker descriptor: its auth_data holds the typed, +-- named fields broker_url / broker_org / broker_connection_id plus the PAT +-- (broker_pat). A broker-backed connection points its existing +-- app_auth_profile_id FK at an oauth_broker profile (instead of an oauth_app), +-- and the runtime fetches a fresh access token from the broker at execution +-- time. This replaces the prior `__broker` magic-key hack that smuggled a +-- broker ref inside an oauth_app profile's credential blob. +-- +-- Postgres can't extend a CHECK in place, so drop the existing profile_kind +-- check and re-add it with `oauth_broker` appended to the value list. +ALTER TABLE auth_profiles + DROP CONSTRAINT IF EXISTS auth_profiles_profile_kind_check; + +ALTER TABLE auth_profiles + ADD CONSTRAINT auth_profiles_profile_kind_check + CHECK (profile_kind = ANY (ARRAY[ + 'env'::text, + 'oauth_app'::text, + 'oauth_account'::text, + 'browser_session'::text, + 'interactive'::text, + 'oauth_broker'::text + ])); + +-- migrate:down + +-- Revert to the original value list (without `oauth_broker`). Any oauth_broker +-- rows must be removed first or the re-ADD will fail; rolling back this +-- migration is only valid before the feature ships rows in prod. +ALTER TABLE auth_profiles + DROP CONSTRAINT IF EXISTS auth_profiles_profile_kind_check; + +ALTER TABLE auth_profiles + ADD CONSTRAINT auth_profiles_profile_kind_check + CHECK (profile_kind = ANY (ARRAY[ + 'env'::text, + 'oauth_app'::text, + 'oauth_account'::text, + 'browser_session'::text, + 'interactive'::text + ])); diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 186841079..6380db41e 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -19,9 +19,10 @@ * token + client secret never appear in the response. * 3. Scope: a PAT for org B cannot fetch org A's connection token (403). No * PAT → 401, bad PAT → 401, malformed body → 400. - * 4. End-to-end runtime hook: a LOCAL broker-backed connection (oauth_app = - * `__broker` ref, broker.url = the in-process broker server) resolves its - * access token through the broker via `resolveExecutionAuth`. + * 4. End-to-end runtime hook: a LOCAL broker-backed connection (app profile = + * a first-class `oauth_broker` profile whose typed fields point broker_url + * at the in-process broker server) resolves its access token through the + * broker via `resolveExecutionAuth`. */ import { serve } from "@hono/node-server"; @@ -29,6 +30,8 @@ import { Hono } from "hono"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { brokerRoutes } from "../../../connect/broker-routes"; import type { Env } from "../../../index"; +import { manageAuthProfiles } from "../../../tools/admin/manage_auth_profiles"; +import type { ToolContext } from "../../../tools/registry"; import { createAuthProfile } from "../../../utils/auth-profiles"; import { resolveExecutionAuth } from "../../../utils/execution-context"; import { initWorkspaceProvider } from "../../../workspace"; @@ -376,24 +379,33 @@ describe("SPIKE: grant-on-broker — local runtime hook", () => { organizationId: localOrg.id, connectorKey: "demo.oauth", displayName: "Broker-backed Demo App", - profileKind: "oauth_app", + profileKind: "oauth_broker", provider: "demo", authData: { - __broker: { - url: brokerBaseUrl, - org: "broker-org", - pat: broker.pat, - connection_id: broker.connectionId, - }, + broker_url: brokerBaseUrl, + broker_org: "broker-org", + broker_pat: broker.pat, + broker_connection_id: broker.connectionId, }, }); - // The broker-ref must survive normalization (no client_id/secret keys). + // The typed broker fields survive normalization (and there are NO + // client_id/secret keys and NO `__`-prefixed magic keys on the profile). const stored = (await sql` - SELECT auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} - `) as unknown as Array<{ auth_data: Record }>; - expect((stored[0].auth_data as { __broker?: unknown }).__broker).toBeTruthy(); + SELECT profile_kind, auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} + `) as unknown as Array<{ + profile_kind: string; + auth_data: Record; + }>; + expect(stored[0].profile_kind).toBe("oauth_broker"); + expect(stored[0].auth_data.broker_url).toBe(brokerBaseUrl); + expect(stored[0].auth_data.broker_org).toBe("broker-org"); + expect(stored[0].auth_data.broker_pat).toBe(broker.pat); + expect(stored[0].auth_data.broker_connection_id).toBe(broker.connectionId); expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); + expect( + Object.keys(stored[0].auth_data).some((k) => k.startsWith("__")), + ).toBe(false); // A local connection backed by the broker-ref app profile (no grant locally). const localConnRows = (await sql` @@ -423,3 +435,112 @@ describe("SPIKE: grant-on-broker — local runtime hook", () => { expect(resolved.connectionCredentials).toEqual({}); }); }); + +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; +} + +describe("oauth_broker profile — create validation via manage_auth_profiles", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + async function seedOrgWithConnector(): Promise<{ + ctx: ToolContext; + orgId: string; + }> { + const org = await createTestOrganization({ name: "Broker Validate Org" }); + const user = await createTestUser({ name: "Broker Validate User" }); + await addUserToOrganization(user.id, org.id, "owner"); + await createTestConnectorDefinition({ + key: "demo.oauth", + name: "Demo OAuth", + organization_id: org.id, + auth_schema: { + methods: [ + { + type: "oauth", + provider: "demo", + requiredScopes: ["read"], + clientIdKey: "DEMO_CLIENT_ID", + clientSecretKey: "DEMO_CLIENT_SECRET", + }, + ], + }, + feeds_schema: { items: {} }, + }); + return { ctx: ctxFor(org.id, user.id), orgId: org.id }; + } + + it("creates an oauth_broker profile when all typed broker fields are present", async () => { + const { ctx, orgId } = await seedOrgWithConnector(); + const res = await manageAuthProfiles( + { + action: "create_auth_profile", + connector_key: "demo.oauth", + profile_kind: "oauth_broker", + display_name: "Broker Ref", + slug: "broker-ref", + auth_data: { + broker_url: "https://broker.lobu.ai", + broker_org: "broker-org", + broker_pat: "owl_pat_example", + broker_connection_id: 42, + }, + }, + TEST_ENV, + ctx, + ); + expect("auth_profile" in res && res.auth_profile).toBeTruthy(); + if ("auth_profile" in res) { + expect(res.auth_profile.profile_kind).toBe("oauth_broker"); + } + + // Persisted with the typed fields and trailing-slash-normalized URL; the + // PAT is never echoed back in the serialized profile. + const sql = getTestDb(); + const rows = (await sql` + SELECT auth_data FROM auth_profiles + WHERE organization_id = ${orgId} AND slug = 'broker-ref' + `) as unknown as Array<{ auth_data: Record }>; + expect(rows).toHaveLength(1); + expect(rows[0].auth_data.broker_connection_id).toBe(42); + expect(rows[0].auth_data.broker_pat).toBe("owl_pat_example"); + expect(JSON.stringify(res)).not.toContain("owl_pat_example"); + }); + + it("rejects an oauth_broker profile missing required fields", async () => { + const { ctx } = await seedOrgWithConnector(); + const res = await manageAuthProfiles( + { + action: "create_auth_profile", + connector_key: "demo.oauth", + profile_kind: "oauth_broker", + display_name: "Incomplete Broker Ref", + slug: "incomplete-broker-ref", + // Missing broker_pat + broker_connection_id. + auth_data: { + broker_url: "https://broker.lobu.ai", + broker_org: "broker-org", + }, + }, + TEST_ENV, + ctx, + ); + expect("error" in res).toBe(true); + if ("error" in res) { + expect(res.error).toContain("broker_pat"); + } + }); +}); diff --git a/packages/server/src/sandbox/namespaces/auth-profiles.ts b/packages/server/src/sandbox/namespaces/auth-profiles.ts index e7183855c..a09ac706c 100644 --- a/packages/server/src/sandbox/namespaces/auth-profiles.ts +++ b/packages/server/src/sandbox/namespaces/auth-profiles.ts @@ -20,7 +20,8 @@ export type AuthProfileKind = | "env" | "oauth_app" | "oauth_account" - | "browser_session"; + | "browser_session" + | "oauth_broker"; export interface AuthProfileCreateInput { profile_kind: AuthProfileKind; diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index 8c4cc6119..734031b10 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -456,15 +456,25 @@ 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 + // explicitly-supplied app profile slug may be an `oauth_app` (local client + // credentials) OR an `oauth_broker` (the grant lives on a remote broker); + // both attach via the same `app_auth_profile_id` FK. Resolve the slug + // without pinning a kind, then accept only those two app-level kinds. const needsAppAuth = authProfile.profile_kind === 'oauth_account' || !!params.appAuthProfileSlug; - const appAuthProfile = needsAppAuth - ? ((await resolveAuthProfileSlugToId({ + const explicitAppProfile = params.appAuthProfileSlug + ? await resolveAuthProfileSlugToId({ organizationId, slug: params.appAuthProfileSlug, - expectedKind: 'oauth_app', connectorKey, - })) ?? + }) + : null; + const appAuthProfile = needsAppAuth + ? ((explicitAppProfile && + (explicitAppProfile.profile_kind === 'oauth_app' || + explicitAppProfile.profile_kind === 'oauth_broker') + ? 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..f0258a401 100644 --- a/packages/server/src/tools/admin/manage_auth_profiles.ts +++ b/packages/server/src/tools/admin/manage_auth_profiles.ts @@ -25,6 +25,7 @@ import { listAuthProfiles, normalizeAuthProfileSlug, normalizeAuthValues, + parseBrokerCredential, revokeOAuthAppProfileAtomic, setDefaultAuthProfileForConnector, summarizeBrowserSessionAuthData, @@ -60,6 +61,7 @@ const ListAuthProfilesAction = Type.Object({ Type.Literal('oauth_app'), Type.Literal('oauth_account'), Type.Literal('browser_session'), + Type.Literal('oauth_broker'), ]) ), }); @@ -87,6 +89,7 @@ const CreateAuthProfileAction = Type.Object({ Type.Literal('oauth_app'), Type.Literal('oauth_account'), Type.Literal('browser_session'), + Type.Literal('oauth_broker'), ]), display_name: Type.String({ description: 'User-facing auth profile name' }), slug: Type.Optional( @@ -491,6 +494,39 @@ async function handleCreateAuthProfile( } const connectorKey: string = args.connector_key; + // oauth_broker: a first-class broker descriptor. The whole profile IS the + // broker ref — its auth_data holds the typed, named fields (broker_url, + // broker_org, broker_connection_id) plus the PAT (broker_pat). Validate that + // all four are present and well-formed; reject otherwise. The broker fields + // arrive via `auth_data` (broker_connection_id is numeric, so the string-only + // `credentials` map can't carry it). + if (args.profile_kind === 'oauth_broker') { + const broker = parseBrokerCredential(args.auth_data ?? {}); + if (!broker) { + return { + error: + 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', + }; + } + const provider = getOAuthMethods(connector.auth_schema)[0]?.provider ?? null; + const authProfile = await createAuthProfile({ + organizationId: ctx.organizationId, + connectorKey, + displayName: args.display_name, + slug: args.slug, + profileKind: 'oauth_broker', + authData: { + broker_url: broker.url, + broker_org: broker.org, + broker_pat: broker.pat, + broker_connection_id: broker.connectionId, + }, + provider: provider ? provider.toLowerCase() : null, + createdBy: ctx.userId ?? 'api', + }); + return { action: 'create_auth_profile', auth_profile: serializeAuthProfile(authProfile) }; + } + if (args.profile_kind === 'oauth_account') { const oauthMethod = getOAuthMethods(connector.auth_schema)[0]; if (!oauthMethod) { @@ -706,6 +742,20 @@ 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.`, }; } + + // oauth_broker profiles must always carry a complete, well-formed broker + // descriptor. An update that supplies auth_data must keep all four typed + // fields valid — otherwise normalizeAuthData would silently wipe them. + if ( + existingForRoleCheck.profile_kind === 'oauth_broker' && + args.auth_data !== undefined && + !parseBrokerCredential(args.auth_data) + ) { + return { + error: + 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', + }; + } } let authProfile = await updateAuthProfile({ diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index 837891559..8f65c4627 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -6,7 +6,8 @@ export type AuthProfileKind = | 'oauth_app' | 'oauth_account' | 'browser_session' - | 'interactive'; + | 'interactive' + | 'oauth_broker'; export type AuthProfileStatus = 'active' | 'pending_auth' | 'error' | 'revoked'; export type BrowserKind = 'chrome' | 'brave' | 'arc' | 'edge'; @@ -48,15 +49,15 @@ interface BrowserSessionReadiness extends BrowserSessionSummary { } /** - * Reference to a remote Lobu "broker" instance that OWNS the OAuth grant for a - * connection. Encoded inside an `oauth_app` profile's `auth_data` under the - * `__broker` key (instead of client_id/secret). The broker holds the managed - * client_id/secret AND the user's grant (oauth_account); the local instance - * fetches a fresh access token at runtime from the broker over HTTP (POST - * `/broker/oauth/token`), authenticating with `pat`. `connection_id` is the - * broker-side connection whose grant backs this local connection. + * The typed broker descriptor carried by an `oauth_broker` profile. The whole + * profile IS the broker reference: a remote Lobu "broker" instance OWNS the + * OAuth grant for a connection. The broker holds the managed client_id/secret + * AND the user's grant (oauth_account); the local instance fetches a fresh + * access token at runtime from the broker over HTTP (POST `/broker/oauth/token`), + * authenticating with `pat`. `connectionId` is the broker-side connection whose + * stored grant backs this local connection. */ -export interface BrokerRef { +export interface BrokerCredential { /** Broker base URL, e.g. `https://broker.lobu.ai` (no `/broker` suffix). */ url: string; /** Broker org slug the managed oauth_app lives under (informational). */ @@ -64,33 +65,58 @@ export interface BrokerRef { /** Lobu Personal Access Token (`owl_pat_*`) the broker authenticates. */ pat: string; /** The broker-side connection id whose stored grant backs this profile. */ - connection_id: number; + connectionId: number; } /** - * Extract a {@link BrokerRef} from an `oauth_app` profile's `auth_data`, or - * `null` when the profile carries local client credentials instead. A - * broker-ref profile has a `__broker` object with `url`/`org`/`pat`/ - * `connection_id` and NO client_id/secret keys. + * The two ways a connection's app-level credentials can resolve, discriminated + * by the backing `app_auth_profile`'s `profile_kind`: + * + * - `local` → an `oauth_app` profile holding client_id/secret on this org. + * - `broker` → an `oauth_broker` profile; the grant lives on a remote broker + * and a fresh access token is fetched at runtime. + * + * Readers branch on `kind` — never sniff keys out of the credential blob. + */ +export type CredentialSource = + | { kind: 'local'; clientId: string | null; clientSecret: string | null } + | { kind: 'broker'; broker: BrokerCredential }; + +/** Field keys an `oauth_broker` profile's auth_data must carry. */ +export const BROKER_AUTH_DATA_KEYS = { + url: 'broker_url', + org: 'broker_org', + pat: 'broker_pat', + connectionId: 'broker_connection_id', +} as const; + +/** + * Parse the typed broker fields out of an `oauth_broker` profile's `auth_data`. + * Returns the validated {@link BrokerCredential}, or `null` when any required + * field is missing/malformed. No `__`-prefixed keys: every field is named. */ -export function getBrokerRef(authData: unknown): BrokerRef | null { +export function parseBrokerCredential(authData: unknown): BrokerCredential | null { if (typeof authData === 'string') { try { - return getBrokerRef(JSON.parse(authData)); + return parseBrokerCredential(JSON.parse(authData)); } catch { return null; } } if (!authData || typeof authData !== 'object' || Array.isArray(authData)) return null; - const broker = (authData as Record).__broker; - if (!broker || typeof broker !== 'object' || Array.isArray(broker)) return null; - const { url, org, pat, connection_id } = broker as Record; - const connectionId = typeof connection_id === 'number' ? connection_id : Number(connection_id); + const data = authData as Record; + const url = data[BROKER_AUTH_DATA_KEYS.url]; + const org = data[BROKER_AUTH_DATA_KEYS.org]; + const pat = data[BROKER_AUTH_DATA_KEYS.pat]; + const rawConnectionId = data[BROKER_AUTH_DATA_KEYS.connectionId]; + const connectionId = + typeof rawConnectionId === 'number' ? rawConnectionId : Number(rawConnectionId); if ( typeof url !== 'string' || typeof org !== 'string' || typeof pat !== 'string' || url.trim().length === 0 || + org.trim().length === 0 || pat.trim().length === 0 || !Number.isInteger(connectionId) || connectionId <= 0 @@ -101,7 +127,7 @@ export function getBrokerRef(authData: unknown): BrokerRef | null { url: url.trim().replace(/\/+$/, ''), org: org.trim(), pat: pat.trim(), - connection_id: connectionId, + connectionId, }; } @@ -131,15 +157,21 @@ function normalizeAuthData( profileKind: AuthProfileKind, raw: unknown ): Record { - if (profileKind === 'oauth_app') { - // A broker-ref oauth_app carries a `__broker` object instead of string - // client credentials. normalizeAuthValues() would strip the object, so - // preserve it explicitly while still normalizing any sibling string keys. - const broker = getBrokerRef(raw); - const normalized = normalizeAuthValues(raw); - return broker ? { ...normalized, __broker: broker } : normalized; + if (profileKind === 'oauth_broker') { + // The whole profile IS the broker descriptor. Persist the typed fields: + // broker_url/broker_org/broker_pat are strings (normalized), and + // broker_connection_id is a number that normalizeAuthValues() would strip, + // so store it explicitly as an integer. + const broker = parseBrokerCredential(raw); + if (!broker) return {}; + return { + [BROKER_AUTH_DATA_KEYS.url]: broker.url, + [BROKER_AUTH_DATA_KEYS.org]: broker.org, + [BROKER_AUTH_DATA_KEYS.pat]: broker.pat, + [BROKER_AUTH_DATA_KEYS.connectionId]: broker.connectionId, + }; } - if (profileKind === 'env') { + if (profileKind === 'env' || profileKind === 'oauth_app') { return normalizeAuthValues(raw); } @@ -376,6 +408,45 @@ export async function getAuthProfileById( return rows.length > 0 ? (rows[0] as AuthProfileRow) : null; } +/** + * Resolve a connection's app-level {@link CredentialSource} from its + * `app_auth_profile`. Branches on the profile's `profile_kind`: + * + * - `oauth_broker` → `{ kind: 'broker', broker }` with the typed broker ref + * parsed from the profile's named fields. + * - `oauth_app` → `{ kind: 'local', clientId, clientSecret }` (best-effort + * extraction; the actual client keys are connector-schema-specific and the + * local path reads them by key in execution-context, so these are only the + * conventional `*_CLIENT_ID`/`*_CLIENT_SECRET` lookups when present). + * + * Returns `null` when there's no app profile, the broker profile is malformed, + * or the profile is some other kind. Readers MUST branch on the returned `kind` + * and never sniff the raw `auth_data`. + */ +export async function resolveCredentialSource( + organizationId: string, + appAuthProfileId: number | null | undefined +): Promise { + const profile = await getAuthProfileById(organizationId, appAuthProfileId ?? null); + if (!profile) return null; + + if (profile.profile_kind === 'oauth_broker') { + const broker = parseBrokerCredential(profile.auth_data ?? null); + return broker ? { kind: 'broker', broker } : null; + } + + if (profile.profile_kind === 'oauth_app') { + const values = normalizeAuthValues(profile.auth_data ?? {}); + const clientId = + Object.entries(values).find(([key]) => /_CLIENT_ID$/.test(key))?.[1] ?? null; + const clientSecret = + Object.entries(values).find(([key]) => /_CLIENT_SECRET$/.test(key))?.[1] ?? null; + return { kind: 'local', clientId, clientSecret }; + } + + return null; +} + export async function createAuthProfile(params: { organizationId: string; connectorKey: string | null; diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 90497a822..809f2bf0e 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -1,7 +1,12 @@ import { CredentialService } from '../auth/credentials'; import { getBuiltinProviderConfig } from '../connect/oauth-providers'; import { type DbClient, getDb } from '../db/client'; -import { type BrokerRef, getAuthProfileById, getBrokerRef, normalizeAuthValues } from './auth-profiles'; +import { + type BrokerCredential, + getAuthProfileById, + normalizeAuthValues, + parseBrokerCredential, +} from './auth-profiles'; import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from './connector-auth'; import { parseJsonObject } from '@lobu/core'; import { errorMessage } from './errors'; @@ -43,12 +48,17 @@ export async function resolveExecutionAuth( let credentials: ExecutionOAuthCredentials | null = null; - // Broker-backed connection: the grant lives on a REMOTE Lobu "broker", which - // holds the managed client_id/secret + the user's refresh token. Fetch a + // Broker-backed connection: the app_auth_profile is a first-class + // `oauth_broker` profile whose typed fields describe a REMOTE Lobu "broker" + // that holds the managed client_id/secret + the user's refresh token. Fetch a // fresh access token from the broker at runtime instead of reading/refreshing - // a local grant. ONLY this branch changes for broker-backed connections; the - // local (non-broker) path below is unchanged. - const broker = getBrokerRef(appAuthProfile?.auth_data ?? null); + // a local grant. ONLY this branch (gated on profile_kind === 'oauth_broker') + // changes for broker-backed connections; the local (non-broker) path below is + // unchanged. + const broker = + appAuthProfile?.profile_kind === 'oauth_broker' + ? parseBrokerCredential(appAuthProfile.auth_data ?? null) + : null; if (broker) { const accessToken = await fetchBrokerAccessToken(broker, { ...params.logContext, @@ -147,17 +157,18 @@ export async function resolveExecutionAuth( * Fetch a fresh access token for a broker-backed connection from the remote * broker. The broker holds the grant + client secret and refreshes server-side; * we only ever receive `{ access_token, expires_at }`. No caller-supplied URLs: - * the broker base URL + PAT + connection id come from the trusted broker-ref - * stored on the org's own oauth_app profile. Returns null on any failure so the + * the broker base URL + PAT + connection id come from the trusted typed + * `oauth_broker` profile on the org. Returns null on any failure so the * connection simply resolves without credentials (fail-soft, like the local * path). * - * The `__broker.pat` MUST be minted with the `broker:token` scope — the broker's - * `/broker/oauth/token` gate rejects (403) any PAT lacking it, so a broad member - * PAT cannot be used here. Mint with `lobu token create --scope broker:token`. + * The broker `pat` (the profile's `broker_pat` field) MUST be minted with the + * `broker:token` scope — the broker's `/broker/oauth/token` gate rejects (403) + * any PAT lacking it, so a broad member PAT cannot be used here. Mint with + * `lobu token create --scope broker:token`. */ async function fetchBrokerAccessToken( - broker: BrokerRef, + broker: BrokerCredential, logContext: Record ): Promise<{ access_token: string; expires_at: string | null } | null> { try { @@ -167,7 +178,7 @@ async function fetchBrokerAccessToken( 'Content-Type': 'application/json', Authorization: `Bearer ${broker.pat}`, }, - body: JSON.stringify({ connection_id: broker.connection_id }), + body: JSON.stringify({ connection_id: broker.connectionId }), }); if (!response.ok) { logger.warn( From f8e8566ed5bc664e9383b306a3a4edadaf2c3b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 15:04:46 +0100 Subject: [PATCH 07/15] fix(connect): create broker-backed connections via tool; wire resolveCredentialSource seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKER: resolveConnectionAuthSelection early-returned before honoring an explicit oauth_broker app_auth_profile_slug when there was no local oauth_account — but a broker-backed connection HAS no local oauth_account (the grant lives on the broker). Now: - resolveConnectionAuthSelection resolves the explicit app profile up front and short-circuits to a broker-backed selection (appAuthProfile = the oauth_broker profile, no runtime authProfile) BEFORE the no-auth-profile early-return. - manage_connections treats an OAuth connector as satisfied when the app profile is oauth_broker (no forced pending_auth / missing-oauth_account rejection), so connections.create yields an ACTIVE broker-backed connection. - Test: creates a broker-backed connection THROUGH manage_connections (referencing an oauth_broker profile), asserting active + FK linkage + no local auth_profile_id. DEAD CODE: resolveCredentialSource + CredentialSource were exported but unused. execution-context now calls resolveCredentialSource() and branches on source.kind ('broker' -> fetch from broker; 'local' -> unchanged path); parseBrokerCredential stays as the internal helper it uses. One typed branch point, no dead export, non-broker path unchanged. --- .../connectors/oauth-broker.test.ts | 71 +++++++++++++++++++ .../tools/admin/helpers/connection-helpers.ts | 49 +++++++++---- .../src/tools/admin/manage_connections.ts | 8 ++- .../server/src/utils/execution-context.ts | 26 ++++--- 4 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 6380db41e..258b59d21 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -31,6 +31,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { brokerRoutes } from "../../../connect/broker-routes"; import type { Env } from "../../../index"; import { manageAuthProfiles } from "../../../tools/admin/manage_auth_profiles"; +import { manageConnections } from "../../../tools/admin/manage_connections"; import type { ToolContext } from "../../../tools/registry"; import { createAuthProfile } from "../../../utils/auth-profiles"; import { resolveExecutionAuth } from "../../../utils/execution-context"; @@ -543,4 +544,74 @@ describe("oauth_broker profile — create validation via manage_auth_profiles", expect(res.error).toContain("broker_pat"); } }); + + it("creates an ACTIVE broker-backed connection through manage_connections referencing an oauth_broker profile", async () => { + const { ctx, orgId } = await seedOrgWithConnector(); + + // Admin sets up the broker app profile (the grant lives on the broker). + const profRes = await manageAuthProfiles( + { + action: "create_auth_profile", + connector_key: "demo.oauth", + profile_kind: "oauth_broker", + display_name: "Broker App", + slug: "broker-app", + auth_data: { + broker_url: "https://broker.lobu.ai", + broker_org: "broker-org", + broker_pat: "owl_pat_example", + broker_connection_id: 7, + }, + }, + TEST_ENV, + ctx, + ); + expect("auth_profile" in profRes && profRes.auth_profile).toBeTruthy(); + + // Create the connection THROUGH the tool (not raw SQL) — the OAuth + // connector has no local oauth_account, but the oauth_broker app profile + // satisfies auth on its own, so the connection must land ACTIVE. + const connRes = await manageConnections( + { + action: "create", + connector_key: "demo.oauth", + slug: "broker-conn", + display_name: "Broker Connection", + app_auth_profile_slug: "broker-app", + }, + TEST_ENV, + ctx, + ); + expect("error" in connRes).toBe(false); + if ("connection" in connRes) { + expect((connRes.connection as { status: string }).status).toBe( + "active", + ); + expect( + (connRes.connection as { app_auth_profile_slug: string }) + .app_auth_profile_slug, + ).toBe("broker-app"); + } + + // The FK points at the oauth_broker profile. + const sql = getTestDb(); + const rows = (await sql` + SELECT c.status, app.profile_kind AS app_kind, app.slug AS app_slug, + c.auth_profile_id + FROM connections c + JOIN auth_profiles app ON app.id = c.app_auth_profile_id + WHERE c.organization_id = ${orgId} AND c.slug = 'broker-conn' + `) as unknown as Array<{ + status: string; + app_kind: string; + app_slug: string; + auth_profile_id: number | null; + }>; + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("active"); + expect(rows[0].app_kind).toBe("oauth_broker"); + expect(rows[0].app_slug).toBe("broker-app"); + // No local runtime auth profile — the broker app profile alone satisfies auth. + expect(rows[0].auth_profile_id ?? null).toBeNull(); + }); }); diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index 734031b10..db15c0a44 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -424,6 +424,37 @@ export async function resolveConnectionAuthSelection(params: { const browserMethod = getBrowserMethods(params.authSchema)[0] ?? null; const preferredMethodType = getPreferredAuthMethodType(params.authSchema); + // 0. An explicit app profile slug may point at an `oauth_app` (local client + // credentials) OR an `oauth_broker` (the grant lives on a remote broker); + // both attach via the same `app_auth_profile_id` FK. Resolve it once, + // without pinning a kind, so it can be honored both as the broker-backed + // auth (below) and as the oauth_account app profile (step 2). + const explicitAppProfile = params.appAuthProfileSlug + ? await resolveAuthProfileSlugToId({ + organizationId, + slug: params.appAuthProfileSlug, + connectorKey, + }) + : null; + + // 0b. Broker-backed connection: an `oauth_broker` app profile satisfies the + // connection's auth on its OWN — there is NO local `oauth_account` grant + // (the grant lives on the broker, and a fresh access token is fetched at + // runtime). Honor it BEFORE the no-auth-profile early-return below: the + // broker app profile IS the connection's app credentials, attached via + // `app_auth_profile_id`, with no runtime auth profile. + if (explicitAppProfile?.profile_kind === 'oauth_broker') { + return { + selectedKind: 'oauth_broker', + authProfile: null, + appAuthProfile: explicitAppProfile, + oauthMethod, + envMethod, + browserMethod, + preferredMethodType, + }; + } + // 1. Resolve explicitly selected auth profile, or auto-select the primary // auth profile for the connector's preferred auth method. const authProfile = @@ -457,22 +488,12 @@ export async function resolveConnectionAuthSelection(params: { } // 2. For OAuth accounts, also resolve the app credentials profile. The - // explicitly-supplied app profile slug may be an `oauth_app` (local client - // credentials) OR an `oauth_broker` (the grant lives on a remote broker); - // both attach via the same `app_auth_profile_id` FK. Resolve the slug - // without pinning a kind, then accept only those two app-level kinds. + // explicit app profile (resolved in step 0) may be an `oauth_app` (local + // client credentials) or an `oauth_broker` (handled in step 0b above); + // here we accept only `oauth_app` since the broker case already returned. const needsAppAuth = authProfile.profile_kind === 'oauth_account' || !!params.appAuthProfileSlug; - const explicitAppProfile = params.appAuthProfileSlug - ? await resolveAuthProfileSlugToId({ - organizationId, - slug: params.appAuthProfileSlug, - connectorKey, - }) - : null; const appAuthProfile = needsAppAuth - ? ((explicitAppProfile && - (explicitAppProfile.profile_kind === 'oauth_app' || - explicitAppProfile.profile_kind === 'oauth_broker') + ? ((explicitAppProfile && explicitAppProfile.profile_kind === 'oauth_app' ? explicitAppProfile : null) ?? (oauthMethod && authProfile.profile_kind === 'oauth_account' diff --git a/packages/server/src/tools/admin/manage_connections.ts b/packages/server/src/tools/admin/manage_connections.ts index 583911f23..f62099712 100644 --- a/packages/server/src/tools/admin/manage_connections.ts +++ b/packages/server/src/tools/admin/manage_connections.ts @@ -926,7 +926,13 @@ async function handleCreate( if (authSelection) { const requiresAuth = !!authSelection.oauthMethod || !!authSelection.envMethod || !!authSelection.browserMethod; - if (requiresAuth && !authSelection.authProfile) { + // A broker-backed connection's auth is fully satisfied by its `oauth_broker` + // app profile alone — there is NO local runtime auth profile (the grant + // lives on the broker; a fresh access token is fetched at runtime). Treat it + // as satisfied so an OAuth connector doesn't reject for a missing + // oauth_account. + const brokerSatisfied = authSelection.appAuthProfile?.profile_kind === 'oauth_broker'; + if (requiresAuth && !authSelection.authProfile && !brokerSatisfied) { return { error: authSelection.browserMethod ? 'Select or create a browser auth profile before creating the connection.' diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 809f2bf0e..373c65862 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -5,7 +5,7 @@ import { type BrokerCredential, getAuthProfileById, normalizeAuthValues, - parseBrokerCredential, + resolveCredentialSource, } from './auth-profiles'; import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from './connector-auth'; import { parseJsonObject } from '@lobu/core'; @@ -48,19 +48,17 @@ export async function resolveExecutionAuth( let credentials: ExecutionOAuthCredentials | null = null; - // Broker-backed connection: the app_auth_profile is a first-class - // `oauth_broker` profile whose typed fields describe a REMOTE Lobu "broker" - // that holds the managed client_id/secret + the user's refresh token. Fetch a - // fresh access token from the broker at runtime instead of reading/refreshing - // a local grant. ONLY this branch (gated on profile_kind === 'oauth_broker') - // changes for broker-backed connections; the local (non-broker) path below is - // unchanged. - const broker = - appAuthProfile?.profile_kind === 'oauth_broker' - ? parseBrokerCredential(appAuthProfile.auth_data ?? null) - : null; - if (broker) { - const accessToken = await fetchBrokerAccessToken(broker, { + // The single typed seam for app-level credentials: branch on the + // CredentialSource kind, never on raw auth_data keys. `broker` → fetch a fresh + // access token from the remote broker (the grant lives there); `local` → fall + // through to the unchanged oauth_app/oauth_account path below. ONLY the broker + // branch changes for broker-backed connections; the local path is unchanged. + const credentialSource = await resolveCredentialSource( + params.organizationId, + params.appAuthProfileId ?? null + ); + if (credentialSource?.kind === 'broker') { + const accessToken = await fetchBrokerAccessToken(credentialSource.broker, { ...params.logContext, connection_id: params.connectionId, }); From 4776f9e9eadab262a60f0779a969ec6261d7b578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 15:20:07 +0100 Subject: [PATCH 08/15] refactor(connect): collapse broker seam, harden broker_url, fix update validation Polish pass (no behavior change to the happy path): 1. Dead code: resolveCredentialSource's {kind:'local'} arm was never consumed (execution-context only used the broker case; the local path stays on its own unchanged code). Collapsed to a broker-only resolver resolveBrokerCredentialForConnection(orgId, appAuthProfileId) -> BrokerCredential | null and removed the CredentialSource union entirely. execution-context branches on the non-null result; the local path is unchanged. 2. broker_url hardening: parseBrokerCredential now rejects a broker_url that is not an absolute http:/https: URL (new URL() + protocol check), closing the broker-URL gap. 3. oauth_broker update validation: validate the payload that will actually be persisted (auth_data, else normalized credentials), not just args.auth_data, so a partial/bad update (e.g. string-only credentials that can't carry the numeric broker_connection_id) is rejected instead of silently wiping the profile. Test: added a broker_url-invalid rejection case (no scheme, relative, ftp:). --- .../connectors/oauth-broker.test.ts | 27 +++++++ .../src/tools/admin/manage_auth_profiles.ts | 47 +++++++----- packages/server/src/utils/auth-profiles.ts | 71 +++++++------------ .../server/src/utils/execution-context.ts | 18 ++--- 4 files changed, 88 insertions(+), 75 deletions(-) diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index 258b59d21..f3ff04395 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -545,6 +545,33 @@ describe("oauth_broker profile — create validation via manage_auth_profiles", } }); + it("rejects an oauth_broker profile whose broker_url is not an absolute http(s) URL", async () => { + const { ctx } = await seedOrgWithConnector(); + for (const badUrl of ["broker.lobu.ai", "/broker", "ftp://broker.lobu.ai"]) { + const res = await manageAuthProfiles( + { + action: "create_auth_profile", + connector_key: "demo.oauth", + profile_kind: "oauth_broker", + display_name: "Bad URL Broker", + slug: "bad-url-broker", + auth_data: { + broker_url: badUrl, + broker_org: "broker-org", + broker_pat: "owl_pat_example", + broker_connection_id: 7, + }, + }, + TEST_ENV, + ctx, + ); + expect("error" in res).toBe(true); + if ("error" in res) { + expect(res.error).toContain("broker_url"); + } + } + }); + it("creates an ACTIVE broker-backed connection through manage_connections referencing an oauth_broker profile", async () => { const { ctx, orgId } = await seedOrgWithConnector(); diff --git a/packages/server/src/tools/admin/manage_auth_profiles.ts b/packages/server/src/tools/admin/manage_auth_profiles.ts index f0258a401..82bcfc8bf 100644 --- a/packages/server/src/tools/admin/manage_auth_profiles.ts +++ b/packages/server/src/tools/admin/manage_auth_profiles.ts @@ -743,19 +743,33 @@ async function handleUpdateAuthProfile( }; } - // oauth_broker profiles must always carry a complete, well-formed broker - // descriptor. An update that supplies auth_data must keep all four typed - // fields valid — otherwise normalizeAuthData would silently wipe them. - if ( - existingForRoleCheck.profile_kind === 'oauth_broker' && - args.auth_data !== undefined && - !parseBrokerCredential(args.auth_data) - ) { - return { - error: - 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', - }; - } + } + + // 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; + + // oauth_broker profiles must always carry a complete, well-formed broker + // descriptor. Validate the payload that will actually be persisted (not just + // `args.auth_data`): a partial/bad update — e.g. `credentials` that can't + // carry the numeric broker_connection_id — would otherwise make + // normalizeAuthData silently wipe the profile. An update that omits the + // payload entirely (re-normalizing the existing valid broker fields) is fine. + if ( + existingForRoleCheck?.profile_kind === 'oauth_broker' && + updateAuthDataPayload !== undefined && + !parseBrokerCredential(updateAuthDataPayload) + ) { + return { + error: + 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', + }; } let authProfile = await updateAuthProfile({ @@ -763,12 +777,7 @@ async function handleUpdateAuthProfile( 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/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index 8f65c4627..c4395a6a8 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -68,20 +68,6 @@ export interface BrokerCredential { connectionId: number; } -/** - * The two ways a connection's app-level credentials can resolve, discriminated - * by the backing `app_auth_profile`'s `profile_kind`: - * - * - `local` → an `oauth_app` profile holding client_id/secret on this org. - * - `broker` → an `oauth_broker` profile; the grant lives on a remote broker - * and a fresh access token is fetched at runtime. - * - * Readers branch on `kind` — never sniff keys out of the credential blob. - */ -export type CredentialSource = - | { kind: 'local'; clientId: string | null; clientSecret: string | null } - | { kind: 'broker'; broker: BrokerCredential }; - /** Field keys an `oauth_broker` profile's auth_data must carry. */ export const BROKER_AUTH_DATA_KEYS = { url: 'broker_url', @@ -123,8 +109,21 @@ export function parseBrokerCredential(authData: unknown): BrokerCredential | nul ) { return null; } + // The broker base URL is fetched at runtime, so it MUST be an absolute + // http:/https: URL — reject relative paths, missing schemes, or non-HTTP + // protocols (file:, javascript:, etc.). Closes the broker-URL hardening gap. + const trimmedUrl = url.trim(); + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmedUrl); + } catch { + return null; + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return null; + } return { - url: url.trim().replace(/\/+$/, ''), + url: trimmedUrl.replace(/\/+$/, ''), org: org.trim(), pat: pat.trim(), connectionId, @@ -409,42 +408,20 @@ export async function getAuthProfileById( } /** - * Resolve a connection's app-level {@link CredentialSource} from its - * `app_auth_profile`. Branches on the profile's `profile_kind`: - * - * - `oauth_broker` → `{ kind: 'broker', broker }` with the typed broker ref - * parsed from the profile's named fields. - * - `oauth_app` → `{ kind: 'local', clientId, clientSecret }` (best-effort - * extraction; the actual client keys are connector-schema-specific and the - * local path reads them by key in execution-context, so these are only the - * conventional `*_CLIENT_ID`/`*_CLIENT_SECRET` lookups when present). - * - * Returns `null` when there's no app profile, the broker profile is malformed, - * or the profile is some other kind. Readers MUST branch on the returned `kind` - * and never sniff the raw `auth_data`. + * Resolve the typed {@link BrokerCredential} for a connection from its + * `app_auth_profile`, or `null` when that profile is NOT an `oauth_broker` + * (i.e. the connection uses the local/unchanged credential path) or the broker + * descriptor is malformed. This is the single seam for the broker branch: + * callers gate the broker path on a non-null result and otherwise fall through + * to their existing local path. Never sniffs raw `auth_data` keys. */ -export async function resolveCredentialSource( +export async function resolveBrokerCredentialForConnection( organizationId: string, appAuthProfileId: number | null | undefined -): Promise { +): Promise { const profile = await getAuthProfileById(organizationId, appAuthProfileId ?? null); - if (!profile) return null; - - if (profile.profile_kind === 'oauth_broker') { - const broker = parseBrokerCredential(profile.auth_data ?? null); - return broker ? { kind: 'broker', broker } : null; - } - - if (profile.profile_kind === 'oauth_app') { - const values = normalizeAuthValues(profile.auth_data ?? {}); - const clientId = - Object.entries(values).find(([key]) => /_CLIENT_ID$/.test(key))?.[1] ?? null; - const clientSecret = - Object.entries(values).find(([key]) => /_CLIENT_SECRET$/.test(key))?.[1] ?? null; - return { kind: 'local', clientId, clientSecret }; - } - - return null; + if (!profile || profile.profile_kind !== 'oauth_broker') return null; + return parseBrokerCredential(profile.auth_data ?? null); } export async function createAuthProfile(params: { diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 373c65862..3f2920bfe 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -5,7 +5,7 @@ import { type BrokerCredential, getAuthProfileById, normalizeAuthValues, - resolveCredentialSource, + resolveBrokerCredentialForConnection, } from './auth-profiles'; import { getOAuthAuthMethods, normalizeConnectorAuthSchema } from './connector-auth'; import { parseJsonObject } from '@lobu/core'; @@ -48,17 +48,17 @@ export async function resolveExecutionAuth( let credentials: ExecutionOAuthCredentials | null = null; - // The single typed seam for app-level credentials: branch on the - // CredentialSource kind, never on raw auth_data keys. `broker` → fetch a fresh - // access token from the remote broker (the grant lives there); `local` → fall - // through to the unchanged oauth_app/oauth_account path below. ONLY the broker - // branch changes for broker-backed connections; the local path is unchanged. - const credentialSource = await resolveCredentialSource( + // The single seam for the broker branch: a non-null result means the + // connection's app profile is an `oauth_broker`, so fetch a fresh access token + // from the remote broker (the grant lives there). A null result means the + // connection uses the local credential path below, which is unchanged. Never + // sniffs raw auth_data keys. + const broker = await resolveBrokerCredentialForConnection( params.organizationId, params.appAuthProfileId ?? null ); - if (credentialSource?.kind === 'broker') { - const accessToken = await fetchBrokerAccessToken(credentialSource.broker, { + if (broker) { + const accessToken = await fetchBrokerAccessToken(broker, { ...params.logContext, connection_id: params.connectionId, }); From 90374630c0f43ca8faa12f483409ca159142dd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 15:34:55 +0100 Subject: [PATCH 09/15] =?UTF-8?q?fix(connect):=20SSRF=20=E2=80=94=20gate?= =?UTF-8?q?=20broker=20fetch=20on=20operator=20origin=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local instance POSTs its broker PAT to broker_url, so an attacker-controlled oauth_broker profile could point that fetch at an internal service or the cloud metadata endpoint (169.254.169.254). Gate it with an operator-configured ORIGIN ALLOWLIST (no IP/host denylist — denylists are bypassable via DNS rebinding + encodings): - New env LOBU_ALLOWED_BROKER_ORIGINS: comma-separated scheme://host[:port] origins this instance may delegate OAuth to. Parsed once. - Enforced at the fetch boundary in fetchBrokerAccessToken (before any outbound request): compute the request URL's origin and reject (return null + clear warn log, no fetch) unless it's an exact allowlist member. - Unset/empty allowlist → reject ALL broker fetches (fail-closed). - https required for non-loopback origins; http allowed only for an explicitly-allowlisted loopback origin (opt-in dev / self-broker). Keeps the existing new URL() + http/https parse check. - Comment: full DNS-rebinding protection (resolve host + verify the connected IP isn't private at fetch time) is a production hardening follow-up; the origin allowlist is the primary control. Tests: set LOBU_ALLOWED_BROKER_ORIGINS to the in-test self-broker loopback origin so existing runtime-hook tests fetch; added a not-allowlisted-origin case and an unset-allowlist fail-closed case — both assert no credentials and zero outbound request (the fake provider is never hit). --- .../connectors/oauth-broker.test.ts | 122 ++++++++++++++++++ .../server/src/utils/execution-context.ts | 112 +++++++++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts index f3ff04395..7c184a274 100644 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts @@ -69,6 +69,9 @@ let lastRefreshBody: Record = {}; let brokerServer: ReturnType | null = null; let brokerBaseUrl = ""; +// Saved value of LOBU_ALLOWED_BROKER_ORIGINS so afterAll can restore it. +let savedAllowedBrokerOrigins: string | undefined; + function buildBrokerApp(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); app.route("/broker", brokerRoutes); @@ -115,9 +118,20 @@ beforeAll(async () => { }, ); }); + + // SSRF control: the runtime broker fetch enforces an operator-configured + // origin allowlist (fail-closed when unset). Allowlist the in-test + // self-broker's loopback origin so the runtime-hook tests can fetch. + savedAllowedBrokerOrigins = process.env.LOBU_ALLOWED_BROKER_ORIGINS; + process.env.LOBU_ALLOWED_BROKER_ORIGINS = new URL(brokerBaseUrl).origin; }); afterAll(async () => { + if (savedAllowedBrokerOrigins === undefined) { + delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; + } else { + process.env.LOBU_ALLOWED_BROKER_ORIGINS = savedAllowedBrokerOrigins; + } await new Promise((done) => providerServer ? providerServer.close(() => done()) : done(), ); @@ -435,6 +449,114 @@ describe("SPIKE: grant-on-broker — local runtime hook", () => { expect(resolved.credentials?.refreshToken).toBeNull(); expect(resolved.connectionCredentials).toEqual({}); }); + + /** + * Seed a local broker-backed connection whose oauth_broker profile points at + * `brokerUrl`, and return the local connection id + org. Reused by the SSRF + * allowlist cases below. + */ + async function seedLocalBrokerConnection( + orgName: string, + brokerUrl: string, + ): Promise<{ + localOrgId: string; + localConnectionId: number; + appAuthProfileId: number; + }> { + const broker = await seedBrokerConnection(`${orgName} Broker`); + const sql = getTestDb(); + const localOrg = await createTestOrganization({ name: orgName }); + const localUser = await createTestUser({ name: `${orgName} 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 brokerProfile = await createAuthProfile({ + organizationId: localOrg.id, + connectorKey: "demo.oauth", + displayName: "Broker-backed Demo App", + profileKind: "oauth_broker", + provider: "demo", + authData: { + broker_url: brokerUrl, + broker_org: "broker-org", + broker_pat: broker.pat, + broker_connection_id: broker.connectionId, + }, + }); + const localConnRows = (await sql` + INSERT INTO connections ( + organization_id, connector_key, slug, display_name, status, + app_auth_profile_id, created_at, updated_at + ) VALUES ( + ${localOrg.id}, 'demo.oauth', ${`demo-local-${localOrg.id}`}, 'Local Demo', 'active', + ${brokerProfile.id}, NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + return { + localOrgId: localOrg.id, + localConnectionId: Number(localConnRows[0].id), + appAuthProfileId: brokerProfile.id, + }; + } + + it("rejects the broker fetch when broker_url's origin is NOT allowlisted (no outbound request)", async () => { + // The allowlist contains the self-broker origin (set in beforeAll), but + // this profile points at a different origin — a stand-in for an internal + // service / metadata endpoint an attacker-controlled profile might target. + const { localOrgId, localConnectionId, appAuthProfileId } = + await seedLocalBrokerConnection( + "Not Allowlisted Org", + "http://169.254.169.254", + ); + + const resolved = await resolveExecutionAuth({ + organizationId: localOrgId, + connectionId: localConnectionId, + authProfileId: null, + appAuthProfileId, + credentialDb: getTestDb(), + }); + + // The fetch is rejected at the allowlist boundary → no credentials, and + // the broker (hence the fake provider) was never contacted. + expect(resolved.credentials).toBeNull(); + expect(lastRefreshBody).toEqual({}); + }); + + it("rejects the broker fetch when LOBU_ALLOWED_BROKER_ORIGINS is unset (fail-closed)", async () => { + // Point at the real self-broker origin, but with NO allowlist configured — + // fail-closed means even a reachable broker must not be fetched. + const { localOrgId, localConnectionId, appAuthProfileId } = + await seedLocalBrokerConnection("Fail Closed Org", brokerBaseUrl); + + const previous = process.env.LOBU_ALLOWED_BROKER_ORIGINS; + delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; + try { + const resolved = await resolveExecutionAuth({ + organizationId: localOrgId, + connectionId: localConnectionId, + authProfileId: null, + appAuthProfileId, + credentialDb: getTestDb(), + }); + expect(resolved.credentials).toBeNull(); + expect(lastRefreshBody).toEqual({}); + } finally { + if (previous === undefined) { + delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; + } else { + process.env.LOBU_ALLOWED_BROKER_ORIGINS = previous; + } + } + }); }); function ctxFor(organizationId: string, userId: string): ToolContext { diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 3f2920bfe..373741f1f 100644 --- a/packages/server/src/utils/execution-context.ts +++ b/packages/server/src/utils/execution-context.ts @@ -151,6 +151,106 @@ export async function resolveExecutionAuth( }; } +/** + * Loopback hostnames an `http:` broker origin is allowed to use when the + * operator explicitly allowlists it (dev / self-broker). Everything else MUST + * be `https:`. + */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** + * Parse `LOBU_ALLOWED_BROKER_ORIGINS` into a set of normalized + * `scheme://host[:port]` origins. This is the SSRF control surface: the local + * instance POSTs its broker PAT to `broker_url`, so an attacker-controlled + * `oauth_broker` profile could otherwise point that fetch at an internal + * service or the cloud metadata endpoint. We do NOT denylist IPs/hosts + * (bypassable via DNS rebinding + encodings); instead the operator names the + * exact origins this instance may delegate OAuth to. + * + * Each entry is a full origin (`https://broker.lobu.ai`, + * `http://localhost:8787`); paths/queries are ignored. Invalid entries are + * dropped. An unset/empty env yields an empty set → ALL broker fetches are + * rejected (fail-closed): a broker-backed connection without a configured + * allowlist must not fetch. + */ +function parseAllowedBrokerOrigins(): Set { + const raw = process.env.LOBU_ALLOWED_BROKER_ORIGINS?.trim(); + const origins = new Set(); + if (!raw) return origins; + for (const part of raw.split(',')) { + const candidate = part.trim(); + if (!candidate) continue; + try { + origins.add(new URL(candidate).origin); + } catch { + // Skip malformed entries rather than failing open. + } + } + return origins; +} + +/** + * Resolve the broker request URL and enforce the operator-configured origin + * allowlist (SSRF control). Returns the full token-endpoint URL when the + * broker's origin is allowlisted, or `null` (with a clear warning) otherwise — + * the caller MUST NOT fetch on null. + * + * Rules: + * - Empty/unset allowlist → reject everything (fail-closed). + * - The request origin must be an exact member of the allowlist. + * - `https:` always; `http:` only for an explicitly-allowlisted loopback + * origin (so opt-in dev / self-broker works). + * + * NOTE: full DNS-rebinding protection (resolve the host, verify the connected + * IP isn't private at fetch time) is a production hardening follow-up; the + * origin allowlist is the primary control here. + */ +function resolveAllowedBrokerTokenUrl( + brokerUrl: string, + logContext: Record +): string | null { + let parsed: URL; + try { + parsed = new URL(`${brokerUrl}/broker/oauth/token`); + } catch { + logger.warn({ ...logContext }, 'Broker URL is not a valid absolute URL'); + return null; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + logger.warn({ ...logContext, origin: parsed.origin }, 'Broker URL is not http(s)'); + return null; + } + + const allowed = parseAllowedBrokerOrigins(); + if (allowed.size === 0) { + logger.warn( + { ...logContext, origin: parsed.origin }, + 'Broker fetch rejected: LOBU_ALLOWED_BROKER_ORIGINS is unset/empty (fail-closed)' + ); + return null; + } + if (!allowed.has(parsed.origin)) { + logger.warn( + { ...logContext, origin: parsed.origin }, + 'Broker fetch rejected: origin not in LOBU_ALLOWED_BROKER_ORIGINS' + ); + return null; + } + + // https everywhere; http only for an explicitly-allowlisted loopback origin. + const isLoopback = LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()); + if (parsed.protocol === 'http:' && !isLoopback) { + logger.warn( + { ...logContext, origin: parsed.origin }, + 'Broker fetch rejected: http is only allowed for an allowlisted loopback origin' + ); + return null; + } + + return parsed.toString(); +} + /** * Fetch a fresh access token for a broker-backed connection from the remote * broker. The broker holds the grant + client secret and refreshes server-side; @@ -160,6 +260,11 @@ export async function resolveExecutionAuth( * connection simply resolves without credentials (fail-soft, like the local * path). * + * SSRF: the broker origin MUST be in the operator-configured + * `LOBU_ALLOWED_BROKER_ORIGINS` allowlist (fail-closed when unset). The local + * instance POSTs its PAT to `broker_url`, so an attacker-controlled profile + * could otherwise reach internal services / cloud metadata. + * * The broker `pat` (the profile's `broker_pat` field) MUST be minted with the * `broker:token` scope — the broker's `/broker/oauth/token` gate rejects (403) * any PAT lacking it, so a broad member PAT cannot be used here. Mint with @@ -169,8 +274,13 @@ async function fetchBrokerAccessToken( broker: BrokerCredential, logContext: Record ): Promise<{ access_token: string; expires_at: string | null } | null> { + // Enforce the origin allowlist BEFORE any outbound request — a rejected + // origin must produce zero network traffic. + const tokenUrl = resolveAllowedBrokerTokenUrl(broker.url, logContext); + if (!tokenUrl) return null; + try { - const response = await fetch(`${broker.url}/broker/oauth/token`, { + const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', From 550d634cfb3931f1b33cece2a7588c31e3227c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 16:52:33 +0100 Subject: [PATCH 10/15] refactor(connect)!: managed connectors via public org (drop bespoke broker) Replace the bespoke oauth_broker model with the simpler public-org model: 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 via created_by). Their local Lobu fetches its own user's connection token from the cloud at runtime and runs the connector locally. The cloud secret never leaves the cloud. Delete the broker layer: oauth_broker profile kind + its (never-on-main) migration, BrokerCredential/parseBrokerCredential/resolveBrokerCredentialForConnection, broker_* auth_data fields, the LOBU_ALLOWED_BROKER_ORIGINS SSRF allowlist, the broker:token scope, and connect/broker-routes.ts. Keep pat-auth.ts. Add POST /oauth/connection-token (connect/connection-token-route.ts), PAT-gated via authenticatePat: 403 non-member, 404/owner-scoped on created_by, returns only { access_token, expires_at } via the existing CredentialService. Add managedBy to the connection (defineConnection({ connector, managedBy })), folded into connection config; execution-context.ts fetches the token from the cloud when a connection is managedBy, caching until near expiry. The local (non-managed) path is unchanged. --- ...120000_auth_profiles_oauth_broker_kind.sql | 44 - .../_lib/apply/__tests__/map-config.test.ts | 31 + .../cli/src/commands/_lib/apply/map-config.ts | 9 +- packages/cli/src/config/define.ts | 22 + .../connectors/connection-token.test.ts | 498 ++++++++++++ .../connectors/oauth-broker.test.ts | 766 ------------------ packages/server/src/auth/oauth/scopes.ts | 5 - packages/server/src/auth/pat-auth.ts | 8 +- packages/server/src/connect/broker-routes.ts | 184 ----- .../src/connect/connection-token-route.ts | 189 +++++ packages/server/src/http/spa-route-filter.ts | 1 - packages/server/src/index.ts | 15 +- packages/server/src/lobu/gateway.ts | 8 +- .../src/sandbox/namespaces/auth-profiles.ts | 3 +- .../tools/admin/helpers/connection-helpers.ts | 31 +- .../src/tools/admin/manage_auth_profiles.ts | 53 -- .../src/tools/admin/manage_connections.ts | 8 +- packages/server/src/utils/auth-profiles.ts | 116 +-- .../server/src/utils/execution-context.ts | Bin 12975 -> 12603 bytes 19 files changed, 772 insertions(+), 1219 deletions(-) delete mode 100644 db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql create mode 100644 packages/server/src/__tests__/integration/connectors/connection-token.test.ts delete mode 100644 packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts delete mode 100644 packages/server/src/connect/broker-routes.ts create mode 100644 packages/server/src/connect/connection-token-route.ts diff --git a/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql b/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql deleted file mode 100644 index 5fd27b19a..000000000 --- a/db/migrations/20260525120000_auth_profiles_oauth_broker_kind.sql +++ /dev/null @@ -1,44 +0,0 @@ --- migrate:up - --- Add `oauth_broker` as a first-class auth_profiles.profile_kind. An --- oauth_broker profile IS a broker descriptor: its auth_data holds the typed, --- named fields broker_url / broker_org / broker_connection_id plus the PAT --- (broker_pat). A broker-backed connection points its existing --- app_auth_profile_id FK at an oauth_broker profile (instead of an oauth_app), --- and the runtime fetches a fresh access token from the broker at execution --- time. This replaces the prior `__broker` magic-key hack that smuggled a --- broker ref inside an oauth_app profile's credential blob. --- --- Postgres can't extend a CHECK in place, so drop the existing profile_kind --- check and re-add it with `oauth_broker` appended to the value list. -ALTER TABLE auth_profiles - DROP CONSTRAINT IF EXISTS auth_profiles_profile_kind_check; - -ALTER TABLE auth_profiles - ADD CONSTRAINT auth_profiles_profile_kind_check - CHECK (profile_kind = ANY (ARRAY[ - 'env'::text, - 'oauth_app'::text, - 'oauth_account'::text, - 'browser_session'::text, - 'interactive'::text, - 'oauth_broker'::text - ])); - --- migrate:down - --- Revert to the original value list (without `oauth_broker`). Any oauth_broker --- rows must be removed first or the re-ADD will fail; rolling back this --- migration is only valid before the feature ships rows in prod. -ALTER TABLE auth_profiles - DROP CONSTRAINT IF EXISTS auth_profiles_profile_kind_check; - -ALTER TABLE auth_profiles - ADD CONSTRAINT auth_profiles_profile_kind_check - CHECK (profile_kind = ANY (ARRAY[ - 'env'::text, - 'oauth_app'::text, - 'oauth_account'::text, - 'browser_session'::text, - 'interactive'::text - ])); 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..29bbf7ee8 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,37 @@ describe("mapProjectToDesiredState", () => { expect(dc?.feeds).toEqual([{ feedKey: "stars", schedule: "0 */6 * * *" }]); }); + test("folds `managedBy` into the connection config", () => { + const conn = defineConnection({ + slug: "gh-managed", + connector: "github", + config: { existing: true }, + managedBy: { org: "lobu-managed", url: "https://app.lobu.ai" }, + }); + const state = mapProjectToDesiredState( + defineConfig({ agents: [], connections: [conn] }) + ); + const dc = state.connectors.connections[0]; + expect(dc?.config).toEqual({ + existing: true, + managedBy: { org: "lobu-managed", url: "https://app.lobu.ai" }, + }); + }); + + 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..05063f764 100644 --- a/packages/cli/src/commands/_lib/apply/map-config.ts +++ b/packages/cli/src/commands/_lib/apply/map-config.ts @@ -644,6 +644,13 @@ function mapConnection(connection: Connection): DesiredConnection { }); 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. + const config = connection.managedBy + ? { ...(connection.config ?? {}), managedBy: { ...connection.managedBy } } + : connection.config; return { slug: connection.slug, connector: connectorKey(connection.connector), @@ -652,7 +659,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..bcd314e12 100644 --- a/packages/cli/src/config/define.ts +++ b/packages/cli/src/config/define.ts @@ -92,6 +92,22 @@ 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. + */ +export interface ManagedBy { + /** The cloud (public) org the managed connector lives under. */ + org: string; + /** Override the cloud base URL (defaults to the instance's `LOBU_CLOUD_URL`). */ + url?: string; +} + export interface Connection { readonly kind: "connection"; /** Stable slug — diff key. */ @@ -103,6 +119,12 @@ 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; /** 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..6743c9644 --- /dev/null +++ b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts @@ -0,0 +1,498 @@ +/** + * 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 connection OWNER (created_by). */ + ownerId: string; + /** The owner's PAT (member of the public org). */ + ownerPat: 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. + */ +async function seedManagedConnection( + orgName: string, +): Promise { + const sql = getTestDb(); + const org = await createTestOrganization({ + name: orgName, + visibility: "public", + }); + 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. + 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, + created_at, updated_at + ) VALUES ( + ${org.id}, ${connectorKey}, ${`demo-${org.id}`}, 'Demo Connection', 'active', + ${accountId}, ${accountProfile.id}, ${appProfile.id}, ${owner.id}, + NOW(), NOW() + ) + RETURNING id + `) as unknown as Array<{ id: number }>; + + const ownerPat = await createTestPAT(owner.id, org.id, { + scope: "mcp:read mcp:write", + }); + return { + orgId: org.id, + ownerId: owner.id, + ownerPat: ownerPat.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 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. + 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", + }); + + 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. + 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", + }); + + 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); + }); +}); + +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("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/oauth-broker.test.ts b/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts deleted file mode 100644 index 7c184a274..000000000 --- a/packages/server/src/__tests__/integration/connectors/oauth-broker.test.ts +++ /dev/null @@ -1,766 +0,0 @@ -/** - * SPIKE: grant-on-broker managed OAuth. - * - * The grant lives on the BROKER. The broker holds the managed app creds + the - * user's grant (oauth_account → account row) and runs consent/refresh through - * its OWN existing flow + CredentialService. The LOCAL instance only fetches a - * fresh ACCESS token at runtime via `POST /broker/oauth/token`. - * - * Proven here without a real external provider: - * - * 1. A "broker" 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 - * and a refresh token, and a connection wiring them together. - * 2. `POST /broker/oauth/token` is PAT-gated. A valid PAT for the broker org + - * the broker connection id → the broker resolves its managed app + connector - * endpoint, REFRESHES the expiring token with its secret (against its own - * tokenUrl), and returns ONLY `{ access_token, expires_at }`. The refresh - * token + client secret never appear in the response. - * 3. Scope: a PAT for org B cannot fetch org A's connection token (403). No - * PAT → 401, bad PAT → 401, malformed body → 400. - * 4. End-to-end runtime hook: a LOCAL broker-backed connection (app profile = - * a first-class `oauth_broker` profile whose typed fields point broker_url - * at the in-process broker server) resolves its access token through the - * broker via `resolveExecutionAuth`. - */ - -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { brokerRoutes } from "../../../connect/broker-routes"; -import type { Env } from "../../../index"; -import { manageAuthProfiles } from "../../../tools/admin/manage_auth_profiles"; -import { manageConnections } from "../../../tools/admin/manage_connections"; -import type { ToolContext } from "../../../tools/registry"; -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: "broker-refresh-token-456", - expires_in: 3600, -}; -const STALE_ACCESS_TOKEN = "stale-access-token-000"; -const BROKER_SECRET = "broker-secret"; - -// Fake OAuth provider token endpoint the broker org's connector points at. -let providerServer: ReturnType | null = null; -let providerTokenUrl = ""; -let lastRefreshBody: Record = {}; - -// Broker app served on a real port so the local runtime hook's `fetch` reaches it. -let brokerServer: ReturnType | null = null; -let brokerBaseUrl = ""; - -// Saved value of LOBU_ALLOWED_BROKER_ORIGINS so afterAll can restore it. -let savedAllowedBrokerOrigins: string | undefined; - -function buildBrokerApp(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); - app.route("/broker", brokerRoutes); - 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 broker 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); - }, - ); - }); - - // Broker app on a real port (Env carries DATABASE_URL so handlers hit test DB). - const brokerApp = buildBrokerApp(); - brokerServer = await new Promise((resolve) => { - const s = serve( - { - fetch: (req: Request) => brokerApp.fetch(req, TEST_ENV), - hostname: "127.0.0.1", - port: 0, - }, - (info) => { - brokerBaseUrl = `http://127.0.0.1:${info.port}`; - resolve(s); - }, - ); - }); - - // SSRF control: the runtime broker fetch enforces an operator-configured - // origin allowlist (fail-closed when unset). Allowlist the in-test - // self-broker's loopback origin so the runtime-hook tests can fetch. - savedAllowedBrokerOrigins = process.env.LOBU_ALLOWED_BROKER_ORIGINS; - process.env.LOBU_ALLOWED_BROKER_ORIGINS = new URL(brokerBaseUrl).origin; -}); - -afterAll(async () => { - if (savedAllowedBrokerOrigins === undefined) { - delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; - } else { - process.env.LOBU_ALLOWED_BROKER_ORIGINS = savedAllowedBrokerOrigins; - } - await new Promise((done) => - providerServer ? providerServer.close(() => done()) : done(), - ); - await new Promise((done) => - brokerServer ? brokerServer.close(() => done()) : done(), - ); -}); - -interface SeededConnection { - orgId: string; - userId: string; - /** PAT minted WITH the `broker:token` scope — the authorized happy-path PAT. */ - pat: string; - /** PAT for the SAME org/user but WITHOUT `broker:token` — must be rejected (403). */ - patNoScope: string; - connectionId: number; -} - -/** - * Seed a broker 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 wiring them. The - * connector endpoints live in the org's OWN metadata — the broker resolves them - * server-side; the caller never supplies them. - */ -async function seedBrokerConnection( - orgName: string, -): Promise { - const sql = getTestDb(); - const org = await createTestOrganization({ name: orgName }); - const user = await createTestUser({ name: `${orgName} Admin` }); - await addUserToOrganization(user.id, org.id, "owner"); - - await createTestConnectorDefinition({ - key: "demo.oauth", - 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 broker). - const appProfile = await createAuthProfile({ - organizationId: org.id, - connectorKey: "demo.oauth", - displayName: "Managed Demo App", - profileKind: "oauth_app", - provider: "demo", - authData: { - DEMO_CLIENT_ID: "broker-cid", - DEMO_CLIENT_SECRET: BROKER_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', ${user.id}, - ${STALE_ACCESS_TOKEN}, ${"broker-refresh-token-original"}, ${expiringSoon}, - 'read', NOW(), NOW() - ) - `; - - // oauth_account profile pointing at the grant. - const accountProfile = await createAuthProfile({ - organizationId: org.id, - connectorKey: "demo.oauth", - displayName: "Demo Account", - profileKind: "oauth_account", - provider: "demo", - accountId, - }); - - // Connection wiring the grant (auth_profile_id) + managed app (app_auth_profile_id). - 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_at, updated_at - ) VALUES ( - ${org.id}, 'demo.oauth', ${`demo-${org.id}`}, 'Demo Connection', 'active', - ${accountId}, ${accountProfile.id}, ${appProfile.id}, NOW(), NOW() - ) - RETURNING id - `) as unknown as Array<{ id: number }>; - - // The broker-ref's PAT carries the least-privilege `broker:token` scope; a - // sibling PAT for the same org/user WITHOUT it proves org membership alone is - // insufficient. - const pat = await createTestPAT(user.id, org.id, { scope: "broker:token" }); - const patNoScope = await createTestPAT(user.id, org.id, { - scope: "mcp:read mcp:write", - }); - return { - orgId: org.id, - userId: user.id, - pat: pat.token, - patNoScope: patNoScope.token, - 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://broker.local/broker/oauth/token", { - method: "POST", - headers, - body: JSON.stringify(opts.body ?? {}), - }), - TEST_ENV, - ); -} - -describe("SPIKE: grant-on-broker — POST /broker/oauth/token", () => { - beforeEach(async () => { - await cleanupTestDatabase(); - lastRefreshBody = {}; - }); - - it("returns a fresh access token, refreshed server-side with the broker secret", async () => { - const { pat, connectionId } = await seedBrokerConnection("Broker Org"); - const app = buildBrokerApp(); - - const res = await tokenRequest(app, { - pat, - body: { connection_id: connectionId }, - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - // The access token is the REFRESHED one — proves the broker 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 broker authed the refresh with ITS managed client_id/secret. - expect(lastRefreshBody.client_id).toBe("broker-cid"); - expect(lastRefreshBody.client_secret).toBe(BROKER_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("broker-refresh-token-original"); - expect(serialized).not.toContain(BROKER_SECRET); - expect(body.refresh_token).toBeUndefined(); - }); - - it("rejects a cross-org connection (403) — a PAT for org B cannot fetch org A's token", async () => { - const orgA = await seedBrokerConnection("Org A"); - const orgB = await seedBrokerConnection("Org B"); - const app = buildBrokerApp(); - - // Org B's PAT asking for Org A's connection id. - const res = await tokenRequest(app, { - pat: orgB.pat, - body: { connection_id: orgA.connectionId }, - }); - - 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 { pat } = await seedBrokerConnection("Broker Org"); - const app = buildBrokerApp(); - - const res = await tokenRequest(app, { - pat, - body: { connection_id: "not-a-number" }, - }); - 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 { connectionId } = await seedBrokerConnection("Broker Org"); - const app = buildBrokerApp(); - const res = await tokenRequest(app, { body: { connection_id: connectionId } }); - expect(res.status).toBe(401); - }); - - it("rejects an invalid PAT (401)", async () => { - const { connectionId } = await seedBrokerConnection("Broker Org"); - const app = buildBrokerApp(); - const res = await tokenRequest(app, { - pat: "owl_pat_totally-bogus-token", - body: { connection_id: connectionId }, - }); - expect(res.status).toBe(401); - }); - - it("rejects a valid org-member PAT WITHOUT the broker:token scope (403)", async () => { - // Same org + same connection as the happy path, but the PAT carries only - // `mcp:read mcp:write`. Org membership is not enough — least-privilege. - const { patNoScope, connectionId } = await seedBrokerConnection("Broker Org"); - const app = buildBrokerApp(); - const res = await tokenRequest(app, { - pat: patNoScope, - body: { connection_id: connectionId }, - }); - expect(res.status).toBe(403); - const body = (await res.json()) as Record; - expect(body.error).toBe("insufficient_scope"); - }); -}); - -describe("SPIKE: grant-on-broker — local runtime hook", () => { - beforeEach(async () => { - await cleanupTestDatabase(); - lastRefreshBody = {}; - }); - - it("a broker-backed local connection resolves its access token via the broker", async () => { - // Broker side: managed app + grant + connection + PAT (broker.url = the - // in-process broker server). - const broker = await seedBrokerConnection("Broker Org"); - - // Local side: a separate org whose oauth_app profile is a broker-ref (no - // local client_id/secret) pointing at the broker connection. - 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 brokerProfile = await createAuthProfile({ - organizationId: localOrg.id, - connectorKey: "demo.oauth", - displayName: "Broker-backed Demo App", - profileKind: "oauth_broker", - provider: "demo", - authData: { - broker_url: brokerBaseUrl, - broker_org: "broker-org", - broker_pat: broker.pat, - broker_connection_id: broker.connectionId, - }, - }); - - // The typed broker fields survive normalization (and there are NO - // client_id/secret keys and NO `__`-prefixed magic keys on the profile). - const stored = (await sql` - SELECT profile_kind, auth_data FROM auth_profiles WHERE id = ${brokerProfile.id} - `) as unknown as Array<{ - profile_kind: string; - auth_data: Record; - }>; - expect(stored[0].profile_kind).toBe("oauth_broker"); - expect(stored[0].auth_data.broker_url).toBe(brokerBaseUrl); - expect(stored[0].auth_data.broker_org).toBe("broker-org"); - expect(stored[0].auth_data.broker_pat).toBe(broker.pat); - expect(stored[0].auth_data.broker_connection_id).toBe(broker.connectionId); - expect(stored[0].auth_data.DEMO_CLIENT_ID).toBeUndefined(); - expect( - Object.keys(stored[0].auth_data).some((k) => k.startsWith("__")), - ).toBe(false); - - // A local connection backed by the broker-ref app profile (no grant locally). - const localConnRows = (await sql` - INSERT INTO connections ( - organization_id, connector_key, slug, display_name, status, - app_auth_profile_id, created_at, updated_at - ) VALUES ( - ${localOrg.id}, 'demo.oauth', 'demo-local', 'Local Demo', 'active', - ${brokerProfile.id}, NOW(), NOW() - ) - RETURNING id - `) as unknown as Array<{ id: number }>; - - // The runtime token-resolution path: detects the broker-ref, fetches the - // access token from the broker, and returns it as the connection's creds. - const resolved = await resolveExecutionAuth({ - organizationId: localOrg.id, - connectionId: Number(localConnRows[0].id), - authProfileId: null, - appAuthProfileId: brokerProfile.id, - 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({}); - }); - - /** - * Seed a local broker-backed connection whose oauth_broker profile points at - * `brokerUrl`, and return the local connection id + org. Reused by the SSRF - * allowlist cases below. - */ - async function seedLocalBrokerConnection( - orgName: string, - brokerUrl: string, - ): Promise<{ - localOrgId: string; - localConnectionId: number; - appAuthProfileId: number; - }> { - const broker = await seedBrokerConnection(`${orgName} Broker`); - const sql = getTestDb(); - const localOrg = await createTestOrganization({ name: orgName }); - const localUser = await createTestUser({ name: `${orgName} 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 brokerProfile = await createAuthProfile({ - organizationId: localOrg.id, - connectorKey: "demo.oauth", - displayName: "Broker-backed Demo App", - profileKind: "oauth_broker", - provider: "demo", - authData: { - broker_url: brokerUrl, - broker_org: "broker-org", - broker_pat: broker.pat, - broker_connection_id: broker.connectionId, - }, - }); - const localConnRows = (await sql` - INSERT INTO connections ( - organization_id, connector_key, slug, display_name, status, - app_auth_profile_id, created_at, updated_at - ) VALUES ( - ${localOrg.id}, 'demo.oauth', ${`demo-local-${localOrg.id}`}, 'Local Demo', 'active', - ${brokerProfile.id}, NOW(), NOW() - ) - RETURNING id - `) as unknown as Array<{ id: number }>; - return { - localOrgId: localOrg.id, - localConnectionId: Number(localConnRows[0].id), - appAuthProfileId: brokerProfile.id, - }; - } - - it("rejects the broker fetch when broker_url's origin is NOT allowlisted (no outbound request)", async () => { - // The allowlist contains the self-broker origin (set in beforeAll), but - // this profile points at a different origin — a stand-in for an internal - // service / metadata endpoint an attacker-controlled profile might target. - const { localOrgId, localConnectionId, appAuthProfileId } = - await seedLocalBrokerConnection( - "Not Allowlisted Org", - "http://169.254.169.254", - ); - - const resolved = await resolveExecutionAuth({ - organizationId: localOrgId, - connectionId: localConnectionId, - authProfileId: null, - appAuthProfileId, - credentialDb: getTestDb(), - }); - - // The fetch is rejected at the allowlist boundary → no credentials, and - // the broker (hence the fake provider) was never contacted. - expect(resolved.credentials).toBeNull(); - expect(lastRefreshBody).toEqual({}); - }); - - it("rejects the broker fetch when LOBU_ALLOWED_BROKER_ORIGINS is unset (fail-closed)", async () => { - // Point at the real self-broker origin, but with NO allowlist configured — - // fail-closed means even a reachable broker must not be fetched. - const { localOrgId, localConnectionId, appAuthProfileId } = - await seedLocalBrokerConnection("Fail Closed Org", brokerBaseUrl); - - const previous = process.env.LOBU_ALLOWED_BROKER_ORIGINS; - delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; - try { - const resolved = await resolveExecutionAuth({ - organizationId: localOrgId, - connectionId: localConnectionId, - authProfileId: null, - appAuthProfileId, - credentialDb: getTestDb(), - }); - expect(resolved.credentials).toBeNull(); - expect(lastRefreshBody).toEqual({}); - } finally { - if (previous === undefined) { - delete process.env.LOBU_ALLOWED_BROKER_ORIGINS; - } else { - process.env.LOBU_ALLOWED_BROKER_ORIGINS = previous; - } - } - }); -}); - -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; -} - -describe("oauth_broker profile — create validation via manage_auth_profiles", () => { - beforeEach(async () => { - await cleanupTestDatabase(); - }); - - async function seedOrgWithConnector(): Promise<{ - ctx: ToolContext; - orgId: string; - }> { - const org = await createTestOrganization({ name: "Broker Validate Org" }); - const user = await createTestUser({ name: "Broker Validate User" }); - await addUserToOrganization(user.id, org.id, "owner"); - await createTestConnectorDefinition({ - key: "demo.oauth", - name: "Demo OAuth", - organization_id: org.id, - auth_schema: { - methods: [ - { - type: "oauth", - provider: "demo", - requiredScopes: ["read"], - clientIdKey: "DEMO_CLIENT_ID", - clientSecretKey: "DEMO_CLIENT_SECRET", - }, - ], - }, - feeds_schema: { items: {} }, - }); - return { ctx: ctxFor(org.id, user.id), orgId: org.id }; - } - - it("creates an oauth_broker profile when all typed broker fields are present", async () => { - const { ctx, orgId } = await seedOrgWithConnector(); - const res = await manageAuthProfiles( - { - action: "create_auth_profile", - connector_key: "demo.oauth", - profile_kind: "oauth_broker", - display_name: "Broker Ref", - slug: "broker-ref", - auth_data: { - broker_url: "https://broker.lobu.ai", - broker_org: "broker-org", - broker_pat: "owl_pat_example", - broker_connection_id: 42, - }, - }, - TEST_ENV, - ctx, - ); - expect("auth_profile" in res && res.auth_profile).toBeTruthy(); - if ("auth_profile" in res) { - expect(res.auth_profile.profile_kind).toBe("oauth_broker"); - } - - // Persisted with the typed fields and trailing-slash-normalized URL; the - // PAT is never echoed back in the serialized profile. - const sql = getTestDb(); - const rows = (await sql` - SELECT auth_data FROM auth_profiles - WHERE organization_id = ${orgId} AND slug = 'broker-ref' - `) as unknown as Array<{ auth_data: Record }>; - expect(rows).toHaveLength(1); - expect(rows[0].auth_data.broker_connection_id).toBe(42); - expect(rows[0].auth_data.broker_pat).toBe("owl_pat_example"); - expect(JSON.stringify(res)).not.toContain("owl_pat_example"); - }); - - it("rejects an oauth_broker profile missing required fields", async () => { - const { ctx } = await seedOrgWithConnector(); - const res = await manageAuthProfiles( - { - action: "create_auth_profile", - connector_key: "demo.oauth", - profile_kind: "oauth_broker", - display_name: "Incomplete Broker Ref", - slug: "incomplete-broker-ref", - // Missing broker_pat + broker_connection_id. - auth_data: { - broker_url: "https://broker.lobu.ai", - broker_org: "broker-org", - }, - }, - TEST_ENV, - ctx, - ); - expect("error" in res).toBe(true); - if ("error" in res) { - expect(res.error).toContain("broker_pat"); - } - }); - - it("rejects an oauth_broker profile whose broker_url is not an absolute http(s) URL", async () => { - const { ctx } = await seedOrgWithConnector(); - for (const badUrl of ["broker.lobu.ai", "/broker", "ftp://broker.lobu.ai"]) { - const res = await manageAuthProfiles( - { - action: "create_auth_profile", - connector_key: "demo.oauth", - profile_kind: "oauth_broker", - display_name: "Bad URL Broker", - slug: "bad-url-broker", - auth_data: { - broker_url: badUrl, - broker_org: "broker-org", - broker_pat: "owl_pat_example", - broker_connection_id: 7, - }, - }, - TEST_ENV, - ctx, - ); - expect("error" in res).toBe(true); - if ("error" in res) { - expect(res.error).toContain("broker_url"); - } - } - }); - - it("creates an ACTIVE broker-backed connection through manage_connections referencing an oauth_broker profile", async () => { - const { ctx, orgId } = await seedOrgWithConnector(); - - // Admin sets up the broker app profile (the grant lives on the broker). - const profRes = await manageAuthProfiles( - { - action: "create_auth_profile", - connector_key: "demo.oauth", - profile_kind: "oauth_broker", - display_name: "Broker App", - slug: "broker-app", - auth_data: { - broker_url: "https://broker.lobu.ai", - broker_org: "broker-org", - broker_pat: "owl_pat_example", - broker_connection_id: 7, - }, - }, - TEST_ENV, - ctx, - ); - expect("auth_profile" in profRes && profRes.auth_profile).toBeTruthy(); - - // Create the connection THROUGH the tool (not raw SQL) — the OAuth - // connector has no local oauth_account, but the oauth_broker app profile - // satisfies auth on its own, so the connection must land ACTIVE. - const connRes = await manageConnections( - { - action: "create", - connector_key: "demo.oauth", - slug: "broker-conn", - display_name: "Broker Connection", - app_auth_profile_slug: "broker-app", - }, - TEST_ENV, - ctx, - ); - expect("error" in connRes).toBe(false); - if ("connection" in connRes) { - expect((connRes.connection as { status: string }).status).toBe( - "active", - ); - expect( - (connRes.connection as { app_auth_profile_slug: string }) - .app_auth_profile_slug, - ).toBe("broker-app"); - } - - // The FK points at the oauth_broker profile. - const sql = getTestDb(); - const rows = (await sql` - SELECT c.status, app.profile_kind AS app_kind, app.slug AS app_slug, - c.auth_profile_id - FROM connections c - JOIN auth_profiles app ON app.id = c.app_auth_profile_id - WHERE c.organization_id = ${orgId} AND c.slug = 'broker-conn' - `) as unknown as Array<{ - status: string; - app_kind: string; - app_slug: string; - auth_profile_id: number | null; - }>; - expect(rows).toHaveLength(1); - expect(rows[0].status).toBe("active"); - expect(rows[0].app_kind).toBe("oauth_broker"); - expect(rows[0].app_slug).toBe("broker-app"); - // No local runtime auth profile — the broker app profile alone satisfies auth. - expect(rows[0].auth_profile_id ?? null).toBeNull(); - }); -}); diff --git a/packages/server/src/auth/oauth/scopes.ts b/packages/server/src/auth/oauth/scopes.ts index 98482c7f2..91ba5c3e8 100644 --- a/packages/server/src/auth/oauth/scopes.ts +++ b/packages/server/src/auth/oauth/scopes.ts @@ -12,11 +12,6 @@ export const AVAILABLE_SCOPES = [ 'mcp:admin', 'profile:read', 'device_worker:run', - // Least-privilege scope for the OAuth broker runtime token fetch - // (POST /broker/oauth/token). Deliberately NOT in DEFAULT_SCOPES so a broad - // member PAT cannot exfiltrate connection access tokens — a broker-ref's PAT - // must be minted explicitly with this scope. - 'broker: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 index eac92eb81..3606b6aba 100644 --- a/packages/server/src/auth/pat-auth.ts +++ b/packages/server/src/auth/pat-auth.ts @@ -2,10 +2,10 @@ * 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 OAuth broker 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. + * 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"; diff --git a/packages/server/src/connect/broker-routes.ts b/packages/server/src/connect/broker-routes.ts deleted file mode 100644 index 8f56edf50..000000000 --- a/packages/server/src/connect/broker-routes.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * OAuth Broker Route (SPIKE) — grant-on-broker model. - * - * Nango-style managed OAuth, Lobu-native. The grant lives on the BROKER: the - * broker runs consent through its OWN existing `/connect/...` flow against its - * managed `oauth_app` (client_id/secret), and stores the resulting grant - * (`oauth_account` → `account` row) — the local instance never sees the client - * secret or the refresh token. At RUNTIME the local instance asks the broker - * for a fresh access token for a specific broker-side connection. - * - * Auth handshake: the local instance calls with `Authorization: Bearer - * `. The broker verifies the PAT (resolving the authenticated org + - * tenant membership via the shared `authenticatePat`, exactly as the embedded - * Agent API does) and scopes the lookup to THAT org — a PAT for org A cannot - * fetch org B's tokens. - * - * Endpoint (PAT-gated): - * - POST /broker/oauth/token → fresh access token for a broker connection, - * refreshed server-side with the broker's secret via the EXISTING - * CredentialService. Returns ONLY `{ access_token, expires_at }` — never the - * refresh token or client secret. - */ - -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 BrokerEnv = { - Bindings: Env; - Variables: { brokerOrgId: string }; -}; - -const brokerRoutes = new Hono(); - -/** - * The least-privilege scope a PAT must carry to fetch connection access tokens - * from the broker. Deliberately separate from the default `mcp:*` scopes so a - * broad org-member PAT cannot exfiltrate every connection's tokens — only a PAT - * minted explicitly with `broker:token` (the scope a broker-ref's PAT is - * expected to carry; see execution-context.ts) is authorized. - */ -const BROKER_TOKEN_SCOPE = "broker:token"; - -/** - * PAT auth for broker calls — the single shared `authenticatePat` gate. No / - * invalid / null-org / cross-tenant PAT short-circuits 401/403; on success the - * resolved org is stashed on the context. Org membership ALONE is not enough: - * the PAT must also carry the `broker:token` scope (403 otherwise), so a broad - * member PAT cannot reach the connection-token endpoint. - */ -brokerRoutes.use("/oauth/*", 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 `broker:token`. A `mcp:read mcp:write` member PAT - // is rejected here (403) before any connection is looked up. - if (!result.scopes.includes(BROKER_TOKEN_SCOPE)) { - return c.json( - { - error: "insufficient_scope", - error_description: `PAT is missing the '${BROKER_TOKEN_SCOPE}' scope`, - }, - 403, - ); - } - - c.set("brokerOrgId", result.organizationId); - return next(); -}); - -const TokenBody = Type.Object({ - connection_id: Type.Integer({ minimum: 1 }), -}); -const tokenValidator = TypeCompiler.Compile(TokenBody); - -/** - * POST /broker/oauth/token - * Return a fresh access token for a broker-side connection. - * - * The broker owns the 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 broker's secret when the - * token is expiring. The body carries ONLY the broker connection id; endpoints, - * secrets and the refresh token are resolved/held server-side and never leave - * the broker. The connection MUST belong to the PAT's org (else 403) so a PAT - * for org A cannot fetch org B's tokens. - */ -brokerRoutes.post("/oauth/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 organizationId = c.get("brokerOrgId"); - const sql = getDb(); - - // Scope check: the connection MUST belong to the PAT's org. A connection in - // another org (or a non-existent one) is indistinguishable from "not found" - // to the caller — return 403 either way so org membership can't be probed. - const rows = await sql` - SELECT id, auth_profile_id, app_auth_profile_id - FROM connections - WHERE id = ${raw.connection_id} - AND organization_id = ${organizationId} - AND deleted_at IS NULL - LIMIT 1 - `; - if (rows.length === 0) { - return c.json( - { - error: "forbidden", - error_description: "Connection not found in this organization", - }, - 403, - ); - } - - const connection = rows[0] as { - id: number; - auth_profile_id: number | null; - app_auth_profile_id: number | null; - }; - - const { credentials } = await resolveExecutionAuth({ - organizationId, - connectionId: Number(connection.id), - authProfileId: connection.auth_profile_id, - appAuthProfileId: connection.app_auth_profile_id, - credentialDb: sql, - logContext: { broker_org_id: organizationId }, - logMessage: "Broker failed to resolve connection token", - }); - - if (!credentials?.accessToken) { - return c.json( - { - error: "no_token", - error_description: "No access token available for this connection", - }, - 502, - ); - } - - logger.info( - { organizationId, connection_id: Number(connection.id) }, - "Broker resolved 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 { brokerRoutes }; 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..c8322f5c5 --- /dev/null +++ b/packages/server/src/connect/connection-token-route.ts @@ -0,0 +1,189 @@ +/** + * 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; authedOrgId: string }; +}; + +const connectionTokenRoutes = new Hono(); + +/** + * 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). 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, + ); + } + + c.set("authedUserId", result.userId); + c.set("authedOrgId", result.organizationId); + 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: + * - 403 if the authed user is not a `member` of `org`. + * - The connection lookup is owner-scoped (`created_by = `), so + * a user can only fetch tokens for connections they own. 404 if none. + */ +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(); + + // 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" = ${raw.org} + LIMIT 1 + `) as unknown as Array; + if (memberRows.length === 0) { + return c.json( + { + error: "forbidden", + error_description: "Not a member of this organization", + }, + 403, + ); + } + + // Owner-scoped connection lookup: a user can only fetch tokens for + // connections they OWN (`created_by`). A connection owned by another member + // of the same org is indistinguishable from "not found". + const rows = (await sql` + SELECT id, auth_profile_id, app_auth_profile_id + FROM connections + WHERE organization_id = ${raw.org} + AND connector_key = ${raw.connector_key} + AND created_by = ${authedUserId} + AND deleted_at IS NULL + AND status = 'active' + 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 connection found for this connector", + }, + 404, + ); + } + + const connection = rows[0]; + + const { credentials } = await resolveExecutionAuth({ + organizationId: raw.org, + connectionId: Number(connection.id), + authProfileId: connection.auth_profile_id, + appAuthProfileId: connection.app_auth_profile_id, + credentialDb: sql, + logContext: { org: raw.org, 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: raw.org, 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/http/spa-route-filter.ts b/packages/server/src/http/spa-route-filter.ts index d27df6d61..9aa9b3dd7 100644 --- a/packages/server/src/http/spa-route-filter.ts +++ b/packages/server/src/http/spa-route-filter.ts @@ -5,7 +5,6 @@ const SPA_ALLOWED_PATHS = new Set(['/oauth/consent', '/oauth/device']); const SPA_EXCLUDED_PREFIXES = [ '/.well-known', '/api', - '/broker', '/connect', '/health', '/legal', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7eafd9cbf..22a5d54fd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,7 +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 { brokerRoutes } from './connect/broker-routes'; +import { connectionTokenRoutes } from './connect/connection-token-route'; import { connectRoutes } from './connect/routes'; import { getDb } from './db/client'; import * as invalidationEmitter from './events/emitter'; @@ -553,13 +553,14 @@ app.route('/mcp', oauthRoutes); app.route('/connect', connectRoutes); /** - * OAuth Broker route (SPIKE) — PAT-gated, grant-on-broker. A remote Lobu - * instance acting as a broker OWNS the OAuth grant + managed client secret; a - * local instance whose oauth_app profile is a broker-ref fetches a fresh access - * token at runtime via POST /broker/oauth/token. The client_secret + refresh - * token never leave the broker. + * 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('/broker', brokerRoutes); +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 f1bd6c80d..03c1f02ef 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -123,9 +123,9 @@ 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/*`. Verified - * via the shared `authenticatePat` (also used by the OAuth broker router), - * which enforces the tenant-membership check (a PAT for org A must verify - * the user is still a member of org A). + * 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 @@ -143,7 +143,7 @@ export function createLobuAuthBridge() { // `Bearer owl_pat_*`. Validate first so an invalid PAT cannot fall // 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 broker router via `authenticatePat`. + // cookie. Shared with the connection-token router via `authenticatePat`. const bearerValue = extractPatBearer(c.req.header('Authorization')); if (bearerValue) { const result = await authenticatePat(getDb(), bearerValue); diff --git a/packages/server/src/sandbox/namespaces/auth-profiles.ts b/packages/server/src/sandbox/namespaces/auth-profiles.ts index a09ac706c..e7183855c 100644 --- a/packages/server/src/sandbox/namespaces/auth-profiles.ts +++ b/packages/server/src/sandbox/namespaces/auth-profiles.ts @@ -20,8 +20,7 @@ export type AuthProfileKind = | "env" | "oauth_app" | "oauth_account" - | "browser_session" - | "oauth_broker"; + | "browser_session"; export interface AuthProfileCreateInput { profile_kind: AuthProfileKind; diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index db15c0a44..12adfd347 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -424,11 +424,9 @@ export async function resolveConnectionAuthSelection(params: { const browserMethod = getBrowserMethods(params.authSchema)[0] ?? null; const preferredMethodType = getPreferredAuthMethodType(params.authSchema); - // 0. An explicit app profile slug may point at an `oauth_app` (local client - // credentials) OR an `oauth_broker` (the grant lives on a remote broker); - // both attach via the same `app_auth_profile_id` FK. Resolve it once, - // without pinning a kind, so it can be honored both as the broker-backed - // auth (below) and as the oauth_account app profile (step 2). + // 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, @@ -437,24 +435,6 @@ export async function resolveConnectionAuthSelection(params: { }) : null; - // 0b. Broker-backed connection: an `oauth_broker` app profile satisfies the - // connection's auth on its OWN — there is NO local `oauth_account` grant - // (the grant lives on the broker, and a fresh access token is fetched at - // runtime). Honor it BEFORE the no-auth-profile early-return below: the - // broker app profile IS the connection's app credentials, attached via - // `app_auth_profile_id`, with no runtime auth profile. - if (explicitAppProfile?.profile_kind === 'oauth_broker') { - return { - selectedKind: 'oauth_broker', - authProfile: null, - appAuthProfile: explicitAppProfile, - oauthMethod, - envMethod, - browserMethod, - preferredMethodType, - }; - } - // 1. Resolve explicitly selected auth profile, or auto-select the primary // auth profile for the connector's preferred auth method. const authProfile = @@ -488,9 +468,8 @@ export async function resolveConnectionAuthSelection(params: { } // 2. For OAuth accounts, also resolve the app credentials profile. The - // explicit app profile (resolved in step 0) may be an `oauth_app` (local - // client credentials) or an `oauth_broker` (handled in step 0b above); - // here we accept only `oauth_app` since the broker case already returned. + // 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 ? ((explicitAppProfile && explicitAppProfile.profile_kind === 'oauth_app' diff --git a/packages/server/src/tools/admin/manage_auth_profiles.ts b/packages/server/src/tools/admin/manage_auth_profiles.ts index 82bcfc8bf..b1fec0144 100644 --- a/packages/server/src/tools/admin/manage_auth_profiles.ts +++ b/packages/server/src/tools/admin/manage_auth_profiles.ts @@ -25,7 +25,6 @@ import { listAuthProfiles, normalizeAuthProfileSlug, normalizeAuthValues, - parseBrokerCredential, revokeOAuthAppProfileAtomic, setDefaultAuthProfileForConnector, summarizeBrowserSessionAuthData, @@ -61,7 +60,6 @@ const ListAuthProfilesAction = Type.Object({ Type.Literal('oauth_app'), Type.Literal('oauth_account'), Type.Literal('browser_session'), - Type.Literal('oauth_broker'), ]) ), }); @@ -89,7 +87,6 @@ const CreateAuthProfileAction = Type.Object({ Type.Literal('oauth_app'), Type.Literal('oauth_account'), Type.Literal('browser_session'), - Type.Literal('oauth_broker'), ]), display_name: Type.String({ description: 'User-facing auth profile name' }), slug: Type.Optional( @@ -494,39 +491,6 @@ async function handleCreateAuthProfile( } const connectorKey: string = args.connector_key; - // oauth_broker: a first-class broker descriptor. The whole profile IS the - // broker ref — its auth_data holds the typed, named fields (broker_url, - // broker_org, broker_connection_id) plus the PAT (broker_pat). Validate that - // all four are present and well-formed; reject otherwise. The broker fields - // arrive via `auth_data` (broker_connection_id is numeric, so the string-only - // `credentials` map can't carry it). - if (args.profile_kind === 'oauth_broker') { - const broker = parseBrokerCredential(args.auth_data ?? {}); - if (!broker) { - return { - error: - 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', - }; - } - const provider = getOAuthMethods(connector.auth_schema)[0]?.provider ?? null; - const authProfile = await createAuthProfile({ - organizationId: ctx.organizationId, - connectorKey, - displayName: args.display_name, - slug: args.slug, - profileKind: 'oauth_broker', - authData: { - broker_url: broker.url, - broker_org: broker.org, - broker_pat: broker.pat, - broker_connection_id: broker.connectionId, - }, - provider: provider ? provider.toLowerCase() : null, - createdBy: ctx.userId ?? 'api', - }); - return { action: 'create_auth_profile', auth_profile: serializeAuthProfile(authProfile) }; - } - if (args.profile_kind === 'oauth_account') { const oauthMethod = getOAuthMethods(connector.auth_schema)[0]; if (!oauthMethod) { @@ -755,23 +719,6 @@ async function handleUpdateAuthProfile( ? normalizeAuthValues(args.credentials) : undefined; - // oauth_broker profiles must always carry a complete, well-formed broker - // descriptor. Validate the payload that will actually be persisted (not just - // `args.auth_data`): a partial/bad update — e.g. `credentials` that can't - // carry the numeric broker_connection_id — would otherwise make - // normalizeAuthData silently wipe the profile. An update that omits the - // payload entirely (re-normalizing the existing valid broker fields) is fine. - if ( - existingForRoleCheck?.profile_kind === 'oauth_broker' && - updateAuthDataPayload !== undefined && - !parseBrokerCredential(updateAuthDataPayload) - ) { - return { - error: - 'oauth_broker auth profiles require broker_url, broker_org, broker_pat, and a positive integer broker_connection_id in auth_data.', - }; - } - let authProfile = await updateAuthProfile({ organizationId: ctx.organizationId, slug: args.auth_profile_slug, diff --git a/packages/server/src/tools/admin/manage_connections.ts b/packages/server/src/tools/admin/manage_connections.ts index f62099712..583911f23 100644 --- a/packages/server/src/tools/admin/manage_connections.ts +++ b/packages/server/src/tools/admin/manage_connections.ts @@ -926,13 +926,7 @@ async function handleCreate( if (authSelection) { const requiresAuth = !!authSelection.oauthMethod || !!authSelection.envMethod || !!authSelection.browserMethod; - // A broker-backed connection's auth is fully satisfied by its `oauth_broker` - // app profile alone — there is NO local runtime auth profile (the grant - // lives on the broker; a fresh access token is fetched at runtime). Treat it - // as satisfied so an OAuth connector doesn't reject for a missing - // oauth_account. - const brokerSatisfied = authSelection.appAuthProfile?.profile_kind === 'oauth_broker'; - if (requiresAuth && !authSelection.authProfile && !brokerSatisfied) { + if (requiresAuth && !authSelection.authProfile) { return { error: authSelection.browserMethod ? 'Select or create a browser auth profile before creating the connection.' diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index c4395a6a8..a6e01f252 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -6,8 +6,7 @@ export type AuthProfileKind = | 'oauth_app' | 'oauth_account' | 'browser_session' - | 'interactive' - | 'oauth_broker'; + | 'interactive'; export type AuthProfileStatus = 'active' | 'pending_auth' | 'error' | 'revoked'; export type BrowserKind = 'chrome' | 'brave' | 'arc' | 'edge'; @@ -48,88 +47,6 @@ interface BrowserSessionReadiness extends BrowserSessionSummary { resolved_cdp_url: string | null; } -/** - * The typed broker descriptor carried by an `oauth_broker` profile. The whole - * profile IS the broker reference: a remote Lobu "broker" instance OWNS the - * OAuth grant for a connection. The broker holds the managed client_id/secret - * AND the user's grant (oauth_account); the local instance fetches a fresh - * access token at runtime from the broker over HTTP (POST `/broker/oauth/token`), - * authenticating with `pat`. `connectionId` is the broker-side connection whose - * stored grant backs this local connection. - */ -export interface BrokerCredential { - /** Broker base URL, e.g. `https://broker.lobu.ai` (no `/broker` suffix). */ - url: string; - /** Broker org slug the managed oauth_app lives under (informational). */ - org: string; - /** Lobu Personal Access Token (`owl_pat_*`) the broker authenticates. */ - pat: string; - /** The broker-side connection id whose stored grant backs this profile. */ - connectionId: number; -} - -/** Field keys an `oauth_broker` profile's auth_data must carry. */ -export const BROKER_AUTH_DATA_KEYS = { - url: 'broker_url', - org: 'broker_org', - pat: 'broker_pat', - connectionId: 'broker_connection_id', -} as const; - -/** - * Parse the typed broker fields out of an `oauth_broker` profile's `auth_data`. - * Returns the validated {@link BrokerCredential}, or `null` when any required - * field is missing/malformed. No `__`-prefixed keys: every field is named. - */ -export function parseBrokerCredential(authData: unknown): BrokerCredential | null { - if (typeof authData === 'string') { - try { - return parseBrokerCredential(JSON.parse(authData)); - } catch { - return null; - } - } - if (!authData || typeof authData !== 'object' || Array.isArray(authData)) return null; - const data = authData as Record; - const url = data[BROKER_AUTH_DATA_KEYS.url]; - const org = data[BROKER_AUTH_DATA_KEYS.org]; - const pat = data[BROKER_AUTH_DATA_KEYS.pat]; - const rawConnectionId = data[BROKER_AUTH_DATA_KEYS.connectionId]; - const connectionId = - typeof rawConnectionId === 'number' ? rawConnectionId : Number(rawConnectionId); - if ( - typeof url !== 'string' || - typeof org !== 'string' || - typeof pat !== 'string' || - url.trim().length === 0 || - org.trim().length === 0 || - pat.trim().length === 0 || - !Number.isInteger(connectionId) || - connectionId <= 0 - ) { - return null; - } - // The broker base URL is fetched at runtime, so it MUST be an absolute - // http:/https: URL — reject relative paths, missing schemes, or non-HTTP - // protocols (file:, javascript:, etc.). Closes the broker-URL hardening gap. - const trimmedUrl = url.trim(); - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmedUrl); - } catch { - return null; - } - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - return null; - } - return { - url: trimmedUrl.replace(/\/+$/, ''), - org: org.trim(), - pat: pat.trim(), - connectionId, - }; -} - export function normalizeAuthValues(raw: unknown): Record { if (typeof raw === 'string') { try { @@ -156,20 +73,6 @@ function normalizeAuthData( profileKind: AuthProfileKind, raw: unknown ): Record { - if (profileKind === 'oauth_broker') { - // The whole profile IS the broker descriptor. Persist the typed fields: - // broker_url/broker_org/broker_pat are strings (normalized), and - // broker_connection_id is a number that normalizeAuthValues() would strip, - // so store it explicitly as an integer. - const broker = parseBrokerCredential(raw); - if (!broker) return {}; - return { - [BROKER_AUTH_DATA_KEYS.url]: broker.url, - [BROKER_AUTH_DATA_KEYS.org]: broker.org, - [BROKER_AUTH_DATA_KEYS.pat]: broker.pat, - [BROKER_AUTH_DATA_KEYS.connectionId]: broker.connectionId, - }; - } if (profileKind === 'env' || profileKind === 'oauth_app') { return normalizeAuthValues(raw); } @@ -407,23 +310,6 @@ export async function getAuthProfileById( return rows.length > 0 ? (rows[0] as AuthProfileRow) : null; } -/** - * Resolve the typed {@link BrokerCredential} for a connection from its - * `app_auth_profile`, or `null` when that profile is NOT an `oauth_broker` - * (i.e. the connection uses the local/unchanged credential path) or the broker - * descriptor is malformed. This is the single seam for the broker branch: - * callers gate the broker path on a non-null result and otherwise fall through - * to their existing local path. Never sniffs raw `auth_data` keys. - */ -export async function resolveBrokerCredentialForConnection( - organizationId: string, - appAuthProfileId: number | null | undefined -): Promise { - const profile = await getAuthProfileById(organizationId, appAuthProfileId ?? null); - if (!profile || profile.profile_kind !== 'oauth_broker') return null; - return parseBrokerCredential(profile.auth_data ?? null); -} - export async function createAuthProfile(params: { organizationId: string; connectorKey: string | null; diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 373741f1f884bad59d74a49d8b15075cedde19c5..7e1d3421afc224739e9eea818afa5211547c3386 100644 GIT binary patch literal 12603 zcmbVSTW{OQ72aq6idkS$Nm-jpU-}@&Np0EbYU@j2E4^U5kSK91v!+Our0i840e$KZ zDEbTcm-IX5%#FHu7Y&fa=FFTq_v@tdMN!u3MfFRa=)6wj>`s?Y=~Sz=x-W~lYDG~u zUe*uYsoPPtjt^3MaHi|CWt!D#{;4dU(nOd2BEL^(;rVHi=XzRq3j%bsF#8%{>eWK4 z^GQETfyJQ$)bmLQBAIljytZ-oDIntF`!v&Mt3h(8@}iu_S^7^+D}Rc!rLH_2ri2in z{f+x^;SHUjzgN0`D3U6G1(Jw>qNL@!>4Tof0Z>C;Q69xu4iQ|$Wu?Ebiu`8scbs=; zexDVSWp`SXI^3=@tT=@b_ai$|S@pG$pT>gs{Nf3OSci^`W-M#uC|ixeA9 z>s^c0v{>lvg>{JOt*(mfNhc0tLYT1(^sV;_K^_+)22dRk+*7w2d`{j8sfUK-_k?@} zin05yj%&TOZ<#;l#dH2XAa+s~&y_AeK@P6^GVN{9D#=>LD6gd6G?M} zZI&HopV3k$+~WgK4rL%`5?8Bys_vKhlqgeB50Y?$3vJv6+W;r%qV09z(m|)EK*~I= z^t+vU@?I3B$Sd6H=~gEyevZ?cyhy+f@(*pfHEQzy&>#~9Kn98W@PYNbV?;uiH2C}4 zJs{AMxOrK))CB17#2#FWe=}VxFEOyo@o~$v#S(y&7$**QyXq>=;Z2ew=cCl5jPvP3 zPdz{AT-6U+UEcIhF9V%ZX{E+M9{BCfkmN2STrC@P#TZqq78mmt?cS9us z1b&mfF_i0wK}G76X%=wMw`EqVxsLOS5%k;X7WtMH(>Mzl3k87Nndq!|J_Kh{Ae~kM z4|@C%=ip(ahTxWA%+X|}5?xKpbb+t{Z0CT?0h3r-m&>ZwNkD|a8Uq)^#xX*RezHEi zjGtM1M+uyYdeo~b!fxz@!kOEgrWs>0QaxLJEGXPkYyvmPs(y{qd4>!~s;uAXy=t4w zI&4)IgCHW2@B-V08uK6B4c#kzZ^2Ap`#n6@N{Nhh@b3SGmc6TmH|Q* zQvmYG39x)I;KNYYw6qVA4T5ClEkk+B;eo6t=YE9pB#Zv|PGD^C;~fG5r7rSNb1 zc{^XVp4}ffpBSyNs?69xUnx61aVoMyF?Lh2ShU3L6QRy%mNFBTFEzKoI{E)R;7q-9(f|jdxy|J7zoxa zcx*O&!S+qz)}|jYd&7_6=D;zSo$d?Ni?keRrZ)6ie{ouOqPiH|-Q02R-tMTTpCEdT z`!GQHH-E2jB-WLz)ikCU-R3gGb4|*M=s7M;Tp_?A>t6HgwAXu}*L^$fMIk}8Pfz|c z$)&+4z})|6^b1JZdWqcl#~AJ>fil>n6ZMHHghCy#W^A;ITq-*>V2qIK@$P%i>C^^P z|FZdg*s?#te9vK{hmTwB<0+-Ggsj?W-_4HXK?vCHl?TFcR8xP2Gv32_i>=T$U>` zs%W{QS2{)dRrWwTwkpml*$s}8v{XO;`#&zB>OY{c(CQick?9o~42{yY;xX0LF_K#g zVVW#P%Q9mfRSY4n>lpR|C5u(L%ySgb%0Qz;wbb*X*2oPf%h`<9s3xL>4hZ!HK5Ec- zrBE7Cs+cf6lsQid;4+QD@nc>=Moc-PWQq?(mT>AiCs-uYU13^^TvHH2XQdMORiS!I z;2XhO@EkoC<)bcxkiCrt#SEVoM3mWk8JL<%VvRAlWbqjsZl1DyZ|o=UzkQ$gSxq%Z zR|^K$ZhVwzc-T~o-VR=h?N}#C7Xu@p`TtyIXz3{d5rSsF)5zw6SS3zlr$3UVkTm(S z%^l+ha8dWon*;R*wUWtpgPJg?WK2GjNHG&{Rfj0j#z2Xx7--@f^=B$07dXXzJJM*6 zF!pklsy1pwQ&Fn0nCy|x6F@1=Qn+H&sah&X)K>dyNVT(kBtRYyg&Z5ETBmb;$ht|L zPJ}%&Pxb(Ww!INZOa1!kbVw9-gT9)ndCpenzVxG*2-b3OC~;8PN^MdCFPX0dfFst1K zmY79w#B^F7A?2XD86Hfupd+Z@<49UNCpOU zgsb=$z(E=cm~b-05Po?3V2h}opZ(8!o156j_>=p4OdI7E(@A%MJS-6rqB9d06JJWun(4XDTsTfg4#kK z-lvH^QfQAqR?T%*Q{Jn$AcFpcbozZ}(bhkfc_#XTvUZ3RQrkV0!WCOex!c+5M5SJ2 z7!TC8%cNe2ElBW#{3feDh7nJ8x$WC2Eh<*PUb1B(VMn11q<$fTj4 z;NZQtL`9_))Rog4L>|Mo#>AkC6t2SJWJO+i48eaDBJq{EYOzfLn%J)i>1 zR_N{{Gte@!Tp`A(n7~YyCA=N3MuJ$Wq|jB~g7?!$l4&9KP^W$+9R;4LM(R_M97&og zQ-E}q!%-46zL@x1{? z|0(mlm8mafQNl>cEb9lLo)yRt4J%XRH%QZ|bmq<#qVpc~FaGjraQoNM+2@ZRFK$Oy zccA2VZ%1$6zC~GY6yoEnqaxs9b;8J;(gK&DNETxUMI?zmIjjtEWV@@W9cZajzD2(T zxeCU8Ip!m8ARQiGA)Anr{HQ`|ajc%9u{k8nlbljdH5wmkY-M341eVnlr`A$2^@*nx znMgTv9hZ)Iwr;f*7_?M{`FmoIi!v%b$5*ndb=`jN=f{PXKw%vGn6K?tePGNOtar? zPh)L<2s0Q~Qn&^v)%cqigCfO?^;iB^!#FO6!tcgutj9h&8Kpl_J4}$;-ferK^O@!Z z{Js3-o3&S!I@*Ah^tx*@YCrxl|WBCO?vtr=UinNotFXJ^utr35W%29q`rkN&% zrs@=KCof=*>IpA#8p0QFRhrp|6C_@HCd>u{Jt5mD8GiPUH&91W3ewQvAq7;@w^jqUy&?|$52q`2sb z6{UIa(Np6ySU%c&UPBQg*JCmw`s3zwlOjR!{{7v}btDcyyH}e-;)I*-R3RCo4RQ^lrt;4NOJ*sU5TZZY{iI)lC9CA zsPIJK%jRc1dk^+A{oyxz~))6Sr}+j~zq(?n)#6~Fc5J2x8} zYRaF#zZqOx)}x)k&AF8PKibIjx`zJTU`G<6QvwU0Hm{N$)c(MZP`1(mnQne->^I&P z4=uv6n>1Ud(J!=Hwm<{9Z(c$aY{Mn!sqhr;^^QmIo&d;-PegSH=%z+w{~WLkd~wRf z-T9Glot`G}8%!}}fkYZ)9^j%FvTuF+R(cd7b}3o#P-79CYq~7r7I85G}xWzK(7OpxjfW07Ywc01@tVpHb|aOHkQUa*q7P zp=m$7ilcq#@OgoD>c%y84WN0skA8pH8x+GGB$9K72!<8#e z5A*H7W$Xhf4uw`<1ABMTzr7f4fgVsZvEc@P2oUx1wI5$)oNN+&_JDQBGH9>3PUk|F zv9vDoWKm#L;FXX%HBAw3?;(lXFv*U=N?szVB}aavS!e@yJPc}hYh>*7e88lLw4IF2 z;7pr?UxTg4k`TlYcj@h~d-Xfl<;=hNg?&=^0!%OiKOtiX5aeiWl8Kg-(!nO^`m8nSYz!!}9b#_(CT6UqMr DTJcdv literal 12975 zcmcgz>vG#hmj17&Xji3%q$85a?p9@r9>=Sjca+GMOOiJ~TrLtMni57JzyUxhima;n zH4iW~PdHDq-*-+o(BMVc>`bi^TV$hg`t-Sc=hE$DvCNCgJes4z#x|>xD801BT{5%g z$;^vrrRl}Tw z`b#&jMW%XKT5~)-N)vFgZ$R~U8j^^o!zJ- z;lpXn(JU_(QJVZ>`Ru==bY)8p7q-mPJG)NjZO*-r^CZvq_iBNNJh%;#ZtViAo|82H zytCC!9+&&gqK;Tz@bTsB#x9~}i$f=*yy!=G9Bk7pqoTAwlzDbO{S%m}-}%oppRVv; zVZ+(BDDvW+Ez9USP~uRA17_3w`q~y=$3y)JI(tc0*jf}tX56l;ntRVLo&U9=^IE>?qYKoVR?T)6dIrL@-@8Ej>dl=cr>?c{qklsqf3 zYO`k=IX|p5dsR&uJ+X-u+HQVawUf~ZOGH8T?l5M!k}ftTfGZmZc12n&EXd@&UP zMkz{+A}fVm9I}8dI0<@NgV=sG z64C7cd8;rcnVYUI-xCk;ECnE5+oq`DP_%u}V7z;M-@R_kU@&m5(VzeWEPoo^j}zdL zo7aqLgf`|$JVn_HSn)i9P#x$?@b~D*hXJYW4{rvrGRaKniyhr^$FsE-w1O1}`gttY z)=UqB8NoA6n`d<(gD*5<8o1$cRCzRLUXk$Drt?IofFR`W!d9yytG5|!-qUDAc+^N{ zWFDXVgm#1qIfg7YVo~#4_y8h1T{O@r5~bgcZ<8!GuV2459d%voI$vc~$NcVhA(m2< z9Eaw;9;@P^*_60Kvo)WB0)*`SW@_r;w|U`s>Ws$QuyAvviRkOvLblk&_FXKOo%$tX z-nc;EoO2C3WfApBYE6H(2Z(?sr|onBM+CpNB&^-`E+pD0vcS3@pL(rD!fzv>RBY!- zW@9xlFDx-|`jW=GMyyT`1EjT(rAve*iz}NAKf8+{6b3fn4bzxFPmb?ek%TAZEO20$ zm5w>*6lT0hQEUzKZEy(T!BOz2&~USc1)D2{Q`zIOI_UrEVX2buK^BpfcBM;nS82^2Gcf{fh|00gy?Za zQ8m-N5bTXYUqEKtYAy(j-J2cr339w*guP>`VZv4j436WZF#q`L-^?P)Py)y1=my!p zHTRg0R4q^8Xfafu-zK(vWpbCR0XpL4cvYlgqedu1RTaTsU}TXgR#}D&*i*YpFr+DG zY^SU1Yd&M)!qg579VvYjiqV6~Gf5?5*^fywGtI%vEP}*e$rJF7q!Bp4zi;w1mfUke zTGZ*bFs+rStJ+|*4BLct!sc&P_*$@5KF9ZYf#g02*_&)IW;AKRpsfQ}_-L$g4i_&! zLc;C0yxd#!U2?jXoiv!$Hghzv;Bej6phm;}wqcC6$WndBgCrLQM_}{+vY6Y;h{nX7 z2C1CjPAi)WVwHl%Ry@*2;ns9yQyUHku+Z@3%RTeboaOm)8qIEDQe~Az3zRghvTmws zIhy#=BQFvduT)?uiW&t~XcEPZ6}u%Rv|x&;Le+<`l_s;KN*_$b>1k3{r9qY4h1HlL z;3u8;!5SxujtuI9}SS`>7+P7`M{dH=S?)?YqG@`0rbO`7|1R`<)vM;y}n7y1$uf;{_4#DzQ<6@1`ml`9 zcbuZ2Cq5p(zXV23Q6kU)zBV?S<@{LoUWp`Zga8h~)yAbJiXl?7KO?|+Nn;)b?ZvkM zsKz$6*T8ZkEer^cmc$oNh}q~OqrkvBXdSR0;jFwX#$Q68D(C@QsDmh(z;~c+@r)sw zp{V((@X_zT`~7zlu*;&q9R7J_3j_@l73f%8BROEbf12H)r5wwO?jdQ#MZR3}`9X%f z8^$%Xi)HnIX?Nx!v4nW2LC0u%j99QxZx5m?U5eBS%g!%+vpYxP(}#*m8>6umx%qv% zxr8;Jrc1obS5P)>5)JtkxFxCDZOvi@4Q9DB40Xi1;fA!UvF;nk%PSv=)UqUAlm|tLt9)uw!v768M!hTd& zseo_sW6T_sz^ba-+3)l`mg}5Z1e1huTe6G!0V^6nU(_>t;?YUAvKq(2vehv#h~l_Q zl=^sa)@@!y!;T=~Gf$U$KZ0BSAVs*mO_oSfQZT=O>S(dFAK)x}mmZrLDx1Co56PGe zXpmysw=RPQ^j(irJWGunlVQ2gH5i1^aVe|`hYy{P=;ExzIZNAEx*@y)+Al;2ihSZ> zQiAk7TF6>q1W{Jd_EWLc@rE#B<9!U933Hf<=}M*svrTPO7*-_^K`(%`fBfa|STsVmbAp_TS5q$S&rd@5VWq?Dn=&8!d6WXU;HTv6Q-t7x%0GwCkX9y zn_kSsTtE`dOgI+dp#cYd;Gm4SF!}1SUjE4iA3YBpisK;2fPk;i6(z#DToT-dSM!8q z=TN)v^lAu+Kpq*5MbQ*>&I*davyMX`hZw$2kaU!os;sg{TIDljE-%>4lRzk4yq%BB zQOBd|GmO%)34_w`Ma*)jxPyN#+bw&~v8)I#<^fj_{N5F(C{gavO&*ju_?2emH`^G; zWd5uPdN==WzSn{xkB`$M^GxWYw!)O4g)&-C@6wqIPku)?QR(%kVy**SsFHv2&YXwR z-T*bv?DP4pfGY$+Y8(ph#xH5+mZu z86Yww?UwXQTsDD=zclwKA!sksJt^$WqAg`6kM0P@o!_u>xIjal2rat~{g|RP# zRc7r-Xh2!O6h!ttr>q z?ajU$?IICF#fIxAhY*_K+KS^O9Fot+0D9MexG`cEd6=OUg!ae7D;E76cW3l^#dM>vRJ;B@yqSh~zSKJQ z4(?Y{tzk7uirTmQs3sl1lw7G+ynl%YGej*A5RcFW& zke0_XNkF-)mVP9b9#$>!;EWG_#S zE>5mCNFyF2>Cl_53WJ{lMjQS5!Dj`hla!wwWNT)HbYr-+&4nTpZJoG-Ev6}M)`p5V z=TL+`EaD?_E@ujw6!`7vHy%p2c*hN`7Fwg@$9o(uapCj^u5kkdfwy5Un2I=#Achdj zo6>dvb76~XTgNc?C;Ita(1bAX35ksJ?N6W4(A;q|TVjw(PskxB+%*$-FTqFa@I_Z* p*W0b>Q!f3N3j>@E;@qsOl;ys3_zxR+f&)gi{}LQ>=yY!Re*iw_+baM7 From 7f50329c75473466ac2242887ea899d2c5678629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 18:01:44 +0100 Subject: [PATCH 11/15] feat(connect): consent-only connections (no feeds) for managed-app grants --- .../cli/src/commands/_lib/apply/map-config.ts | 27 +- packages/cli/src/config/define.ts | 8 + .../connectors/consent-only-feeds.test.ts | 307 ++++++++++++++++++ .../server/src/tools/admin/manage_feeds.ts | 15 +- 4 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts diff --git a/packages/cli/src/commands/_lib/apply/map-config.ts b/packages/cli/src/commands/_lib/apply/map-config.ts index 05063f764..2e605d10e 100644 --- a/packages/cli/src/commands/_lib/apply/map-config.ts +++ b/packages/cli/src/commands/_lib/apply/map-config.ts @@ -642,15 +642,34 @@ 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. - const config = connection.managedBy - ? { ...(connection.config ?? {}), managedBy: { ...connection.managedBy } } - : connection.config; + // 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 baseConfig = + connection.managedBy || connection.consentOnly + ? { + ...(connection.config ?? {}), + ...(connection.managedBy + ? { managedBy: { ...connection.managedBy } } + : {}), + ...(connection.consentOnly ? { consent_only: true } : {}), + } + : connection.config; + const config = baseConfig; return { slug: connection.slug, connector: connectorKey(connection.connector), diff --git a/packages/cli/src/config/define.ts b/packages/cli/src/config/define.ts index bcd314e12..c84b55780 100644 --- a/packages/cli/src/config/define.ts +++ b/packages/cli/src/config/define.ts @@ -125,6 +125,14 @@ export interface Connection { * {@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/consent-only-feeds.test.ts b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts new file mode 100644 index 000000000..7eadb432b --- /dev/null +++ b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts @@ -0,0 +1,307 @@ +/** + * 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 { 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' }); + 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); + }); +}); 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. From 954c0a6d58dbfa41ec3209c4124c1d9a14b34217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 18:15:22 +0100 Subject: [PATCH 12/15] fix(connect): pin managed-token PAT to LOBU_CLOUD_URL; scope token endpoint to public consent-only grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two security blockers + one cache-key bug on the public-org managed connector model: - PAT exfiltration: fetchManagedConnectionToken sent LOBU_CLOUD_PAT to a connection-config-controlled managedBy.url, letting a malicious connection redirect the PAT to an arbitrary host. The PAT is now ALWAYS sent to the instance-configured LOBU_CLOUD_URL only; drop the per-connection url override entirely (managedBy is just { org }) in execution-context, the CLI defineConnection ManagedBy type, and map-config. - Over-broad token endpoint: /oauth/connection-token returned a token for any owner-created active connection in any org the user belongs to (could export private-org connection tokens). The lookup now requires the org to be visibility='public' AND the connection to be a consent-only managed grant-holder (config.consent_only=true); otherwise 404 (same not-found shape, no leak). Keeps the member + created_by owner checks. - Cache-key bug: managed-token cache key used literal NUL separators; replaced with JSON.stringify([org, connectorKey, baseUrl]) so keys can't collide. Tests: connection-token suite extended — non-consent-only and private-org connections -> 404; managedBy.url ignored (PAT always targets LOBU_CLOUD_URL); map-config managedBy fold is org-only. server + cli tsc clean. --- .../_lib/apply/__tests__/map-config.test.ts | 8 +- packages/cli/src/config/define.ts | 6 +- .../connectors/connection-token.test.ts | 104 +++++++++++++++++- .../src/connect/connection-token-route.ts | 42 ++++--- .../server/src/utils/execution-context.ts | Bin 12603 -> 12856 bytes 5 files changed, 139 insertions(+), 21 deletions(-) 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 29bbf7ee8..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,20 +304,22 @@ describe("mapProjectToDesiredState", () => { expect(dc?.feeds).toEqual([{ feedKey: "stars", schedule: "0 */6 * * *" }]); }); - test("folds `managedBy` into the connection config", () => { + 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", url: "https://app.lobu.ai" }, + 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", url: "https://app.lobu.ai" }, + managedBy: { org: "lobu-managed" }, }); }); diff --git a/packages/cli/src/config/define.ts b/packages/cli/src/config/define.ts index c84b55780..bc2002540 100644 --- a/packages/cli/src/config/define.ts +++ b/packages/cli/src/config/define.ts @@ -100,12 +100,14 @@ export interface ConnectionFeed { * `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; - /** Override the cloud base URL (defaults to the instance's `LOBU_CLOUD_URL`). */ - url?: string; } export interface Connection { diff --git a/packages/server/src/__tests__/integration/connectors/connection-token.test.ts b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts index 6743c9644..6258e9013 100644 --- a/packages/server/src/__tests__/integration/connectors/connection-token.test.ts +++ b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts @@ -151,14 +151,22 @@ interface SeededManagedConnection { * 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: "public", + visibility, }); const owner = await createTestUser({ name: `${orgName} Owner` }); await addUserToOrganization(owner.id, org.id, "member"); @@ -223,14 +231,16 @@ async function seedManagedConnection( }); // 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, + 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 @@ -390,6 +400,45 @@ describe("managed connector — POST /oauth/connection-token", () => { }); 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", () => { @@ -451,6 +500,57 @@ describe("managed connector — local resolver", () => { 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"; diff --git a/packages/server/src/connect/connection-token-route.ts b/packages/server/src/connect/connection-token-route.ts index c8322f5c5..f697ab940 100644 --- a/packages/server/src/connect/connection-token-route.ts +++ b/packages/server/src/connect/connection-token-route.ts @@ -81,10 +81,13 @@ const tokenValidator = TypeCompiler.Compile(TokenBody); * managed secret when the token is expiring. Secrets + the refresh token are * held server-side and never returned. * - * Authorization: + * Authorization (narrow by design — this delegates ONLY managed grant-holders, + * never a user's ordinary connection tokens): * - 403 if the authed user is not a `member` of `org`. - * - The connection lookup is owner-scoped (`created_by = `), so - * a user can only fetch tokens for connections they own. 404 if none. + * - 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); @@ -125,17 +128,28 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { ); } - // Owner-scoped connection lookup: a user can only fetch tokens for - // connections they OWN (`created_by`). A connection owned by another member - // of the same org is indistinguishable from "not found". + // 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 id, auth_profile_id, app_auth_profile_id - FROM connections - WHERE organization_id = ${raw.org} - AND connector_key = ${raw.connector_key} - AND created_by = ${authedUserId} - AND deleted_at IS NULL - AND status = 'active' + 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 = ${raw.org} + 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; @@ -146,7 +160,7 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { return c.json( { error: "not_found", - error_description: "No active connection found for this connector", + error_description: "No active managed connection found for this connector", }, 404, ); diff --git a/packages/server/src/utils/execution-context.ts b/packages/server/src/utils/execution-context.ts index 7e1d3421afc224739e9eea818afa5211547c3386..33d007d792cda5d15bef54e13316df609d5e0db0 100644 GIT binary patch delta 850 zcmZXS&ui2`6vvUhRrK2SQ2M}yZZ)PN=%t8+_M}Tz+AUI+!cHVFWq!1q)8gNd zf54MxVK0I={{a6F{|Lc1*~D7CC6mnOz3=C}Z`oJ=<?3T+ym z)H#zNlyywy7{fT!&`M$MgjV6P5G7c@SV)1ksnO8rx2ZPe^caMLlO#rV2G#eQ!T`5UOyrqExgY>(Tzl-s#;8gUOsJRw9p^B;8A^ SvrW{UwodLzjI delta 561 zcmZvZ&q~8U5XK7y5kUn}w1V~ngI3YtQIs00M@6O7lb5W?GzPc3WwQ}ODaB`q58xvR zy$IgCdG!r^0dL;j)YN~tr=9s`zx~b3ga3Gbe>Xd_j#zOgy_rU;RYl}CO$J$Ke?N(FsE;L>9}a|%VIneb>GN5>6g)F!pz zNg>G7p%iGbAoOraiH+J{&22Hk0`m6~+rYV*o4g{WLZ70+CqoPyb<(jC5k_1QbMB_? zl2tVI>21lHM~*Zk`EIWNR(dM-3{9lJoKF=;bqwa{q)oDE2!BAJTCHLw(kc|e3YM3D ztG3CvDyudp5$Fr-Vsl^wIRDmtifhPSZY!t3k zK_Q={R0ot4@^^0fTwa(2UAIG(nB{xS#I(dV(@#_|> VPWrkuJ(FFA`P{ba_D8MFi4R?Qx!M2# From 32dcb877e54fedf3ab003c72d8dd76a5e246e773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 18:26:49 +0100 Subject: [PATCH 13/15] fix(connect): reject making a feed-having connection consent_only --- .../cli/src/commands/_lib/apply/map-config.ts | 3 +- .../connectors/consent-only-feeds.test.ts | 112 ++++++++++++++++++ .../src/tools/admin/manage_connections.ts | 33 +++++- 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/_lib/apply/map-config.ts b/packages/cli/src/commands/_lib/apply/map-config.ts index 2e605d10e..d3a110a18 100644 --- a/packages/cli/src/commands/_lib/apply/map-config.ts +++ b/packages/cli/src/commands/_lib/apply/map-config.ts @@ -659,7 +659,7 @@ function mapConnection(connection: Connection): DesiredConnection { // 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 baseConfig = + const config = connection.managedBy || connection.consentOnly ? { ...(connection.config ?? {}), @@ -669,7 +669,6 @@ function mapConnection(connection: Connection): DesiredConnection { ...(connection.consentOnly ? { consent_only: true } : {}), } : connection.config; - const config = baseConfig; return { slug: connection.slug, connector: connectorKey(connection.connector), 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 index 7eadb432b..9891dfeee 100644 --- a/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts +++ b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts @@ -21,6 +21,7 @@ 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'; @@ -305,3 +306,114 @@ describe('consent-only connections — feed creation is rejected by construction 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/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 From a06c61b80254ccc7582e324674b111de9cd09622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 18:39:03 +0100 Subject: [PATCH 14/15] fix(connect): require connections:token scope on /oauth/connection-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed-connector token endpoint minted OAuth access tokens but accepted any valid PAT (e.g. a default mcp:read mcp:write member PAT) — too permissive for a token-minting endpoint. - Add a dedicated least-privilege scope connections:token to AVAILABLE_SCOPES (NOT in DEFAULT_SCOPES, so a default member PAT never carries it). - /oauth/connection-token now rejects 403 insufficient_scope (in the auth middleware, before any org/connection lookup) unless the PAT carries connections:token. The local instance's LOBU_CLOUD_PAT must be minted with it (lobu token create --scope connections:token). Keeps the existing member + owner(created_by) + public-org + consent_only checks — this is in addition. - Resolve the request org by EITHER organization.id OR slug, then use the resolved id consistently in the membership + connection queries. Tests: connection-token suite extended — a right-user/org PAT WITHOUT connections:token -> 403; passing the org slug resolves (200); happy-path PATs mint with the scope; consent-only-feeds token test updated to carry it. --- .../connectors/connection-token.test.ts | 60 +++++++++++++++-- .../connectors/consent-only-feeds.test.ts | 4 +- packages/server/src/auth/oauth/scopes.ts | 6 ++ .../src/connect/connection-token-route.ts | 66 ++++++++++++++++--- 4 files changed, 122 insertions(+), 14 deletions(-) diff --git a/packages/server/src/__tests__/integration/connectors/connection-token.test.ts b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts index 6258e9013..b709110c0 100644 --- a/packages/server/src/__tests__/integration/connectors/connection-token.test.ts +++ b/packages/server/src/__tests__/integration/connectors/connection-token.test.ts @@ -137,10 +137,14 @@ afterAll(async () => { 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). */ + /** 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; } @@ -246,13 +250,20 @@ async function seedManagedConnection( 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), }; @@ -312,14 +323,51 @@ describe("managed connector — POST /oauth/connection-token", () => { 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. + // 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", + scope: "mcp:read mcp:write connections:token", }); const app = buildCloudApp(); @@ -340,12 +388,14 @@ describe("managed connector — POST /oauth/connection-token", () => { 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. + // 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", + scope: "mcp:read mcp:write connections:token", }); const app = buildCloudApp(); 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 index 9891dfeee..78b816e39 100644 --- a/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts +++ b/packages/server/src/__tests__/integration/connectors/consent-only-feeds.test.ts @@ -283,7 +283,9 @@ describe('consent-only connections — feed creation is rejected by construction ) `; - const ownerPat = await createTestPAT(owner.id, org.id, { scope: 'mcp:read mcp:write' }); + 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', { 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/connect/connection-token-route.ts b/packages/server/src/connect/connection-token-route.ts index f697ab940..39d4cb7be 100644 --- a/packages/server/src/connect/connection-token-route.ts +++ b/packages/server/src/connect/connection-token-route.ts @@ -36,11 +36,24 @@ type ConnectionTokenEnv = { 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). On success the authenticated user + org are - * stashed on the context for the handler's owner-scoped lookup. + * 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")); @@ -59,6 +72,19 @@ connectionTokenRoutes.use("/oauth/connection-token", async (c, next) => { ); } + // 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); c.set("authedOrgId", result.organizationId); return next(); @@ -83,7 +109,10 @@ const tokenValidator = TypeCompiler.Compile(TokenBody); * * Authorization (narrow by design — this delegates ONLY managed grant-holders, * never a user's ordinary connection tokens): - * - 403 if the authed user is not a `member` of `org`. + * - 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 @@ -107,6 +136,27 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { 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 @@ -115,7 +165,7 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { SELECT 1 FROM "member" WHERE "userId" = ${authedUserId} - AND "organizationId" = ${raw.org} + AND "organizationId" = ${organizationId} LIMIT 1 `) as unknown as Array; if (memberRows.length === 0) { @@ -143,7 +193,7 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { 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 = ${raw.org} + WHERE c.organization_id = ${organizationId} AND c.connector_key = ${raw.connector_key} AND c.created_by = ${authedUserId} AND c.deleted_at IS NULL @@ -169,12 +219,12 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { const connection = rows[0]; const { credentials } = await resolveExecutionAuth({ - organizationId: raw.org, + organizationId, connectionId: Number(connection.id), authProfileId: connection.auth_profile_id, appAuthProfileId: connection.app_auth_profile_id, credentialDb: sql, - logContext: { org: raw.org, connector_key: raw.connector_key }, + logContext: { org: organizationId, connector_key: raw.connector_key }, logMessage: "Failed to resolve managed connection token", }); @@ -189,7 +239,7 @@ connectionTokenRoutes.post("/oauth/connection-token", async (c) => { } logger.info( - { org: raw.org, connector_key: raw.connector_key, connection_id: Number(connection.id) }, + { org: organizationId, connector_key: raw.connector_key, connection_id: Number(connection.id) }, "Resolved managed connection token", ); From 09134f7887602eb89e5dd01f8a562a8ffbe54e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 25 May 2026 18:50:22 +0100 Subject: [PATCH 15/15] fix(connect): make connections:token mintable via PAT route; drop dead authedOrgId The /oauth/connection-token endpoint requires the connections:token scope, but the PAT-creation route's allowlist (AVAILABLE_PAT_SCOPES) rejected it, so the documented LOBU_CLOUD_PAT could never be minted. Add it there (still NOT a default scope). Also remove the unused authedOrgId context var. --- .../src/auth/__tests__/token-routes.test.ts | 21 +++++++++++++++++++ packages/server/src/auth/routes.ts | 11 +++++++++- .../src/connect/connection-token-route.ts | 3 +-- 3 files changed, 32 insertions(+), 3 deletions(-) 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/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 index 39d4cb7be..0f0cc7615 100644 --- a/packages/server/src/connect/connection-token-route.ts +++ b/packages/server/src/connect/connection-token-route.ts @@ -31,7 +31,7 @@ import logger from "../utils/logger"; type ConnectionTokenEnv = { Bindings: Env; - Variables: { authedUserId: string; authedOrgId: string }; + Variables: { authedUserId: string }; }; const connectionTokenRoutes = new Hono(); @@ -86,7 +86,6 @@ connectionTokenRoutes.use("/oauth/connection-token", async (c, next) => { } c.set("authedUserId", result.userId); - c.set("authedOrgId", result.organizationId); return next(); });