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
@@ -0,0 +1,34 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.5.0', 1778550000000)
export class CreateSigningKeyTableFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "core"."signingKey" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"publicKey" character varying NOT NULL,
"privateKey" character varying,
"isCurrent" boolean NOT NULL DEFAULT false,
"revokedAt" TIMESTAMP WITH TIME ZONE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_signingKey_id" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SIGNING_KEY_IS_CURRENT_UNIQUE" ON "core"."signingKey" ("isCurrent") WHERE "isCurrent" = true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_SIGNING_KEY_IS_CURRENT_UNIQUE"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "core"."signingKey"`);
}
}
@@ -32,6 +32,7 @@ import { AddMetadataToBillingPriceFastInstanceCommand } from 'src/database/comma
import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type';
import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain';
import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled';
import { CreateSigningKeyTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table';
import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens';
export const INSTANCE_COMMANDS = [
@@ -67,5 +68,6 @@ export const INSTANCE_COMMANDS = [
AddEmailGroupChannelTypeFastInstanceCommand,
AddApplicationIdToPublicDomainFastInstanceCommand,
AddIsInternalMessagesImportEnabledFastInstanceCommand,
CreateSigningKeyTableFastInstanceCommand,
EncryptConnectedAccountTokensSlowInstanceCommand,
];
@@ -6,6 +6,7 @@ export type CoreEntityCacheDataMap = {
workspaceEntity: FlatWorkspace;
user: FlatUser;
userWorkspaceEntity: FlatUserWorkspace;
signingKeyPublicKey: string;
};
export type CoreEntityCacheKeyName = keyof CoreEntityCacheDataMap;
@@ -14,4 +15,5 @@ export const CORE_ENTITY_CACHE_KEYS: Record<CoreEntityCacheKeyName, string> = {
workspaceEntity: 'workspace',
user: 'user',
userWorkspaceEntity: 'user-workspace',
signingKeyPublicKey: 'signing-key-public-key',
};
@@ -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);
};
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JWT Asymmetric Signing & Key Rotation (integration) rejects a token signed by a revoked kid (publicKey present, revokedAt set) 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;
exports[`JWT Asymmetric Signing & Key Rotation (integration) rejects a token whose kid was never registered without leaking a 500 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;
@@ -0,0 +1,13 @@
export const PREVIOUS_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgc39GS4ubhdkNWp7L
b8sC0ROUVBGNO8DvltFB9yHCDLWhRANCAASnreXZnMdvXRwuymcxYO0puFCnIQ9n
xySPRmivfklUKwm00ZMXX3AxStxeqp0vr6qOu54JpPdRFfdeYp5E53OF
-----END PRIVATE KEY-----`;
export const PREVIOUS_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEp63l2ZzHb10cLspnMWDtKbhQpyEP
Z8ckj0Zor35JVCsJtNGTF19wMUrcXqqdL6+qjrueCaT3URX3XmKeROdzhQ==
-----END PUBLIC KEY-----`;
export const PREVIOUS_KID = '00000000-0000-4000-8000-000000000001';
export const REVOKED_KID = '00000000-0000-4000-8000-000000000002';
@@ -0,0 +1,243 @@
import { createHash, randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { isDefined } from 'twenty-shared/utils';
import {
type AccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import {
PREVIOUS_KID,
PREVIOUS_PRIVATE_KEY_PEM,
PREVIOUS_PUBLIC_KEY_PEM,
REVOKED_KID,
} from './jwt-key-rotation.fixture';
const HS256_APP_SECRET = 'replace_me_with_a_random_string';
const generateLegacyHs256Secret = (
type: JwtTokenTypeEnum,
appSecretBody: string,
): string =>
createHash('sha256')
.update(`${HS256_APP_SECRET}${appSecretBody}${type}`)
.digest('hex');
const decodeJwtCompleteOrThrow = (token: string) => {
const decoded = jwt.decode(token, { complete: true });
if (!isDefined(decoded)) {
throw new Error('Failed to decode JWT');
}
return decoded;
};
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
let sharedAccessToken: string;
let sharedPayload: AccessTokenJwtPayload;
let currentKid: string;
describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
beforeAll(async () => {
const uniqueEmail = `jwt-rotation-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedPayload = jwt.decode(sharedAccessToken) as AccessTokenJwtPayload;
currentKid = decodeJwtCompleteOrThrow(sharedAccessToken).header
.kid as string;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
await global.testDataSource.query(
`DELETE FROM core."signingKey" WHERE "id" = ANY($1::uuid[])`,
[[PREVIOUS_KID, REVOKED_KID]],
);
});
it('auto-generates an ES256 signing key on first boot and signs ACCESS tokens with kid + isCurrent row', async () => {
const decoded = decodeJwtCompleteOrThrow(sharedAccessToken);
expect(decoded.header.alg).toBe('ES256');
expect(isNonEmptyString(decoded.header.kid)).toBe(true);
const rows = await global.testDataSource.query(
`SELECT "id", "publicKey", "privateKey", "isCurrent", "revokedAt"
FROM core."signingKey" WHERE "id" = $1`,
[currentKid],
);
expect(rows).toHaveLength(1);
expect(rows[0].isCurrent).toBe(true);
expect(rows[0].revokedAt).toBeNull();
expect(isNonEmptyString(rows[0].publicKey)).toBe(true);
expect(isNonEmptyString(rows[0].privateKey)).toBe(true);
expect(rows[0].publicKey).toMatch(
/^-----BEGIN PUBLIC KEY-----[\s\S]+-----END PUBLIC KEY-----\s*$/,
);
const { data, errors } = await getCurrentUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 ACCESS token via the legacy fallback', async () => {
const legacyHs256Token = jwt.sign(
buildAccessTokenPayload(sharedPayload),
generateLegacyHs256Secret(
JwtTokenTypeEnum.ACCESS,
sharedPayload.workspaceId,
),
{ algorithm: 'HS256', expiresIn: '5m' },
);
const decoded = decodeJwtCompleteOrThrow(legacyHs256Token);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getCurrentUser({
accessToken: legacyHs256Token,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedPayload.userId);
});
it('verifies a token signed by a previously rotated-out kid (privateKey null, public key still present)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, NULL, false)
ON CONFLICT ("id") DO NOTHING`,
[PREVIOUS_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByPreviousKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: PREVIOUS_KID, expiresIn: '5m' },
);
const decoded = decodeJwtCompleteOrThrow(tokenSignedByPreviousKey);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(PREVIOUS_KID);
expect(decoded.header.kid).not.toBe(currentKid);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByPreviousKey,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedPayload.userId);
});
it('rejects a token signed by a revoked kid (publicKey present, revokedAt set)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent", "revokedAt")
VALUES ($1, $2, NULL, false, NOW())
ON CONFLICT ("id") DO UPDATE SET "revokedAt" = NOW(), "isCurrent" = false`,
[REVOKED_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByRevokedKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: REVOKED_KID, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByRevokedKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('rejects a token whose kid was never registered without leaking a 500', async () => {
const unknownKid = '00000000-0000-4000-8000-000000000099';
const tokenSignedByOrphanKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: unknownKid, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByOrphanKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
});