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
@@ -7,7 +7,7 @@ import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import {
ConnectedAccountRefreshTokensService,
type ConnectedAccountTokens,
@@ -43,6 +43,7 @@ export class EmailAliasManagerService {
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
handleAliases = [];
break;
default:
@@ -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 { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module';
@@ -13,6 +14,7 @@ import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-acco
TypeOrmModule.forFeature([ConnectedAccountEntity]),
GoogleAPIRefreshAccessTokenModule,
MicrosoftAPIRefreshAccessTokenModule,
AppOAuthRefreshModule,
],
providers: [ConnectedAccountRefreshTokensService],
exports: [ConnectedAccountRefreshTokensService],
@@ -7,7 +7,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { parseGoogleOAuthError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util';
@@ -3,7 +3,7 @@ import { type GaxiosError } from 'gaxios';
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { isGmailNetworkError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/is-gmail-network-error.util';
export const parseGoogleOAuthError = (
@@ -6,7 +6,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util';
@@ -7,7 +7,7 @@ import {
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
/**
* @see https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes
@@ -1,46 +0,0 @@
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),
});
}
}
@@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
@@ -10,7 +11,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service';
@@ -42,6 +43,12 @@ describe('ConnectedAccountRefreshTokensService', () => {
refreshTokens: jest.fn(),
},
},
{
provide: AppOAuthRefreshAccessTokenService,
useValue: {
refreshTokens: jest.fn(),
},
},
{
provide: GlobalWorkspaceOrmManager,
useValue: {
@@ -5,6 +5,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -13,7 +14,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
export type ConnectedAccountTokens = {
accessToken: string;
@@ -31,6 +32,7 @@ export class ConnectedAccountRefreshTokensService {
constructor(
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService,
private readonly appOAuthRefreshAccessTokenService: AppOAuthRefreshAccessTokenService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@@ -99,7 +101,8 @@ export class ConnectedAccountRefreshTokensService {
): Promise<boolean> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
case ConnectedAccountProvider.MICROSOFT: {
case ConnectedAccountProvider.MICROSOFT:
case ConnectedAccountProvider.APP: {
if (!connectedAccount.lastCredentialsRefreshedAt) {
return false;
}
@@ -141,6 +144,11 @@ export class ConnectedAccountRefreshTokensService {
return await this.microsoftAPIRefreshAccessTokenService.refreshTokens(
refreshToken,
);
case ConnectedAccountProvider.APP:
return await this.appOAuthRefreshAccessTokenService.refreshTokens(
connectedAccount,
refreshToken,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import {
ConnectedAccountRefreshTokensService,
type ConnectedAccountTokens,
@@ -40,6 +40,7 @@ export class MessagingMessageOutboundService {
);
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
throw new Error(
`Provider ${connectedAccount.provider} does not support sending messages`,
);
@@ -73,6 +74,7 @@ export class MessagingMessageOutboundService {
);
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
throw new Error(
`Provider ${connectedAccount.provider} does not support creating drafts`,
);