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:
+34
@@ -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"`);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+2
@@ -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',
|
||||
};
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export class ApplicationOAuthResolver {
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationTokenPairDTO> {
|
||||
const applicationRefreshTokenPayload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(
|
||||
applicationRefreshToken,
|
||||
);
|
||||
|
||||
|
||||
+8
-4
@@ -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 },
|
||||
|
||||
+7
-7
@@ -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({
|
||||
|
||||
+4
-4
@@ -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');
|
||||
|
||||
+4
@@ -47,6 +47,10 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
jwtWrapperService = {
|
||||
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||
resolveVerificationKey: jest.fn(async () => ({
|
||||
key: 'mock-key',
|
||||
algorithm: 'HS256',
|
||||
})),
|
||||
};
|
||||
|
||||
permissionsService = {
|
||||
|
||||
+14
-28
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Strategy, type SecretOrKeyProvider } from 'passport-jwt';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
@@ -20,13 +20,13 @@ import {
|
||||
ApplicationAccessTokenJwtPayload,
|
||||
type AuthContext,
|
||||
type AuthContextUser,
|
||||
FileTokenJwtPayloadLegacy,
|
||||
type JwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
type WorkspaceAgnosticTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
@@ -44,36 +44,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {
|
||||
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
|
||||
try {
|
||||
const decodedToken = jwtWrapperService.decode<
|
||||
| FileTokenJwtPayloadLegacy
|
||||
| AccessTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
>(rawJwtToken);
|
||||
|
||||
const appSecretBody =
|
||||
decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
|
||||
? decodedToken.userId
|
||||
: decodedToken.workspaceId;
|
||||
|
||||
const secret = jwtWrapperService.generateAppSecret(
|
||||
decodedToken.type,
|
||||
appSecretBody,
|
||||
);
|
||||
|
||||
done(null, secret);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
const secretOrKeyProvider: SecretOrKeyProvider = (
|
||||
_request,
|
||||
rawJwtToken,
|
||||
done,
|
||||
) => {
|
||||
jwtWrapperService.resolveVerificationKey(rawJwtToken).then(
|
||||
({ key }) => done(null, key),
|
||||
(error) => done(error, undefined),
|
||||
);
|
||||
};
|
||||
|
||||
super({
|
||||
jwtFromRequest: jwtFromRequestFunction,
|
||||
jwtFromRequest: jwtWrapperService.extractJwtFromRequest(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKeyProvider: secretOrKeyProviderFunction,
|
||||
algorithms: [...JWT_SUPPORTED_VERIFY_ALGORITHMS],
|
||||
secretOrKeyProvider,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -38,6 +38,7 @@ describe('AccessTokenService', () => {
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
signAsync: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
@@ -137,7 +138,7 @@ describe('AccessTokenService', () => {
|
||||
jest.spyOn(globalWorkspaceOrmManager, 'getRepository').mockResolvedValue({
|
||||
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
|
||||
} as any);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
|
||||
|
||||
const result = await service.generateAccessToken({
|
||||
userId,
|
||||
@@ -149,7 +150,7 @@ describe('AccessTokenService', () => {
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sub: userId,
|
||||
workspaceId: workspaceId,
|
||||
@@ -197,8 +198,8 @@ describe('AccessTokenService', () => {
|
||||
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
|
||||
} as any);
|
||||
const signSpy = jest
|
||||
.spyOn(jwtWrapperService, 'sign')
|
||||
.mockReturnValue(mockToken);
|
||||
.spyOn(jwtWrapperService, 'signAsync')
|
||||
.mockResolvedValue(mockToken);
|
||||
|
||||
await service.generateAccessToken({
|
||||
userId,
|
||||
|
||||
+5
-10
@@ -141,16 +141,11 @@ export class AccessTokenService {
|
||||
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<AuthContext> {
|
||||
|
||||
+18
-18
@@ -172,7 +172,7 @@ describe('ApplicationTokenService', () => {
|
||||
});
|
||||
|
||||
describe('validateApplicationRefreshToken', () => {
|
||||
it('should validate and return payload for a valid refresh token', () => {
|
||||
it('should validate and return payload for a valid refresh token', async () => {
|
||||
const mockToken = 'valid-refresh-token';
|
||||
const mockPayload = {
|
||||
sub: 'application-id',
|
||||
@@ -183,10 +183,10 @@ describe('ApplicationTokenService', () => {
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = service.validateApplicationRefreshToken(mockToken);
|
||||
const result = await service.validateApplicationRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockPayload);
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
|
||||
@@ -195,12 +195,12 @@ describe('ApplicationTokenService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when token type is not APPLICATION_REFRESH', () => {
|
||||
it('should throw when token type is not APPLICATION_REFRESH', async () => {
|
||||
const mockToken = 'access-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
@@ -208,12 +208,12 @@ describe('ApplicationTokenService', () => {
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
|
||||
try {
|
||||
service.validateApplicationRefreshToken(mockToken);
|
||||
await service.validateApplicationRefreshToken(mockToken);
|
||||
} catch (error) {
|
||||
expect((error as AuthException).code).toBe(
|
||||
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
|
||||
@@ -221,7 +221,7 @@ describe('ApplicationTokenService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw dedicated code when token verification fails', () => {
|
||||
it('should throw dedicated code when token verification fails', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
@@ -231,12 +231,12 @@ describe('ApplicationTokenService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
|
||||
try {
|
||||
service.validateApplicationRefreshToken(mockToken);
|
||||
await service.validateApplicationRefreshToken(mockToken);
|
||||
} catch (error) {
|
||||
expect((error as AuthException).code).toBe(
|
||||
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
|
||||
@@ -244,16 +244,16 @@ describe('ApplicationTokenService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should rethrow unexpected token verification errors', () => {
|
||||
it('should rethrow unexpected token verification errors', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
throw new Error('Unexpected verification error');
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
'Unexpected verification error',
|
||||
);
|
||||
await expect(
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).rejects.toThrow('Unexpected verification error');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -112,11 +112,11 @@ export class ApplicationTokenService {
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
|
||||
validateApplicationRefreshToken(
|
||||
async validateApplicationRefreshToken(
|
||||
refreshToken: string,
|
||||
): ApplicationRefreshTokenJwtPayload {
|
||||
): Promise<ApplicationRefreshTokenJwtPayload> {
|
||||
try {
|
||||
this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
await this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
|
||||
@@ -148,11 +148,11 @@ export class ApplicationTokenService {
|
||||
}
|
||||
}
|
||||
|
||||
validateApplicationAccessToken(
|
||||
async validateApplicationAccessToken(
|
||||
token: string,
|
||||
): ApplicationAccessTokenJwtPayload {
|
||||
): Promise<ApplicationAccessTokenJwtPayload> {
|
||||
try {
|
||||
this.jwtWrapperService.verifyJwtToken(token);
|
||||
await this.jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationAccessTokenJwtPayload>(token, {
|
||||
|
||||
+3
-6
@@ -32,6 +32,7 @@ describe('RefreshTokenService', () => {
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
signAsync: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
},
|
||||
},
|
||||
@@ -125,10 +126,7 @@ describe('RefreshTokenService', () => {
|
||||
const mockExpiresIn = '7d';
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue('mock-secret');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'create')
|
||||
.mockReturnValue({ id: 'new-token-id' } as AppTokenEntity);
|
||||
@@ -147,7 +145,7 @@ describe('RefreshTokenService', () => {
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(appTokenRepository.save).toHaveBeenCalled();
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
@@ -156,7 +154,6 @@ describe('RefreshTokenService', () => {
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: 'mock-secret',
|
||||
expiresIn: mockExpiresIn,
|
||||
jwtid: 'new-token-id',
|
||||
}),
|
||||
|
||||
+11
-18
@@ -112,10 +112,6 @@ export class RefreshTokenService {
|
||||
payload: Omit<RefreshTokenJwtPayload, 'type' | 'sub' | 'jti'>,
|
||||
isImpersonationToken: boolean = false,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.REFRESH,
|
||||
payload.workspaceId ?? payload.userId,
|
||||
);
|
||||
const expiresIn = isImpersonationToken
|
||||
? '1d'
|
||||
: this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
|
||||
@@ -137,20 +133,17 @@ export class RefreshTokenService {
|
||||
|
||||
await this.appTokenRepository.save(refreshToken);
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(
|
||||
{
|
||||
...payload,
|
||||
sub: payload.userId,
|
||||
type: JwtTokenTypeEnum.REFRESH,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: refreshToken.id,
|
||||
},
|
||||
),
|
||||
expiresAt,
|
||||
const jwtPayload: RefreshTokenJwtPayload = {
|
||||
...payload,
|
||||
sub: payload.userId,
|
||||
type: JwtTokenTypeEnum.REFRESH,
|
||||
};
|
||||
|
||||
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
|
||||
expiresIn,
|
||||
jwtid: refreshToken.id,
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+174
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+162
-51
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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;
|
||||
}
|
||||
};
|
||||
+43
@@ -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);
|
||||
};
|
||||
+29
@@ -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.",
|
||||
}
|
||||
`;
|
||||
+13
@@ -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';
|
||||
+243
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user