Make ConnectionProvider a true SyncableEntity (#20232)

## Summary

PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but
bypassing the standard sync pipeline — manifest sync called the bespoke
`ApplicationOAuthProviderService.upsertManyFromManifest()` instead of
going through the workspace-migration orchestrator like every other
SyncableEntity. Anything that assumed *"all SyncableEntity values flow
through the same pipeline"* (dev UI sync tracking, verification tooling)
was wrong about ConnectionProvider — that's the inconsistency this PR
closes.

This PR follows the `.cursor/skills/syncable-entity-*` guides
religiously, all six steps.

## What changes

**Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`)
- Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared)
- Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops
the ad-hoc columns since the base class provides them, adds `deletedAt`,
drops the old `(applicationId, universalIdentifier)` unique in favour of
SyncableEntity's `(workspaceId, universalIdentifier)`)
- `FlatConnectionProvider`, `FlatConnectionProviderMaps`,
`FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`,
`UniversalFlatConnectionProvider`, six action types
- Register in **all** the central registries:
`AllFlatEntityTypesByMetadataName`,
`ALL_METADATA_ENTITY_BY_METADATA_NAME`,
`ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`,
`ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`,
`ALL_METADATA_SERIALIZED_RELATION`,
`ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`,
`WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`),
`METADATA_EVENTS_TO_EMIT`
- `case 'connectionProvider':` in seven discriminated-union switches
(`derive-metadata-events-*`, `optimistically-apply-*`,
`enrich-create-*`)

**Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`)
- `WorkspaceFlatConnectionProviderMapCacheService` (extends
`WorkspaceCacheProvider`, decorated with `@WorkspaceCache`,
soft-delete-aware)
- `fromConnectionProviderEntityToFlatConnectionProvider` util
- `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util
- `FlatConnectionProviderModule` wires the cache service
- Wired the manifest converter into
`compute-application-manifest-all-universal-flat-entity-maps`

**Step 3 — Builder & Validation**
(`@syncable-entity-builder-and-validation`)
- `FlatConnectionProviderValidatorService` — never throws, returns error
arrays; uses indexed `byUniversalIdentifier` for the (name,
applicationUniversalIdentifier) uniqueness check (no
`Object.values().find()` on the hot path)
- `WorkspaceMigrationConnectionProviderActionsBuilderService`
- Registered in both validators-module + builder-module
- **Wired into the orchestrator** (the most-commonly-forgotten step per
the rule) — constructor inject, destructure
`flatConnectionProviderMaps`, `validateAndBuild`, append actions to the
final migration

**Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`)
- Three handlers (create / update / delete) using the canonical
`WorkspaceMigrationRunnerActionHandler` mixin
- Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule`

**Step 5 — Integration** (`@syncable-entity-integration`)
- Delete the `upsertManyFromManifest` bypass on
`ApplicationOAuthProviderService`
- Remove the bypass call from `ApplicationSyncService` — manifest sync
now flows through the standard pipeline
- Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule`
(no longer needed)
- Import `FlatConnectionProviderModule` from
`ApplicationOAuthProviderModule` to keep the cache discoverable
- 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`,
`CONNECTION_PROVIDER_NOT_FOUND`,
`CONNECTION_PROVIDER_NAME_ALREADY_EXISTS`

**Migration**
- Generated via `database:migrate:generate` (instance command
`1777896012579`): drops the old `(applicationId, universalIdentifier)`
unique constraint, adds `deletedAt` column, adds the `(workspaceId,
universalIdentifier)` unique index that `SyncableEntity` requires.
- Verified clean — a second `migrate:generate` pass produces zero drift.

**Step 6 — Tests** (`@syncable-entity-testing`)
- 3 new specs for the manifest converter (defaults, optional fields,
all-fields)
- All 32 existing OAuth-provider tests still pass
- ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven
only), so the GraphQL integration suite that other SyncableEntities ship
doesn't apply here

**Codegen**
- Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk)
against the live schema

## Why this matters

Before:
- `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum)
- But the entity didn't extend `SyncableEntity`
- And the manifest sync bypassed the standard pipeline
- → Verification tooling, dev UI sync tracking, anything iterating over
`ALL_METADATA_NAME` got inconsistent behaviour

After:
- `ConnectionProvider` is a `SyncableEntity` end-to-end
- Single sync path through the workspace-migration orchestrator (same as
`agent`, `skill`, `frontComponent`, `webhook`, …)
- One mental model

## Out of scope (deliberate)

- **Renaming the table** from `applicationOAuthProvider` to
`connectionProvider` — the `metadataName` is `connectionProvider` (what
consumers see in code); the table name is internal. A rename would
balloon this PR with mechanical churn unrelated to the sync-pipeline
wiring. Worth doing as a follow-up.
- **`applicationVariable` SyncableEntity conversion** — the other
manifest-sync holdout. Tracked in #20215.

## Test plan

