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
@@ -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,
@@ -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,
@@ -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,
});
}