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:
+2
@@ -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,
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+384
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
+142
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+50
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
+9
@@ -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',
|
||||
}
|
||||
+309
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
+43
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+39
@@ -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 {}
|
||||
+285
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+405
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+30
@@ -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 {}
|
||||
+7
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class GetAppConnectionDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
+18
@@ -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';
|
||||
}
|
||||
+249
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+18
@@ -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 {}
|
||||
+97
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type TokenExchangeResponse = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
scopes: string[] | null;
|
||||
};
|
||||
+167
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
@@ -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();
|
||||
+6
@@ -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());
|
||||
+15
@@ -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',
|
||||
};
|
||||
+36
@@ -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,
|
||||
});
|
||||
};
|
||||
+26
@@ -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,
|
||||
},
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
|
||||
export const generatePkceVerifier = (): string =>
|
||||
base64UrlEncode(randomBytes(32));
|
||||
+24
@@ -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,
|
||||
};
|
||||
};
|
||||
+53
@@ -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>);
|
||||
};
|
||||
Reference in New Issue
Block a user