feat(apps): generic OAuth provider support for app SDK (#20181)

## Summary

App developers can now declare third-party OAuth integrations (GitHub,
Linear, Slack, etc.) in their manifest and the platform handles the full
authorize → callback → token-exchange → refresh → injection lifecycle.
The dev writes ~10 lines of config and reads tokens via
`useOAuth('linear')` inside any logic function.

```ts
// app/src/oauth-providers/linear.ts
export default defineOAuthProvider({
  universalIdentifier: '...',
  name: 'linear',
  displayName: 'Linear',
  authorizationEndpoint: 'https://linear.app/oauth/authorize',
  tokenEndpoint: 'https://api.linear.app/oauth/token',
  scopes: ['read', 'write'],
  connectionMode: 'per-user',
  clientIdVariable: 'LINEAR_CLIENT_ID',
  clientSecretVariable: 'LINEAR_CLIENT_SECRET',
  tokenRequestContentType: 'form-urlencoded',
});

// app/src/logic-functions/handlers/...
const { accessToken } = useOAuth('linear'); // throws OAuthNotConnectedError if missing
```

## Architecture

- **Storage**: extends the existing `connectedAccount` table — new
nullable `applicationOAuthProviderId` FK + new `app` value on the
`ConnectedAccountProvider` enum. Existing Google/Microsoft flows are
untouched.
- **OAuth flow**: a single `/apps/oauth/authorize` +
`/apps/oauth/callback` controller pair handles every app provider. State
travels in a JWT signed via the existing `JwtWrapperService` (new
`APP_OAUTH_STATE` token type).
- **Token exchange**: goes through
`SecureHttpClientService.createSsrfSafeFetch()` (so an installed app
can't point `tokenEndpoint` at internal hosts).
- **Refresh**: piggybacks on the existing
`ConnectedAccountRefreshTokensService` dispatch — Google/Microsoft
drivers untouched, new app driver lives engine-side under
`application-oauth-provider/refresh/`.
- **Injection**: the executor injects refreshed tokens as env vars
(`OAUTH_<NAME>_ACCESS_TOKEN`, `_HANDLE`, `_SCOPES`, `_CONNECTED`); the
SDK helpers `useOAuth` / `useOptionalOAuth` read them.
- **Frontend**: auto-rendered "OAuth Connections" section under each
app's settings tab (no custom front component needed). App-managed
connections are filtered out of `/settings/accounts` so the
email/calendar page stays focused.
- **Disconnect**: best-effort revoke against the manifest's
`revokeEndpoint` before deleting the row.

## Reference app

`packages/twenty-apps/internal/twenty-linear/` exercises the full
pipeline:

- `defineOAuthProvider` for Linear
- `POST /linear/create-issue` and `GET /linear/teams` HTTP-route logic
functions
- Vitest tests for the handlers

## Tests

- 14 server-side Jest tests: token-exchange util (form-urlencoded vs
JSON, PKCE, error paths), flow service (authorize URL shape, state
binding, ConnectedAccount upsert on first/reconnect, per-workspace mode,
invalid state)
- 8 app-level Vitest tests: handler error paths, GraphQL request shape,
Linear error propagation
- All 4 packages clean: `npx nx lint:diff-with-main` and `npx tsc
--noEmit`

## Test plan

- [ ] Apply migration on a dev DB: `npx nx run
twenty-server:database:migrate:prod`
- [ ] Regenerate frontend types: `npx nx run
twenty-front:graphql:generate --configuration=metadata`
- [ ] Create a Linear OAuth app at
https://linear.app/settings/api/applications/new with redirect URI
`<SERVER_URL>/apps/oauth/callback`
- [ ] Deploy + install `twenty-linear` on a workspace, paste the Linear
client id/secret into the app's variables
- [ ] Click "Connect Linear" in the app's settings tab → complete OAuth
→ verify `connectedAccount` row created with `provider = 'app'`
- [ ] Trigger `POST /linear/create-issue` with a valid teamId → verify
issue lands in Linear
- [ ] Disconnect → verify the row is deleted and (if Linear's revoke
endpoint is configured in the manifest) the revoke call fires
- [ ] Verify `/settings/accounts` does NOT show the Linear connection —
it appears only under the Linear app's settings tab

## Out of scope (deliberately)

- **Cron + per-user providers**: a cron-triggered function with a
per-user OAuth provider currently returns `CONNECTED=false` (no user
context). The follow-up design is `useOAuthForUser(name,
userWorkspaceId)` paired with a `POST /apps/oauth/connection-token`
endpoint, deferred to keep this PR focused.
- **Token encryption at rest**: tokens stored as plain `varchar`
matching the existing Google/Microsoft pattern. Worth a separate
cross-cutting PR.
- **Manifest endpoint pinning**: a malicious app upgrade could change
`tokenEndpoint` silently. Same trust model as logic-function source code
(which already runs arbitrary server-side); worth tightening across the
whole upgrade pipeline rather than just OAuth.
- **CLI helpers** (`twenty oauth show-callback-url`, `twenty oauth
connect`): manual setup for v1.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-04 11:26:34 +02:00
committed by GitHub
parent ff22988caf
commit 9e94045fa5
132 changed files with 6533 additions and 595 deletions
@@ -4,6 +4,7 @@ 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';
@@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ApplicationOAuthProviderModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
@@ -12,6 +12,7 @@ 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';
@@ -33,6 +34,7 @@ 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,
@@ -153,6 +155,12 @@ export class ApplicationSyncService {
},
);
await this.applicationOAuthProviderService.upsertManyFromManifest({
connectionProviders: manifest.connectionProviders,
applicationId: application.id,
workspaceId,
});
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -0,0 +1,384 @@
// 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/);
});
});
});
@@ -0,0 +1,142 @@
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',
}),
]);
});
});
});
@@ -0,0 +1,50 @@
import { UseGuards } from '@nestjs/common';
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 { 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';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard)
@MetadataResolver(() => ApplicationConnectionProviderDTO)
export class ApplicationConnectionProviderResolver {
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
) {}
@Query(() => [ApplicationConnectionProviderDTO])
@UseGuards(NoPermissionGuard)
async applicationConnectionProviders(
@Args('applicationId', { type: () => UUIDScalarType })
applicationId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ApplicationConnectionProviderDTO[]> {
const providers = await this.oauthProviderService.findManyByApplication({
applicationId,
workspaceId: workspace.id,
});
const credentialsConfiguredByProviderId =
await this.oauthProviderService.areClientCredentialsConfiguredBatch(
providers,
);
return providers.map((provider) => ({
id: provider.id,
applicationId: provider.applicationId,
type: 'oauth',
name: provider.name,
displayName: provider.displayName,
oauth: {
scopes: provider.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
},
}));
}
}
@@ -0,0 +1,9 @@
export enum ApplicationOAuthProviderExceptionCode {
PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND',
CLIENT_CREDENTIALS_NOT_CONFIGURED = 'CLIENT_CREDENTIALS_NOT_CONFIGURED',
TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED',
REFRESH_FAILED = 'REFRESH_FAILED',
INVALID_STATE = 'INVALID_STATE',
INVALID_REQUEST = 'INVALID_REQUEST',
FORBIDDEN = 'FORBIDDEN',
}
@@ -0,0 +1,309 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
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 AppOAuthStateJwtPayload,
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';
const STATE_JWT_EXPIRES_IN = '10m';
type AuthorizeArgs = {
applicationOAuthProvider: ApplicationOAuthProviderEntity;
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;
};
type CallbackArgs = {
code: string;
state: string;
};
type CallbackResult = {
connectedAccountId: string;
workspaceId: string;
applicationId: string;
redirectLocation: string | null;
};
@Injectable()
export class ApplicationOAuthProviderFlowService {
private readonly logger = new Logger(
ApplicationOAuthProviderFlowService.name,
);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
) {}
async startAuthorizationFlow(
args: AuthorizeArgs,
): Promise<{ authorizationUrl: string }> {
const { applicationOAuthProvider, 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.
if (isDefined(args.reconnectingConnectedAccountId)) {
const target = await this.connectedAccountRepository.findOne({
where: {
id: args.reconnectingConnectedAccountId,
workspaceId,
applicationConnectionProviderId: applicationOAuthProvider.id,
},
});
if (!isDefined(target)) {
throw new ApplicationOAuthProviderException(
`Cannot reconnect connectedAccount ${args.reconnectingConnectedAccountId}: not found in this workspace for the requested provider.`,
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
);
}
}
const { clientId } = await this.oauthProviderService.getClientCredentials(
applicationOAuthProvider,
);
const codeVerifier = applicationOAuthProvider.usePkce
? generatePkceVerifier()
: null;
const state = this.signState({
sub: applicationOAuthProvider.id,
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: applicationOAuthProvider.id,
workspaceId,
userId,
userWorkspaceId,
visibility: args.visibility,
reconnectingConnectedAccountId: args.reconnectingConnectedAccountId,
redirectLocation: args.redirectLocation,
codeVerifier,
});
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
const authorizationUrl = new URL(
applicationOAuthProvider.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('state', state);
if (codeVerifier) {
authorizationUrl.searchParams.set(
'code_challenge',
computePkceChallenge(codeVerifier),
);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
}
for (const [key, value] of Object.entries(
applicationOAuthProvider.authorizationParams ?? {},
)) {
authorizationUrl.searchParams.set(key, value);
}
return { authorizationUrl: authorizationUrl.toString() };
}
async completeAuthorizationFlow(args: CallbackArgs): Promise<CallbackResult> {
const statePayload = this.verifyState(args.state);
const provider = await this.oauthProviderService.findOneByIdOrThrow(
statePayload.applicationOAuthProviderId,
);
const { clientId, clientSecret } =
await this.oauthProviderService.getClientCredentials(provider);
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
let tokenResponse: TokenExchangeResponse;
try {
tokenResponse = await exchangeCodeForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
clientId,
clientSecret,
code: args.code,
redirectUri: callbackUrl,
codeVerifier: statePayload.codeVerifier,
contentType: provider.tokenRequestContentType,
});
} catch (error) {
this.logger.error(
`OAuth token exchange failed for provider ${provider.id}: ${(error as Error).message}`,
);
throw new ApplicationOAuthProviderException(
(error as Error).message,
ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
);
}
const connectedAccount = await this.persistConnectedAccount({
provider,
tokenResponse,
workspaceId: statePayload.workspaceId,
userWorkspaceId: statePayload.userWorkspaceId,
visibility: statePayload.visibility,
reconnectingConnectedAccountId:
statePayload.reconnectingConnectedAccountId,
});
return {
connectedAccountId: connectedAccount.id,
workspaceId: statePayload.workspaceId,
applicationId: provider.applicationId,
redirectLocation: statePayload.redirectLocation,
};
}
private signState(payload: AppOAuthStateJwtPayload): string {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APP_OAUTH_STATE,
payload.workspaceId,
);
return this.jwtWrapperService.sign(payload, {
secret,
expiresIn: STATE_JWT_EXPIRES_IN,
});
}
private verifyState(state: string): AppOAuthStateJwtPayload {
try {
const verified = this.jwtWrapperService.verifyJwtToken(
state,
) as AppOAuthStateJwtPayload;
if (verified.type !== JwtTokenTypeEnum.APP_OAUTH_STATE) {
throw new Error('Wrong JWT type for OAuth state');
}
return verified;
} catch (error) {
this.logger.warn(
`Rejected OAuth state: ${(error as Error).message ?? 'unknown reason'}`,
);
throw new ApplicationOAuthProviderException(
'OAuth state signature invalid or expired',
ApplicationOAuthProviderExceptionCode.INVALID_STATE,
);
}
}
private getServerUrl(): string {
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,
workspaceId,
userWorkspaceId,
visibility,
reconnectingConnectedAccountId,
}: {
provider: ApplicationOAuthProviderEntity;
tokenResponse: TokenExchangeResponse;
workspaceId: string;
userWorkspaceId: string;
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
}): Promise<ConnectedAccountEntity> {
const sharedFields = {
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
scopes: tokenResponse.scopes ?? provider.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.
await this.connectedAccountRepository.update(
{ id: reconnectingConnectedAccountId, workspaceId },
sharedFields,
);
return this.connectedAccountRepository.findOneByOrFail({
id: reconnectingConnectedAccountId,
workspaceId,
});
}
const existingCount = await this.connectedAccountRepository.count({
where: { applicationConnectionProviderId: 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({
...sharedFields,
handle: name,
name,
visibility,
provider: ConnectedAccountProvider.APP,
workspaceId,
applicationId: provider.applicationId,
applicationConnectionProviderId: provider.id,
userWorkspaceId,
});
return this.connectedAccountRepository.save(created);
}
}
@@ -0,0 +1,245 @@
import { Controller, Get, Logger, Query, Res, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type Response } from 'express';
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 {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
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);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderFlowService: ApplicationOAuthProviderFlowService,
private readonly transientTokenService: TransientTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly guardRedirectService: GuardRedirectService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
// Public endpoint — the transient token carries workspace + user context
// so we don't need a session cookie here.
@Get('authorize')
async authorize(
@Query('applicationId') applicationId: string,
@Query('providerName') providerName: string,
@Query('transientToken') transientToken: string,
@Query('visibility') visibility: string | undefined,
@Query('reconnectingConnectedAccountId')
reconnectingConnectedAccountId: string | undefined,
@Query('redirectLocation') redirectLocation: string | undefined,
@Res() res: Response,
) {
// Captured early so the error-redirect lands on the user's own
// subdomain (different cookie domain otherwise = de-facto logout).
let workspace: WorkspaceEntity | null = null;
try {
if (!applicationId || !providerName || !transientToken) {
throw new ApplicationOAuthProviderException(
'Missing required query parameters: applicationId, providerName, transientToken',
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
if (
visibility !== undefined &&
visibility !== 'user' &&
visibility !== 'workspace'
) {
throw new ApplicationOAuthProviderException(
`Invalid visibility "${visibility}" — must be 'user' or 'workspace'`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
const { userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
if (!workspaceId || !userId) {
throw new AuthException(
'Workspace or user not found in transient token',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
`Workspace ${workspaceId} not found`,
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const provider =
await this.oauthProviderService.findOneByApplicationAndName({
applicationId,
name: providerName,
});
if (!provider) {
throw new ApplicationOAuthProviderException(
`OAuth provider "${providerName}" not found for application ${applicationId}`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
if (provider.workspaceId !== workspaceId) {
throw new ApplicationOAuthProviderException(
'OAuth provider does not belong to the requesting workspace',
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId, workspaceId },
});
if (!isDefined(userWorkspace)) {
throw new AuthException(
`UserWorkspace not found for user ${userId} in workspace ${workspaceId}`,
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const { authorizationUrl } =
await this.oauthProviderFlowService.startAuthorizationFlow({
applicationOAuthProvider: provider,
workspaceId,
userId,
userWorkspaceId: userWorkspace.id,
visibility:
(visibility as 'user' | 'workspace' | undefined) ?? 'user',
reconnectingConnectedAccountId:
reconnectingConnectedAccountId ?? null,
redirectLocation: redirectLocation ?? null,
});
return res.redirect(authorizationUrl);
} catch (error) {
// Without an explicit log, CustomException would 500 silently
// (it doesn't extend HttpException, so Nest's default filter swallows it).
this.logger.error(
`OAuth authorize failed (applicationId=${applicationId}, providerName=${providerName}): ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
return this.redirectToError(res, error, workspace);
}
}
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Query('error') errorParam: string | undefined,
@Query('error_description') errorDescription: string | undefined,
@Res() res: Response,
) {
let workspace: WorkspaceEntity | null = null;
if (errorParam) {
return this.redirectToError(
res,
new Error(
`OAuth provider returned error: ${errorParam}${errorDescription ? `: ${errorDescription}` : ''}`,
),
workspace,
);
}
if (!code || !state) {
return this.redirectToError(
res,
new Error(
'OAuth callback is missing the `code` or `state` query parameter',
),
workspace,
);
}
try {
const { workspaceId, applicationId, redirectLocation } =
await this.oauthProviderFlowService.completeAuthorizationFlow({
code,
state,
});
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new ApplicationOAuthProviderException(
`Workspace ${workspaceId} not found after OAuth callback`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
const pathname =
redirectLocation ||
getSettingsPath(SettingsPath.ApplicationDetail, { applicationId });
const url = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname,
});
// Frontend tab list reads the URL hash to pick the active tab.
if (!redirectLocation) {
url.hash = 'settings';
}
return res.redirect(url.toString());
} catch (error) {
return this.redirectToError(res, error, workspace);
}
}
private redirectToError(
res: Response,
error: unknown,
workspace: WorkspaceEntity | null,
) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error: error instanceof Error ? error : new Error(String(error)),
workspace: {
id: workspace?.id,
subdomain:
workspace?.subdomain ??
this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: workspace?.customDomain ?? null,
},
pathname: getSettingsPath(SettingsPath.Accounts),
}),
);
}
}
@@ -0,0 +1,81 @@
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;
}
@@ -0,0 +1,43 @@
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),
});
}
}
@@ -0,0 +1,39 @@
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 { 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';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
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';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationOAuthProviderEntity,
ApplicationEntity,
ApplicationRegistrationVariableEntity,
ConnectedAccountEntity,
]),
JwtModule,
SecretEncryptionModule,
SecureHttpClientModule,
TwentyConfigModule,
],
providers: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
ApplicationConnectionProviderResolver,
],
exports: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
],
})
export class ApplicationOAuthProviderModule {}
@@ -0,0 +1,285 @@
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 },
);
}
}
@@ -0,0 +1,405 @@
// 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);
});
});
});
@@ -0,0 +1,87 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
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 { 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';
// On-demand connection lookup for app logic functions. Authenticated via the
// application access token (already injected into the function runtime as
// TWENTY_APP_ACCESS_TOKEN). Apps can only list their own connections.
@Controller('apps/connections')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
export class ApplicationConnectionsController {
constructor(
private readonly listService: ApplicationConnectionsListService,
) {}
@Post('list')
@HttpCode(HttpStatus.OK)
async list(
@Req() request: Request,
@Body() filter: ListAppConnectionsDto,
): Promise<AppConnectionDto[]> {
const { applicationId, workspaceId, requestUserWorkspaceId } =
this.requireAppContext(request);
return this.listService.list({
applicationId,
workspaceId,
requestUserWorkspaceId,
filter,
});
}
@Post('get')
@HttpCode(HttpStatus.OK)
async get(
@Req() request: Request,
@Body() body: GetAppConnectionDto,
): Promise<AppConnectionDto> {
const { applicationId, workspaceId, requestUserWorkspaceId } =
this.requireAppContext(request);
return this.listService.getOne({
applicationId,
workspaceId,
requestUserWorkspaceId,
id: body.id,
});
}
private requireAppContext(request: Request): {
applicationId: string;
workspaceId: string;
requestUserWorkspaceId: string | null;
} {
if (!isDefined(request.application) || !isDefined(request.workspace)) {
throw new ForbiddenException(
'This endpoint requires an APPLICATION_ACCESS token.',
);
}
return {
applicationId: request.application.id,
workspaceId: request.workspace.id,
requestUserWorkspaceId: request.userWorkspaceId ?? null,
};
}
}
@@ -0,0 +1,30 @@
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 { 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';
import { RefreshTokensManagerModule } from 'src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module';
// Top-level consumer: depends on RefreshTokensManagerModule (which itself
// imports the engine-side AppOAuthRefreshModule). Kept separate from
// ApplicationOAuthProviderModule to avoid the import cycle. TokenModule +
// WorkspaceCacheStorageModule are pulled in for the controller's JwtAuthGuard.
@Module({
imports: [
TypeOrmModule.forFeature([
ConnectedAccountEntity,
ApplicationOAuthProviderEntity,
]),
TokenModule,
WorkspaceCacheStorageModule,
RefreshTokensManagerModule,
],
providers: [ApplicationConnectionsListService],
controllers: [ApplicationConnectionsController],
exports: [ApplicationConnectionsListService],
})
export class ApplicationConnectionsModule {}
@@ -0,0 +1,7 @@
import { type AppConnection } from 'twenty-shared/application';
// Wire shape returned to apps from POST /apps/connections/list and /get.
// Re-exported under the `…Dto` suffix to follow the server-side naming
// convention; the canonical shape lives in twenty-shared so the SDK and
// the server stay in lock-step (changes that drift would fail typecheck).
export type AppConnectionDto = AppConnection;
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class GetAppConnectionDto {
@IsUUID()
id: string;
}
@@ -0,0 +1,18 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class ListAppConnectionsDto {
@IsString()
@IsOptional()
providerName?: string;
// Optional UUID filter — when set, only credentials owned by this user are
// returned. The privacy filter on the server still applies on top of this.
@IsUUID()
@IsOptional()
userWorkspaceId?: string;
// Optional visibility filter — narrow to 'user' or 'workspace' only.
@IsString()
@IsOptional()
visibility?: 'user' | 'workspace';
}
@@ -0,0 +1,249 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
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 { 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';
type ListArgs = {
applicationId: string;
workspaceId: string;
// The userWorkspaceId of the request initiator, when known. null for
// cron / database-event triggers (the app is trusted to use its own
// criteria when picking among workspace credentials).
requestUserWorkspaceId: string | null;
filter: {
providerName?: string;
userWorkspaceId?: string;
visibility?: 'user' | 'workspace';
};
};
type GetArgs = {
applicationId: string;
workspaceId: string;
requestUserWorkspaceId: string | null;
id: string;
};
@Injectable()
export class ApplicationConnectionsListService {
private readonly logger = new Logger(ApplicationConnectionsListService.name);
constructor(
private readonly refreshTokensService: ConnectedAccountRefreshTokensService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
) {}
async list({
applicationId,
workspaceId,
requestUserWorkspaceId,
filter,
}: ListArgs): Promise<AppConnectionDto[]> {
const providers = await this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
const providerById = new Map(providers.map((p) => [p.id, p]));
let providerIds: string[] | undefined;
if (isDefined(filter.providerName)) {
const matching = providers.find((p) => p.name === filter.providerName);
if (!matching) {
return [];
}
providerIds = [matching.id];
}
const baseWhere: FindOptionsWhere<ConnectedAccountEntity> = {
applicationId,
workspaceId,
provider: ConnectedAccountProvider.APP,
...(isDefined(providerIds)
? { applicationConnectionProviderId: In(providerIds) }
: {}),
...(isDefined(filter.userWorkspaceId)
? { userWorkspaceId: filter.userWorkspaceId }
: {}),
};
const accounts = await this.connectedAccountRepository.find({
where: this.buildPrivacyWhere(
baseWhere,
requestUserWorkspaceId,
filter.visibility,
),
});
const refreshed = await Promise.all(
accounts.map((account) =>
this.refreshAndMap(account, workspaceId, providerById),
),
);
return refreshed.filter(isDefined);
}
async getOne({
applicationId,
workspaceId,
requestUserWorkspaceId,
id,
}: GetArgs): Promise<AppConnectionDto> {
const account = await this.connectedAccountRepository.findOne({
where: {
id,
applicationId,
workspaceId,
provider: ConnectedAccountProvider.APP,
},
});
if (!isDefined(account)) {
throw new NotFoundException(`Connection ${id} not found`);
}
// Same privacy rule as list(): a request-user can only see their own
// user-visibility credentials. Workspace-shared ones are visible to
// anyone in the workspace. Cron has no request user — sees all.
if (
isDefined(requestUserWorkspaceId) &&
account.visibility === 'user' &&
account.userWorkspaceId !== requestUserWorkspaceId
) {
throw new NotFoundException(`Connection ${id} not found`);
}
if (!isDefined(account.applicationConnectionProviderId)) {
throw new NotFoundException(`Connection ${id} has no provider`);
}
const provider = await this.oauthProviderRepository.findOneByOrFail({
id: account.applicationConnectionProviderId,
workspaceId,
});
const dto = await this.refreshAndMap(
account,
workspaceId,
new Map([[provider.id, provider]]),
);
if (!isDefined(dto)) {
throw new NotFoundException(
`Connection ${id} could not be refreshed; ask the user to reconnect`,
);
}
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,
visibilityFilter: 'user' | 'workspace' | undefined,
):
| 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,
visibility: 'user',
userWorkspaceId: requestUserWorkspaceId,
};
}
// 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' },
{
...baseWhere,
visibility: 'user',
userWorkspaceId: requestUserWorkspaceId,
},
];
}
private async refreshAndMap(
account: ConnectedAccountEntity,
workspaceId: string,
providerById: Map<string, ApplicationOAuthProviderEntity>,
): Promise<AppConnectionDto | null> {
const provider = isDefined(account.applicationConnectionProviderId)
? providerById.get(account.applicationConnectionProviderId)
: undefined;
// Connections without a resolvable provider can't be refreshed and the
// app has no way to use them — drop them from the response so the dev
// doesn't see ghost rows. The upstream cleanup happens via the FK
// ON DELETE CASCADE when the provider is removed.
if (!isDefined(provider)) {
this.logger.warn(
`Connection ${account.id} references missing provider ${account.applicationConnectionProviderId}`,
);
return null;
}
try {
const tokens = await this.refreshTokensService.refreshAndSaveTokens(
account,
workspaceId,
);
return {
id: account.id,
providerName: provider.name,
name: account.name ?? account.handle,
handle: account.handle,
visibility: account.visibility as 'user' | 'workspace',
userWorkspaceId: account.userWorkspaceId,
accessToken: tokens.accessToken,
scopes: account.scopes ?? provider.scopes,
authFailedAt: account.authFailedAt?.toISOString() ?? null,
};
} catch (error) {
this.logger.warn(
`Failed to refresh tokens for connection ${account.id}: ${(error as Error).message}`,
);
return null;
}
}
}
@@ -0,0 +1,42 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { type ConnectionProviderType } from 'twenty-shared/application';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ApplicationConnectionProviderOAuthConfig')
export class ApplicationConnectionProviderOAuthConfigDTO {
@Field(() => [String])
scopes: string[];
// false when the server admin hasn't filled in the OAuth client_id /
// client_secret on the application registration. The frontend uses it to
// disable "Add connection" and surface a "needs server admin" hint.
@Field()
isClientCredentialsConfigured: boolean;
}
@ObjectType('ApplicationConnectionProvider')
export class ApplicationConnectionProviderDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field()
applicationId: string;
// Explicit String type because @nestjs/graphql can't infer GraphQL types
// from TS string unions. The TS type is the source of truth for the union.
@Field(() => String)
type: ConnectionProviderType;
@Field()
name: string;
@Field()
displayName: string;
@Field(() => ApplicationConnectionProviderOAuthConfigDTO, { nullable: true })
oauth: ApplicationConnectionProviderOAuthConfigDTO | null;
}
@@ -0,0 +1,18 @@
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 { 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,
ApplicationVariableEntityModule,
SecureHttpClientModule,
],
providers: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService],
exports: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService],
})
export class AppOAuthRefreshModule {}
@@ -0,0 +1,97 @@
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 { 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 { 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 {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
@Injectable()
export class AppOAuthRefreshAccessTokenService {
private readonly logger = new Logger(AppOAuthRefreshAccessTokenService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
async refreshTokens(
connectedAccount: ConnectedAccountEntity,
refreshToken: string,
): Promise<ConnectedAccountTokens> {
if (!isDefined(connectedAccount.applicationConnectionProviderId)) {
throw new ConnectedAccountRefreshAccessTokenException(
`Connected account ${connectedAccount.id} has no applicationConnectionProviderId`,
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;
}
try {
const tokenResponse = await exchangeRefreshTokenForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
clientId,
clientSecret,
refreshToken,
contentType: provider.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.
refreshToken: tokenResponse.refreshToken ?? refreshToken,
};
} catch (error) {
this.logger.warn(
`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.
const isTransient =
!(error instanceof OAuthTokenEndpointError) || error.status >= 500;
throw new ConnectedAccountRefreshAccessTokenException(
`App OAuth refresh failed: ${(error as Error).message}`,
isTransient
? ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR
: ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
);
}
}
}
@@ -0,0 +1,71 @@
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 { 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';
@Injectable()
export class AppOAuthRevokeService {
private readonly logger = new Logger(AppOAuthRevokeService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
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.
async revokeIfApp(connectedAccount: ConnectedAccountEntity): Promise<void> {
if (
!isDefined(connectedAccount.applicationConnectionProviderId) ||
!isDefined(connectedAccount.accessToken)
) {
return;
}
let provider;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
);
} catch {
return;
}
if (!provider.revokeEndpoint) {
return;
}
try {
const response = await this.secureHttpClientService.createSsrfSafeFetch()(
provider.revokeEndpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: connectedAccount.accessToken,
token_type_hint: 'access_token',
}).toString(),
},
);
if (!response.ok) {
this.logger.warn(
`Provider ${provider.id} revoke endpoint responded with ${response.status} for connected account ${connectedAccount.id}`,
);
}
} catch (error) {
this.logger.warn(
`Provider revoke call failed for connected account ${connectedAccount.id}: ${(error as Error).message}`,
);
}
}
}
@@ -0,0 +1,5 @@
export type TokenExchangeResponse = {
accessToken: string;
refreshToken: string | null;
scopes: string[] | null;
};
@@ -0,0 +1,167 @@
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';
const buildResponse = (
json: unknown,
options: { ok?: boolean; status?: number } = {},
): Response =>
({
ok: options.ok ?? true,
status: options.status ?? 200,
json: async () => json,
text: async () => JSON.stringify(json),
}) as Response;
const baseExchangeArgs = {
tokenEndpoint: 'https://example.com/token',
contentType: 'form-urlencoded' as const,
clientId: 'cid',
clientSecret: 'csec',
code: 'c',
redirectUri: 'https://example.com/cb',
codeVerifier: null,
};
describe('exchangeCodeForToken', () => {
it('POSTs form-urlencoded with the OAuth2 standard fields and parses the response', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({
access_token: 'lin_access',
refresh_token: 'lin_refresh',
expires_in: 315360000,
scope: 'read write',
}),
);
const result = await exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
codeVerifier: 'verifier_123',
});
expect(result).toEqual({
accessToken: 'lin_access',
refreshToken: 'lin_refresh',
scopes: ['read', 'write'],
});
const init = (
fetchFn.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
]
)[1];
expect(init.headers['Content-Type']).toBe(
'application/x-www-form-urlencoded',
);
const params = new URLSearchParams(init.body);
expect(params.get('grant_type')).toBe('authorization_code');
expect(params.get('code')).toBe('c');
expect(params.get('client_id')).toBe('cid');
expect(params.get('client_secret')).toBe('csec');
expect(params.get('code_verifier')).toBe('verifier_123');
});
it('POSTs JSON when contentType is json', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ access_token: 'a', refresh_token: 'r' }),
);
await exchangeCodeForToken({
...baseExchangeArgs,
contentType: 'json',
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
const init = (
fetchFn.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
]
)[1];
expect(init.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body)).toMatchObject({
grant_type: 'authorization_code',
code: 'c',
});
});
it('throws on non-2xx response', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ error: 'invalid_grant' }, { ok: false, status: 400 }),
);
await expect(
exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow(/400/);
});
it('throws when 200 response is missing access_token', async () => {
const fetchFn = jest.fn(async () => buildResponse({ refresh_token: 'r' }));
await expect(
exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow(/access_token/);
});
});
describe('exchangeRefreshTokenForToken', () => {
const baseRefreshArgs = {
tokenEndpoint: 'https://example.com/token',
contentType: 'form-urlencoded' as const,
clientId: 'cid',
clientSecret: 'csec',
refreshToken: 'old_refresh',
};
it('uses grant_type=refresh_token and returns the rotated tokens', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({
access_token: 'new_access',
refresh_token: 'new_refresh',
}),
);
const result = await exchangeRefreshTokenForToken({
...baseRefreshArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
expect(result).toEqual({
accessToken: 'new_access',
refreshToken: 'new_refresh',
scopes: null,
});
const init = (
fetchFn.mock.calls[0] as unknown as [string, { body: string }]
)[1];
const params = new URLSearchParams(init.body);
expect(params.get('grant_type')).toBe('refresh_token');
expect(params.get('refresh_token')).toBe('old_refresh');
});
it('returns refreshToken=null when the provider omits it (caller applies fallback)', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ access_token: 'new_access' }),
);
const result = await exchangeRefreshTokenForToken({
...baseRefreshArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
expect(result.refreshToken).toBeNull();
});
});
@@ -0,0 +1,5 @@
// Workspace-agnostic by design: the workspace identity travels in the
// signed `state` parameter, so a single redirect URL configured at the
// OAuth provider serves every workspace.
export const buildAppOAuthCallbackUrl = (serverUrl: string): string =>
new URL('/apps/oauth/callback', serverUrl).toString();
@@ -0,0 +1,6 @@
import { createHash } from 'crypto';
import { base64UrlEncode } from 'twenty-shared/utils';
export const computePkceChallenge = (verifier: string): string =>
base64UrlEncode(createHash('sha256').update(verifier).digest());
@@ -0,0 +1,15 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
export const encodeOAuthBody = (
contentType: OAuthProviderTokenRequestContentType,
params: Record<string, string>,
): { body: string; contentTypeHeader: string } =>
contentType === 'json'
? {
body: JSON.stringify(params),
contentTypeHeader: 'application/json',
}
: {
body: new URLSearchParams(params).toString(),
contentTypeHeader: 'application/x-www-form-urlencoded',
};
@@ -0,0 +1,36 @@
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';
type FetchFn = typeof globalThis.fetch;
export const exchangeCodeForToken = (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
codeVerifier: string | null;
}): Promise<TokenExchangeResponse> => {
const params: Record<string, string> = {
grant_type: 'authorization_code',
code: args.code,
redirect_uri: args.redirectUri,
client_id: args.clientId,
client_secret: args.clientSecret,
};
if (args.codeVerifier) {
params.code_verifier = args.codeVerifier;
}
return postOAuthTokenRequest({
fetchFn: args.fetchFn,
tokenEndpoint: args.tokenEndpoint,
contentType: args.contentType,
params,
});
};
@@ -0,0 +1,26 @@
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';
type FetchFn = typeof globalThis.fetch;
export const exchangeRefreshTokenForToken = (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
clientId: string;
clientSecret: string;
refreshToken: string;
}): Promise<TokenExchangeResponse> =>
postOAuthTokenRequest({
fetchFn: args.fetchFn,
tokenEndpoint: args.tokenEndpoint,
contentType: args.contentType,
params: {
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
client_id: args.clientId,
client_secret: args.clientSecret,
},
});
@@ -0,0 +1,6 @@
import { randomBytes } from 'crypto';
import { base64UrlEncode } from 'twenty-shared/utils';
export const generatePkceVerifier = (): string =>
base64UrlEncode(randomBytes(32));
@@ -0,0 +1,24 @@
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
export const parseTokenResponse = (
json: Record<string, unknown>,
): TokenExchangeResponse => {
const accessToken =
typeof json.access_token === 'string' ? json.access_token : null;
if (!accessToken) {
throw new Error(
`Token endpoint did not return an access_token. Response keys: ${Object.keys(json).join(', ')}`,
);
}
return {
accessToken,
refreshToken:
typeof json.refresh_token === 'string' ? json.refresh_token : null,
scopes:
typeof json.scope === 'string'
? json.scope.split(/[\s,]+/).filter(Boolean)
: null,
};
};
@@ -0,0 +1,53 @@
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';
type FetchFn = typeof globalThis.fetch;
// Carries the HTTP status alongside the message so callers can distinguish
// transient (5xx, network) from permanent (4xx) failures.
export class OAuthTokenEndpointError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'OAuthTokenEndpointError';
}
}
export const postOAuthTokenRequest = async (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
params: Record<string, string>;
}): Promise<TokenExchangeResponse> => {
const { body, contentTypeHeader } = encodeOAuthBody(
args.contentType,
args.params,
);
const response = await args.fetchFn(args.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': contentTypeHeader,
// Many providers (notably GitHub) default to URL-encoded responses
// unless we explicitly ask for JSON.
Accept: 'application/json',
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new OAuthTokenEndpointError(
`Token endpoint responded with ${response.status}: ${text.slice(0, 500)}`,
response.status,
);
}
return parseTokenResponse((await response.json()) as Record<string, unknown>);
};