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

## Summary

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

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

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

## Architecture

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

## Reference app

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

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

## Tests

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

## Test plan

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

## Out of scope (deliberately)

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-04 11:26:34 +02:00
committed by GitHub
parent ff22988caf
commit 9e94045fa5
132 changed files with 6533 additions and 595 deletions
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
@@ -19,6 +20,7 @@ import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/
CalendarChannelEntity,
MessageChannelEntity,
]),
AppOAuthRefreshModule,
FeatureFlagModule,
PermissionsModule,
WorkspaceEventEmitterModule,
@@ -5,6 +5,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { In, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
@@ -33,6 +34,7 @@ export class ConnectedAccountMetadataService {
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly appOAuthRevokeService: AppOAuthRevokeService,
) {}
async findAll(workspaceId: string): Promise<ConnectedAccountDTO[]> {
@@ -182,6 +184,12 @@ export class ConnectedAccountMetadataService {
`WorkspaceId: ${workspaceId} Deleting connected account ${id} with ${messageChannels.length} message channel(s) and ${calendarChannels.length} calendar channel(s)`,
);
// Best-effort revocation against the provider's revokeEndpoint (no-op
// for non-app providers and for app providers without a revokeEndpoint
// declared). We don't want a slow or failing provider to block the
// local disconnect, so any error is swallowed inside the service.
await this.appOAuthRevokeService.revokeIfApp(connectedAccount);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
await this.repository.delete({
id,
@@ -72,6 +72,28 @@ export class ConnectedAccountDTO {
@Field(() => UUIDScalarType)
userWorkspaceId: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
applicationConnectionProviderId: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
applicationId: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
name: string | null;
// 'user' = private to the connecting user.
// 'workspace' = shared with all members.
// Named `visibility` to disambiguate from the OAuth `scopes` array.
@IsString()
@Field(() => String)
visibility: string;
@HideField()
workspaceId: string;
@@ -2,6 +2,9 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
@@ -10,12 +13,23 @@ import {
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
// Distinguishes who can use this credential. Named `visibility` (not
// `scope`) so it doesn't clash with the OAuth `scopes` array on the same
// row — those are unrelated concepts that used to differ by one letter.
export type ConnectedAccountVisibility = 'user' | 'workspace';
@Entity({ name: 'connectedAccount', schema: 'core' })
@Index('IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID', [
'applicationConnectionProviderId',
])
@Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId'])
export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -56,6 +70,32 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@Column({ type: 'uuid', nullable: false })
userWorkspaceId: string;
@Column({ type: 'uuid', nullable: true, name: 'applicationOAuthProviderId' })
applicationConnectionProviderId: string | null;
@ManyToOne(() => ApplicationOAuthProviderEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'applicationOAuthProviderId' })
applicationConnectionProvider: Relation<ApplicationOAuthProviderEntity> | null;
@Column({ type: 'uuid', nullable: true })
applicationId: string | null;
@ManyToOne(() => ApplicationEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity> | null;
@Column({ type: 'varchar', nullable: true })
name: string | null;
@Column({ type: 'varchar', nullable: false, default: 'user' })
visibility: ConnectedAccountVisibility;
@OneToMany(
'MessageChannelEntity',
(messageChannel: MessageChannelEntity) => messageChannel.connectedAccount,
@@ -0,0 +1,46 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ConnectedAccountRefreshAccessTokenExceptionCode {
REFRESH_TOKEN_NOT_FOUND = 'REFRESH_TOKEN_NOT_FOUND',
INVALID_REFRESH_TOKEN = 'INVALID_REFRESH_TOKEN',
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
TEMPORARY_NETWORK_ERROR = 'TEMPORARY_NETWORK_ERROR',
ACCESS_TOKEN_NOT_FOUND = 'ACCESS_TOKEN_NOT_FOUND',
}
const getConnectedAccountRefreshAccessTokenExceptionUserFriendlyMessage = (
code: ConnectedAccountRefreshAccessTokenExceptionCode,
) => {
switch (code) {
case ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND:
return msg`Refresh token not found.`;
case ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN:
return msg`Invalid refresh token.`;
case ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED:
return msg`This provider is not supported.`;
case ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR:
return msg`A temporary network error occurred.`;
case ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND:
return msg`Access token not found.`;
default:
assertUnreachable(code);
}
};
export class ConnectedAccountRefreshAccessTokenException extends CustomException<ConnectedAccountRefreshAccessTokenExceptionCode> {
constructor(
message: string,
code: ConnectedAccountRefreshAccessTokenExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectedAccountRefreshAccessTokenExceptionUserFriendlyMessage(code),
});
}
}