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');