feat(server): migrate all remaining JWT token types to ES256 (#20513)

## Summary

Extends the asymmetric signing work from #20467 to cover **every
remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`,
`FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`,
`APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the
code interpreter tool.

After this PR, every JWT the server signs is ES256 with a `kid` pointing
at the current `core."signingKey"` row, while legacy HS256 tokens (no
`kid` header) remain verifiable indefinitely through the existing
fallback in `JwtWrapperService.resolveVerificationKey`. No new entity /
migration / config: this is a pure routing change on top of the
infrastructure that already shipped.

## Why

`#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT
type was still HS256-signed against the global `APP_SECRET`, which kept
the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT
type forever). Migrating the rest unifies the sign path on rotatable
per-server private keys without forcing any token reissue.

## Mechanical changes

### Sign side (8 services)
- `LoginTokenService.generateLoginToken`
- `TransientTokenService.generateTransientToken`
- `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken`
- `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` +
`APPLICATION_REFRESH`)
- `ApiKeyService.generateApiKeyToken`
- `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl`
- `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`)
- `CodeInterpreterTool.generateSessionToken`

Each call site swaps `jwtWrapperService.sign(payload, { secret:
generateAppSecret(...), ... })` for `await
jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The
`generateAppSecret` calls on the sign side are dropped (verifier-side
`generateAppSecret` stays in `resolveVerificationKey` for the HS256
fallback).

### Verifier side
- `WorkspaceAgnosticTokenService.validateToken` now goes through
`verifyJwtToken` instead of the bespoke `verify({ secret })` path, so
new ES256 tokens are accepted while the legacy HS256 fallback inside
`resolveVerificationKey` still serves the old shape.
- `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now
strictly deprecated — there are no remaining production callers.

### Async ripple (`signFileByIdUrl` was synchronous)
- `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now
`async`; the `signUrl` callback used by `getRecordImageIdentifier` is
widened to accept `Promise<string | null>`.
- Every direct/indirect caller is updated: admin panel (user lookup +
statistics + top workspaces), search service
(`computeSearchObjectResults`, `getImageIdentifierValue`), workspace
resolver (`logo` resolver, public workspace by domain/id),
`WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` /
`toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`),
`UserService.loadSignedAvatarUrlsByUserId`,
`UserWorkspaceService.castWorkspaceToAvailableWorkspace`,
workspace-invitation, approved-access-domain, agent-chat-streaming,
agent-message-part resolver, navigation-menu-item record identifier,
file-ai-chat / file-core-picture / file-email-attachment / file-workflow
/ files-field services, rich-text & files-field query result getters,
and the code-interpreter tool.

## Backward compatibility

- **Legacy HS256 tokens (no `kid`)** keep verifying via
`resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret`
for both `workspaceId`-bearing and `userId`-bearing payloads.
- The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in
inside `verifyJwtToken` for pre-2025-12-12 API keys.
- No payload shape changes, no DB writes, no env var changes — old
tokens issued by `main` continue to authenticate.

## Tests

### Unit (all green locally — 63/63)
Updated specs for every migrated service to mock `signAsync` instead of
`sign` and assert the new option shape:
- `login-token.service.spec.ts`, `transient-token.service.spec.ts`,
`workspace-agnostic-token.service.spec.ts`,
`application-token.service.spec.ts`, `api-key.service.spec.ts`,
`connection-provider-oauth-flow.service.spec.ts`.

