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:
+6
-1
@@ -166,17 +166,22 @@ export class RouteTriggerService {
|
||||
|
||||
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
let userWorkspaceId: string | null = null;
|
||||
|
||||
if (httpRouteSettings?.isAuthRequired) {
|
||||
await this.validateWorkspaceFromRequest({
|
||||
const authContext = await this.validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
});
|
||||
|
||||
userWorkspaceId = authContext.userWorkspaceId ?? null;
|
||||
}
|
||||
|
||||
const event = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: pathParams,
|
||||
forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
+11
@@ -332,6 +332,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: { id: '123' },
|
||||
forwardedRequestHeaders: ['content-type', 'authorization'],
|
||||
userWorkspaceId: 'uws-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -349,6 +350,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
path: '/s/users/123',
|
||||
},
|
||||
},
|
||||
userWorkspaceId: 'uws-1',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,6 +363,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/s/api/users');
|
||||
@@ -375,6 +378,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/api/users');
|
||||
@@ -391,6 +395,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.body).toBeNull();
|
||||
@@ -407,6 +412,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: { userId: '456' },
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.method).toBe('DELETE');
|
||||
@@ -427,6 +433,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: ['x-api-key'],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
@@ -443,6 +450,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.isBase64Encoded).toBe(false);
|
||||
@@ -464,6 +472,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.rawBody).toBe(original);
|
||||
@@ -480,6 +489,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.rawBody).toBeUndefined();
|
||||
@@ -498,6 +508,7 @@ describe('buildLogicFunctionEvent', () => {
|
||||
userId: 'user1',
|
||||
},
|
||||
forwardedRequestHeaders: [],
|
||||
userWorkspaceId: null,
|
||||
});
|
||||
|
||||
expect(result.pathParameters).toEqual({
|
||||
|
||||
+3
@@ -117,10 +117,12 @@ export const buildLogicFunctionEvent = ({
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
userWorkspaceId: string | null;
|
||||
}): LogicFunctionEvent => {
|
||||
const rawBody = extractRawBody(request);
|
||||
|
||||
@@ -140,5 +142,6 @@ export const buildLogicFunctionEvent = ({
|
||||
path: request.path,
|
||||
},
|
||||
},
|
||||
userWorkspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user