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 {
@@ -52,6 +52,16 @@ export class ApiService {
return this.applicationApi.createApplicationRegistration(...args);
}
rotateApplicationRegistrationClientSecret(
...args: Parameters<
ApplicationApi['rotateApplicationRegistrationClientSecret']
>
) {
return this.applicationApi.rotateApplicationRegistrationClientSecret(
...args,
);
}
createDevelopmentApplication(
...args: Parameters<ApplicationApi['createDevelopmentApplication']>
) {
@@ -70,12 +80,14 @@ export class ApiService {
return this.applicationApi.syncMarketplaceCatalog();
}
getSchema(options?: { authToken?: string }): Promise<ApiResponse<string>> {
getSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getSchema(options);
}
getMetadataSchema(options?: {
authToken?: string;
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getMetadataSchema(options);
}
@@ -109,7 +109,8 @@ export class ApplicationApi {
universalIdentifier: string;
oAuthClientId: string;
};
clientSecret: string;
accessToken: string;
refreshToken: string;
}>
> {
try {
@@ -121,7 +122,8 @@ export class ApplicationApi {
universalIdentifier
oAuthClientId
}
clientSecret
accessToken
refreshToken
}
}
`;
@@ -159,6 +161,51 @@ export class ApplicationApi {
}
}
async rotateApplicationRegistrationClientSecret(
id: string,
): Promise<ApiResponse<{ clientSecret: string }>> {
try {
const mutation = `
mutation RotateApplicationRegistrationClientSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { id },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.rotateApplicationRegistrationClientSecret,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
@@ -6,20 +6,20 @@ export class SchemaApi {
constructor(private readonly client: AxiosInstance) {}
async getSchema(options?: {
authToken?: string;
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/graphql', options);
}
async getMetadataSchema(options?: {
authToken?: string;
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/metadata', options);
}
private async introspectEndpoint(
endpoint: string,
options?: { authToken?: string },
options?: { appAccessToken?: string },
): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
@@ -29,8 +29,8 @@ export class SchemaApi {
Accept: '*/*',
};
if (options?.authToken) {
headers.Authorization = `Bearer ${options.authToken}`;
if (options?.appAccessToken) {
headers.Authorization = `Bearer ${options.appAccessToken}`;
}
const response = await this.client.post(