diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts index dc4eeeeebe..6caa708d29 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-field-query-result-getter.handler.ts index 0e6a21a973..a749a061fc 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-field-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-field-query-result-getter.handler.ts @@ -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 => { + 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, + }, + }; + }), + ); }; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts index af5f5c3777..7481df3433 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts @@ -32,7 +32,7 @@ export class WorkspaceMemberQueryResultGetterHandler }; } - const signedUrl = this.fileUrlService.signFileByIdUrl({ + const signedUrl = await this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, fileFolder: FileFolder.CorePicture, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-statistics.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-statistics.service.ts index eafbf98eab..d61eee4c87 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-statistics.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-statistics.service.ts @@ -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( diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts index 253f65a087..e6b7806e46 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts index 850df68f10..3e57eb59fa 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts @@ -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, }), diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts index 051412ed9d..943c32cdde 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts @@ -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, }, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.resolver.ts index 1da20702cd..2ba7cf2d0c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts index 34ec597e7f..0375720f0f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index 3f9822969b..39c902c2b3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts @@ -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 { + return this.jwtWrapperService.signAsyncOrThrow(payload, { expiresIn: STATE_JWT_EXPIRES_IN, }); } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 1fedf717ab..49a9b8fd08 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index e9c1858d27..f590010269 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -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({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index b738ed6ba6..ecd84aeccf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -141,7 +141,7 @@ export class AccessTokenService { impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId, }; - const token = await this.jwtWrapperService.signAsync(jwtPayload, { + const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, { expiresIn, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts index 82c240f1e0..96a02b2fbb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts index b5db14774b..ae0818b90c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts @@ -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 { 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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index 163eb76c28..499ec45bc6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -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) }, ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index 6604cb226e..c016e5c2c5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 36a25978f0..5d74c1044d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index 4043e10d84..c592a6ff52 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -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, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts index fd48dabf40..cd10898fdc 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -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 }, ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts index 73bfd381b9..6cf9175908 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts index 96a295e7fd..2064948594 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts @@ -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, + ); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts index fe52f2da59..49e8535a8d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts @@ -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 { try { + await this.jwtWrapperService.verifyJwtToken(token); + const decoded = this.jwtWrapperService.decode(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 }, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts index 0074a20951..f637faa930 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts @@ -61,7 +61,7 @@ export class FileAiChatService { return { ...savedFile, - url: this.fileUrlService.signFileByIdUrl({ + url: await this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, fileFolder: FileFolder.AgentChat, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts index a554e012fb..9ea12f9a20 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts index d6aaa4ba78..f84bea0516 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts @@ -64,7 +64,7 @@ export class FileEmailAttachmentService { return { ...savedFile, - url: this.fileUrlService.signFileByIdUrl({ + url: await this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, fileFolder: FileFolder.EmailAttachment, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts index 6072a21954..e0fcfbe687 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts @@ -18,9 +18,9 @@ export class FileUrlService { private readonly twentyConfigService: TwentyConfigService, ) {} - signWorkspaceLogoUrl( + async signWorkspaceLogoUrl( workspace: Pick, - ): string | null { + ): Promise { 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 { 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, }); diff --git a/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts index 5ccb41aff2..95aa486490 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts @@ -62,7 +62,7 @@ export class FileWorkflowService { return { ...savedFile, - url: this.fileUrlService.signFileByIdUrl({ + url: await this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, fileFolder: FileFolder.Workflow, diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts index 2f16534ff5..4f978915df 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts @@ -97,7 +97,7 @@ export class FilesFieldService { return { ...savedFile, - url: this.fileUrlService.signFileByIdUrl({ + url: await this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, fileFolder: FileFolder.FilesField, diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 409a945414..f13557e263 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -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 { - 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 { const header = decodeJwtHeader(rawToken); - const payload = decodeJwtPayload(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(rawToken); + if (!isDefined(payload)) { throw new AuthException( 'Token invalid.', diff --git a/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts b/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts index a7c1e01cb5..910346e36b 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util.ts @@ -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 = 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; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts index d4559d89eb..4f5012d0d3 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts @@ -13,15 +13,18 @@ type GetRecordImageIdentifierOptions = { record: Record; flatObjectMetadata: FlatObjectMetadata; flatFieldMetadataMaps: FlatEntityMaps; - signUrl?: (fileId: string, fileFolder: FileFolder) => string | null; + signUrl?: ( + fileId: string, + fileFolder: FileFolder, + ) => Promise | string | null; }; -export const getRecordImageIdentifier = ({ +export const getRecordImageIdentifier = async ({ record, flatObjectMetadata, flatFieldMetadataMaps, signUrl, -}: GetRecordImageIdentifierOptions): string | null => { +}: GetRecordImageIdentifierOptions): Promise => { if (flatObjectMetadata.nameSingular === 'company') { const domainNameObj = record.domainName as | { primaryLinkUrl?: string } diff --git a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts index 374b7c2f38..d654f3f130 100644 --- a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index 05d4669acc..7c919ec824 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -543,11 +543,11 @@ export class SearchService { return imageIdentifierField.name; } - private getImageUrlWithToken( + private async getImageUrlWithToken( avatarFileId: string, fileFolder: FileFolder, workspaceId: string, - ): string { + ): Promise { return this.fileUrlService.signFileByIdUrl({ fileId: avatarFileId, workspaceId, @@ -555,12 +555,12 @@ export class SearchService { }); } - getImageIdentifierValue( + async getImageIdentifierValue( record: ObjectRecord, flatObjectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, workspaceId: string, - ): string { + ): Promise { 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 { + 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, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index 770bcaca27..2ef0b4a032 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -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 { 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, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 4cc32042e9..ed052584be 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -536,13 +536,13 @@ export class UserWorkspaceService extends TypeOrmQueryService, 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, }; } diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 0b86994075..b2f70ba3b9 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -141,11 +141,11 @@ export class UserService extends TypeOrmQueryService { 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 { } 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 { ]; }), ); + + return new Map(entries); } async loadWorkspaceMembersByUserIds({ diff --git a/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts index c83d4d6888..a441e8e0db 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/workspace-member-transpiler.service.ts @@ -28,13 +28,13 @@ export type ToWorkspaceMemberDtoArgs = { export class WorkspaceMemberTranspiler { constructor(private readonly fileUrlService: FileUrlService) {} - generateSignedAvatarUrl({ + async generateSignedAvatarUrl({ workspaceId, workspaceMember, }: { workspaceMember: Pick; workspaceId: string; - }): string { + }): Promise { if ( !isDefined(workspaceMember.avatarUrl) || !isNonEmptyString(workspaceMember.avatarUrl) @@ -58,11 +58,11 @@ export class WorkspaceMemberTranspiler { }); } - toWorkspaceMemberDto({ + async toWorkspaceMemberDto({ userWorkspace, workspaceMemberEntity, userWorkspaceRoles, - }: ToWorkspaceMemberDtoArgs): WorkspaceMemberDTO { + }: ToWorkspaceMemberDtoArgs): Promise { 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 { + return Promise.all( + allWorkspaceEntitiesBundles.map((bundle) => + this.toWorkspaceMemberDto(bundle), + ), ); } - toDeletedWorkspaceMemberDto( + async toDeletedWorkspaceMemberDto( workspaceMember: WorkspaceMemberWorkspaceEntity, userWorkspaceId?: string, - ): DeletedWorkspaceMemberDTO { + ): Promise { 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 { + return Promise.all( + workspaceMembers.map((workspaceMember) => + this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId), + ), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index ff781137cc..d4754d936e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 6e4e0a4e0e..434ccb7abe 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-message-part.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-message-part.resolver.ts index 3f32416232..70892f72dc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-message-part.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-message-part.resolver.ts @@ -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 { if (!part.fileId) { return null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index 4a716d479b..ddf720d3c0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -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)) { - const filePart = part as ExtendedFileUIPart; + parts: await Promise.all( + mapDBPartsToUIMessageParts(message.parts ?? []).map(async (part) => { + if (isExtendedFileUIPart(part as Record)) { + 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( diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts index 3c595c54e5..9a17747817 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service.ts @@ -121,7 +121,7 @@ export class NavigationMenuItemRecordIdentifierService { flatFieldMetadataMaps, ); - const imageIdentifier = getRecordImageIdentifier({ + const imageIdentifier = await getRecordImageIdentifier({ record, flatObjectMetadata: objectMetadata, flatFieldMetadataMaps, diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-asymmetric-signing.integration-spec.ts.snap similarity index 64% rename from packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap rename to packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-asymmetric-signing.integration-spec.ts.snap index 9aeb17661e..9eebe663c5 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-key-rotation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/__snapshots__/jwt-asymmetric-signing.integration-spec.ts.snap @@ -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", diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts similarity index 52% rename from packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts rename to packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts index 00855b3c7b..f76d20be44 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-key-rotation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-asymmetric-signing.integration-spec.ts @@ -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'); + }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts new file mode 100644 index 0000000000..f02c434335 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/jwt-key-rotation/jwt-legacy-hs256-fallback.integration-spec.ts @@ -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 & { + 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); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/decode-jwt-complete-or-throw.util.ts b/packages/twenty-server/test/integration/graphql/utils/decode-jwt-complete-or-throw.util.ts new file mode 100644 index 0000000000..5f81160b76 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/decode-jwt-complete-or-throw.util.ts @@ -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; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts new file mode 100644 index 0000000000..57e3cf491b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/forge-legacy-hs256-token.util.ts @@ -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 = >( + payload: TPayload & { type: JwtTokenTypeEnum }, + appSecretBody: string, + options: jwt.SignOptions = { expiresIn: '5m' }, +): string => + jwt.sign(payload, generateLegacyHs256Secret(payload.type, appSecretBody), { + algorithm: 'HS256', + ...options, + }); diff --git a/packages/twenty-server/test/integration/graphql/utils/generate-api-key-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/generate-api-key-token.util.ts new file mode 100644 index 0000000000..53378830e5 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/generate-api-key-token.util.ts @@ -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, + ); +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/renew-application-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/renew-application-token.util.ts new file mode 100644 index 0000000000..76ac243522 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/renew-application-token.util.ts @@ -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, + ); +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/renew-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/renew-token.util.ts new file mode 100644 index 0000000000..323545f60c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/renew-token.util.ts @@ -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, + ); +};