From b561f2c08c169fb4e3c7fd1b023294288a23299d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 30 Apr 2026 08:35:55 -0400 Subject: [PATCH 1/2] fix(integrations): source OAuth initiator userId from auth context The frontend hook posted `userId: ''` with a comment that the API would fill it in from the auth context, but the controller was reading userId straight from the request body, so every IntegrationOAuthState row was written with an empty userId. This was harmless until #2712 added a defense-in-depth session check on the OAuth callback that compares `session.user.id` to `oauthState.userId`. Because the stored value was always empty, every OAuth flow (GitHub, GCP, AWS, Rippling, etc.) now redirects with `error=session_mismatch` and "OAuth flow can only be completed by the user who initiated it." The spec did not catch this because the callback tests mocked `oauthState` directly with a non-empty userId rather than exercising start -> callback end-to-end. Resolve userId via the @UserId() decorator instead of trusting the request body, drop it from StartOAuthDto, and stop sending it from the client. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/oauth.controller.spec.ts | 15 +++++---------- .../controllers/oauth.controller.ts | 6 +++--- apps/app/src/hooks/use-integration-platform.ts | 1 - 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts index 01cf1192c0..0113ee0b76 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts @@ -176,9 +176,8 @@ describe('OAuthController', () => { mockedGetManifest.mockReturnValue(undefined as never); await expect( - controller.startOAuth('org_1', { + controller.startOAuth('org_1', 'user_1', { providerSlug: 'nonexistent', - userId: 'user_1', }), ).rejects.toThrow(HttpException); }); @@ -189,9 +188,8 @@ describe('OAuthController', () => { } as never); await expect( - controller.startOAuth('org_1', { + controller.startOAuth('org_1', 'user_1', { providerSlug: 'datadog', - userId: 'user_1', }), ).rejects.toThrow(HttpException); }); @@ -219,9 +217,8 @@ describe('OAuthController', () => { }); await expect( - controller.startOAuth('org_1', { + controller.startOAuth('org_1', 'user_1', { providerSlug: 'github', - userId: 'user_1', }), ).rejects.toThrow(HttpException); }); @@ -254,9 +251,8 @@ describe('OAuthController', () => { state: 'random_state_token', }); - const result = await controller.startOAuth('org_1', { + const result = await controller.startOAuth('org_1', 'user_1', { providerSlug: 'github', - userId: 'user_1', }); expect(result.authorizationUrl).toContain( @@ -303,9 +299,8 @@ describe('OAuthController', () => { state: 'state_abc', }); - const result = await controller.startOAuth('org_1', { + const result = await controller.startOAuth('org_1', 'user_1', { providerSlug: 'linear', - userId: 'user_1', }); expect(result.authorizationUrl).toContain('code_challenge='); diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 3d3d5e8120..83eca185f6 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -18,7 +18,7 @@ import { auth } from '../../auth/auth.server'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; -import { OrganizationId } from '../../auth/auth-context.decorator'; +import { OrganizationId, UserId } from '../../auth/auth-context.decorator'; import { OAuthStateRepository } from '../repositories/oauth-state.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { ConnectionRepository } from '../repositories/connection.repository'; @@ -30,7 +30,6 @@ import { getManifest, type OAuthConfig } from '@trycompai/integration-platform'; interface StartOAuthDto { providerSlug: string; - userId: string; redirectUrl?: string; } @@ -90,9 +89,10 @@ export class OAuthController { @RequirePermission('integration', 'create') async startOAuth( @OrganizationId() organizationId: string, + @UserId() userId: string, @Body() body: StartOAuthDto, ): Promise<{ authorizationUrl: string }> { - const { providerSlug, userId, redirectUrl } = body; + const { providerSlug, redirectUrl } = body; // Get manifest and OAuth config const manifest = getManifest(providerSlug); diff --git a/apps/app/src/hooks/use-integration-platform.ts b/apps/app/src/hooks/use-integration-platform.ts index a1f001af0c..cd6a9e1d0f 100644 --- a/apps/app/src/hooks/use-integration-platform.ts +++ b/apps/app/src/hooks/use-integration-platform.ts @@ -216,7 +216,6 @@ export function useIntegrationMutations() { const response = await api.post('/v1/integrations/oauth/start', { providerSlug, organizationId: orgId, - userId: '', // Will be filled by API from auth context redirectUrl, }); From 210a4f7e1120d01867cd20605ff84f8f0416e8bd Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 30 Apr 2026 08:49:16 -0400 Subject: [PATCH 2/2] fix(integrations): gate startOAuth with SessionOnlyGuard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address cubic-dev-ai review feedback. With HybridAuthGuard alone, an API-key or service-token caller would reach the @UserId() decorator, which throws a plain Error (turning into a generic 500) when no session user is present. Add SessionOnlyGuard between HybridAuthGuard and PermissionGuard so non-session auth is rejected with a clean 403 and a clear message. The OAuth callback already requires a real session (see checkSessionMatchesState), so non-session callers could never have completed the flow anyway — this just moves the rejection upfront. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/oauth.controller.spec.ts | 7 +++++++ .../integration-platform/controllers/oauth.controller.ts | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts index 0113ee0b76..576aaafa7e 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts @@ -4,6 +4,7 @@ import type { Request } from 'express'; import { OAuthController } from './oauth.controller'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; +import { SessionOnlyGuard } from '../../auth/session-only.guard'; import { OAuthStateRepository } from '../repositories/oauth-state.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { ConnectionRepository } from '../repositories/connection.repository'; @@ -36,6 +37,10 @@ jest.mock('../../auth/permission.guard', () => ({ PermissionGuard: class PermissionGuard {}, })); +jest.mock('../../auth/session-only.guard', () => ({ + SessionOnlyGuard: class SessionOnlyGuard {}, +})); + jest.mock('@trycompai/auth', () => ({ statement: { integration: ['create', 'read', 'update', 'delete'], @@ -134,6 +139,8 @@ describe('OAuthController', () => { }) .overrideGuard(HybridAuthGuard) .useValue(mockGuard) + .overrideGuard(SessionOnlyGuard) + .useValue(mockGuard) .overrideGuard(PermissionGuard) .useValue(mockGuard) .compile(); diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 83eca185f6..e62c6ca58b 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -17,6 +17,7 @@ import { randomBytes, createHash } from 'crypto'; import { auth } from '../../auth/auth.server'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; +import { SessionOnlyGuard } from '../../auth/session-only.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; import { OrganizationId, UserId } from '../../auth/auth-context.decorator'; import { OAuthStateRepository } from '../repositories/oauth-state.repository'; @@ -85,7 +86,11 @@ export class OAuthController { */ @Post('start') @ApiOperation({ summary: 'Start an OAuth authorization flow' }) - @UseGuards(HybridAuthGuard, PermissionGuard) + // SessionOnlyGuard rejects API-key and service-token callers with a 403 + // before @UserId() is evaluated. The OAuth callback also requires a real + // session (see checkSessionMatchesState), so non-session auth could never + // complete the flow anyway. + @UseGuards(HybridAuthGuard, SessionOnlyGuard, PermissionGuard) @RequirePermission('integration', 'create') async startOAuth( @OrganizationId() organizationId: string,