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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user