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:
-2
@@ -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,
|
||||
|
||||
-8
@@ -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;
|
||||
|
||||
|
||||
+97
@@ -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);
|
||||
});
|
||||
});
|
||||
+49
@@ -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,
|
||||
};
|
||||
};
|
||||
+14
@@ -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({
|
||||
|
||||
-384
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
-142
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
-81
@@ -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;
|
||||
}
|
||||
-43
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
-285
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
-405
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-9
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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',
|
||||
}
|
||||
+52
-68
@@ -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,
|
||||
});
|
||||
|
||||
+19
-19
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+47
@@ -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;
|
||||
}
|
||||
+49
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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 {}
|
||||
+208
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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';
|
||||
+5
-5
@@ -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,
|
||||
+12
-27
@@ -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) {
|
||||
+4
-4
@@ -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,
|
||||
],
|
||||
+42
-38
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-12
@@ -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: {
|
||||
+2
-2
@@ -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,
|
||||
+22
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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>,
|
||||
+3
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user