From 9e515afb13a36f2c348fc3a2cae6021513089a2e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 12 May 2026 17:54:44 +0200 Subject: [PATCH] feat(server): asymmetric JWT signing with kid + key rotation table (#20467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 = '';` 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 = '';`. 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` --- ...-1778550000000-create-signing-key-table.ts | 34 +++ .../instance-commands.constant.ts | 2 + .../types/core-entity-cache-key.type.ts | 2 + .../application-oauth.resolver.ts | 2 +- .../application-oauth/oauth.service.ts | 12 +- ...ection-provider-oauth-flow.service.spec.ts | 14 +- .../connection-provider-oauth-flow.service.ts | 8 +- .../auth/strategies/jwt.auth.strategy.spec.ts | 4 + .../auth/strategies/jwt.auth.strategy.ts | 42 +-- .../services/access-token.service.spec.ts | 9 +- .../token/services/access-token.service.ts | 15 +- .../application-token.service.spec.ts | 36 +-- .../services/application-token.service.ts | 12 +- .../services/refresh-token.service.spec.ts | 9 +- .../token/services/refresh-token.service.ts | 29 +-- .../jwt/constants/jwt-algorithm.constant.ts | 9 + .../jwt/entities/signing-key.entity.ts | 36 +++ .../jwt/jwt-key-manager.exception.ts | 40 +++ .../src/engine/core-modules/jwt/jwt.module.ts | 30 ++- .../jwt/services/jwt-key-manager.service.ts | 174 +++++++++++++ .../jwt/services/jwt-wrapper.service.ts | 213 +++++++++++---- ...gning-key-entity-cache-provider.service.ts | 32 +++ .../jwt/utils/decode-jwt-header.util.ts | 18 ++ .../jwt/utils/decode-jwt-payload.util.ts | 16 ++ .../utils/is-asymmetric-jwt-header.util.ts | 43 ++++ .../jwt-key-rotation.integration-spec.ts.snap | 29 +++ .../jwt-key-rotation.fixture.ts | 13 + .../jwt-key-rotation.integration-spec.ts | 243 ++++++++++++++++++ 28 files changed, 964 insertions(+), 162 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/constants/jwt-algorithm.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-header.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-payload.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.fixture.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table.ts new file mode 100644 index 0000000000..9d88948a94 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_SIGNING_KEY_IS_CURRENT_UNIQUE"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "core"."signingKey"`); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 1fe49589d1..7c26268ec2 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -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, ]; diff --git a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts index 1c628b6170..5356763cd2 100644 --- a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts +++ b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts @@ -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 = { workspaceEntity: 'workspace', user: 'user', userWorkspaceEntity: 'user-workspace', + signingKeyPublicKey: 'signing-key-public-key', }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.resolver.ts index ba880adb17..3e94c06ca6 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.resolver.ts @@ -31,7 +31,7 @@ export class ApplicationOAuthResolver { @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { const applicationRefreshTokenPayload = - this.applicationTokenService.validateApplicationRefreshToken( + await this.applicationTokenService.validateApplicationRefreshToken( applicationRefreshToken, ); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts index 395c64a205..df4a3bb4d0 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts @@ -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 }, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts index 0e027cdc60..34ec597e7f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts @@ -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({ diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index 4b66d634f4..3f9822969b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -141,7 +141,7 @@ export class ConnectionProviderOAuthFlowService { } async completeAuthorizationFlow(args: CallbackArgs): Promise { - 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 { 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'); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index c8dc8d52cc..f623b974f8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -47,6 +47,10 @@ describe('JwtAuthStrategy', () => { jwtWrapperService = { extractJwtFromRequest: jest.fn(() => () => 'token'), + resolveVerificationKey: jest.fn(async () => ({ + key: 'mock-key', + algorithm: 'HS256', + })), }; permissionsService = { diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 7553388757..f722d5008d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -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, }); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index fbb271f8b0..e9c1858d27 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 8f6c3c4340..b738ed6ba6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -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 { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts index 4b36ce941d..82c240f1e0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts @@ -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'); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts index bd6d6feccd..b5db14774b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts @@ -112,11 +112,11 @@ export class ApplicationTokenService { return { applicationAccessToken, applicationRefreshToken }; } - validateApplicationRefreshToken( + async validateApplicationRefreshToken( refreshToken: string, - ): ApplicationRefreshTokenJwtPayload { + ): Promise { try { - this.jwtWrapperService.verifyJwtToken(refreshToken); + await this.jwtWrapperService.verifyJwtToken(refreshToken); const payload = this.jwtWrapperService.decode( @@ -148,11 +148,11 @@ export class ApplicationTokenService { } } - validateApplicationAccessToken( + async validateApplicationAccessToken( token: string, - ): ApplicationAccessTokenJwtPayload { + ): Promise { try { - this.jwtWrapperService.verifyJwtToken(token); + await this.jwtWrapperService.verifyJwtToken(token); const payload = this.jwtWrapperService.decode(token, { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index fd116bb4eb..36a25978f0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -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', }), diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index a90a875804..4043e10d84 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -112,10 +112,6 @@ export class RefreshTokenService { payload: Omit, isImpersonationToken: boolean = false, ): Promise { - 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 }; } } diff --git a/packages/twenty-server/src/engine/core-modules/jwt/constants/jwt-algorithm.constant.ts b/packages/twenty-server/src/engine/core-modules/jwt/constants/jwt-algorithm.constant.ts new file mode 100644 index 0000000000..957e01b5b2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/constants/jwt-algorithm.constant.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts b/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts new file mode 100644 index 0000000000..ef931de4d8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/entities/signing-key.entity.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts new file mode 100644 index 0000000000..9552c81e3c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt-key-manager.exception.ts @@ -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), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts index 43e67c006f..eb86025490 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts new file mode 100644 index 0000000000..99cf184718 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-key-manager.service.ts @@ -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 | null = + null; + + constructor( + @InjectRepository(SigningKeyEntity) + private readonly signingKeyRepository: Repository, + private readonly coreEntityCacheService: CoreEntityCacheService, + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + async getCurrentSigningKey(): Promise { + 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 { + if (!isNonEmptyString(id) || !isValidUuid(id)) { + return null; + } + + return this.coreEntityCacheService.get('signingKeyPublicKey', id); + } + + private async loadOrCreateCurrentSigningKey(): Promise { + 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 { + 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 { + 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 + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index fcf5957e29..409a945414 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -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 { + 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( token: string, @@ -46,23 +107,34 @@ export class JwtWrapperService { return this.jwtService.decode(payload, options); } - verifyJwtToken(token: string, options?: JwtVerifyOptions) { - const payload = this.decode(token, { - json: true, - }); + async resolveVerificationKey( + rawToken: string, + ): Promise { + const header = decodeJwtHeader(rawToken); + const payload = decodeJwtPayload(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 { + const payload = this.decode(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, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service.ts new file mode 100644 index 0000000000..00bc87303c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service.ts @@ -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 { + constructor( + @InjectRepository(SigningKeyEntity) + private readonly signingKeyRepository: Repository, + ) { + super(); + } + + async computeForCache(entityId: string): Promise { + const signingKey = await this.signingKeyRepository.findOne({ + where: { id: entityId, revokedAt: IsNull() }, + }); + + if (!isDefined(signingKey)) { + return null; + } + + return signingKey.publicKey; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-header.util.ts b/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-header.util.ts new file mode 100644 index 0000000000..05cf90bfb2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-header.util.ts @@ -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; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-payload.util.ts b/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-payload.util.ts new file mode 100644 index 0000000000..404e2d6107 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/utils/decode-jwt-payload.util.ts @@ -0,0 +1,16 @@ +import * as jwt from 'jsonwebtoken'; +import { isDefined } from 'twenty-shared/utils'; + +export const decodeJwtPayload = (rawJwtToken: string): T | undefined => { + try { + const decoded = jwt.decode(rawJwtToken, { json: true }); + + if (!isDefined(decoded)) { + return undefined; + } + + return decoded as T; + } catch { + return undefined; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts b/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts new file mode 100644 index 0000000000..a7c1e01cb5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts @@ -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 = 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); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap new file mode 100644 index 0000000000..9aeb17661e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap @@ -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, + "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, + "message": "You must be authenticated to perform this action.", + }, + }, + "message": "Token invalid.", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.fixture.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.fixture.ts new file mode 100644 index 0000000000..82f9a77b5c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.fixture.ts @@ -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'; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts new file mode 100644 index 0000000000..00855b3c7b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts @@ -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 }); + }); +});