feat(server): migrate all remaining JWT token types to ES256 (#20513)
## Summary Extends the asymmetric signing work from #20467 to cover **every remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`, `FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`, `APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the code interpreter tool. After this PR, every JWT the server signs is ES256 with a `kid` pointing at the current `core."signingKey"` row, while legacy HS256 tokens (no `kid` header) remain verifiable indefinitely through the existing fallback in `JwtWrapperService.resolveVerificationKey`. No new entity / migration / config: this is a pure routing change on top of the infrastructure that already shipped. ## Why `#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT type was still HS256-signed against the global `APP_SECRET`, which kept the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT type forever). Migrating the rest unifies the sign path on rotatable per-server private keys without forcing any token reissue. ## Mechanical changes ### Sign side (8 services) - `LoginTokenService.generateLoginToken` - `TransientTokenService.generateTransientToken` - `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken` - `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` + `APPLICATION_REFRESH`) - `ApiKeyService.generateApiKeyToken` - `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl` - `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`) - `CodeInterpreterTool.generateSessionToken` Each call site swaps `jwtWrapperService.sign(payload, { secret: generateAppSecret(...), ... })` for `await jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The `generateAppSecret` calls on the sign side are dropped (verifier-side `generateAppSecret` stays in `resolveVerificationKey` for the HS256 fallback). ### Verifier side - `WorkspaceAgnosticTokenService.validateToken` now goes through `verifyJwtToken` instead of the bespoke `verify({ secret })` path, so new ES256 tokens are accepted while the legacy HS256 fallback inside `resolveVerificationKey` still serves the old shape. - `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now strictly deprecated — there are no remaining production callers. ### Async ripple (`signFileByIdUrl` was synchronous) - `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now `async`; the `signUrl` callback used by `getRecordImageIdentifier` is widened to accept `Promise<string | null>`. - Every direct/indirect caller is updated: admin panel (user lookup + statistics + top workspaces), search service (`computeSearchObjectResults`, `getImageIdentifierValue`), workspace resolver (`logo` resolver, public workspace by domain/id), `WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` / `toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`), `UserService.loadSignedAvatarUrlsByUserId`, `UserWorkspaceService.castWorkspaceToAvailableWorkspace`, workspace-invitation, approved-access-domain, agent-chat-streaming, agent-message-part resolver, navigation-menu-item record identifier, file-ai-chat / file-core-picture / file-email-attachment / file-workflow / files-field services, rich-text & files-field query result getters, and the code-interpreter tool. ## Backward compatibility - **Legacy HS256 tokens (no `kid`)** keep verifying via `resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret` for both `workspaceId`-bearing and `userId`-bearing payloads. - The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in inside `verifyJwtToken` for pre-2025-12-12 API keys. - No payload shape changes, no DB writes, no env var changes — old tokens issued by `main` continue to authenticate. ## Tests ### Unit (all green locally — 63/63) Updated specs for every migrated service to mock `signAsync` instead of `sign` and assert the new option shape: - `login-token.service.spec.ts`, `transient-token.service.spec.ts`, `workspace-agnostic-token.service.spec.ts`, `application-token.service.spec.ts`, `api-key.service.spec.ts`, `connection-provider-oauth-flow.service.spec.ts`. ### Integration (`jwt-key-rotation.integration-spec.ts`) - Existing ACCESS coverage (current key, legacy HS256 fallback, rotated-out key, revoked key, unknown kid) is preserved. - New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN` tokens emitted by the real signUp → signUpInNewWorkspace → getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the current signing key — proves end-to-end that the migration didn't regress those flows. ## Open question (separate decision) This PR keeps the legacy HS256 verification fallback **forever**. We may eventually want to sunset it for `API_KEY` once telemetry shows pre-migration tokens are gone, but that's a separate product/security decision and not part of this change. ## Test plan - [ ] CI green - [ ] `npx nx lint:diff-with-main twenty-server` passes - [ ] `npx nx typecheck twenty-server` passes - [ ] `jwt-key-rotation` integration suite passes (new + existing assertions) - [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH token, generating an API key issues an ES256 token with `kid`, signed file URL JWT is ES256 with `kid` - [ ] Pre-existing HS256 tokens still authenticate (covered by integration test, but worth a manual check with a token from `main`)
This commit is contained in:
+1
-1
@@ -293,7 +293,7 @@ export class ApplicationRegistrationResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.fileUrlService.signFileByIdUrl({
|
||||
return await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: registration.tarballFileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
|
||||
+12
-12
@@ -34,9 +34,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
getClientCredentials: jest.Mock;
|
||||
};
|
||||
let jwtWrapperService: {
|
||||
sign: jest.Mock;
|
||||
signAsyncOrThrow: jest.Mock;
|
||||
verifyJwtToken: jest.Mock;
|
||||
generateAppSecret: jest.Mock;
|
||||
};
|
||||
let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
|
||||
let connectedAccountRepository: {
|
||||
@@ -80,9 +79,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
})),
|
||||
};
|
||||
jwtWrapperService = {
|
||||
sign: jest.fn(),
|
||||
signAsyncOrThrow: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
generateAppSecret: jest.fn(() => 'derived-secret'),
|
||||
};
|
||||
secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
|
||||
connectedAccountRepository = {
|
||||
@@ -147,7 +145,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
|
||||
describe('startAuthorizationFlow', () => {
|
||||
it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
|
||||
jwtWrapperService.sign.mockReturnValue('signed-state-token');
|
||||
jwtWrapperService.signAsyncOrThrow.mockResolvedValue(
|
||||
'signed-state-token',
|
||||
);
|
||||
|
||||
const { authorizationUrl } = await service.startAuthorizationFlow({
|
||||
connectionProvider: baseProvider,
|
||||
@@ -176,7 +176,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
expect(url.searchParams.has('code_challenge')).toBe(false);
|
||||
|
||||
// signed payload carries workspace identity for the callback to use
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -184,12 +184,12 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
visibility: 'user',
|
||||
reconnectingConnectedAccountId: null,
|
||||
}),
|
||||
expect.objectContaining({ secret: 'derived-secret' }),
|
||||
expect.objectContaining({ expiresIn: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits PKCE challenge params when usePkce is enabled', async () => {
|
||||
jwtWrapperService.sign.mockReturnValue('signed-state');
|
||||
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('signed-state');
|
||||
|
||||
const { authorizationUrl } = await service.startAuthorizationFlow({
|
||||
connectionProvider: {
|
||||
@@ -247,7 +247,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
},
|
||||
});
|
||||
// No state JWT signed, no upstream URL built.
|
||||
expect(jwtWrapperService.sign).not.toHaveBeenCalled();
|
||||
expect(jwtWrapperService.signAsyncOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
|
||||
@@ -269,7 +269,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
workspaceId: 'workspace-1',
|
||||
connectionProviderId: 'provider-1',
|
||||
});
|
||||
jwtWrapperService.sign.mockReturnValue('state');
|
||||
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('state');
|
||||
|
||||
const { authorizationUrl } = await service.startAuthorizationFlow({
|
||||
...validateArgs,
|
||||
@@ -279,11 +279,11 @@ describe('ConnectionProviderOAuthFlowService', () => {
|
||||
expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
|
||||
'state',
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalled();
|
||||
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
|
||||
jwtWrapperService.sign.mockReturnValue('state');
|
||||
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('state');
|
||||
|
||||
await service.startAuthorizationFlow({
|
||||
...validateArgs,
|
||||
|
||||
+3
-9
@@ -102,7 +102,7 @@ export class ConnectionProviderOAuthFlowService {
|
||||
|
||||
const codeVerifier = usePkce ? generatePkceVerifier() : null;
|
||||
|
||||
const state = this.signState({
|
||||
const state = await this.signState({
|
||||
sub: connectionProvider.id,
|
||||
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
|
||||
connectionProviderId: connectionProvider.id,
|
||||
@@ -196,14 +196,8 @@ export class ConnectionProviderOAuthFlowService {
|
||||
};
|
||||
}
|
||||
|
||||
private signState(payload: AppOAuthStateJwtPayload): string {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.APP_OAUTH_STATE,
|
||||
payload.workspaceId,
|
||||
);
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
private async signState(payload: AppOAuthStateJwtPayload): Promise<string> {
|
||||
return this.jwtWrapperService.signAsyncOrThrow(payload, {
|
||||
expiresIn: STATE_JWT_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user