feat(server): asymmetric JWT signing with kid + key rotation table (#20467)

## Context

Today every JWT issued by Twenty (access, refresh, login, file, etc.) is
HMAC-signed with a per-token-type secret derived from the global
`APP_SECRET`. Rotating that secret invalidates **every** active token at
once and there is no way to scope a leak to a subset of tokens.

This PR is the first slice of a broader effort to **decouple stateful
encryption (`APP_SECRET`-derived secrets) from stateless encryption
(JWTs)**. It introduces an asymmetric (private/public key) signing path
for `ACCESS` and `REFRESH` tokens and a signing-key registry to enable
**safe rotation**: leaked keys can be revoked by flipping
`revokedAt`/`isCurrent` on the matching row without invalidating tokens
issued by other keys.

> Out of scope (intentionally): swapping stateful encryption for
`APP_SECRET`, asymmetric signing for token types other than
`ACCESS`/`REFRESH`, an admin-panel rotation UI, and an enterprise
re-encryption command. Those will land in follow-up PRs.

## What changes

- **New `core.signingKey` table** (instance command `2.5.0` /
`1778550000000`) storing both the public key (PEM, in clear) and the
private key (PEM, encrypted with `APP_SECRET` via
`SecretEncryptionService`). One row is marked `isCurrent = true`
(enforced by a partial unique index). The row's UUID `id` is used
directly as the JWT `kid`.
- When a key is rotated out, its `privateKey` is nulled (we never keep
historical private keys) but the `publicKey` row stays so previously
issued tokens can still be verified.
- **`JwtKeyManagerService`** lazily loads-or-generates the current
signing key on first use:
  - If a row with `isCurrent = true` exists → decrypts and uses it.
- Otherwise → generates a fresh EC P-256 keypair, encrypts the private
key, inserts the row (UUID id = kid). Handles concurrent insert races
via the unique constraint.
- **`JwtWrapperService.signAsync()`** signs `ACCESS`/`REFRESH` payloads
with `ES256` and a `kid` header. Falls back to `HS256` if no signing key
is available (boot-time DB error, transient failure).
- **Dual-path verification** in both `JwtWrapperService.verifyJwtToken`
and the Passport `JwtAuthStrategy.secretOrKeyProvider`:
- JWT with a `kid` header → resolve the public key PEM by id and verify
with `ES256`,
- otherwise → fall back to the existing `APP_SECRET`-derived `HS256`
path (unchanged).
- **`AccessTokenService` / `RefreshTokenService`** now sign through
`signAsync` (single public surface; the routing detail stays internal to
the wrapper).
- **Public key cache**: a new `SigningKeyEntityCacheProviderService`
plugs into `CoreEntityCacheService` (`signingKeyPublicKey` namespace)
and serves PEMs by id, with the standard local-memo + Redis layering.
- **PEM strings end-to-end**: `jsonwebtoken` accepts PEM strings
directly for both sign and verify, so the manager never converts to a
Node `KeyObject` and the cache hands the PEM straight to `jwt.verify`.

## Why ES256 (and not EdDSA / RS256)

- `@nestjs/jwt` is backed by `jsonwebtoken`, which does **not** support
EdDSA today.
- ES256 keys are tiny (~120 bytes vs 1.6 kB for RS256), signatures are
short (~64 bytes), and signing/verification is fast — important since
JWT verification runs on every authenticated request.
- ES256 is widely supported and standardized (RFC 7518), with mature
ecosystem support.

## Why store the private key in DB (not env)

- No new secret to provision: existing instances already have
`APP_SECRET`, which we reuse to encrypt the private key at rest.
- Self-healing: a fresh instance auto-generates its first signing key on
first boot. Nothing to copy/paste.
- Rotation is a SQL operation against `core.signingKey`, not a redeploy
+ env mutation.

## Backward compatibility

- All previously-issued tokens (no `kid`) keep verifying through the
legacy HS256 path with their existing `APP_SECRET`-derived secret. No
forced re-login.
- Token types not in scope (`WORKSPACE_AGNOSTIC`, `API_KEY`, `FILE`,
`LOGIN`, `EMAIL_VERIFICATION`, etc.) keep their current HS256 behavior
unchanged — they still go through the synchronous
`JwtWrapperService.sign(payload, options)` with a caller-supplied
secret.
- `signWithAppSecret` is kept intentionally as the HS256 fallback path;
it will be deprecated in a follow-up PR.
- If the DB lookup/generation fails for any reason, the wrapper logs the
error and falls back to HS256 — no startup crash, no silent regression.

## Rotation story

1. Bootstrap: first signing call lazily inserts a row in
`core.signingKey` with `isCurrent = true`, `privateKey =
encrypt(pem_A)`. New tokens carry `kid_A`.
2. Rotate: `UPDATE core."signingKey" SET "isCurrent" = false,
"privateKey" = NULL WHERE id = '<kid_A>';` then insert a new row with
`isCurrent = true`. New tokens carry `kid_B`. Tokens still in flight
with `kid_A` keep verifying because the public-key row for `kid_A` is
still there.
3. Revoke: `UPDATE core."signingKey" SET "revokedAt" = now() WHERE id =
'<kid_A>';`. All tokens with `kid_A` now fail verification cleanly with
`UNAUTHENTICATED` (no 500).
4. Tokens with no `kid` (legacy) are unaffected throughout.

## Test plan

- [x] Unit: `JwtWrapperService` dual-path verification (HS256 no-kid vs
ES256 with-kid), unknown-kid → `UNAUTHENTICATED`, `signAsync` happy path
+ `null` when no key, `signAsync` rejection for non-rotatable types.
- [x] Unit: `JwtAuthStrategy` `secretOrKeyProvider` dual-path resolution
and algorithm validation.
- [x] All existing JWT/auth/application unit tests adjusted to the
renamed public method.
- [x] Integration (`jwt-key-rotation.integration-spec.ts`):
- **Happy path**: signed-up user's `ACCESS` token has `alg=ES256` +
correct UUID `kid`, the `isCurrent=true` row exists in
`core.signingKey`, `getCurrentUser` resolves.
- **Legacy fallback**: hand-crafted no-kid HS256 token verifies via the
legacy `APP_SECRET`-derived path.
- **Previous-key rotation**: token signed by a hardcoded *previous* key
whose row is pre-inserted with `privateKey = NULL` (rotated-out) still
verifies — proves the leaked-key revocation flow works in both
directions.
- **Unknown kid**: token signed with an orphan UUID `kid` is cleanly
rejected (no 500).
- [x] `npx nx typecheck twenty-server`
- [x] `npx nx test twenty-server`
- [x] `npx nx run twenty-server:lint`
This commit is contained in:
Charles Bochet
2026-05-12 17:54:44 +02:00
committed by GitHub
parent a34bf11dae
commit 9e515afb13
28 changed files with 964 additions and 162 deletions
@@ -31,7 +31,7 @@ export class ApplicationOAuthResolver {
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
await this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
@@ -334,7 +334,7 @@ export class OAuthService {
try {
const payload =
this.applicationTokenService.validateApplicationRefreshToken(
await this.applicationTokenService.validateApplicationRefreshToken(
refreshToken,
);
@@ -409,7 +409,9 @@ export class OAuthService {
// We validate the token to log that revocation was requested.
try {
const payload =
this.applicationTokenService.validateApplicationRefreshToken(token);
await this.applicationTokenService.validateApplicationRefreshToken(
token,
);
this.logger.log(
`Token revocation requested for application ${payload.applicationId}`,
@@ -448,7 +450,7 @@ export class OAuthService {
}
try {
this.applicationTokenService.validateApplicationRefreshToken(token);
await this.applicationTokenService.validateApplicationRefreshToken(token);
const decoded = this.applicationTokenService.decodeToken(token);
@@ -483,7 +485,9 @@ export class OAuthService {
// Try as access token (with signature verification)
try {
const payload =
this.applicationTokenService.validateApplicationAccessToken(token);
await this.applicationTokenService.validateApplicationAccessToken(
token,
);
const application = await this.applicationRepository.findOne({
where: { id: payload.applicationId },
@@ -321,7 +321,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
};
beforeEach(() => {
jwtWrapperService.verifyJwtToken.mockReturnValue(stateClaims);
jwtWrapperService.verifyJwtToken.mockResolvedValue(stateClaims);
connectionProviderService.findOneByIdOrThrow.mockResolvedValue(
baseProvider,
);
@@ -358,7 +358,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
});
it('updates the existing ConnectedAccount when reconnectingConnectedAccountId is supplied', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
jwtWrapperService.verifyJwtToken.mockResolvedValue({
...stateClaims,
reconnectingConnectedAccountId: 'existing-account-id',
});
@@ -389,7 +389,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
});
it('updates visibility on an existing ConnectedAccount when reconnecting', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
jwtWrapperService.verifyJwtToken.mockResolvedValue({
...stateClaims,
visibility: 'workspace',
reconnectingConnectedAccountId: 'existing-account-id',
@@ -409,7 +409,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
});
it('persists the workspace visibility when state asks for it', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
jwtWrapperService.verifyJwtToken.mockResolvedValue({
...stateClaims,
visibility: 'workspace',
});
@@ -425,9 +425,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
});
it('rejects an invalid state', async () => {
jwtWrapperService.verifyJwtToken.mockImplementation(() => {
throw new Error('JWT expired');
});
jwtWrapperService.verifyJwtToken.mockRejectedValue(
new Error('JWT expired'),
);
await expect(
service.completeAuthorizationFlow({
@@ -141,7 +141,7 @@ export class ConnectionProviderOAuthFlowService {
}
async completeAuthorizationFlow(args: CallbackArgs): Promise<CallbackResult> {
const statePayload = this.verifyState(args.state);
const statePayload = await this.verifyState(args.state);
const provider = await this.oauthProviderService.findOneByIdOrThrow(
statePayload.connectionProviderId,
@@ -208,11 +208,11 @@ export class ConnectionProviderOAuthFlowService {
});
}
private verifyState(state: string): AppOAuthStateJwtPayload {
private async verifyState(state: string): Promise<AppOAuthStateJwtPayload> {
try {
const verified = this.jwtWrapperService.verifyJwtToken(
const verified = (await this.jwtWrapperService.verifyJwtToken(
state,
) as AppOAuthStateJwtPayload;
)) as AppOAuthStateJwtPayload;
if (verified.type !== JwtTokenTypeEnum.APP_OAUTH_STATE) {
throw new Error('Wrong JWT type for OAuth state');
@@ -47,6 +47,10 @@ describe('JwtAuthStrategy', () => {
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
resolveVerificationKey: jest.fn(async () => ({
key: 'mock-key',
algorithm: 'HS256',
})),
};
permissionsService = {
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { Strategy } from 'passport-jwt';
import { Strategy, type SecretOrKeyProvider } from 'passport-jwt';
import { PermissionFlagType } from 'twenty-shared/constants';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
@@ -20,13 +20,13 @@ import {
ApplicationAccessTokenJwtPayload,
type AuthContext,
type AuthContextUser,
FileTokenJwtPayloadLegacy,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -44,36 +44,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
try {
const decodedToken = jwtWrapperService.decode<
| FileTokenJwtPayloadLegacy
| AccessTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
>(rawJwtToken);
const appSecretBody =
decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? decodedToken.userId
: decodedToken.workspaceId;
const secret = jwtWrapperService.generateAppSecret(
decodedToken.type,
appSecretBody,
);
done(null, secret);
} catch (error) {
done(error, null);
}
const secretOrKeyProvider: SecretOrKeyProvider = (
_request,
rawJwtToken,
done,
) => {
jwtWrapperService.resolveVerificationKey(rawJwtToken).then(
({ key }) => done(null, key),
(error) => done(error, undefined),
);
};
super({
jwtFromRequest: jwtFromRequestFunction,
jwtFromRequest: jwtWrapperService.extractJwtFromRequest(),
ignoreExpiration: false,
secretOrKeyProvider: secretOrKeyProviderFunction,
algorithms: [...JWT_SUPPORTED_VERIFY_ALGORITHMS],
secretOrKeyProvider,
});
}
@@ -38,6 +38,7 @@ describe('AccessTokenService', () => {
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsync: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
@@ -137,7 +138,7 @@ describe('AccessTokenService', () => {
jest.spyOn(globalWorkspaceOrmManager, 'getRepository').mockResolvedValue({
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
const result = await service.generateAccessToken({
userId,
@@ -149,7 +150,7 @@ describe('AccessTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
expect.objectContaining({
sub: userId,
workspaceId: workspaceId,
@@ -197,8 +198,8 @@ describe('AccessTokenService', () => {
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
const signSpy = jest
.spyOn(jwtWrapperService, 'sign')
.mockReturnValue(mockToken);
.spyOn(jwtWrapperService, 'signAsync')
.mockResolvedValue(mockToken);
await service.generateAccessToken({
userId,
@@ -141,16 +141,11 @@ export class AccessTokenService {
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
workspaceId,
),
expiresIn,
}),
expiresAt,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
expiresIn,
});
return { token, expiresAt };
}
async validateToken(token: string): Promise<AuthContext> {
@@ -172,7 +172,7 @@ describe('ApplicationTokenService', () => {
});
describe('validateApplicationRefreshToken', () => {
it('should validate and return payload for a valid refresh token', () => {
it('should validate and return payload for a valid refresh token', async () => {
const mockToken = 'valid-refresh-token';
const mockPayload = {
sub: 'application-id',
@@ -183,10 +183,10 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockReturnValue(undefined);
.mockResolvedValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
const result = service.validateApplicationRefreshToken(mockToken);
const result = await service.validateApplicationRefreshToken(mockToken);
expect(result).toEqual(mockPayload);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
@@ -195,12 +195,12 @@ describe('ApplicationTokenService', () => {
});
});
it('should throw when token type is not APPLICATION_REFRESH', () => {
it('should throw when token type is not APPLICATION_REFRESH', async () => {
const mockToken = 'access-token';
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockReturnValue(undefined);
.mockResolvedValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
sub: 'application-id',
applicationId: 'application-id',
@@ -208,12 +208,12 @@ describe('ApplicationTokenService', () => {
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
});
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
AuthException,
);
await expect(
service.validateApplicationRefreshToken(mockToken),
).rejects.toThrow(AuthException);
try {
service.validateApplicationRefreshToken(mockToken);
await service.validateApplicationRefreshToken(mockToken);
} catch (error) {
expect((error as AuthException).code).toBe(
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
@@ -221,7 +221,7 @@ describe('ApplicationTokenService', () => {
}
});
it('should throw dedicated code when token verification fails', () => {
it('should throw dedicated code when token verification fails', async () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
@@ -231,12 +231,12 @@ describe('ApplicationTokenService', () => {
);
});
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
AuthException,
);
await expect(
service.validateApplicationRefreshToken(mockToken),
).rejects.toThrow(AuthException);
try {
service.validateApplicationRefreshToken(mockToken);
await service.validateApplicationRefreshToken(mockToken);
} catch (error) {
expect((error as AuthException).code).toBe(
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
@@ -244,16 +244,16 @@ describe('ApplicationTokenService', () => {
}
});
it('should rethrow unexpected token verification errors', () => {
it('should rethrow unexpected token verification errors', async () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
throw new Error('Unexpected verification error');
});
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
'Unexpected verification error',
);
await expect(
service.validateApplicationRefreshToken(mockToken),
).rejects.toThrow('Unexpected verification error');
});
});
@@ -112,11 +112,11 @@ export class ApplicationTokenService {
return { applicationAccessToken, applicationRefreshToken };
}
validateApplicationRefreshToken(
async validateApplicationRefreshToken(
refreshToken: string,
): ApplicationRefreshTokenJwtPayload {
): Promise<ApplicationRefreshTokenJwtPayload> {
try {
this.jwtWrapperService.verifyJwtToken(refreshToken);
await this.jwtWrapperService.verifyJwtToken(refreshToken);
const payload =
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
@@ -148,11 +148,11 @@ export class ApplicationTokenService {
}
}
validateApplicationAccessToken(
async validateApplicationAccessToken(
token: string,
): ApplicationAccessTokenJwtPayload {
): Promise<ApplicationAccessTokenJwtPayload> {
try {
this.jwtWrapperService.verifyJwtToken(token);
await this.jwtWrapperService.verifyJwtToken(token);
const payload =
this.jwtWrapperService.decode<ApplicationAccessTokenJwtPayload>(token, {
@@ -32,6 +32,7 @@ describe('RefreshTokenService', () => {
verifyJwtToken: jest.fn(),
decode: jest.fn(),
sign: jest.fn(),
signAsync: jest.fn(),
generateAppSecret: jest.fn(),
},
},
@@ -125,10 +126,7 @@ describe('RefreshTokenService', () => {
const mockExpiresIn = '7d';
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue('mock-secret');
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
jest
.spyOn(appTokenRepository, 'create')
.mockReturnValue({ id: 'new-token-id' } as AppTokenEntity);
@@ -147,7 +145,7 @@ describe('RefreshTokenService', () => {
expiresAt: expect.any(Date),
});
expect(appTokenRepository.save).toHaveBeenCalled();
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
{
sub: userId,
workspaceId,
@@ -156,7 +154,6 @@ describe('RefreshTokenService', () => {
targetedTokenType: JwtTokenTypeEnum.ACCESS,
},
expect.objectContaining({
secret: 'mock-secret',
expiresIn: mockExpiresIn,
jwtid: 'new-token-id',
}),
@@ -112,10 +112,6 @@ export class RefreshTokenService {
payload: Omit<RefreshTokenJwtPayload, 'type' | 'sub' | 'jti'>,
isImpersonationToken: boolean = false,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.REFRESH,
payload.workspaceId ?? payload.userId,
);
const expiresIn = isImpersonationToken
? '1d'
: this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
@@ -137,20 +133,17 @@ export class RefreshTokenService {
await this.appTokenRepository.save(refreshToken);
return {
token: this.jwtWrapperService.sign(
{
...payload,
sub: payload.userId,
type: JwtTokenTypeEnum.REFRESH,
},
{
secret,
expiresIn,
jwtid: refreshToken.id,
},
),
expiresAt,
const jwtPayload: RefreshTokenJwtPayload = {
...payload,
sub: payload.userId,
type: JwtTokenTypeEnum.REFRESH,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
expiresIn,
jwtid: refreshToken.id,
});
return { token, expiresAt };
}
}
@@ -0,0 +1,9 @@
import { type Algorithm } from 'jsonwebtoken';
export const JWT_LEGACY_ALGORITHM = 'HS256' as const satisfies Algorithm;
export const JWT_ASYMMETRIC_ALGORITHM = 'ES256' as const satisfies Algorithm;
export const JWT_SUPPORTED_VERIFY_ALGORITHMS: readonly Algorithm[] = [
JWT_LEGACY_ALGORITHM,
JWT_ASYMMETRIC_ALGORITHM,
] as const;
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'signingKey', schema: 'core' })
@Index('IDX_SIGNING_KEY_IS_CURRENT_UNIQUE', ['isCurrent'], {
unique: true,
where: '"isCurrent" = true',
})
export class SigningKeyEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar' })
publicKey: string;
@Column({ type: 'varchar', nullable: true })
privateKey: string | null;
@Column({ type: 'boolean', default: false })
isCurrent: boolean;
@Column({ type: 'timestamptz', nullable: true })
revokedAt: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,40 @@
import { type MessageDescriptor } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export const JwtKeyManagerExceptionCode = appendCommonExceptionCode({
INVALID_PRIVATE_KEY: 'INVALID_PRIVATE_KEY',
} as const);
const getJwtKeyManagerExceptionUserFriendlyMessage = (
code: keyof typeof JwtKeyManagerExceptionCode,
): MessageDescriptor => {
switch (code) {
case JwtKeyManagerExceptionCode.INVALID_PRIVATE_KEY:
case JwtKeyManagerExceptionCode.INTERNAL_SERVER_ERROR:
return STANDARD_ERROR_MESSAGE;
default:
return assertUnreachable(code);
}
};
export class JwtKeyManagerException extends CustomException<
keyof typeof JwtKeyManagerExceptionCode
> {
constructor(
message: string,
code: keyof typeof JwtKeyManagerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getJwtKeyManagerExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,7 +1,17 @@
import { Module } from '@nestjs/common';
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import {
JWT_LEGACY_ALGORITHM,
JWT_SUPPORTED_VERIFY_ALGORITHMS,
} from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SigningKeyEntityCacheProviderService } from 'src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -10,11 +20,11 @@ const InternalJwtModule = NestJwtModule.registerAsync({
return {
secret: twentyConfigService.get('APP_SECRET'),
signOptions: {
algorithm: 'HS256',
algorithm: JWT_LEGACY_ALGORITHM,
expiresIn: twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN'),
},
verifyOptions: {
algorithms: ['HS256'],
algorithms: [...JWT_SUPPORTED_VERIFY_ALGORITHMS],
},
};
},
@@ -22,9 +32,19 @@ const InternalJwtModule = NestJwtModule.registerAsync({
});
@Module({
imports: [InternalJwtModule, TwentyConfigModule],
imports: [
InternalJwtModule,
TwentyConfigModule,
TypeOrmModule.forFeature([SigningKeyEntity]),
CoreEntityCacheModule,
SecretEncryptionModule,
],
controllers: [],
providers: [JwtWrapperService],
exports: [JwtWrapperService],
providers: [
JwtWrapperService,
JwtKeyManagerService,
SigningKeyEntityCacheProviderService,
],
exports: [JwtWrapperService, JwtKeyManagerService],
})
export class JwtModule {}
@@ -0,0 +1,174 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { generateKeyPairSync, randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { IsNull, QueryFailedError, Repository } from 'typeorm';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import {
JwtKeyManagerException,
JwtKeyManagerExceptionCode,
} from 'src/engine/core-modules/jwt/jwt-key-manager.exception';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
export type CurrentSigningKey = {
id: string;
privateKeyPem: string;
};
const UNIQUE_VIOLATION_PG_CODE = '23505';
@Injectable()
export class JwtKeyManagerService {
private readonly logger = new Logger(JwtKeyManagerService.name);
private currentSigningKeyPromise: Promise<CurrentSigningKey | null> | null =
null;
constructor(
@InjectRepository(SigningKeyEntity)
private readonly signingKeyRepository: Repository<SigningKeyEntity>,
private readonly coreEntityCacheService: CoreEntityCacheService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async getCurrentSigningKey(): Promise<CurrentSigningKey | null> {
if (this.currentSigningKeyPromise === null) {
this.currentSigningKeyPromise = this.loadOrCreateCurrentSigningKey();
}
try {
const result = await this.currentSigningKeyPromise;
if (!isDefined(result)) {
this.currentSigningKeyPromise = null;
}
return result;
} catch (error) {
this.currentSigningKeyPromise = null;
throw error;
}
}
async getValidPublicKeyPemById(id: string): Promise<string | null> {
if (!isNonEmptyString(id) || !isValidUuid(id)) {
return null;
}
return this.coreEntityCacheService.get('signingKeyPublicKey', id);
}
private async loadOrCreateCurrentSigningKey(): Promise<CurrentSigningKey | null> {
try {
const existing = await this.findCurrentSigningKeyRow();
if (isDefined(existing)) {
return {
id: existing.id,
privateKeyPem: this.decryptPrivateKey(
existing.privateKey,
existing.id,
),
};
}
return await this.generateAndPersistCurrent();
} catch (error) {
this.logger.error(
`Failed to load or create current signing key. Falling back to legacy HS256 signing. Error: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
}
private async findCurrentSigningKeyRow(): Promise<SigningKeyEntity | null> {
return this.signingKeyRepository.findOne({
where: { isCurrent: true, revokedAt: IsNull() },
});
}
private decryptPrivateKey(
encryptedPrivateKey: string | null,
id: string,
): string {
if (!isDefined(encryptedPrivateKey)) {
throw new JwtKeyManagerException(
`Current signing key (id=${id}) has no privateKey`,
JwtKeyManagerExceptionCode.INVALID_PRIVATE_KEY,
);
}
return this.secretEncryptionService.decrypt(encryptedPrivateKey);
}
private async generateAndPersistCurrent(): Promise<CurrentSigningKey> {
const generated = this.generateEcP256KeyPair();
const id = randomUUID();
try {
await this.signingKeyRepository.insert({
id,
publicKey: generated.publicKeyPem,
privateKey: this.secretEncryptionService.encrypt(
generated.privateKeyPem,
),
isCurrent: true,
revokedAt: null,
});
await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id);
return { id, privateKeyPem: generated.privateKeyPem };
} catch (error) {
if (this.isUniqueViolation(error)) {
const existing = await this.findCurrentSigningKeyRow();
if (isDefined(existing)) {
return {
id: existing.id,
privateKeyPem: this.decryptPrivateKey(
existing.privateKey,
existing.id,
),
};
}
}
throw error;
}
}
private generateEcP256KeyPair(): {
privateKeyPem: string;
publicKeyPem: string;
} {
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const privateKeyPem = privateKey
.export({ format: 'pem', type: 'pkcs8' })
.toString();
const publicKeyPem = publicKey
.export({ format: 'pem', type: 'spki' })
.toString();
return { privateKeyPem, publicKeyPem };
}
private isUniqueViolation(error: unknown): boolean {
return (
error instanceof QueryFailedError &&
(error as QueryFailedError & { code?: string }).code ===
UNIQUE_VIOLATION_PG_CODE
);
}
}
@@ -10,6 +10,7 @@ import { createHash } from 'crypto';
import * as jwt from 'jsonwebtoken';
import { ExtractJwt, type JwtFromRequestFunction } from 'passport-jwt';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
AuthException,
@@ -19,20 +20,80 @@ import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import {
JWT_ASYMMETRIC_ALGORITHM,
JWT_LEGACY_ALGORITHM,
} from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util';
import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util';
import {
isAsymmetricJwtHeader,
isAsymmetricSigningEligible,
} from 'src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util';
type ResolvedVerificationKey = {
key: string;
algorithm: typeof JWT_LEGACY_ALGORITHM | typeof JWT_ASYMMETRIC_ALGORITHM;
};
const APP_SECRET_BODY_WORKSPACE_SCHEMA = z.object({
workspaceId: z.string().min(1),
});
const APP_SECRET_BODY_USER_SCHEMA = z.object({
userId: z.string().min(1),
});
@Injectable()
export class JwtWrapperService {
constructor(
private readonly jwtService: JwtService,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtKeyManagerService: JwtKeyManagerService,
) {}
/**
* @deprecated Use {@link signAsync} for ACCESS / REFRESH tokens (ES256, with
* rotatable signing keys). Synchronous HS256 signing remains in place for
* token types not yet migrated to asymmetric signing, but new call sites
* should not be introduced.
*/
sign(payload: JwtPayload, options?: JwtSignOptions): string {
// Typescript does not handle well the overloads of the sign method, helping it a little bit
return this.jwtService.sign(payload, options);
}
async signAsync(
payload: JwtPayload,
options: { expiresIn: string | number; jwtid?: string },
): Promise<string> {
if (!isAsymmetricSigningEligible(payload.type)) {
throw new AuthException(
`signAsync called with non-rotatable token type "${payload.type}"`,
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
const signingKey = await this.jwtKeyManagerService.getCurrentSigningKey();
if (!isDefined(signingKey)) {
throw new AuthException(
'No active signing key available to sign ACCESS / REFRESH token',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const signOptions: jwt.SignOptions = {
expiresIn: options.expiresIn as jwt.SignOptions['expiresIn'],
algorithm: JWT_ASYMMETRIC_ALGORITHM,
keyid: signingKey.id,
...(isDefined(options.jwtid) ? { jwtid: options.jwtid } : {}),
};
return jwt.sign(payload as object, signingKey.privateKeyPem, signOptions);
}
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
verify<T extends object = any>(
token: string,
@@ -46,23 +107,34 @@ export class JwtWrapperService {
return this.jwtService.decode(payload, options);
}
verifyJwtToken(token: string, options?: JwtVerifyOptions) {
const payload = this.decode<JwtPayload>(token, {
json: true,
});
async resolveVerificationKey(
rawToken: string,
): Promise<ResolvedVerificationKey> {
const header = decodeJwtHeader(rawToken);
const payload = decodeJwtPayload<JwtPayload>(rawToken);
if (!isDefined(payload)) {
throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED);
if (isAsymmetricJwtHeader(header, payload)) {
const publicKeyPem =
await this.jwtKeyManagerService.getValidPublicKeyPemById(header.kid);
if (!isDefined(publicKeyPem)) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
return { key: publicKeyPem, algorithm: JWT_ASYMMETRIC_ALGORITHM };
}
const type = payload.type;
if (!isDefined(payload)) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const appSecretBody =
'workspaceId' in payload
? payload.workspaceId
: 'userId' in payload
? payload.userId
: undefined;
const appSecretBody = this.extractAppSecretBody(payload);
if (!isDefined(appSecretBody)) {
throw new AuthException(
@@ -71,49 +143,52 @@ export class JwtWrapperService {
);
}
return {
key: this.generateAppSecret(payload.type, appSecretBody),
algorithm: JWT_LEGACY_ALGORITHM,
};
}
async verifyJwtToken(
token: string,
options?: JwtVerifyOptions,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
): Promise<any> {
const payload = this.decode<JwtPayload>(token, { json: true });
if (!isDefined(payload)) {
throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED);
}
const { key, algorithm } = await this.resolveVerificationKey(token);
try {
return jwt.verify(token, key, { ...options, algorithms: [algorithm] });
} catch (error) {
// API_KEY tokens created before 12/12/2025 were accidentally signed
// with ACCESS type instead of API_KEY. Try the correct secret first,
// fall back to the old one for backward compatibility.
// with ACCESS type instead of API_KEY. Fall back to the legacy ACCESS
// secret for backward compatibility.
// See https://github.com/twentyhq/twenty/pull/16504
if (type === JwtTokenTypeEnum.API_KEY) {
try {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(type, appSecretBody),
});
} catch {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
appSecretBody,
),
});
if (
payload.type === JwtTokenTypeEnum.API_KEY &&
algorithm === JWT_LEGACY_ALGORITHM
) {
const appSecretBody = this.extractAppSecretBody(payload);
if (isDefined(appSecretBody)) {
try {
return jwt.verify(
token,
this.generateAppSecret(JwtTokenTypeEnum.ACCESS, appSecretBody),
{ ...options, algorithms: [JWT_LEGACY_ALGORITHM] },
);
} catch {
throw this.toAuthException(error);
}
}
}
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(type, appSecretBody),
});
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthException(
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
throw this.toAuthException(error);
}
}
@@ -132,4 +207,40 @@ export class JwtWrapperService {
extractJwtFromRequest(): JwtFromRequestFunction {
return ExtractJwt.fromAuthHeaderAsBearerToken();
}
private extractAppSecretBody(payload: JwtPayload): string | undefined {
const workspaceParse = APP_SECRET_BODY_WORKSPACE_SCHEMA.safeParse(payload);
if (workspaceParse.success) {
return workspaceParse.data.workspaceId;
}
const userParse = APP_SECRET_BODY_USER_SCHEMA.safeParse(payload);
if (userParse.success) {
return userParse.data.userId;
}
return undefined;
}
private toAuthException(error: unknown): AuthException {
if (error instanceof jwt.TokenExpiredError) {
return new AuthException(
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
if (error instanceof jwt.JsonWebTokenError) {
return new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
}
return new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
@Injectable()
@CoreEntityCache('signingKeyPublicKey')
export class SigningKeyEntityCacheProviderService extends CoreEntityCacheProvider<string> {
constructor(
@InjectRepository(SigningKeyEntity)
private readonly signingKeyRepository: Repository<SigningKeyEntity>,
) {
super();
}
async computeForCache(entityId: string): Promise<string | null> {
const signingKey = await this.signingKeyRepository.findOne({
where: { id: entityId, revokedAt: IsNull() },
});
if (!isDefined(signingKey)) {
return null;
}
return signingKey.publicKey;
}
}
@@ -0,0 +1,18 @@
import * as jwt from 'jsonwebtoken';
import { isDefined } from 'twenty-shared/utils';
export const decodeJwtHeader = (
rawJwtToken: string,
): jwt.JwtHeader | undefined => {
try {
const decoded = jwt.decode(rawJwtToken, { complete: true });
if (!isDefined(decoded)) {
return undefined;
}
return decoded.header;
} catch {
return undefined;
}
};
@@ -0,0 +1,16 @@
import * as jwt from 'jsonwebtoken';
import { isDefined } from 'twenty-shared/utils';
export const decodeJwtPayload = <T>(rawJwtToken: string): T | undefined => {
try {
const decoded = jwt.decode(rawJwtToken, { json: true });
if (!isDefined(decoded)) {
return undefined;
}
return decoded as T;
} catch {
return undefined;
}
};
@@ -0,0 +1,43 @@
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { isDefined } from 'twenty-shared/utils';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JWT_ASYMMETRIC_ALGORITHM } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
const ASYMMETRIC_TOKEN_TYPES: ReadonlySet<JwtTokenTypeEnum> = new Set([
JwtTokenTypeEnum.ACCESS,
JwtTokenTypeEnum.REFRESH,
]);
export const isAsymmetricSigningEligible = (type: JwtTokenTypeEnum): boolean =>
ASYMMETRIC_TOKEN_TYPES.has(type);
export const isAsymmetricJwtHeader = (
header: jwt.JwtHeader | undefined,
payload: JwtPayload | undefined,
): header is jwt.JwtHeader & {
kid: string;
alg: typeof JWT_ASYMMETRIC_ALGORITHM;
} => {
if (!isDefined(header)) {
return false;
}
if (!isNonEmptyString(header.kid)) {
return false;
}
if (header.alg !== JWT_ASYMMETRIC_ALGORITHM) {
return false;
}
if (!isDefined(payload)) {
return false;
}
return isAsymmetricSigningEligible(payload.type);
};