- [ ] Migration up/down clean against fresh DB
- [ ] Install an app whose manifest declares connection providers —
providers appear in the workspace
- [ ] Re-deploy the app with one provider added, one removed, one
renamed → all reconciled correctly via the sync pipeline
- [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider
entries the same way it shows agents/skills/etc
- [ ] OAuth flow still works (existing connections, new connections,
reconnect, list/get from SDK) — should be unchanged since the runtime
code path didn't move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-05 14:04:15 +02:00
committed by GitHub
parent 59107b5b23
commit e3be1f4971
106 changed files with 2076 additions and 1746 deletions
@@ -4,7 +4,6 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -17,7 +16,6 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ApplicationOAuthProviderModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
@@ -12,7 +12,6 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
@@ -34,7 +33,6 @@ export class ApplicationSyncService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly applicationManifestMigrationService: ApplicationManifestMigrationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
@@ -155,12 +153,6 @@ export class ApplicationSyncService {
},
);
await this.applicationOAuthProviderService.upsertManyFromManifest({
connectionProviders: manifest.connectionProviders,
applicationId: application.id,
workspaceId,
});
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -0,0 +1,97 @@
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
const APP_UID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8';
const PROVIDER_UID = '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa';
const NOW = '2026-05-04T00:00:00.000Z';
const buildManifest = (
overrides: Partial<ConnectionProviderManifest> = {},
): ConnectionProviderManifest =>
({
universalIdentifier: PROVIDER_UID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
...overrides,
}) as ConnectionProviderManifest;
describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () => {
it('moves OAuth manifest fields into the resolved oauthConfig blob with defaults filled', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest(),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result).toEqual({
universalIdentifier: PROVIDER_UID,
applicationUniversalIdentifier: APP_UID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauthConfig: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'json',
usePkce: true,
},
createdAt: NOW,
updatedAt: NOW,
});
});
it('passes through optional oauth config when provided', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest({
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.oauthConfig).toMatchObject({
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
});
});
it('defaults to json content-type and PKCE-on when oauth config omits them', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest(),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.oauthConfig?.tokenRequestContentType).toBe('json');
expect(result.oauthConfig?.usePkce).toBe(true);
});
});
@@ -0,0 +1,49 @@
import {
type ConnectionProviderManifest,
type StoredOAuthConnectionProviderConfig,
} from 'twenty-shared/application';
import { type UniversalFlatConnectionProvider } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type';
export const fromConnectionProviderManifestToUniversalFlatConnectionProvider =
({
connectionProviderManifest,
applicationUniversalIdentifier,
now,
}: {
connectionProviderManifest: ConnectionProviderManifest;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatConnectionProvider => {
const oauthConfig: StoredOAuthConnectionProviderConfig | null =
connectionProviderManifest.type === 'oauth'
? {
authorizationEndpoint:
connectionProviderManifest.oauth.authorizationEndpoint,
tokenEndpoint: connectionProviderManifest.oauth.tokenEndpoint,
revokeEndpoint:
connectionProviderManifest.oauth.revokeEndpoint ?? null,
scopes: connectionProviderManifest.oauth.scopes,
clientIdVariable: connectionProviderManifest.oauth.clientIdVariable,
clientSecretVariable:
connectionProviderManifest.oauth.clientSecretVariable,
authorizationParams:
connectionProviderManifest.oauth.authorizationParams ?? null,
tokenRequestContentType:
connectionProviderManifest.oauth.tokenRequestContentType ??
'json',
usePkce: connectionProviderManifest.oauth.usePkce ?? true,
}
: null;
return {
universalIdentifier: connectionProviderManifest.universalIdentifier,
applicationUniversalIdentifier,
name: connectionProviderManifest.name,
displayName: connectionProviderManifest.displayName,
type: connectionProviderManifest.type,
oauthConfig,
createdAt: now,
updatedAt: now,
};
};
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util';
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util';
@@ -176,6 +177,19 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
for (const connectionProviderManifest of manifest.connectionProviders ?? []) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatConnectionProviderMaps,
});
}
for (const roleManifest of manifest.roles) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity: fromRoleManifestToUniversalFlatRole({
@@ -1,384 +0,0 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// We never use the real implementation here (the test always injects a
// mock via `useValue`), so stub the module to avoid loading the dep at all.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
describe('ApplicationOAuthProviderFlowService', () => {
let service: ApplicationOAuthProviderFlowService;
let oauthProviderService: {
findOneByIdOrThrow: jest.Mock;
getClientCredentials: jest.Mock;
};
let jwtWrapperService: {
sign: jest.Mock;
verifyJwtToken: jest.Mock;
generateAppSecret: jest.Mock;
};
let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
let connectedAccountRepository: {
count: jest.Mock;
update: jest.Mock;
create: jest.Mock;
save: jest.Mock;
findOne: jest.Mock;
findOneByOrFail: jest.Mock;
};
const baseProvider: ApplicationOAuthProviderEntity = {
id: 'provider-1',
universalIdentifier: 'provider-uid',
applicationId: 'app-1',
workspaceId: 'workspace-1',
name: 'linear',
displayName: 'Linear',
icon: null,
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as ApplicationOAuthProviderEntity;
beforeEach(async () => {
oauthProviderService = {
findOneByIdOrThrow: jest.fn(),
getClientCredentials: jest.fn(async () => ({
clientId: 'lin_client_id',
clientSecret: 'lin_client_secret',
})),
};
jwtWrapperService = {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
generateAppSecret: jest.fn(() => 'derived-secret'),
};
secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
connectedAccountRepository = {
count: jest.fn(async () => 0),
update: jest.fn(),
create: jest.fn((entity) => entity),
save: jest.fn(async (entity) => ({ ...entity, id: 'new-account-id' })),
findOne: jest.fn(async () => null),
findOneByOrFail: jest.fn(async ({ id }) => ({
id,
provider: ConnectedAccountProvider.APP,
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderFlowService,
{
provide: ApplicationOAuthProviderService,
useValue: oauthProviderService,
},
{ provide: JwtWrapperService, useValue: jwtWrapperService },
{ provide: SecureHttpClientService, useValue: secureHttpClientService },
{
provide: TwentyConfigService,
useValue: { get: jest.fn(() => 'https://api.example.com') },
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
],
}).compile();
service = module.get(ApplicationOAuthProviderFlowService);
});
afterEach(() => jest.clearAllMocks());
describe('startAuthorizationFlow', () => {
it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state-token');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.origin + url.pathname).toBe(
'https://linear.app/oauth/authorize',
);
expect(url.searchParams.get('client_id')).toBe('lin_client_id');
expect(url.searchParams.get('response_type')).toBe('code');
// OAuth-standard `scope` (plural meaning) — these are the upstream
// permissions we're requesting, unrelated to the row-visibility field.
expect(url.searchParams.get('scope')).toBe('read write');
expect(url.searchParams.get('state')).toBe('signed-state-token');
expect(url.searchParams.get('redirect_uri')).toBe(
'https://api.example.com/apps/oauth/callback',
);
expect(url.searchParams.has('code_challenge')).toBe(false);
// signed payload carries workspace identity for the callback to use
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
workspaceId: 'workspace-1',
applicationOAuthProviderId: 'provider-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
}),
expect.objectContaining({ secret: 'derived-secret' }),
);
});
it('emits PKCE challenge params when usePkce is enabled', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: { ...baseProvider, usePkce: true },
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toMatch(/^[\w-]+$/);
});
describe('reconnect target validation', () => {
// Cross-workspace reconnect was a real bug: the persist UPDATE filtered
// by (id, workspaceId) so it wrote nothing, but the subsequent
// findOneByOrFail({ id }) returned the foreign-workspace row with stale
// tokens, making the reconnect look successful. Catch it at authorize
// time before the upstream OAuth round-trip.
const validateArgs = {
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
redirectLocation: null,
};
it('throws FORBIDDEN when reconnecting an id that lives in another workspace', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
const error = await service
.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'foreign-account-id',
})
.catch((caught) => caught);
expect(error).toMatchObject({
code: 'FORBIDDEN',
});
expect(error.message).toContain('foreign-account-id');
expect(connectedAccountRepository.findOne).toHaveBeenCalledWith({
where: {
id: 'foreign-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
},
});
// No state JWT signed, no upstream URL built.
expect(jwtWrapperService.sign).not.toHaveBeenCalled();
});
it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
// findOne with the provider filter returns null even though the row
// exists in this workspace under a different provider.
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'wrong-provider-account-id',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('proceeds when the reconnect target matches workspace and provider', async () => {
connectedAccountRepository.findOne.mockResolvedValue({
id: 'existing-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
});
jwtWrapperService.sign.mockReturnValue('state');
const { authorizationUrl } = await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'existing-account-id',
});
expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
'state',
);
expect(jwtWrapperService.sign).toHaveBeenCalled();
});
it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
jwtWrapperService.sign.mockReturnValue('state');
await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: null,
});
expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
});
});
});
describe('completeAuthorizationFlow', () => {
const stateClaims = {
sub: 'provider-1',
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: 'provider-1',
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
reconnectingConnectedAccountId: null,
redirectLocation: null,
codeVerifier: null,
};
const successfulTokenResponse = {
ok: true,
status: 200,
json: async () => ({
access_token: 'new_access',
refresh_token: 'new_refresh',
scope: 'read write',
}),
text: async () => '',
};
beforeEach(() => {
jwtWrapperService.verifyJwtToken.mockReturnValue(stateClaims);
oauthProviderService.findOneByIdOrThrow.mockResolvedValue(baseProvider);
secureHttpClientService.createSsrfSafeFetch.mockReturnValue(
jest.fn(async () => successfulTokenResponse),
);
});
it('always creates a new ConnectedAccount when no reconnect id is supplied', async () => {
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('new-account-id');
expect(result.workspaceId).toBe('workspace-1');
expect(result.applicationId).toBe('app-1');
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
provider: ConnectedAccountProvider.APP,
accessToken: 'new_access',
refreshToken: 'new_refresh',
applicationConnectionProviderId: 'provider-1',
applicationId: 'app-1',
workspaceId: 'workspace-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
}),
);
expect(connectedAccountRepository.save).toHaveBeenCalled();
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
it('updates the existing ConnectedAccount when reconnectingConnectedAccountId is supplied', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
reconnectingConnectedAccountId: 'existing-account-id',
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('existing-account-id');
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: 'existing-account-id', workspaceId: 'workspace-1' },
expect.objectContaining({
accessToken: 'new_access',
refreshToken: 'new_refresh',
authFailedAt: null,
}),
);
// Defense-in-depth: the post-update read MUST also be workspace-scoped,
// otherwise a foreign-id that slipped past the authorize-time guard
// would still surface stale fields from another workspace.
expect(connectedAccountRepository.findOneByOrFail).toHaveBeenCalledWith({
id: 'existing-account-id',
workspaceId: 'workspace-1',
});
expect(connectedAccountRepository.create).not.toHaveBeenCalled();
});
it('persists the workspace visibility when state asks for it', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
visibility: 'workspace',
});
await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ visibility: 'workspace' }),
);
});
it('rejects an invalid state', async () => {
jwtWrapperService.verifyJwtToken.mockImplementation(() => {
throw new Error('JWT expired');
});
await expect(
service.completeAuthorizationFlow({
code: 'auth_code',
state: 'bad-state',
}),
).rejects.toThrow(/state/);
});
});
});
@@ -1,142 +0,0 @@
jest.mock(
'src/engine/core-modules/secret-encryption/secret-encryption.service',
() => ({
SecretEncryptionService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const APP_ID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8';
const WORKSPACE_ID = 'b8b8b8b8-b8b8-4b8b-b8b8-b8b8b8b8b8b8';
const buildOAuthManifest = (
overrides: Partial<ConnectionProviderManifest> = {},
): ConnectionProviderManifest =>
({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
...overrides,
}) as ConnectionProviderManifest;
describe('ApplicationOAuthProviderService', () => {
let service: ApplicationOAuthProviderService;
let oauthProviderRepository: {
find: jest.Mock;
save: jest.Mock;
delete: jest.Mock;
};
beforeEach(async () => {
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderService,
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: { findOneBy: jest.fn() },
},
{
provide: getRepositoryToken(ApplicationRegistrationVariableEntity),
useValue: { find: jest.fn() },
},
{ provide: SecretEncryptionService, useValue: {} },
],
}).compile();
service = module.get(ApplicationOAuthProviderService);
});
afterEach(() => jest.clearAllMocks());
describe('upsertManyFromManifest', () => {
it('rejects a manifest whose connection provider has a non-UUID universalIdentifier', async () => {
const manifestWithBadId = buildOAuthManifest({
universalIdentifier: 'linear-provider',
});
const error = await service
.upsertManyFromManifest({
connectionProviders: [manifestWithBadId],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(ApplicationOAuthProviderException);
expect(error.code).toBe(
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
expect(error.message).toContain('linear');
expect(error.message).toContain('linear-provider');
// Crucially: the failing validation must run before any DB write.
expect(oauthProviderRepository.save).not.toHaveBeenCalled();
expect(oauthProviderRepository.delete).not.toHaveBeenCalled();
});
it('points at the first invalid provider when multiple are wrong', async () => {
const error = await service
.upsertManyFromManifest({
connectionProviders: [
buildOAuthManifest({
name: 'first-bad',
universalIdentifier: 'not-a-uuid',
}),
buildOAuthManifest({
name: 'second-bad',
universalIdentifier: 'also-bad',
}),
],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error.message).toContain('first-bad');
});
it('accepts a valid UUID and persists the provider', async () => {
await service.upsertManyFromManifest({
connectionProviders: [buildOAuthManifest()],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
});
expect(oauthProviderRepository.save).toHaveBeenCalledWith([
expect.objectContaining({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
}),
]);
});
});
});
@@ -1,81 +0,0 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'applicationOAuthProvider', schema: 'core' })
@Unique('IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE', [
'name',
'applicationId',
])
@Unique('IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE', [
'universalIdentifier',
'applicationId',
])
@Index('IDX_APP_OAUTH_PROVIDER_APPLICATION_ID', ['applicationId'])
@Index('IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID', ['workspaceId'])
export class ApplicationOAuthProviderEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Column({ nullable: false, type: 'uuid' })
applicationId: string;
@ManyToOne(() => ApplicationEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity>;
@Column({ nullable: false, type: 'varchar' })
name: string;
@Column({ nullable: false, type: 'varchar' })
displayName: string;
@Column({ nullable: false, type: 'varchar' })
authorizationEndpoint: string;
@Column({ nullable: false, type: 'varchar' })
tokenEndpoint: string;
@Column({ nullable: true, type: 'varchar' })
revokeEndpoint: string | null;
@Column({ type: 'varchar', array: true, nullable: false, default: '{}' })
scopes: string[];
@Column({ nullable: false, type: 'varchar' })
clientIdVariable: string;
@Column({ nullable: false, type: 'varchar' })
clientSecretVariable: string;
@Column({ nullable: true, type: 'jsonb' })
authorizationParams: Record<string, string> | null;
@Column({ nullable: false, type: 'varchar', default: 'json' })
tokenRequestContentType: OAuthProviderTokenRequestContentType;
@Column({ nullable: false, type: 'boolean', default: true })
usePkce: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -1,43 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getApplicationOAuthProviderExceptionUserFriendlyMessage = (
code: ApplicationOAuthProviderExceptionCode,
) => {
switch (code) {
case ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND:
return msg`OAuth provider not found.`;
case ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED:
return msg`Client credentials are not configured for this OAuth provider.`;
case ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED:
return msg`Failed to exchange the authorization code for an access token.`;
case ApplicationOAuthProviderExceptionCode.REFRESH_FAILED:
return msg`Failed to refresh the access token.`;
case ApplicationOAuthProviderExceptionCode.INVALID_STATE:
return msg`The OAuth state parameter is invalid or expired.`;
case ApplicationOAuthProviderExceptionCode.INVALID_REQUEST:
return msg`The OAuth request is missing required parameters.`;
case ApplicationOAuthProviderExceptionCode.FORBIDDEN:
return msg`Not authorized to access this OAuth provider.`;
default:
assertUnreachable(code);
}
};
export class ApplicationOAuthProviderException extends CustomException<ApplicationOAuthProviderExceptionCode> {
constructor(
message: string,
code: ApplicationOAuthProviderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getApplicationOAuthProviderExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,285 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isUUID } from 'class-validator';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, Repository } from 'typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ApplicationOAuthProviderService {
constructor(
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
// Stored on the registration (one OAuth app per Twenty server, set by
// the server admin) — not per-workspace.
async getClientCredentials(
provider: ApplicationOAuthProviderEntity,
): Promise<{ clientId: string; clientSecret: string }> {
const application = await this.applicationRepository.findOneBy({
id: provider.applicationId,
});
if (!isDefined(application?.applicationRegistrationId)) {
throw new ApplicationOAuthProviderException(
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: application.applicationRegistrationId,
key: In([provider.clientIdVariable, provider.clientSecretVariable]),
},
});
const valuesByKey = new Map(
variables.map((v) => [
v.key,
v.encryptedValue
? this.secretEncryptionService.decrypt(v.encryptedValue)
: '',
]),
);
const clientId = valuesByKey.get(provider.clientIdVariable) ?? '';
const clientSecret = valuesByKey.get(provider.clientSecretVariable) ?? '';
if (!clientId || !clientSecret) {
throw new ApplicationOAuthProviderException(
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${provider.clientIdVariable}" and "${provider.clientSecretVariable}" on the application registration.`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
return { clientId, clientSecret };
}
// For batched calls (e.g. the resolver listing path) prefer
// `areClientCredentialsConfiguredBatch` to avoid N+1.
async areClientCredentialsConfigured(
provider: ApplicationOAuthProviderEntity,
): Promise<boolean> {
const result = await this.areClientCredentialsConfiguredBatch([provider]);
return result.get(provider.id) ?? false;
}
async areClientCredentialsConfiguredBatch(
providers: ApplicationOAuthProviderEntity[],
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (providers.length === 0) {
return result;
}
const applicationIds = [...new Set(providers.map((p) => p.applicationId))];
const applications = await this.applicationRepository.find({
where: { id: In(applicationIds) },
});
const registrationIdByApplicationId = new Map(
applications.map((app) => [app.id, app.applicationRegistrationId]),
);
const registrationIds = [
...new Set(
applications
.map((app) => app.applicationRegistrationId)
.filter(isDefined),
),
];
if (registrationIds.length === 0) {
providers.forEach((p) => result.set(p.id, false));
return result;
}
const allKeys = providers.flatMap((p) => [
p.clientIdVariable,
p.clientSecretVariable,
]);
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: In(registrationIds),
key: In(allKeys),
},
});
const filledKeysByRegistrationId = new Map<string, Set<string>>();
for (const variable of variables) {
if (variable.encryptedValue === '') continue;
const set =
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
new Set<string>();
set.add(variable.key);
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
}
for (const provider of providers) {
const registrationId = registrationIdByApplicationId.get(
provider.applicationId,
);
if (!isDefined(registrationId)) {
result.set(provider.id, false);
continue;
}
const filled = filledKeysByRegistrationId.get(registrationId);
result.set(
provider.id,
filled?.has(provider.clientIdVariable) === true &&
filled?.has(provider.clientSecretVariable) === true,
);
}
return result;
}
async findOneByApplicationAndName({
applicationId,
name,
}: {
applicationId: string;
name: string;
}): Promise<ApplicationOAuthProviderEntity | null> {
return this.oauthProviderRepository.findOne({
where: { applicationId, name },
});
}
async findOneByIdOrThrow(
id: string,
): Promise<ApplicationOAuthProviderEntity> {
const provider = await this.oauthProviderRepository.findOne({
where: { id },
});
if (!isDefined(provider)) {
throw new ApplicationOAuthProviderException(
`OAuth provider with id "${id}" not found`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
return provider;
}
async findManyByApplication({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<ApplicationOAuthProviderEntity[]> {
return this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
}
// Persists OAuth-typed entries only. Other connection-provider types get
// their own sibling persistence helpers when added.
async upsertManyFromManifest({
connectionProviders,
applicationId,
workspaceId,
}: {
connectionProviders?: ConnectionProviderManifest[];
applicationId: string;
workspaceId: string;
}): Promise<void> {
const oauthProviders = (connectionProviders ?? []).filter(
(provider) => provider.type === 'oauth',
);
// The DB column is `uuid NOT NULL`. The manifest type is just `string`
// because manifests are dev-supplied and TS can't enforce UUID at the
// type level. Validate up-front so we throw a typed exception instead
// of letting Postgres reject the insert with an opaque type error.
for (const provider of oauthProviders) {
if (!isUUID(provider.universalIdentifier)) {
throw new ApplicationOAuthProviderException(
`Connection provider "${provider.name}" has an invalid universalIdentifier "${provider.universalIdentifier}" — must be a UUID.`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
}
const existing = await this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
if (oauthProviders.length === 0 && existing.length === 0) {
return;
}
const existingByUniversalIdentifier = new Map(
existing.map((p) => [p.universalIdentifier, p]),
);
const toSave: Partial<ApplicationOAuthProviderEntity>[] =
oauthProviders.map((manifest) => {
const fields = {
applicationId,
workspaceId,
universalIdentifier: manifest.universalIdentifier,
name: manifest.name,
displayName: manifest.displayName,
authorizationEndpoint: manifest.oauth.authorizationEndpoint,
tokenEndpoint: manifest.oauth.tokenEndpoint,
revokeEndpoint: manifest.oauth.revokeEndpoint ?? null,
scopes: manifest.oauth.scopes,
clientIdVariable: manifest.oauth.clientIdVariable,
clientSecretVariable: manifest.oauth.clientSecretVariable,
authorizationParams: manifest.oauth.authorizationParams ?? null,
tokenRequestContentType:
manifest.oauth.tokenRequestContentType ?? 'json',
usePkce: manifest.oauth.usePkce ?? true,
};
const existingEntity = existingByUniversalIdentifier.get(
manifest.universalIdentifier,
);
return isDefined(existingEntity)
? { id: existingEntity.id, ...fields }
: fields;
});
if (toSave.length > 0) {
await this.oauthProviderRepository.save(toSave);
}
await this.oauthProviderRepository.delete(
oauthProviders.length > 0
? {
applicationId,
workspaceId,
universalIdentifier: Not(
In(oauthProviders.map((p) => p.universalIdentifier)),
),
}
: { applicationId, workspaceId },
);
}
}
@@ -1,405 +0,0 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// The list service uses ConnectedAccountRefreshTokensService (which pulls in
// the SSRF-safe HTTP client), so stub the module to avoid loading the dep.
// We never use the real implementation here — the test always injects a mock.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
const APP_ID = 'app-1';
const WORKSPACE_ID = 'workspace-1';
const REQUEST_USER_WORKSPACE_ID = 'uws-request';
const OTHER_USER_WORKSPACE_ID = 'uws-other';
const PROVIDER_ID = 'provider-1';
const buildProvider = (
overrides: Partial<ApplicationOAuthProviderEntity> = {},
): ApplicationOAuthProviderEntity =>
({
id: PROVIDER_ID,
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
name: 'linear',
displayName: 'Linear',
scopes: ['read', 'write'],
...overrides,
}) as unknown as ApplicationOAuthProviderEntity;
const buildAccount = (
overrides: Partial<ConnectedAccountEntity> = {},
): ConnectedAccountEntity =>
({
id: 'conn-1',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
applicationId: APP_ID,
applicationConnectionProviderId: PROVIDER_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
provider: ConnectedAccountProvider.APP,
accessToken: 'enc',
refreshToken: 'enc',
// OAuth scopes granted by the upstream provider — distinct from the
// row-level `visibility` field above.
scopes: ['read', 'write'],
lastCredentialsRefreshedAt: new Date('2024-01-01T00:00:00Z'),
authFailedAt: null,
...overrides,
}) as unknown as ConnectedAccountEntity;
describe('ApplicationConnectionsListService', () => {
let service: ApplicationConnectionsListService;
let connectedAccountRepository: { find: jest.Mock; findOne: jest.Mock };
let oauthProviderRepository: {
find: jest.Mock;
findOneByOrFail: jest.Mock;
};
let refreshTokensService: { refreshAndSaveTokens: jest.Mock };
beforeEach(async () => {
connectedAccountRepository = { find: jest.fn(), findOne: jest.fn() };
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([buildProvider()]),
findOneByOrFail: jest.fn().mockResolvedValue(buildProvider()),
};
refreshTokensService = {
refreshAndSaveTokens: jest.fn(async () => ({
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationConnectionsListService,
{
provide: ConnectedAccountRefreshTokensService,
useValue: refreshTokensService,
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
],
}).compile();
service = module.get(ApplicationConnectionsListService);
});
afterEach(() => jest.clearAllMocks());
describe('list', () => {
it('asks SQL to OR (visibility = workspace) with (visibility = user AND userWorkspaceId = me) when there is a request user', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'shared',
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'shared']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: [
expect.objectContaining({ visibility: 'workspace' }),
expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
],
});
});
it('skips the privacy OR clause when no request user is provided (cron)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'theirs',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'theirs']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.not.objectContaining({ visibility: expect.anything() }),
});
});
it('respects filter.visibility=user under request-user privacy (regression)', async () => {
// Bug guard: an earlier version OR'd { visibility: 'workspace' } into
// the privacy where regardless of the caller's filter, so requesting
// user-visibility only would silently leak workspace-shared rows back.
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
});
// Specifically not the OR shape — single AND object.
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(Array.isArray(passed.where)).toBe(false);
});
it('respects filter.visibility=workspace under request-user privacy', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'workspace' },
});
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(passed.where).toEqual(
expect.objectContaining({ visibility: 'workspace' }),
);
expect(passed.where).not.toHaveProperty('userWorkspaceId');
expect(Array.isArray(passed.where)).toBe(false);
});
it('passes filter.visibility through unchanged in cron context', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({ visibility: 'user' }),
});
});
it('returns empty list when filter.providerName matches no provider for this app', async () => {
oauthProviderRepository.find.mockResolvedValue([]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { providerName: 'unknown-provider' },
});
expect(result).toEqual([]);
expect(connectedAccountRepository.find).not.toHaveBeenCalled();
});
it('refreshes the access token before returning', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(refreshTokensService.refreshAndSaveTokens).toHaveBeenCalledTimes(
1,
);
expect(result[0].accessToken).toBe('fresh-access');
});
it('exposes provider name and other public fields in the DTO', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection).toEqual({
id: 'conn-1',
providerName: 'linear',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
accessToken: 'fresh-access',
scopes: ['read', 'write'],
authFailedAt: null,
});
});
it('falls back to handle when name is null', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ name: null }),
]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection.name).toBe('octocat@example.com');
});
it('skips a connection when the refresh fails', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'good' }),
buildAccount({ id: 'broken' }),
]);
refreshTokensService.refreshAndSaveTokens
.mockResolvedValueOnce({ accessToken: 'fresh', refreshToken: 'r' })
.mockRejectedValueOnce(new Error('refresh failed'));
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id)).toEqual(['good']);
});
it('skips a connection whose provider was deleted (orphan)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({
id: 'orphan',
applicationConnectionProviderId: 'gone-provider',
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result).toEqual([]);
});
});
describe('getOne', () => {
it('returns the connection when the request user owns it', async () => {
connectedAccountRepository.findOne.mockResolvedValue(buildAccount());
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
expect(result.providerName).toBe('linear');
expect(result.accessToken).toBe('fresh-access');
});
it('returns the connection when visibility is workspace, regardless of owner', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
});
it('throws NotFound when the connection does not exist', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'missing',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFound when a request user asks for another user-visibility connection', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns another user-visibility connection in cron context (no request user)', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
id: 'conn-1',
});
expect(result.userWorkspaceId).toBe(OTHER_USER_WORKSPACE_ID);
});
});
});
@@ -3,8 +3,8 @@ import { Args, Query } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/connection-provider/dtos/application-connection-provider.dto';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -14,7 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@MetadataResolver(() => ApplicationConnectionProviderDTO)
export class ApplicationConnectionProviderResolver {
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderService: ConnectionProviderService,
) {}
@Query(() => [ApplicationConnectionProviderDTO])
@@ -37,14 +37,17 @@ export class ApplicationConnectionProviderResolver {
return providers.map((provider) => ({
id: provider.id,
applicationId: provider.applicationId,
type: 'oauth',
type: provider.type,
name: provider.name,
displayName: provider.displayName,
oauth: {
scopes: provider.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
},
oauth:
provider.type === 'oauth' && provider.oauthConfig
? {
scopes: provider.oauthConfig.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
}
: null,
}));
}
}
@@ -1,4 +1,4 @@
export enum ApplicationOAuthProviderExceptionCode {
export enum ConnectionProviderExceptionCode {
PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND',
CLIENT_CREDENTIALS_NOT_CONFIGURED = 'CLIENT_CREDENTIALS_NOT_CONFIGURED',
TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED',
@@ -6,4 +6,7 @@ export enum ApplicationOAuthProviderExceptionCode {
INVALID_STATE = 'INVALID_STATE',
INVALID_REQUEST = 'INVALID_REQUEST',
FORBIDDEN = 'FORBIDDEN',
INVALID_CONNECTION_PROVIDER_INPUT = 'INVALID_CONNECTION_PROVIDER_INPUT',
CONNECTION_PROVIDER_NOT_FOUND = 'CONNECTION_PROVIDER_NOT_FOUND',
CONNECTION_PROVIDER_NAME_ALREADY_EXISTS = 'CONNECTION_PROVIDER_NAME_ALREADY_EXISTS',
}
@@ -6,15 +6,19 @@ import { Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util';
import { computePkceChallenge } from 'src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import {
assertOAuthProvider,
type OAuthConnectionProvider,
} from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/connection-provider/utils/build-callback-url.util';
import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
import {
type AppOAuthStateJwtPayload,
JwtTokenTypeEnum,
@@ -27,13 +31,10 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
const STATE_JWT_EXPIRES_IN = '10m';
type AuthorizeArgs = {
applicationOAuthProvider: ApplicationOAuthProviderEntity;
connectionProvider: ConnectionProviderEntity;
workspaceId: string;
userId: string;
userWorkspaceId: string;
// Connection-row visibility: 'user' = private to userWorkspaceId,
// 'workspace' = shared with all members. Distinct from OAuth `scopes`
// on the row, which are the upstream-granted permissions.
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
@@ -52,13 +53,11 @@ type CallbackResult = {
};
@Injectable()
export class ApplicationOAuthProviderFlowService {
private readonly logger = new Logger(
ApplicationOAuthProviderFlowService.name,
);
export class ConnectionProviderOAuthFlowService {
private readonly logger = new Logger(ConnectionProviderOAuthFlowService.name);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderService: ConnectionProviderService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
@@ -69,45 +68,42 @@ export class ApplicationOAuthProviderFlowService {
async startAuthorizationFlow(
args: AuthorizeArgs,
): Promise<{ authorizationUrl: string }> {
const { applicationOAuthProvider, workspaceId, userId, userWorkspaceId } =
args;
const { connectionProvider, workspaceId, userId, userWorkspaceId } = args;
// Reconnect can only target a row that lives in the requesting workspace
// *and* belongs to the same provider. Without this check, a caller could
// pass any connectedAccount id from any workspace; persist() filters its
// UPDATE by workspaceId so nothing would be written, but the subsequent
// findOneByOrFail (and the redirect URL we build from it) would happily
// surface stale fields from the foreign row. Fail fast at authorize time
// so the user sees the error before the upstream OAuth round-trip.
assertOAuthProvider(connectionProvider);
// Reconnect target must live in the requesting workspace and belong to
// the same provider — without this guard a foreign id would silently
// leak through findOneByOrFail later in the flow.
if (isDefined(args.reconnectingConnectedAccountId)) {
const target = await this.connectedAccountRepository.findOne({
where: {
id: args.reconnectingConnectedAccountId,
workspaceId,
applicationConnectionProviderId: applicationOAuthProvider.id,
connectionProviderId: connectionProvider.id,
},
});
if (!isDefined(target)) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Cannot reconnect connectedAccount ${args.reconnectingConnectedAccountId}: not found in this workspace for the requested provider.`,
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
ConnectionProviderExceptionCode.FORBIDDEN,
);
}
}
const { clientId } = await this.oauthProviderService.getClientCredentials(
applicationOAuthProvider,
);
const { clientId } =
await this.oauthProviderService.getClientCredentials(connectionProvider);
const codeVerifier = applicationOAuthProvider.usePkce
? generatePkceVerifier()
: null;
const { authorizationEndpoint, scopes, authorizationParams, usePkce } =
connectionProvider.oauthConfig;
const codeVerifier = usePkce ? generatePkceVerifier() : null;
const state = this.signState({
sub: applicationOAuthProvider.id,
sub: connectionProvider.id,
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: applicationOAuthProvider.id,
connectionProviderId: connectionProvider.id,
workspaceId,
userId,
userWorkspaceId,
@@ -119,17 +115,12 @@ export class ApplicationOAuthProviderFlowService {
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
const authorizationUrl = new URL(
applicationOAuthProvider.authorizationEndpoint,
);
const authorizationUrl = new URL(authorizationEndpoint);
authorizationUrl.searchParams.set('client_id', clientId);
authorizationUrl.searchParams.set('redirect_uri', callbackUrl);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set(
'scope',
applicationOAuthProvider.scopes.join(' '),
);
authorizationUrl.searchParams.set('scope', scopes.join(' '));
authorizationUrl.searchParams.set('state', state);
if (codeVerifier) {
@@ -140,9 +131,7 @@ export class ApplicationOAuthProviderFlowService {
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
}
for (const [key, value] of Object.entries(
applicationOAuthProvider.authorizationParams ?? {},
)) {
for (const [key, value] of Object.entries(authorizationParams ?? {})) {
authorizationUrl.searchParams.set(key, value);
}
@@ -153,9 +142,11 @@ export class ApplicationOAuthProviderFlowService {
const statePayload = this.verifyState(args.state);
const provider = await this.oauthProviderService.findOneByIdOrThrow(
statePayload.applicationOAuthProviderId,
statePayload.connectionProviderId,
);
assertOAuthProvider(provider);
const { clientId, clientSecret } =
await this.oauthProviderService.getClientCredentials(provider);
@@ -166,22 +157,22 @@ export class ApplicationOAuthProviderFlowService {
try {
tokenResponse = await exchangeCodeForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
tokenEndpoint: provider.oauthConfig.tokenEndpoint,
clientId,
clientSecret,
code: args.code,
redirectUri: callbackUrl,
codeVerifier: statePayload.codeVerifier,
contentType: provider.tokenRequestContentType,
contentType: provider.oauthConfig.tokenRequestContentType,
});
} catch (error) {
this.logger.error(
`OAuth token exchange failed for provider ${provider.id}: ${(error as Error).message}`,
);
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
(error as Error).message,
ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
);
}
@@ -231,9 +222,9 @@ export class ApplicationOAuthProviderFlowService {
`Rejected OAuth state: ${(error as Error).message ?? 'unknown reason'}`,
);
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'OAuth state signature invalid or expired',
ApplicationOAuthProviderExceptionCode.INVALID_STATE,
ConnectionProviderExceptionCode.INVALID_STATE,
);
}
}
@@ -242,9 +233,6 @@ export class ApplicationOAuthProviderFlowService {
return this.twentyConfigService.get('SERVER_URL');
}
// Reconnect updates an existing row (preserves the id so logic-function
// bindings via id keep working). New connections always create — multiple
// credentials per (user, provider) are now allowed and intentional.
private async persistConnectedAccount({
provider,
tokenResponse,
@@ -253,7 +241,7 @@ export class ApplicationOAuthProviderFlowService {
visibility,
reconnectingConnectedAccountId,
}: {
provider: ApplicationOAuthProviderEntity;
provider: OAuthConnectionProvider;
tokenResponse: TokenExchangeResponse;
workspaceId: string;
userWorkspaceId: string;
@@ -263,17 +251,14 @@ export class ApplicationOAuthProviderFlowService {
const sharedFields = {
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
scopes: tokenResponse.scopes ?? provider.scopes,
scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
};
if (isDefined(reconnectingConnectedAccountId)) {
// Workspace-scope BOTH the update and the read a foreign-id passed
// through here (the authorize-time guard should have caught it) would
// otherwise update zero rows but still return the foreign row from
// findOneByOrFail({ id }), making a silently-failed reconnect look
// successful.
// Workspace-scope both the update and the read so a foreign id can't
// leak through findOneByOrFail.
await this.connectedAccountRepository.update(
{ id: reconnectingConnectedAccountId, workspaceId },
sharedFields,
@@ -286,10 +271,9 @@ export class ApplicationOAuthProviderFlowService {
}
const existingCount = await this.connectedAccountRepository.count({
where: { applicationConnectionProviderId: provider.id, workspaceId },
where: { connectionProviderId: provider.id, workspaceId },
});
// Auto-generated default — the user can rename from the app settings tab.
const name = `${provider.displayName} #${existingCount + 1}`;
const created = this.connectedAccountRepository.create({
@@ -300,7 +284,7 @@ export class ApplicationOAuthProviderFlowService {
provider: ConnectedAccountProvider.APP,
workspaceId,
applicationId: provider.applicationId,
applicationConnectionProviderId: provider.id,
connectionProviderId: provider.id,
userWorkspaceId,
});
@@ -6,10 +6,10 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import {
AuthException,
AuthExceptionCode,
@@ -25,12 +25,12 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('apps/oauth')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class ApplicationOAuthProviderController {
private readonly logger = new Logger(ApplicationOAuthProviderController.name);
export class ConnectionProviderOAuthController {
private readonly logger = new Logger(ConnectionProviderOAuthController.name);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderFlowService: ApplicationOAuthProviderFlowService,
private readonly oauthProviderService: ConnectionProviderService,
private readonly oauthProviderFlowService: ConnectionProviderOAuthFlowService,
private readonly transientTokenService: TransientTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly guardRedirectService: GuardRedirectService,
@@ -60,9 +60,9 @@ export class ApplicationOAuthProviderController {
try {
if (!applicationId || !providerName || !transientToken) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'Missing required query parameters: applicationId, providerName, transientToken',
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
@@ -71,9 +71,9 @@ export class ApplicationOAuthProviderController {
visibility !== 'user' &&
visibility !== 'workspace'
) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Invalid visibility "${visibility}" — must be 'user' or 'workspace'`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
@@ -105,16 +105,16 @@ export class ApplicationOAuthProviderController {
});
if (!provider) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`OAuth provider "${providerName}" not found for application ${applicationId}`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
if (provider.workspaceId !== workspaceId) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'OAuth provider does not belong to the requesting workspace',
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
ConnectionProviderExceptionCode.FORBIDDEN,
);
}
@@ -131,7 +131,7 @@ export class ApplicationOAuthProviderController {
const { authorizationUrl } =
await this.oauthProviderFlowService.startAuthorizationFlow({
applicationOAuthProvider: provider,
connectionProvider: provider,
workspaceId,
userId,
userWorkspaceId: userWorkspace.id,
@@ -197,9 +197,9 @@ export class ApplicationOAuthProviderController {
});
if (!workspace) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Workspace ${workspaceId} not found after OAuth callback`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
@@ -0,0 +1,47 @@
import {
type ConnectionProviderType,
type StoredOAuthConnectionProviderConfig,
} from 'twenty-shared/application';
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'connectionProvider', schema: 'core' })
@Unique('IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE', [
'name',
'applicationId',
])
@Index('IDX_CONNECTION_PROVIDER_APPLICATION_ID', ['applicationId'])
export class ConnectionProviderEntity
extends SyncableEntity
implements Required<ConnectionProviderEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'varchar' })
name: string;
@Column({ nullable: false, type: 'varchar' })
displayName: string;
@Column({ nullable: false, type: 'varchar' })
type: ConnectionProviderType;
@Column({ nullable: true, type: 'jsonb' })
oauthConfig: StoredOAuthConnectionProviderConfig | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getConnectionProviderExceptionUserFriendlyMessage = (
code: ConnectionProviderExceptionCode,
) => {
switch (code) {
case ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND:
return msg`OAuth provider not found.`;
case ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED:
return msg`Client credentials are not configured for this OAuth provider.`;
case ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED:
return msg`Failed to exchange the authorization code for an access token.`;
case ConnectionProviderExceptionCode.REFRESH_FAILED:
return msg`Failed to refresh the access token.`;
case ConnectionProviderExceptionCode.INVALID_STATE:
return msg`The OAuth state parameter is invalid or expired.`;
case ConnectionProviderExceptionCode.INVALID_REQUEST:
return msg`The OAuth request is missing required parameters.`;
case ConnectionProviderExceptionCode.FORBIDDEN:
return msg`Not authorized to access this OAuth provider.`;
case ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT:
return msg`The connection-provider manifest is missing required fields.`;
case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND:
return msg`Connection provider not found.`;
case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NAME_ALREADY_EXISTS:
return msg`A connection provider with this name already exists for this application.`;
default:
assertUnreachable(code);
}
};
export class ConnectionProviderException extends CustomException<ConnectionProviderExceptionCode> {
constructor(
message: string,
code: ConnectionProviderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectionProviderExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/connection-provider/application-connection-provider.resolver';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -12,11 +12,12 @@ import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryptio
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationOAuthProviderEntity,
ConnectionProviderEntity,
ApplicationEntity,
ApplicationRegistrationVariableEntity,
ConnectedAccountEntity,
@@ -25,15 +26,13 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
SecretEncryptionModule,
SecureHttpClientModule,
TwentyConfigModule,
FlatConnectionProviderModule,
],
providers: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
ConnectionProviderService,
ConnectionProviderOAuthFlowService,
ApplicationConnectionProviderResolver,
],
exports: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
],
exports: [ConnectionProviderService, ConnectionProviderOAuthFlowService],
})
export class ApplicationOAuthProviderModule {}
export class ConnectionProviderModule {}
@@ -0,0 +1,208 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ConnectionProviderService {
constructor(
@InjectRepository(ConnectionProviderEntity)
private readonly connectionProviderRepository: Repository<ConnectionProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async getClientCredentials(
provider: ConnectionProviderEntity,
): Promise<{ clientId: string; clientSecret: string }> {
assertOAuthProvider(provider);
const application = await this.applicationRepository.findOneBy({
id: provider.applicationId,
});
if (!isDefined(application?.applicationRegistrationId)) {
throw new ConnectionProviderException(
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
const { clientIdVariable, clientSecretVariable } = provider.oauthConfig;
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: application.applicationRegistrationId,
key: In([clientIdVariable, clientSecretVariable]),
},
});
const valuesByKey = new Map(
variables.map((v) => [
v.key,
v.encryptedValue
? this.secretEncryptionService.decrypt(v.encryptedValue)
: '',
]),
);
const clientId = valuesByKey.get(clientIdVariable) ?? '';
const clientSecret = valuesByKey.get(clientSecretVariable) ?? '';
if (!clientId || !clientSecret) {
throw new ConnectionProviderException(
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${clientIdVariable}" and "${clientSecretVariable}" on the application registration.`,
ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
return { clientId, clientSecret };
}
async areClientCredentialsConfigured(
provider: ConnectionProviderEntity,
): Promise<boolean> {
const result = await this.areClientCredentialsConfiguredBatch([provider]);
return result.get(provider.id) ?? false;
}
async areClientCredentialsConfiguredBatch(
providers: ConnectionProviderEntity[],
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (providers.length === 0) {
return result;
}
const oauthProviders = providers.filter(
(p) => p.type === 'oauth' && isDefined(p.oauthConfig),
);
for (const provider of providers) {
result.set(provider.id, false);
}
if (oauthProviders.length === 0) {
return result;
}
const applicationIds = [
...new Set(oauthProviders.map((p) => p.applicationId)),
];
const applications = await this.applicationRepository.find({
where: { id: In(applicationIds) },
});
const registrationIdByApplicationId = new Map(
applications.map((app) => [app.id, app.applicationRegistrationId]),
);
const registrationIds = [
...new Set(
applications
.map((app) => app.applicationRegistrationId)
.filter(isDefined),
),
];
if (registrationIds.length === 0) {
return result;
}
const allKeys = oauthProviders.flatMap((p) => [
p.oauthConfig!.clientIdVariable,
p.oauthConfig!.clientSecretVariable,
]);
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: In(registrationIds),
key: In(allKeys),
},
});
const filledKeysByRegistrationId = new Map<string, Set<string>>();
for (const variable of variables) {
if (variable.encryptedValue === '') continue;
const set =
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
new Set<string>();
set.add(variable.key);
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
}
for (const provider of oauthProviders) {
const registrationId = registrationIdByApplicationId.get(
provider.applicationId,
);
if (!isDefined(registrationId)) {
continue;
}
const filled = filledKeysByRegistrationId.get(registrationId);
const { clientIdVariable, clientSecretVariable } = provider.oauthConfig!;
result.set(
provider.id,
filled?.has(clientIdVariable) === true &&
filled?.has(clientSecretVariable) === true,
);
}
return result;
}
async findOneByApplicationAndName({
applicationId,
name,
}: {
applicationId: string;
name: string;
}): Promise<ConnectionProviderEntity | null> {
return this.connectionProviderRepository.findOne({
where: { applicationId, name },
});
}
async findOneByIdOrThrow(id: string): Promise<ConnectionProviderEntity> {
const provider = await this.connectionProviderRepository.findOne({
where: { id },
});
if (!isDefined(provider)) {
throw new ConnectionProviderException(
`Connection provider with id "${id}" not found`,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
return provider;
}
async findManyByApplication({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<ConnectionProviderEntity[]> {
return this.connectionProviderRepository.find({
where: { applicationId, workspaceId },
});
}
}
@@ -14,10 +14,10 @@ import {
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { GetAppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto';
import { ListAppConnectionsDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
import { GetAppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/get-app-connection.dto';
import { ListAppConnectionsDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.dto';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.controller';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -11,13 +11,13 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
// Top-level consumer: depends on RefreshTokensManagerModule (which itself
// imports the engine-side AppOAuthRefreshModule). Kept separate from
// ApplicationOAuthProviderModule to avoid the import cycle. TokenModule +
// ConnectionProviderModule to avoid the import cycle. TokenModule +
// WorkspaceCacheStorageModule are pulled in for the controller's JwtAuthGuard.
@Module({
imports: [
TypeOrmModule.forFeature([
ConnectedAccountEntity,
ApplicationOAuthProviderEntity,
ConnectionProviderEntity,
]),
TokenModule,
WorkspaceCacheStorageModule,
@@ -6,8 +6,8 @@ import { type FindOptionsWhere, In, Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@@ -40,8 +40,8 @@ export class ApplicationConnectionsListService {
private readonly refreshTokensService: ConnectedAccountRefreshTokensService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
@InjectRepository(ConnectionProviderEntity)
private readonly oauthProviderRepository: Repository<ConnectionProviderEntity>,
) {}
async list({
@@ -72,7 +72,7 @@ export class ApplicationConnectionsListService {
workspaceId,
provider: ConnectedAccountProvider.APP,
...(isDefined(providerIds)
? { applicationConnectionProviderId: In(providerIds) }
? { connectionProviderId: In(providerIds) }
: {}),
...(isDefined(filter.userWorkspaceId)
? { userWorkspaceId: filter.userWorkspaceId }
@@ -126,12 +126,12 @@ export class ApplicationConnectionsListService {
throw new NotFoundException(`Connection ${id} not found`);
}
if (!isDefined(account.applicationConnectionProviderId)) {
if (!isDefined(account.connectionProviderId)) {
throw new NotFoundException(`Connection ${id} has no provider`);
}
const provider = await this.oauthProviderRepository.findOneByOrFail({
id: account.applicationConnectionProviderId,
id: account.connectionProviderId,
workspaceId,
});
@@ -150,14 +150,6 @@ export class ApplicationConnectionsListService {
return dto;
}
// Composes the caller's `visibility` filter with the per-request privacy
// rule. Always returns a TypeORM where (single object = AND, array = OR)
// so the caller doesn't have to branch.
//
// The earlier inline version OR'd `{ ...baseWhere, visibility: 'workspace' }`
// with `{ ...baseWhere, userWorkspaceId: me }` regardless of caller intent,
// which silently overrode an explicit `filter.visibility: 'user'` (the
// first OR branch always returned workspace-shared rows).
private buildPrivacyWhere(
baseWhere: FindOptionsWhere<ConnectedAccountEntity>,
requestUserWorkspaceId: string | null,
@@ -165,15 +157,12 @@ export class ApplicationConnectionsListService {
):
| FindOptionsWhere<ConnectedAccountEntity>
| FindOptionsWhere<ConnectedAccountEntity>[] {
// Cron / DB-event triggers carry no user — the app is trusted to use
// its own criteria, so honour the visibility filter as-is.
if (!isDefined(requestUserWorkspaceId)) {
return isDefined(visibilityFilter)
? { ...baseWhere, visibility: visibilityFilter }
: baseWhere;
}
// Caller asked for user-visibility only → must be theirs.
if (visibilityFilter === 'user') {
return {
...baseWhere,
@@ -182,14 +171,10 @@ export class ApplicationConnectionsListService {
};
}
// Caller asked for workspace-shared only → no per-user restriction
// (workspace-shared credentials are visible to everyone in the workspace).
if (visibilityFilter === 'workspace') {
return { ...baseWhere, visibility: 'workspace' };
}
// No visibility filter → return both: every workspace-shared row, plus
// the request user's own user-visibility rows.
return [
{ ...baseWhere, visibility: 'workspace' },
{
@@ -203,10 +188,10 @@ export class ApplicationConnectionsListService {
private async refreshAndMap(
account: ConnectedAccountEntity,
workspaceId: string,
providerById: Map<string, ApplicationOAuthProviderEntity>,
providerById: Map<string, ConnectionProviderEntity>,
): Promise<AppConnectionDto | null> {
const provider = isDefined(account.applicationConnectionProviderId)
? providerById.get(account.applicationConnectionProviderId)
const provider = isDefined(account.connectionProviderId)
? providerById.get(account.connectionProviderId)
: undefined;
// Connections without a resolvable provider can't be refreshed and the
@@ -215,7 +200,7 @@ export class ApplicationConnectionsListService {
// ON DELETE CASCADE when the provider is removed.
if (!isDefined(provider)) {
this.logger.warn(
`Connection ${account.id} references missing provider ${account.applicationConnectionProviderId}`,
`Connection ${account.id} references missing provider ${account.connectionProviderId}`,
);
return null;
@@ -235,7 +220,7 @@ export class ApplicationConnectionsListService {
visibility: account.visibility as 'user' | 'workspace',
userWorkspaceId: account.userWorkspaceId,
accessToken: tokens.accessToken,
scopes: account.scopes ?? provider.scopes,
scopes: account.scopes ?? provider.oauthConfig?.scopes ?? [],
authFailedAt: account.authFailedAt?.toISOString() ?? null,
};
} catch (error) {
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service';
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
@Module({
imports: [
ApplicationOAuthProviderModule,
ConnectionProviderModule,
ApplicationVariableEntityModule,
SecureHttpClientModule,
],
@@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util';
import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
@@ -19,7 +20,7 @@ export class AppOAuthRefreshAccessTokenService {
private readonly logger = new Logger(AppOAuthRefreshAccessTokenService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly connectionProviderService: ConnectionProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
@@ -27,52 +28,31 @@ export class AppOAuthRefreshAccessTokenService {
connectedAccount: ConnectedAccountEntity,
refreshToken: string,
): Promise<ConnectedAccountTokens> {
if (!isDefined(connectedAccount.applicationConnectionProviderId)) {
if (!isDefined(connectedAccount.connectionProviderId)) {
throw new ConnectedAccountRefreshAccessTokenException(
`Connected account ${connectedAccount.id} has no applicationConnectionProviderId`,
`Connected account ${connectedAccount.id} has no connectionProviderId`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
let provider, clientId, clientSecret;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
);
({ clientId, clientSecret } =
await this.applicationOAuthProviderService.getClientCredentials(
provider,
));
} catch (error) {
// Provider lookup or credential resolution failed (provider deleted,
// server admin hasn't filled in client_id/secret). Translate so callers
// see one exception class regardless of provider.
if (error instanceof ApplicationOAuthProviderException) {
throw new ConnectedAccountRefreshAccessTokenException(
error.message,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
throw error;
}
const { provider, clientId, clientSecret } = await this.resolveProvider(
connectedAccount.connectionProviderId,
);
try {
const tokenResponse = await exchangeRefreshTokenForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
tokenEndpoint: provider.oauthConfig.tokenEndpoint,
clientId,
clientSecret,
refreshToken,
contentType: provider.tokenRequestContentType,
contentType: provider.oauthConfig.tokenRequestContentType,
});
return {
accessToken: tokenResponse.accessToken,
// Some providers (e.g. Google) keep the refresh token stable across
// refreshes; others rotate. Fall back to the original when the
// response omits one.
// Fall back to the original when the response omits one — some
// providers don't rotate refresh tokens.
refreshToken: tokenResponse.refreshToken ?? refreshToken,
};
} catch (error) {
@@ -80,9 +60,8 @@ export class AppOAuthRefreshAccessTokenService {
`App OAuth refresh failed for connected account ${connectedAccount.id}: ${(error as Error).message}`,
);
// 5xx and network/transport errors are transient — don't mark the
// credential as permanently invalid. Only 4xx responses from the
// token endpoint (esp. invalid_grant) imply the user must reconnect.
// Only 4xx token-endpoint responses (esp. invalid_grant) imply the
// user must reconnect — 5xx and transport errors stay transient.
const isTransient =
!(error instanceof OAuthTokenEndpointError) || error.status >= 500;
@@ -94,4 +73,29 @@ export class AppOAuthRefreshAccessTokenService {
);
}
}
private async resolveProvider(connectionProviderId: string) {
try {
const provider =
await this.connectionProviderService.findOneByIdOrThrow(
connectionProviderId,
);
assertOAuthProvider(provider);
const { clientId, clientSecret } =
await this.connectionProviderService.getClientCredentials(provider);
return { provider, clientId, clientSecret };
} catch (error) {
if (error instanceof ConnectionProviderException) {
throw new ConnectedAccountRefreshAccessTokenException(
error.message,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
throw error;
}
}
}
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -11,18 +11,14 @@ export class AppOAuthRevokeService {
private readonly logger = new Logger(AppOAuthRevokeService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly connectionProviderService: ConnectionProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
// Best-effort revoke against the provider's `revokeEndpoint` if declared
// in the manifest. Failures are swallowed (logged as warnings) so a
// disconnect always succeeds locally even when the provider is down or
// doesn't support revocation. RFC 7009 form-urlencoded body is the
// de-facto standard.
// Best-effort: failures are logged but never block disconnect.
async revokeIfApp(connectedAccount: ConnectedAccountEntity): Promise<void> {
if (
!isDefined(connectedAccount.applicationConnectionProviderId) ||
!isDefined(connectedAccount.connectionProviderId) ||
!isDefined(connectedAccount.accessToken)
) {
return;
@@ -31,20 +27,22 @@ export class AppOAuthRevokeService {
let provider;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
provider = await this.connectionProviderService.findOneByIdOrThrow(
connectedAccount.connectionProviderId,
);
} catch {
return;
}
if (!provider.revokeEndpoint) {
const revokeEndpoint = provider.oauthConfig?.revokeEndpoint;
if (provider.type !== 'oauth' || !isDefined(revokeEndpoint)) {
return;
}
try {
const response = await this.secureHttpClientService.createSsrfSafeFetch()(
provider.revokeEndpoint,
revokeEndpoint,
{
method: 'POST',
headers: {
@@ -1,5 +1,5 @@
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util';
const buildResponse = (
json: unknown,
@@ -0,0 +1,22 @@
import { type StoredOAuthConnectionProviderConfig } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
export type OAuthConnectionProvider = ConnectionProviderEntity & {
type: 'oauth';
oauthConfig: StoredOAuthConnectionProviderConfig;
};
export function assertOAuthProvider(
provider: ConnectionProviderEntity,
): asserts provider is OAuthConnectionProvider {
if (provider.type !== 'oauth' || !isDefined(provider.oauthConfig)) {
throw new ConnectionProviderException(
`Connection provider "${provider.name}" (id ${provider.id}) is not OAuth-typed or has no oauthConfig`,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
}
@@ -1,7 +1,7 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
@@ -1,7 +1,7 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
@@ -1,4 +1,4 @@
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
export const parseTokenResponse = (
json: Record<string, unknown>,
@@ -1,8 +1,8 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { encodeOAuthBody } from 'src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util';
import { parseTokenResponse } from 'src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { encodeOAuthBody } from 'src/engine/core-modules/application/connection-provider/utils/encode-oauth-body.util';
import { parseTokenResponse } from 'src/engine/core-modules/application/connection-provider/utils/parse-token-response.util';
type FetchFn = typeof globalThis.fetch;