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:
+4
@@ -47,6 +47,10 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
jwtWrapperService = {
|
||||
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||
resolveVerificationKey: jest.fn(async () => ({
|
||||
key: 'mock-key',
|
||||
algorithm: 'HS256',
|
||||
})),
|
||||
};
|
||||
|
||||
permissionsService = {
|
||||
|
||||
+14
-28
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+5
-10
@@ -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> {
|
||||
|
||||
+18
-18
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -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, {
|
||||
|
||||
+3
-6
@@ -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',
|
||||
}),
|
||||
|
||||
+11
-18
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user