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:
Charles Bochet
2026-05-13 12:53:45 +02:00
committed by GitHub
parent a159a68e2c
commit ac653182b2
51 changed files with 959 additions and 474 deletions
@@ -67,8 +67,7 @@ describe('ApiKeyService', () => {
};
mockJwtWrapperService = {
generateAppSecret: jest.fn(),
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
};
mockApiKeyRoleService = {
@@ -377,12 +376,10 @@ describe('ApiKeyService', () => {
});
describe('generateApiKeyToken', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-jwt-token';
beforeEach(() => {
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
mockJwtWrapperService.sign.mockReturnValue(mockToken);
mockJwtWrapperService.signAsyncOrThrow.mockResolvedValue(mockToken);
});
it('should generate a JWT token for a valid API key', async () => {
@@ -395,18 +392,13 @@ describe('ApiKeyService', () => {
expiresAt,
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.API_KEY,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
{
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
},
@@ -418,7 +410,7 @@ describe('ApiKeyService', () => {
const result = await service.generateApiKeyToken(mockWorkspaceId);
expect(result).toBeUndefined();
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
expect(mockJwtWrapperService.signAsyncOrThrow).not.toHaveBeenCalled();
});
it('should use default expiration if no expiresAt provided', async () => {
@@ -426,7 +418,7 @@ describe('ApiKeyService', () => {
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
expiresIn: '100y',
@@ -444,14 +436,13 @@ describe('ApiKeyService', () => {
expiresAt,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
expect.objectContaining({
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
}),
@@ -147,11 +147,6 @@ export class ApiKeyService {
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.API_KEY,
workspaceId,
);
let expiresIn: string | number;
if (expiresAt) {
@@ -162,14 +157,13 @@ export class ApiKeyService {
expiresIn = '100y';
}
const token = this.jwtWrapperService.sign(
const token = await this.jwtWrapperService.signAsyncOrThrow(
{
sub: workspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId,
},
{
secret,
expiresIn,
jwtid: apiKeyId,
},