### Integration (`jwt-key-rotation.integration-spec.ts`)
- Existing ACCESS coverage (current key, legacy HS256 fallback,
rotated-out key, revoked key, unknown kid) is preserved.
- New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN`
tokens emitted by the real signUp → signUpInNewWorkspace →
getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the
current signing key — proves end-to-end that the migration didn't
regress those flows.

## Open question (separate decision)

This PR keeps the legacy HS256 verification fallback **forever**. We may
eventually want to sunset it for `API_KEY` once telemetry shows
pre-migration tokens are gone, but that's a separate product/security
decision and not part of this change.

## Test plan

- [ ] CI green
- [ ] `npx nx lint:diff-with-main twenty-server` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `jwt-key-rotation` integration suite passes (new + existing
assertions)
- [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH
token, generating an API key issues an ES256 token with `kid`, signed
file URL JWT is ES256 with `kid`
- [ ] Pre-existing HS256 tokens still authenticate (covered by
integration test, but worth a manual check with a token from `main`)
This commit is contained in:
Charles Bochet
2026-05-13 12:53:45 +02:00
committed by GitHub
parent a159a68e2c
commit ac653182b2
51 changed files with 959 additions and 474 deletions
@@ -39,7 +39,7 @@ export class FilesFieldQueryResultGetterHandler
const signedFilesFieldValue: SignedFileOutput[] = [];
for (const file of filesFieldValue) {
const url = this.fileUrlService.signFileByIdUrl({
const url = await this.fileUrlService.signFileByIdUrl({
fileId: file.fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
@@ -62,7 +62,7 @@ export class RichTextFieldQueryResultGetterHandler
continue;
}
const signedBlocks = this.signBlocknoteImageUrls(
const signedBlocks = await this.signBlocknoteImageUrls(
blocknoteBlocks,
workspaceId,
);
@@ -76,37 +76,39 @@ export class RichTextFieldQueryResultGetterHandler
return record;
}
signBlocknoteImageUrls = (
signBlocknoteImageUrls = async (
blocknoteBlocks: RichTextBlock[],
workspaceId: string,
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (!isDefined(block.props?.url)) {
return block;
}
): Promise<RichTextBlock[]> => {
return Promise.all(
blocknoteBlocks.map(async (block: RichTextBlock) => {
if (!isDefined(block.props?.url)) {
return block;
}
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
if (!isDefined(fileIdFromUrl)) {
return block;
}
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
const url = await this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
...block,
props: {
...block.props,
url,
},
};
});
return {
...block,
props: {
...block.props,
url,
},
};
}),
);
};
}
@@ -32,7 +32,7 @@ export class WorkspaceMemberQueryResultGetterHandler
};
}
const signedUrl = this.fileUrlService.signFileByIdUrl({
const signedUrl = await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
@@ -67,23 +67,25 @@ export class AdminPanelStatisticsService {
const signedAvatarUrlByUserId =
await this.buildSignedAvatarUrlByUserId(users);
return users.map((user) => {
const displayWorkspace = user.userWorkspaces[0]?.workspace;
return Promise.all(
users.map(async (user) => {
const displayWorkspace = user.userWorkspaces[0]?.workspace;
return {
id: user.id,
email: user.email,
firstName: user.firstName ?? undefined,
lastName: user.lastName ?? undefined,
createdAt: user.createdAt,
avatarUrl: signedAvatarUrlByUserId.get(user.id) ?? null,
workspaceName: displayWorkspace?.displayName ?? null,
workspaceId: displayWorkspace?.id ?? null,
workspaceLogo: displayWorkspace
? this.fileUrlService.signWorkspaceLogoUrl(displayWorkspace)
: null,
};
});
return {
id: user.id,
email: user.email,
firstName: user.firstName ?? undefined,
lastName: user.lastName ?? undefined,
createdAt: user.createdAt,
avatarUrl: signedAvatarUrlByUserId.get(user.id) ?? null,
workspaceName: displayWorkspace?.displayName ?? null,
workspaceId: displayWorkspace?.id ?? null,
workspaceLogo: displayWorkspace
? await this.fileUrlService.signWorkspaceLogoUrl(displayWorkspace)
: null,
};
}),
);
}
async getTopWorkspaces(
@@ -128,16 +130,18 @@ export class AdminPanelStatisticsService {
totalUsers: number;
}> = await queryBuilder.getRawMany();
return rows.map((row) => ({
id: row.id,
logoUrl: this.fileUrlService.signWorkspaceLogoUrl({
return Promise.all(
rows.map(async (row) => ({
id: row.id,
logoFileId: row.logoFileId,
}),
name: row.name ?? '',
subdomain: row.subdomain ?? '',
totalUsers: row.totalUsers,
}));
logoUrl: await this.fileUrlService.signWorkspaceLogoUrl({
id: row.id,
logoFileId: row.logoFileId,
}),
name: row.name ?? '',
subdomain: row.subdomain ?? '',
totalUsers: row.totalUsers,
})),
);
}
private async buildSignedAvatarUrlByUserId(
@@ -99,8 +99,9 @@ export class AdminPanelUserLookupService {
activationStatus: userWorkspace.workspace.activationStatus,
createdAt: userWorkspace.workspace.createdAt,
logo:
this.fileUrlService.signWorkspaceLogoUrl(userWorkspace.workspace) ??
undefined,
(await this.fileUrlService.signWorkspaceLogoUrl(
userWorkspace.workspace,
)) ?? undefined,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: userWorkspace.workspace.subdomain,
@@ -182,7 +183,9 @@ export class AdminPanelUserLookupService {
totalUsers: workspaceUsers.length,
activationStatus: workspace.activationStatus,
createdAt: workspace.createdAt,
logo: this.fileUrlService.signWorkspaceLogoUrl(workspace) ?? undefined,
logo:
(await this.fileUrlService.signWorkspaceLogoUrl(workspace)) ??
undefined,
allowImpersonation: workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: workspace.subdomain,
@@ -67,8 +67,7 @@ describe('ApiKeyService', () => {
};
mockJwtWrapperService = {
generateAppSecret: jest.fn(),
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
};
mockApiKeyRoleService = {
@@ -377,12 +376,10 @@ describe('ApiKeyService', () => {
});
describe('generateApiKeyToken', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-jwt-token';
beforeEach(() => {
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
mockJwtWrapperService.sign.mockReturnValue(mockToken);
mockJwtWrapperService.signAsyncOrThrow.mockResolvedValue(mockToken);
});
it('should generate a JWT token for a valid API key', async () => {
@@ -395,18 +392,13 @@ describe('ApiKeyService', () => {
expiresAt,
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.API_KEY,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
{
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
},
@@ -418,7 +410,7 @@ describe('ApiKeyService', () => {
const result = await service.generateApiKeyToken(mockWorkspaceId);
expect(result).toBeUndefined();
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
expect(mockJwtWrapperService.signAsyncOrThrow).not.toHaveBeenCalled();
});
it('should use default expiration if no expiresAt provided', async () => {
@@ -426,7 +418,7 @@ describe('ApiKeyService', () => {
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
expiresIn: '100y',
@@ -444,14 +436,13 @@ describe('ApiKeyService', () => {
expiresAt,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
expect.objectContaining({
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
}),
@@ -147,11 +147,6 @@ export class ApiKeyService {
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.API_KEY,
workspaceId,
);
let expiresIn: string | number;
if (expiresAt) {
@@ -162,14 +157,13 @@ export class ApiKeyService {
expiresIn = '100y';
}
const token = this.jwtWrapperService.sign(
const token = await this.jwtWrapperService.signAsyncOrThrow(
{
sub: workspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId,
},
{
secret,
expiresIn,
jwtid: apiKeyId,
},
@@ -293,7 +293,7 @@ export class ApplicationRegistrationResolver {
return null;
}
return this.fileUrlService.signFileByIdUrl({
return await this.fileUrlService.signFileByIdUrl({
fileId: registration.tarballFileId,
workspaceId,
fileFolder: FileFolder.AppTarball,
@@ -34,9 +34,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
getClientCredentials: jest.Mock;
};
let jwtWrapperService: {
sign: jest.Mock;
signAsyncOrThrow: jest.Mock;
verifyJwtToken: jest.Mock;
generateAppSecret: jest.Mock;
};
let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
let connectedAccountRepository: {
@@ -80,9 +79,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
})),
};
jwtWrapperService = {
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
generateAppSecret: jest.fn(() => 'derived-secret'),
};
secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
connectedAccountRepository = {
@@ -147,7 +145,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
describe('startAuthorizationFlow', () => {
it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state-token');
jwtWrapperService.signAsyncOrThrow.mockResolvedValue(
'signed-state-token',
);
const { authorizationUrl } = await service.startAuthorizationFlow({
connectionProvider: baseProvider,
@@ -176,7 +176,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
expect(url.searchParams.has('code_challenge')).toBe(false);
// signed payload carries workspace identity for the callback to use
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
workspaceId: 'workspace-1',
@@ -184,12 +184,12 @@ describe('ConnectionProviderOAuthFlowService', () => {
visibility: 'user',
reconnectingConnectedAccountId: null,
}),
expect.objectContaining({ secret: 'derived-secret' }),
expect.objectContaining({ expiresIn: expect.any(String) }),
);
});
it('emits PKCE challenge params when usePkce is enabled', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state');
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('signed-state');
const { authorizationUrl } = await service.startAuthorizationFlow({
connectionProvider: {
@@ -247,7 +247,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
},
});
// No state JWT signed, no upstream URL built.
expect(jwtWrapperService.sign).not.toHaveBeenCalled();
expect(jwtWrapperService.signAsyncOrThrow).not.toHaveBeenCalled();
});
it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
@@ -269,7 +269,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
workspaceId: 'workspace-1',
connectionProviderId: 'provider-1',
});
jwtWrapperService.sign.mockReturnValue('state');
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('state');
const { authorizationUrl } = await service.startAuthorizationFlow({
...validateArgs,
@@ -279,11 +279,11 @@ describe('ConnectionProviderOAuthFlowService', () => {
expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
'state',
);
expect(jwtWrapperService.sign).toHaveBeenCalled();
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalled();
});
it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
jwtWrapperService.sign.mockReturnValue('state');
jwtWrapperService.signAsyncOrThrow.mockResolvedValue('state');
await service.startAuthorizationFlow({
...validateArgs,
@@ -102,7 +102,7 @@ export class ConnectionProviderOAuthFlowService {
const codeVerifier = usePkce ? generatePkceVerifier() : null;
const state = this.signState({
const state = await this.signState({
sub: connectionProvider.id,
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
connectionProviderId: connectionProvider.id,
@@ -196,14 +196,8 @@ export class ConnectionProviderOAuthFlowService {
};
}
private signState(payload: AppOAuthStateJwtPayload): string {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APP_OAUTH_STATE,
payload.workspaceId,
);
return this.jwtWrapperService.sign(payload, {
secret,
private async signState(payload: AppOAuthStateJwtPayload): Promise<string> {
return this.jwtWrapperService.signAsyncOrThrow(payload, {
expiresIn: STATE_JWT_EXPIRES_IN,
});
}
@@ -74,17 +74,19 @@ export class ApprovedAccessDomainService {
throw new Error(`Sender ${sender.id} has an empty userEmail`);
}
const logo = isDefined(workspace.logoFileId)
? await this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined;
const emailTemplate = SendApprovedAccessDomainValidation({
link: link.toString(),
workspace: {
name: workspace.displayName,
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
logo,
},
domain: approvedAccessDomain.domain,
sender: {
@@ -37,8 +37,7 @@ describe('AccessTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsync: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
@@ -138,7 +137,9 @@ describe('AccessTokenService', () => {
jest.spyOn(globalWorkspaceOrmManager, 'getRepository').mockResolvedValue({
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateAccessToken({
userId,
@@ -150,7 +151,7 @@ describe('AccessTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: userId,
workspaceId: workspaceId,
@@ -198,7 +199,7 @@ describe('AccessTokenService', () => {
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
const signSpy = jest
.spyOn(jwtWrapperService, 'signAsync')
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
await service.generateAccessToken({
@@ -141,7 +141,7 @@ export class AccessTokenService {
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
});
@@ -29,10 +29,9 @@ describe('ApplicationTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
@@ -81,7 +80,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationAccessToken({
workspaceId,
@@ -92,7 +93,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
@@ -116,7 +117,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationAccessToken({
workspaceId,
@@ -129,7 +132,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
@@ -271,7 +274,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationTokenPair({
workspaceId,
@@ -286,7 +291,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2);
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledTimes(2);
});
});
@@ -304,7 +309,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.renewApplicationTokens({
workspaceId,
@@ -91,23 +91,26 @@ export class ApplicationTokenService {
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
);
const applicationAccessToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
});
const applicationRefreshToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
});
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
[
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
}),
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
}),
],
);
return { applicationAccessToken, applicationRefreshToken };
}
@@ -229,7 +232,7 @@ export class ApplicationTokenService {
);
}
private signApplicationToken({
private async signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
@@ -245,7 +248,7 @@ export class ApplicationTokenService {
| JwtTokenTypeEnum.APPLICATION_ACCESS
| JwtTokenTypeEnum.APPLICATION_REFRESH;
expiresIn: string;
}): AuthToken {
}): Promise<AuthToken> {
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload:
@@ -260,11 +263,7 @@ export class ApplicationTokenService {
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
tokenType,
workspaceId,
),
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -19,8 +19,7 @@ describe('LoginTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
generateAppSecret: jest.fn(),
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
},
@@ -46,16 +45,14 @@ describe('LoginTokenService', () => {
describe('generateLoginToken', () => {
it('should generate a login token successfully', async () => {
const email = 'test@example.com';
const mockSecret = 'mock-secret';
const mockExpiresIn = '1h';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue(mockSecret);
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateLoginToken(
email,
@@ -67,39 +64,33 @@ describe('LoginTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(twentyConfigService.get).toHaveBeenCalledWith(
'LOGIN_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Password,
impersonatorUserId: undefined,
impersonatorUserWorkspaceId: undefined,
},
{ secret: mockSecret, expiresIn: mockExpiresIn },
{ expiresIn: mockExpiresIn },
);
});
});
describe('generateLoginToken with impersonation', () => {
it('should include impersonatorUserId in JWT payload when using Impersonation auth provider', async () => {
it('should include impersonatorUserWorkspaceId in JWT payload when using Impersonation auth provider', async () => {
const email = 'test@example.com';
const mockSecret = 'mock-secret';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
const impersonatorUserWorkspaceId = 'impersonator-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue(mockSecret);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateLoginToken(
email,
@@ -112,11 +103,7 @@ describe('LoginTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
@@ -124,7 +111,7 @@ describe('LoginTokenService', () => {
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserWorkspaceId,
},
{ secret: mockSecret, expiresIn: expect.any(String) },
{ expiresIn: expect.any(String) },
);
});
});
@@ -37,18 +37,12 @@ export class LoginTokenService {
impersonatorUserWorkspaceId: options?.impersonatorUserWorkspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
jwtPayload.type,
workspaceId,
);
const expiresIn = this.twentyConfigService.get('LOGIN_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -31,8 +31,7 @@ describe('RefreshTokenService', () => {
useValue: {
verifyJwtToken: jest.fn(),
decode: jest.fn(),
sign: jest.fn(),
signAsync: jest.fn(),
signAsyncOrThrow: jest.fn(),
generateAppSecret: jest.fn(),
},
},
@@ -126,7 +125,9 @@ describe('RefreshTokenService', () => {
const mockExpiresIn = '7d';
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
jest
.spyOn(appTokenRepository, 'create')
.mockReturnValue({ id: 'new-token-id' } as AppTokenEntity);
@@ -145,7 +146,7 @@ describe('RefreshTokenService', () => {
expiresAt: expect.any(Date),
});
expect(appTokenRepository.save).toHaveBeenCalled();
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: userId,
workspaceId,
@@ -139,7 +139,7 @@ export class RefreshTokenService {
type: JwtTokenTypeEnum.REFRESH,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
jwtid: refreshToken.id,
});
@@ -18,10 +18,9 @@ describe('TransientTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
@@ -55,7 +54,9 @@ describe('TransientTokenService', () => {
return undefined;
});
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateTransientToken({
workspaceMemberId,
@@ -70,7 +71,7 @@ describe('TransientTokenService', () => {
expect(twentyConfigService.get).toHaveBeenCalledWith(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
type: JwtTokenTypeEnum.LOGIN,
@@ -78,10 +79,7 @@ describe('TransientTokenService', () => {
workspaceId,
workspaceMemberId,
},
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: mockExpiresIn,
}),
{ expiresIn: mockExpiresIn },
);
});
});
@@ -35,10 +35,6 @@ export class TransientTokenService {
type: JwtTokenTypeEnum.LOGIN,
};
const secret = this.jwtWrapperService.generateAppSecret(
jwtPayload.type,
workspaceId,
);
const expiresIn = this.twentyConfigService.get(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
@@ -46,8 +42,7 @@ export class TransientTokenService {
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -24,10 +24,9 @@ describe('WorkspaceAgnosticToken', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verify: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
@@ -71,7 +70,9 @@ describe('WorkspaceAgnosticToken', () => {
return undefined;
});
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(mockUser as UserEntity);
@@ -91,17 +92,14 @@ describe('WorkspaceAgnosticToken', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
authProvider: AuthProviderEnum.Password,
sub: userId,
userId: userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
},
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: mockExpiresIn,
}),
{ expiresIn: mockExpiresIn },
);
});
@@ -143,7 +141,9 @@ describe('WorkspaceAgnosticToken', () => {
} as unknown as UserEntity;
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(mockUser as UserEntity);
@@ -153,13 +153,8 @@ describe('WorkspaceAgnosticToken', () => {
expect(result.user).toMatchObject({
id: userId,
});
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.verify).toHaveBeenCalledWith(
mockToken,
expect.objectContaining({
secret: 'mocked-secret',
}),
);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
});
@@ -168,9 +163,9 @@ describe('WorkspaceAgnosticToken', () => {
it('should throw an error if token verification fails', async () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verify').mockImplementation(() => {
throw new Error('Invalid token');
});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockRejectedValue(new Error('Invalid token'));
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
@@ -187,12 +182,33 @@ describe('WorkspaceAgnosticToken', () => {
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
);
});
it('should reject a valid token that is not of WORKSPACE_AGNOSTIC type', async () => {
const mockToken = 'valid-but-wrong-type-token';
const userId = 'user-id';
const mockPayload = {
sub: userId,
userId: userId,
type: JwtTokenTypeEnum.ACCESS,
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
);
});
});
});
@@ -60,11 +60,7 @@ export class WorkspaceAgnosticTokenService {
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
user.id,
),
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -73,15 +69,17 @@ export class WorkspaceAgnosticTokenService {
async validateToken(token: string): Promise<AuthContext> {
try {
await this.jwtWrapperService.verifyJwtToken(token);
const decoded =
this.jwtWrapperService.decode<WorkspaceAgnosticTokenJwtPayload>(token);
this.jwtWrapperService.verify(token, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
decoded.userId,
),
});
if (decoded.type !== JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
throw new AuthException(
'Expected a workspace-agnostic token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
const user = await this.userRepository.findOne({
where: { id: decoded.sub },
@@ -61,7 +61,7 @@ export class FileAiChatService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
@@ -126,7 +126,7 @@ export class FileCorePictureService {
});
}
const url = this.fileUrlService.signFileByIdUrl({
const url = await this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
fileFolder: FileFolder.CorePicture,
workspaceId: workspace.id,
@@ -159,7 +159,7 @@ export class FileCorePictureService {
queryRunner,
});
const url = this.fileUrlService.signFileByIdUrl({
const url = await this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.CorePicture,
@@ -64,7 +64,7 @@ export class FileEmailAttachmentService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.EmailAttachment,
@@ -18,9 +18,9 @@ export class FileUrlService {
private readonly twentyConfigService: TwentyConfigService,
) {}
signWorkspaceLogoUrl(
async signWorkspaceLogoUrl(
workspace: Pick<WorkspaceEntity, 'id' | 'logoFileId'>,
): string | null {
): Promise<string | null> {
if (!isDefined(workspace.logoFileId)) {
return null;
}
@@ -32,7 +32,7 @@ export class FileUrlService {
});
}
signFileByIdUrl({
async signFileByIdUrl({
fileId,
workspaceId,
fileFolder,
@@ -40,7 +40,7 @@ export class FileUrlService {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): string {
}): Promise<string> {
const fileTokenExpiresIn = this.twentyConfigService.get(
'FILE_TOKEN_EXPIRES_IN',
);
@@ -52,13 +52,7 @@ export class FileUrlService {
type: JwtTokenTypeEnum.FILE,
};
const secret = this.jwtWrapperService.generateAppSecret(
payload.type,
workspaceId,
);
const token = this.jwtWrapperService.sign(payload, {
secret,
const token = await this.jwtWrapperService.signAsyncOrThrow(payload, {
expiresIn: fileTokenExpiresIn,
});
@@ -62,7 +62,7 @@ export class FileWorkflowService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.Workflow,
@@ -97,7 +97,7 @@ export class FilesFieldService {
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
url: await this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
@@ -1,9 +1,5 @@
import { Injectable } from '@nestjs/common';
import {
JwtService,
type JwtSignOptions,
type JwtVerifyOptions,
} from '@nestjs/jwt';
import { JwtService, type JwtVerifyOptions } from '@nestjs/jwt';
import { createHash } from 'crypto';
@@ -28,10 +24,7 @@ import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-k
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';
import { isAsymmetricJwtHeader } from 'src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util';
type ResolvedVerificationKey = {
key: string;
@@ -54,32 +47,15 @@ export class JwtWrapperService {
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 {
return this.jwtService.sign(payload, options);
}
async signAsync(
async signAsyncOrThrow(
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',
'No active signing key available to sign asymmetric token',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
@@ -111,9 +87,8 @@ export class JwtWrapperService {
rawToken: string,
): Promise<ResolvedVerificationKey> {
const header = decodeJwtHeader(rawToken);
const payload = decodeJwtPayload<JwtPayload>(rawToken);
if (isAsymmetricJwtHeader(header, payload)) {
if (isAsymmetricJwtHeader(header)) {
const publicKeyPem =
await this.jwtKeyManagerService.getValidPublicKeyPemById(header.kid);
@@ -127,6 +102,8 @@ export class JwtWrapperService {
return { key: publicKeyPem, algorithm: JWT_ASYMMETRIC_ALGORITHM };
}
const payload = decodeJwtPayload<JwtPayload>(rawToken);
if (!isDefined(payload)) {
throw new AuthException(
'Token invalid.',
@@ -2,42 +2,14 @@ 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);
};
} =>
isDefined(header) &&
isNonEmptyString(header.kid) &&
header.alg === JWT_ASYMMETRIC_ALGORITHM;
@@ -13,15 +13,18 @@ type GetRecordImageIdentifierOptions = {
record: Record<string, unknown>;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
signUrl?: (fileId: string, fileFolder: FileFolder) => string | null;
signUrl?: (
fileId: string,
fileFolder: FileFolder,
) => Promise<string | null> | string | null;
};
export const getRecordImageIdentifier = ({
export const getRecordImageIdentifier = async ({
record,
flatObjectMetadata,
flatFieldMetadataMaps,
signUrl,
}: GetRecordImageIdentifierOptions): string | null => {
}: GetRecordImageIdentifierOptions): Promise<string | null> => {
if (flatObjectMetadata.nameSingular === 'company') {
const domainNameObj = record.domainName as
| { primaryLinkUrl?: string }
@@ -71,7 +71,7 @@ export class SearchResolver {
workspaceId: workspace.id,
});
return this.searchService.computeSearchObjectResults({
return await this.searchService.computeSearchObjectResults({
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
flatFieldMetadataMaps,
workspaceId: workspace.id,
@@ -543,11 +543,11 @@ export class SearchService {
return imageIdentifierField.name;
}
private getImageUrlWithToken(
private async getImageUrlWithToken(
avatarFileId: string,
fileFolder: FileFolder,
workspaceId: string,
): string {
): Promise<string> {
return this.fileUrlService.signFileByIdUrl({
fileId: avatarFileId,
workspaceId,
@@ -555,12 +555,12 @@ export class SearchService {
});
}
getImageIdentifierValue(
async getImageIdentifierValue(
record: ObjectRecord,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
workspaceId: string,
): string {
): Promise<string> {
const imageIdentifierField = this.getImageIdentifierColumn(
flatObjectMetadata,
flatFieldMetadataMaps,
@@ -648,7 +648,7 @@ export class SearchService {
return recordEdges;
}
computeSearchObjectResults({
async computeSearchObjectResults({
recordsWithObjectMetadataItems,
flatFieldMetadataMaps,
workspaceId,
@@ -660,10 +660,10 @@ export class SearchService {
workspaceId: string;
limit: number;
after?: string;
}): SearchResultConnectionDTO {
const searchRecords = recordsWithObjectMetadataItems.flatMap(
}): Promise<SearchResultConnectionDTO> {
const recordPromises = recordsWithObjectMetadataItems.flatMap(
({ objectMetadataItem, records }) => {
return records.map((record) => {
return records.map(async (record) => {
return {
recordId: record.id,
objectNameSingular: objectMetadataItem.nameSingular,
@@ -675,7 +675,7 @@ export class SearchService {
objectMetadataItem,
flatFieldMetadataMaps,
),
imageUrl: this.getImageIdentifierValue(
imageUrl: await this.getImageIdentifierValue(
record,
objectMetadataItem,
flatFieldMetadataMaps,
@@ -687,6 +687,7 @@ export class SearchService {
});
},
);
const searchRecords = await Promise.all(recordPromises);
const sortedRecords = this.sortSearchObjectResults(searchRecords).slice(
0,
@@ -110,7 +110,7 @@ export class CodeInterpreterTool implements Tool {
);
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const sessionToken = this.generateSessionToken(
const sessionToken = await this.generateSessionToken(
workspaceId,
userId,
userWorkspaceId,
@@ -293,16 +293,11 @@ export class CodeInterpreterTool implements Tool {
return inputFiles;
}
private generateSessionToken(
private async generateSessionToken(
workspaceId: string,
userId?: string,
userWorkspaceId?: string,
): string {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
workspaceId,
);
): Promise<string> {
const payload: AccessTokenJwtPayload = {
sub: userId ?? workspaceId,
type: JwtTokenTypeEnum.ACCESS,
@@ -312,9 +307,8 @@ export class CodeInterpreterTool implements Tool {
authProvider: AuthProviderEnum.Password,
};
return this.jwtWrapperService.sign(payload, {
secret,
expiresIn: '5m', // Short-lived token for code execution session
return this.jwtWrapperService.signAsyncOrThrow(payload, {
expiresIn: '5m',
});
}
@@ -349,7 +343,7 @@ export class CodeInterpreterTool implements Tool {
},
});
const signedUrl = this.fileUrlService.signFileByIdUrl({
const signedUrl = await this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.AgentChat,
@@ -536,13 +536,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
});
}
castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
async castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
return {
id: workspace.id,
displayName: workspace.displayName,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls(workspace),
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
? await this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
@@ -583,37 +583,44 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
user: Pick<UserEntity, 'email'>,
authProvider: AuthProviderEnum,
) {
const [availableWorkspacesForSignUp, availableWorkspacesForSignIn] =
await Promise.all([
Promise.all(
availableWorkspaces.availableWorkspacesForSignUp.map(
async ({ workspace, appToken }) => {
return {
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
...(appToken ? { personalInviteToken: appToken.value } : {}),
};
},
),
),
Promise.all(
availableWorkspaces.availableWorkspacesForSignIn.map(
async ({ workspace }) => {
return {
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
loginToken: workspaceValidator.isAuthEnabled(
authProvider,
workspace,
)
? (
await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
AuthProviderEnum.Password,
)
).token
: undefined,
};
},
),
),
]);
return {
availableWorkspacesForSignUp:
availableWorkspaces.availableWorkspacesForSignUp.map(
({ workspace, appToken }) => {
return {
...this.castWorkspaceToAvailableWorkspace(workspace),
...(appToken ? { personalInviteToken: appToken.value } : {}),
};
},
),
availableWorkspacesForSignIn: await Promise.all(
availableWorkspaces.availableWorkspacesForSignIn.map(
async ({ workspace }) => {
return {
...this.castWorkspaceToAvailableWorkspace(workspace),
loginToken: workspaceValidator.isAuthEnabled(
authProvider,
workspace,
)
? (
await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
AuthProviderEnum.Password,
)
).token
: undefined,
};
},
),
),
availableWorkspacesForSignUp,
availableWorkspacesForSignIn,
};
}
@@ -141,11 +141,11 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
workspaceMembers.map((member) => [member.userId, member]),
);
return new Map(
userIds.map((userId) => {
const entries = await Promise.all(
userIds.map(async (userId): Promise<[string, string | null]> => {
const member = memberByUserId.get(userId);
const memberSigned = isDefined(member)
? this.workspaceMemberTranspiler.generateSignedAvatarUrl({
? await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
workspaceId: workspace.id,
workspaceMember: member,
})
@@ -162,7 +162,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
}
const fallbackSigned =
this.workspaceMemberTranspiler.generateSignedAvatarUrl({
await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
workspaceId: workspace.id,
workspaceMember: { avatarUrl: fallbackAvatarUrl, id: userId },
});
@@ -173,6 +173,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
];
}),
);
return new Map(entries);
}
async loadWorkspaceMembersByUserIds({
@@ -28,13 +28,13 @@ export type ToWorkspaceMemberDtoArgs = {
export class WorkspaceMemberTranspiler {
constructor(private readonly fileUrlService: FileUrlService) {}
generateSignedAvatarUrl({
async generateSignedAvatarUrl({
workspaceId,
workspaceMember,
}: {
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
workspaceId: string;
}): string {
}): Promise<string> {
if (
!isDefined(workspaceMember.avatarUrl) ||
!isNonEmptyString(workspaceMember.avatarUrl)
@@ -58,11 +58,11 @@ export class WorkspaceMemberTranspiler {
});
}
toWorkspaceMemberDto({
async toWorkspaceMemberDto({
userWorkspace,
workspaceMemberEntity,
userWorkspaceRoles,
}: ToWorkspaceMemberDtoArgs): WorkspaceMemberDTO {
}: ToWorkspaceMemberDtoArgs): Promise<WorkspaceMemberDTO> {
const {
avatarUrl: avatarUrlFromEntity,
id,
@@ -77,7 +77,7 @@ export class WorkspaceMemberTranspiler {
numberFormat,
} = workspaceMemberEntity;
const avatarUrl = this.generateSignedAvatarUrl({
const avatarUrl = await this.generateSignedAvatarUrl({
workspaceId: userWorkspace.workspaceId,
workspaceMember: {
avatarUrl: avatarUrlFromEntity,
@@ -108,18 +108,20 @@ export class WorkspaceMemberTranspiler {
} satisfies WorkspaceMemberDTO;
}
toWorkspaceMemberDtos(
async toWorkspaceMemberDtos(
allWorkspaceEntitiesBundles: ToWorkspaceMemberDtoArgs[],
) {
return allWorkspaceEntitiesBundles.map((bundle) =>
this.toWorkspaceMemberDto(bundle),
): Promise<WorkspaceMemberDTO[]> {
return Promise.all(
allWorkspaceEntitiesBundles.map((bundle) =>
this.toWorkspaceMemberDto(bundle),
),
);
}
toDeletedWorkspaceMemberDto(
async toDeletedWorkspaceMemberDto(
workspaceMember: WorkspaceMemberWorkspaceEntity,
userWorkspaceId?: string,
): DeletedWorkspaceMemberDTO {
): Promise<DeletedWorkspaceMemberDTO> {
const {
avatarUrl: avatarUrlFromEntity,
id,
@@ -132,7 +134,7 @@ export class WorkspaceMemberTranspiler {
}
const avatarUrl = userWorkspaceId
? this.generateSignedAvatarUrl({
? await this.generateSignedAvatarUrl({
workspaceId: userWorkspaceId,
workspaceMember: {
avatarUrl: avatarUrlFromEntity,
@@ -150,12 +152,14 @@ export class WorkspaceMemberTranspiler {
} satisfies DeletedWorkspaceMemberDTO;
}
toDeletedWorkspaceMemberDtos(
async toDeletedWorkspaceMemberDtos(
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
userWorkspaceId?: string,
): DeletedWorkspaceMemberDTO[] {
return workspaceMembers.map((workspaceMember) =>
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
): Promise<DeletedWorkspaceMemberDTO[]> {
return Promise.all(
workspaceMembers.map((workspaceMember) =>
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
),
);
}
}
@@ -314,17 +314,19 @@ export class WorkspaceInvitationService {
);
}
const logo = isDefined(workspace.logoFileId)
? await this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined;
const emailData = {
link: link.toString(),
workspace: {
name: workspace.displayName,
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
logo,
},
sender: {
email: sender.userEmail,
@@ -406,7 +406,7 @@ export class WorkspaceResolver {
let workspaceLogoWithToken = '';
if (isDefined(workspace.logoFileId)) {
workspaceLogoWithToken = this.fileUrlService.signFileByIdUrl({
workspaceLogoWithToken = await this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
@@ -448,7 +448,7 @@ export class WorkspaceResolver {
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const logo = isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
? await this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
@@ -13,10 +13,10 @@ export class AgentMessagePartResolver {
constructor(private readonly fileUrlService: FileUrlService) {}
@ResolveField(() => String, { nullable: true })
fileUrl(
async fileUrl(
@Parent() part: AgentMessagePartEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): string | null {
): Promise<string | null> {
if (!part.fileId) {
return null;
}
@@ -250,29 +250,35 @@ export class AgentChatStreamingService {
userWorkspaceId,
);
return allMessages
.filter((message) => message.status !== AgentMessageStatus.QUEUED)
.map((message) => ({
const filteredMessages = allMessages.filter(
(message) => message.status !== AgentMessageStatus.QUEUED,
);
return Promise.all(
filteredMessages.map(async (message) => ({
id: message.id,
role: message.role as 'user' | 'assistant' | 'system',
parts: mapDBPartsToUIMessageParts(message.parts ?? []).map((part) => {
if (isExtendedFileUIPart(part as Record<string, unknown>)) {
const filePart = part as ExtendedFileUIPart;
parts: await Promise.all(
mapDBPartsToUIMessageParts(message.parts ?? []).map(async (part) => {
if (isExtendedFileUIPart(part as Record<string, unknown>)) {
const filePart = part as ExtendedFileUIPart;
return {
...filePart,
url: this.fileUrlService.signFileByIdUrl({
fileId: filePart.fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
} as ExtendedFileUIPart;
}
return {
...filePart,
url: await this.fileUrlService.signFileByIdUrl({
fileId: filePart.fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
} as ExtendedFileUIPart;
}
return part;
}),
return part;
}),
),
createdAt: message.createdAt,
}));
})),
);
}
private async buildFilePartsFromIds(
@@ -121,7 +121,7 @@ export class NavigationMenuItemRecordIdentifierService {
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
const imageIdentifier = await getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
@@ -1,6 +1,6 @@
// 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`] = `
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token signed by a revoked kid (publicKey present, revokedAt set) 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
@@ -14,7 +14,7 @@ exports[`JWT Asymmetric Signing & Key Rotation (integration) rejects a token sig
}
`;
exports[`JWT Asymmetric Signing & Key Rotation (integration) rejects a token whose kid was never registered without leaking a 500 1`] = `
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token whose kid was never registered without leaking a 500 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
@@ -1,19 +1,27 @@
import { createHash, randomUUID } from 'crypto';
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
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 { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.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 { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.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 { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import {
type AccessTokenJwtPayload,
type ApplicationAccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import {
PREVIOUS_KID,
@@ -22,26 +30,6 @@ import {
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,
@@ -53,13 +41,14 @@ const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
type: JwtTokenTypeEnum.ACCESS,
});
let sharedAccessToken: string;
let sharedPayload: AccessTokenJwtPayload;
let currentKid: string;
describe('JWT Asymmetric Signing - new ES256 + kid implementation (integration)', () => {
let sharedAccessToken: string;
let sharedRefreshToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let currentKid: string;
describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
beforeAll(async () => {
const uniqueEmail = `jwt-rotation-${randomUUID()}@example.com`;
const uniqueEmail = `jwt-asymmetric-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
@@ -81,6 +70,7 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
@@ -92,7 +82,11 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedPayload = jwt.decode(sharedAccessToken) as AccessTokenJwtPayload;
sharedRefreshToken =
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
currentKid = decodeJwtCompleteOrThrow(sharedAccessToken).header
.kid as string;
});
@@ -145,30 +139,6 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
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")
@@ -178,7 +148,7 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
);
const tokenSignedByPreviousKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: PREVIOUS_KID, expiresIn: '5m' },
);
@@ -195,7 +165,7 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedPayload.userId);
expect(data?.currentUser?.id).toBe(sharedAccessPayload.userId);
});
it('rejects a token signed by a revoked kid (publicKey present, revokedAt set)', async () => {
@@ -207,7 +177,7 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
);
const tokenSignedByRevokedKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: REVOKED_KID, expiresIn: '5m' },
);
@@ -226,7 +196,7 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
const unknownKid = '00000000-0000-4000-8000-000000000099';
const tokenSignedByOrphanKey = jwt.sign(
buildAccessTokenPayload(sharedPayload),
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: unknownKid, expiresIn: '5m' },
);
@@ -240,4 +210,129 @@ describe('JWT Asymmetric Signing & Key Rotation (integration)', () => {
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('round-trips a new ES256 REFRESH token through renewToken', async () => {
const response = await renewToken(sharedRefreshToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.refreshToken.token,
),
).toBe(true);
});
});
describe('JWT Asymmetric Signing - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededCurrentKid: string;
let seededApplicationId: string;
beforeAll(async () => {
const [{ id: currentKidRow }] = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
seededCurrentKid = currentKidRow;
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('signs new API_KEY tokens with ES256 + kid and authenticates against the GraphQL API', async () => {
const response = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const apiKeyToken: string =
response.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(apiKeyToken)).toBe(true);
const decoded = decodeJwtCompleteOrThrow(apiKeyToken);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(seededCurrentKid);
const { data, errors } = await findManyApplications({
accessToken: apiKeyToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('signs new APPLICATION_ACCESS + APPLICATION_REFRESH tokens with ES256 + kid via generateApplicationToken', async () => {
const { data, errors } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
expect(errors).toBeUndefined();
const { applicationAccessToken, applicationRefreshToken } =
data.generateApplicationToken;
const decodedAccess = decodeJwtCompleteOrThrow(
applicationAccessToken.token,
);
const decodedRefresh = decodeJwtCompleteOrThrow(
applicationRefreshToken.token,
);
expect(decodedAccess.header.alg).toBe('ES256');
expect(decodedAccess.header.kid).toBe(seededCurrentKid);
expect(decodedRefresh.header.alg).toBe('ES256');
expect(decodedRefresh.header.kid).toBe(seededCurrentKid);
const accessPayload = jwt.decode(
applicationAccessToken.token,
) as ApplicationAccessTokenJwtPayload;
expect(accessPayload.type).toBe(JwtTokenTypeEnum.APPLICATION_ACCESS);
expect(accessPayload.workspaceId).toBe(seededWorkspaceId);
expect(accessPayload.applicationId).toBe(seededApplicationId);
});
it('round-trips a new ES256 APPLICATION_REFRESH token through renewApplicationToken', async () => {
const { data } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
const response = await renewApplicationToken({
applicationRefreshToken:
data.generateApplicationToken.applicationRefreshToken.token,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
expect(
decodeJwtCompleteOrThrow(renewed.applicationAccessToken.token).header.alg,
).toBe('ES256');
});
});
@@ -0,0 +1,325 @@
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { forgeLegacyHs256Token } from 'test/integration/graphql/utils/forge-legacy-hs256-token.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 { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.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 {
type AccessTokenJwtPayload,
type ApplicationRefreshTokenJwtPayload,
JwtTokenTypeEnum,
type LoginTokenJwtPayload,
type RefreshTokenJwtPayload,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
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,
});
describe('JWT Legacy HS256 no-kid fallback (integration)', () => {
let sharedAccessToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let sharedRefreshPayload: RefreshTokenJwtPayload;
let sharedLoginPayload: LoginTokenJwtPayload;
let sharedWorkspaceAgnosticPayload: WorkspaceAgnosticTokenJwtPayload;
let sharedSubdomainUrl: string;
beforeAll(async () => {
const uniqueEmail = `jwt-legacy-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
sharedWorkspaceAgnosticPayload = jwt.decode(
workspaceAgnosticToken,
) as WorkspaceAgnosticTokenJwtPayload;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
sharedSubdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
sharedLoginPayload = jwt.decode(loginToken) as LoginTokenJwtPayload;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
sharedRefreshPayload = jwt.decode(
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token,
) as RefreshTokenJwtPayload;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
});
it('verifies a hand-crafted no-kid HS256 ACCESS token via the legacy fallback', async () => {
const legacyHs256Token = forgeLegacyHs256Token(
buildAccessTokenPayload(sharedAccessPayload),
sharedAccessPayload.workspaceId,
);
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(sharedAccessPayload.userId);
});
it('verifies a hand-crafted no-kid HS256 LOGIN token via the legacy fallback (round-trip through getAuthTokensFromLoginToken)', async () => {
const forgedLoginPayload: LoginTokenJwtPayload = {
sub: sharedLoginPayload.sub,
type: JwtTokenTypeEnum.LOGIN,
workspaceId: sharedLoginPayload.workspaceId,
authProvider:
sharedLoginPayload.authProvider ?? AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedLoginPayload,
sharedLoginPayload.workspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getAuthTokensFromLoginToken({
loginToken: forgedToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
isNonEmptyString(
data?.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
it('verifies a hand-crafted no-kid HS256 WORKSPACE_AGNOSTIC token via the legacy fallback (round-trip through signUpInNewWorkspace)', async () => {
const forgedAgnosticPayload: WorkspaceAgnosticTokenJwtPayload = {
sub: sharedWorkspaceAgnosticPayload.sub,
userId: sharedWorkspaceAgnosticPayload.userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
authProvider:
sharedWorkspaceAgnosticPayload.authProvider ??
AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedAgnosticPayload,
sharedWorkspaceAgnosticPayload.userId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await signUpInNewWorkspace({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(isNonEmptyString(data?.signUpInNewWorkspace.workspace.id)).toBe(
true,
);
});
it('verifies a hand-crafted no-kid HS256 REFRESH token via the legacy fallback (round-trip through renewToken)', async () => {
expect(isNonEmptyString(sharedRefreshPayload.jti)).toBe(true);
const forgedRefreshPayload = {
sub: sharedRefreshPayload.sub,
type: JwtTokenTypeEnum.REFRESH,
userId: sharedRefreshPayload.userId,
workspaceId: sharedRefreshPayload.workspaceId,
authProvider:
sharedRefreshPayload.authProvider ?? AuthProviderEnum.Password,
targetedTokenType:
sharedRefreshPayload.targetedTokenType ?? JwtTokenTypeEnum.ACCESS,
};
const forgedToken = forgeLegacyHs256Token(
forgedRefreshPayload,
sharedRefreshPayload.workspaceId ?? sharedRefreshPayload.userId,
{ expiresIn: '5m', jwtid: sharedRefreshPayload.jti },
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewToken(forgedToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
});
describe('JWT Legacy HS256 no-kid fallback - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededApplicationId: string;
beforeAll(async () => {
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('verifies the seeded legacy HS256 no-kid API_KEY token via the legacy fallback', async () => {
const decoded = decodeJwtCompleteOrThrow(API_KEY_ACCESS_TOKEN);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: API_KEY_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 API_KEY token via the legacy fallback', async () => {
const forgedPayload = {
sub: seededWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: seededWorkspaceId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload,
seededWorkspaceId,
{
expiresIn: '5m',
jwtid: seededApiKeyId,
},
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 APPLICATION_REFRESH token via the legacy fallback (round-trip through renewApplicationToken)', async () => {
const forgedPayload: ApplicationRefreshTokenJwtPayload = {
sub: seededApplicationId,
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
workspaceId: seededWorkspaceId,
applicationId: seededApplicationId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload as unknown as Record<string, unknown> & {
type: JwtTokenTypeEnum;
},
seededWorkspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewApplicationToken({
applicationRefreshToken: forgedToken,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
});
});
@@ -0,0 +1,12 @@
import * as jwt from 'jsonwebtoken';
import { isDefined } from 'twenty-shared/utils';
export const decodeJwtCompleteOrThrow = (token: string) => {
const decoded = jwt.decode(token, { complete: true });
if (!isDefined(decoded)) {
throw new Error('Failed to decode JWT');
}
return decoded;
};
@@ -0,0 +1,28 @@
import { createHash } from 'crypto';
import * as jwt from 'jsonwebtoken';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
// Mirrors JwtWrapperService.generateAppSecret + extractAppSecretBody so tests
// can hand-craft tokens that match what a pre-2.5 server would have signed.
// extractAppSecretBody resolves to workspaceId when present, else userId.
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');
export const forgeLegacyHs256Token = <TPayload extends Record<string, unknown>>(
payload: TPayload & { type: JwtTokenTypeEnum },
appSecretBody: string,
options: jwt.SignOptions = { expiresIn: '5m' },
): string =>
jwt.sign(payload, generateLegacyHs256Secret(payload.type, appSecretBody), {
algorithm: 'HS256',
...options,
});
@@ -0,0 +1,25 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export const generateApiKeyToken = async ({
apiKeyId,
accessToken,
expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(),
}: {
apiKeyId: string;
accessToken: string;
expiresAt?: string;
}) => {
const mutation = gql`
mutation GenerateApiKeyToken($apiKeyId: UUID!, $expiresAt: String!) {
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
token
}
}
`;
return await makeMetadataAPIRequest(
{ query: mutation, variables: { apiKeyId, expiresAt } },
accessToken,
);
};
@@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export const renewApplicationToken = async ({
applicationRefreshToken,
accessToken,
}: {
applicationRefreshToken: string;
accessToken: string;
}) => {
const mutation = gql`
mutation RenewApplicationToken($applicationRefreshToken: String!) {
renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) {
applicationAccessToken {
token
}
applicationRefreshToken {
token
}
}
}
`;
return await makeMetadataAPIRequest(
{ query: mutation, variables: { applicationRefreshToken } },
accessToken,
);
};
@@ -0,0 +1,24 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export const renewToken = async (appToken: string) => {
const mutation = gql`
mutation RenewToken($appToken: String!) {
renewToken(appToken: $appToken) {
tokens {
accessOrWorkspaceAgnosticToken {
token
}
refreshToken {
token
}
}
}
}
`;
return await makeMetadataAPIRequest(
{ query: mutation, variables: { appToken } },
undefined,
);
};