Files
twenty/packages/twenty-sdk/src/cli/utilities/auth/resolve-app-access-token.ts
T
Charles Bochet 53065f241f Exchange clientSecret for tokens after app registration + bump canary (#19582)
## Summary

- **Fix `createApplicationRegistration` flow**: The server's
`createApplicationRegistration` mutation returns a `clientSecret`, not
`accessToken`/`refreshToken` directly. The SDK now correctly requests
`clientSecret` and immediately performs an OAuth `client_credentials`
exchange to obtain `appAccessToken` and `appRefreshToken`, then stores
them in config.
- **New `exchangeCredentialsForTokens` helper**: Shared by both `dev`
and `dev --once` flows. Takes `clientId` + `clientSecret`, calls
`/oauth/token` with `client_credentials` grant, and persists the
resulting tokens.
- **Bump `twenty-sdk`, `twenty-client-sdk`, `create-twenty-app` to
`1.22.0-canary.2`**

## Context

The `1.22.0-canary.1` SDK release expected
`createApplicationRegistration` to return `accessToken`/`refreshToken`
directly, but the `v1.22.0` server returns `clientSecret`. This caused
`yarn twenty dev` and `yarn twenty dev --once` to fail with "No
registration found" errors.
2026-04-11 12:26:18 +02:00

105 lines
2.7 KiB
TypeScript

import { type ConfigService } from '@/cli/utilities/config/config-service';
const EXPIRATION_MARGIN_MS = 30_000;
const isTokenExpired = (token: string): boolean => {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
return payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_MS;
} catch {
return false;
}
};
/**
* Exchanges an app registration's clientId + clientSecret for access/refresh
* tokens via the OAuth client_credentials grant, then persists them in config.
*/
export const exchangeCredentialsForTokens = async (
configService: ConfigService,
params: { clientId: string; clientSecret: string },
): Promise<{ accessToken: string; refreshToken?: string }> => {
const config = await configService.getConfig();
const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: params.clientId,
client_secret: params.clientSecret,
}),
});
if (!response.ok) {
throw new Error(
`Token exchange failed: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
};
await configService.setConfig({
appAccessToken: data.access_token,
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
});
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
};
/**
* Returns a valid appAccessToken from config, refreshing it first if expired.
*/
export const ensureValidAppAccessTokenOrRefresh = async (
configService: ConfigService,
): Promise<string | undefined> => {
const config = await configService.getConfig();
if (!config.appAccessToken) {
return undefined;
}
if (!isTokenExpired(config.appAccessToken)) {
return config.appAccessToken;
}
if (!config.appRefreshToken || !config.appRegistrationClientId) {
return undefined;
}
const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: config.appRefreshToken,
client_id: config.appRegistrationClientId,
}),
});
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
};
await configService.setConfig({
appAccessToken: data.access_token,
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
});
return data.access_token;
};