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
@@ -141,11 +141,11 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
workspaceMembers.map((member) => [member.userId, member]),
);
return new Map(
userIds.map((userId) => {
const entries = await Promise.all(
userIds.map(async (userId): Promise<[string, string | null]> => {
const member = memberByUserId.get(userId);
const memberSigned = isDefined(member)
? this.workspaceMemberTranspiler.generateSignedAvatarUrl({
? await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
workspaceId: workspace.id,
workspaceMember: member,
})
@@ -162,7 +162,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
}
const fallbackSigned =
this.workspaceMemberTranspiler.generateSignedAvatarUrl({
await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
workspaceId: workspace.id,
workspaceMember: { avatarUrl: fallbackAvatarUrl, id: userId },
});
@@ -173,6 +173,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
];
}),
);
return new Map(entries);
}
async loadWorkspaceMembersByUserIds({
@@ -28,13 +28,13 @@ export type ToWorkspaceMemberDtoArgs = {
export class WorkspaceMemberTranspiler {
constructor(private readonly fileUrlService: FileUrlService) {}
generateSignedAvatarUrl({
async generateSignedAvatarUrl({
workspaceId,
workspaceMember,
}: {
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
workspaceId: string;
}): string {
}): Promise<string> {
if (
!isDefined(workspaceMember.avatarUrl) ||
!isNonEmptyString(workspaceMember.avatarUrl)
@@ -58,11 +58,11 @@ export class WorkspaceMemberTranspiler {
});
}
toWorkspaceMemberDto({
async toWorkspaceMemberDto({
userWorkspace,
workspaceMemberEntity,
userWorkspaceRoles,
}: ToWorkspaceMemberDtoArgs): WorkspaceMemberDTO {
}: ToWorkspaceMemberDtoArgs): Promise<WorkspaceMemberDTO> {
const {
avatarUrl: avatarUrlFromEntity,
id,
@@ -77,7 +77,7 @@ export class WorkspaceMemberTranspiler {
numberFormat,
} = workspaceMemberEntity;
const avatarUrl = this.generateSignedAvatarUrl({
const avatarUrl = await this.generateSignedAvatarUrl({
workspaceId: userWorkspace.workspaceId,
workspaceMember: {
avatarUrl: avatarUrlFromEntity,
@@ -108,18 +108,20 @@ export class WorkspaceMemberTranspiler {
} satisfies WorkspaceMemberDTO;
}
toWorkspaceMemberDtos(
async toWorkspaceMemberDtos(
allWorkspaceEntitiesBundles: ToWorkspaceMemberDtoArgs[],
) {
return allWorkspaceEntitiesBundles.map((bundle) =>
this.toWorkspaceMemberDto(bundle),
): Promise<WorkspaceMemberDTO[]> {
return Promise.all(
allWorkspaceEntitiesBundles.map((bundle) =>
this.toWorkspaceMemberDto(bundle),
),
);
}
toDeletedWorkspaceMemberDto(
async toDeletedWorkspaceMemberDto(
workspaceMember: WorkspaceMemberWorkspaceEntity,
userWorkspaceId?: string,
): DeletedWorkspaceMemberDTO {
): Promise<DeletedWorkspaceMemberDTO> {
const {
avatarUrl: avatarUrlFromEntity,
id,
@@ -132,7 +134,7 @@ export class WorkspaceMemberTranspiler {
}
const avatarUrl = userWorkspaceId
? this.generateSignedAvatarUrl({
? await this.generateSignedAvatarUrl({
workspaceId: userWorkspaceId,
workspaceMember: {
avatarUrl: avatarUrlFromEntity,
@@ -150,12 +152,14 @@ export class WorkspaceMemberTranspiler {
} satisfies DeletedWorkspaceMemberDTO;
}
toDeletedWorkspaceMemberDtos(
async toDeletedWorkspaceMemberDtos(
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
userWorkspaceId?: string,
): DeletedWorkspaceMemberDTO[] {
return workspaceMembers.map((workspaceMember) =>
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
): Promise<DeletedWorkspaceMemberDTO[]> {
return Promise.all(
workspaceMembers.map((workspaceMember) =>
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
),
);
}
}