Use app's own OAuth credentials for CoreApiClient generation (#19563)

## Summary

- **SDK (`dev` & `dev --once`)**: After app registration, the CLI now
obtains an `APPLICATION_ACCESS` token via `client_credentials` grant
using the app's own `clientId`/`clientSecret`, and uses that token for
CoreApiClient schema introspection — instead of the user's
`config.accessToken` which returns the full unscoped schema.
- **Config**: `oauthClientSecret` is now persisted alongside
`oauthClientId` in `~/.twenty/config.json` when creating a new app
registration, so subsequent `dev`/`dev --once` runs can obtain fresh app
tokens without re-registration.
- **CI action**: `spawn-twenty-app-dev-test` now outputs a proper
`API_KEY` JWT (signed with the seeded dev workspace secret) instead of
the previous hardcoded `ACCESS` token — giving consumers a real API key
rather than a user session token.

## Motivation

When developing Twenty apps, `yarn twenty dev` was using the CLI user's
OAuth token for GraphQL schema introspection during CoreApiClient
generation. This token (type `ACCESS`) has no `applicationId` claim, so
the server returns the **full workspace schema** — including all objects
— rather than the scoped schema the app should see at runtime (filtered
by `applicationId`).

This caused a discrepancy: the generated CoreApiClient contained fields
the app couldn't actually query at runtime with its `APPLICATION_ACCESS`
token.

By switching to `client_credentials` grant, the SDK now introspects with
the same token type the app will use in production, ensuring the
generated client accurately reflects the app's runtime capabilities.
This commit is contained in:
Charles Bochet
2026-04-11 11:24:28 +02:00
committed by GitHub
parent f52d66b960
commit c26c0b9d71
22 changed files with 263 additions and 142 deletions
@@ -115,23 +115,26 @@ export class ApiClient {
async refreshToken(): Promise<string | null> {
const config = await this.configService.getConfig();
if (!config.refreshToken || !config.oauthClientId) {
if (
!config.twentyCLIRefreshToken ||
!config.twentyCLIRegistrationClientId
) {
return null;
}
try {
const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: config.refreshToken,
client_id: config.oauthClientId,
refresh_token: config.twentyCLIRefreshToken,
client_id: config.twentyCLIRegistrationClientId,
});
const { access_token: newAccessToken, refresh_token: newRefreshToken } =
tokenResponse.data;
await this.configService.setConfig({
accessToken: newAccessToken,
...(newRefreshToken ? { refreshToken: newRefreshToken } : {}),
twentyCLIAccessToken: newAccessToken,
...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}),
});
return newAccessToken;
@@ -146,9 +149,9 @@ export class ApiClient {
}
const config = await this.configService.getConfig();
const accessToken = config.accessToken;
const cliToken = config.twentyCLIAccessToken;
if (accessToken && this.isTokenExpired(accessToken)) {
if (cliToken && this.isTokenExpired(cliToken)) {
const refreshed = await this.refreshToken();
if (refreshed) {
@@ -156,7 +159,7 @@ export class ApiClient {
}
}
return accessToken ?? config.apiKey;
return cliToken ?? config.apiKey;
}
private isTokenExpired(token: string): boolean {