feat(server): migrate all remaining JWT token types to ES256 (#20513)
## Summary Extends the asymmetric signing work from #20467 to cover **every remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`, `FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`, `APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the code interpreter tool. After this PR, every JWT the server signs is ES256 with a `kid` pointing at the current `core."signingKey"` row, while legacy HS256 tokens (no `kid` header) remain verifiable indefinitely through the existing fallback in `JwtWrapperService.resolveVerificationKey`. No new entity / migration / config: this is a pure routing change on top of the infrastructure that already shipped. ## Why `#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT type was still HS256-signed against the global `APP_SECRET`, which kept the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT type forever). Migrating the rest unifies the sign path on rotatable per-server private keys without forcing any token reissue. ## Mechanical changes ### Sign side (8 services) - `LoginTokenService.generateLoginToken` - `TransientTokenService.generateTransientToken` - `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken` - `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` + `APPLICATION_REFRESH`) - `ApiKeyService.generateApiKeyToken` - `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl` - `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`) - `CodeInterpreterTool.generateSessionToken` Each call site swaps `jwtWrapperService.sign(payload, { secret: generateAppSecret(...), ... })` for `await jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The `generateAppSecret` calls on the sign side are dropped (verifier-side `generateAppSecret` stays in `resolveVerificationKey` for the HS256 fallback). ### Verifier side - `WorkspaceAgnosticTokenService.validateToken` now goes through `verifyJwtToken` instead of the bespoke `verify({ secret })` path, so new ES256 tokens are accepted while the legacy HS256 fallback inside `resolveVerificationKey` still serves the old shape. - `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now strictly deprecated — there are no remaining production callers. ### Async ripple (`signFileByIdUrl` was synchronous) - `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now `async`; the `signUrl` callback used by `getRecordImageIdentifier` is widened to accept `Promise<string | null>`. - Every direct/indirect caller is updated: admin panel (user lookup + statistics + top workspaces), search service (`computeSearchObjectResults`, `getImageIdentifierValue`), workspace resolver (`logo` resolver, public workspace by domain/id), `WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` / `toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`), `UserService.loadSignedAvatarUrlsByUserId`, `UserWorkspaceService.castWorkspaceToAvailableWorkspace`, workspace-invitation, approved-access-domain, agent-chat-streaming, agent-message-part resolver, navigation-menu-item record identifier, file-ai-chat / file-core-picture / file-email-attachment / file-workflow / files-field services, rich-text & files-field query result getters, and the code-interpreter tool. ## Backward compatibility - **Legacy HS256 tokens (no `kid`)** keep verifying via `resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret` for both `workspaceId`-bearing and `userId`-bearing payloads. - The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in inside `verifyJwtToken` for pre-2025-12-12 API keys. - No payload shape changes, no DB writes, no env var changes — old tokens issued by `main` continue to authenticate. ## Tests ### Unit (all green locally — 63/63) Updated specs for every migrated service to mock `signAsync` instead of `sign` and assert the new option shape: - `login-token.service.spec.ts`, `transient-token.service.spec.ts`, `workspace-agnostic-token.service.spec.ts`, `application-token.service.spec.ts`, `api-key.service.spec.ts`, `connection-provider-oauth-flow.service.spec.ts`. ### Integration (`jwt-key-rotation.integration-spec.ts`) - Existing ACCESS coverage (current key, legacy HS256 fallback, rotated-out key, revoked key, unknown kid) is preserved. - New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN` tokens emitted by the real signUp → signUpInNewWorkspace → getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the current signing key — proves end-to-end that the migration didn't regress those flows. ## Open question (separate decision) This PR keeps the legacy HS256 verification fallback **forever**. We may eventually want to sunset it for `API_KEY` once telemetry shows pre-migration tokens are gone, but that's a separate product/security decision and not part of this change. ## Test plan - [ ] CI green - [ ] `npx nx lint:diff-with-main twenty-server` passes - [ ] `npx nx typecheck twenty-server` passes - [ ] `jwt-key-rotation` integration suite passes (new + existing assertions) - [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH token, generating an API key issues an ES256 token with `kid`, signed file URL JWT is ES256 with `kid` - [ ] Pre-existing HS256 tokens still authenticate (covered by integration test, but worth a manual check with a token from `main`)
This commit is contained in:
+29
-25
@@ -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(
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+6
-15
@@ -67,8 +67,7 @@ describe('ApiKeyService', () => {
|
||||
};
|
||||
|
||||
mockJwtWrapperService = {
|
||||
generateAppSecret: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
signAsyncOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
mockApiKeyRoleService = {
|
||||
@@ -377,12 +376,10 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('generateApiKeyToken', () => {
|
||||
const mockSecret = 'mock-secret';
|
||||
const mockToken = 'mock-jwt-token';
|
||||
|
||||
beforeEach(() => {
|
||||
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
|
||||
mockJwtWrapperService.sign.mockReturnValue(mockToken);
|
||||
mockJwtWrapperService.signAsyncOrThrow.mockResolvedValue(mockToken);
|
||||
});
|
||||
|
||||
it('should generate a JWT token for a valid API key', async () => {
|
||||
@@ -395,18 +392,13 @@ describe('ApiKeyService', () => {
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
|
||||
JwtTokenTypeEnum.API_KEY,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: mockWorkspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
{
|
||||
secret: mockSecret,
|
||||
expiresIn: expect.any(Number),
|
||||
jwtid: mockApiKeyId,
|
||||
},
|
||||
@@ -418,7 +410,7 @@ describe('ApiKeyService', () => {
|
||||
const result = await service.generateApiKeyToken(mockWorkspaceId);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
|
||||
expect(mockJwtWrapperService.signAsyncOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default expiration if no expiresAt provided', async () => {
|
||||
@@ -426,7 +418,7 @@ describe('ApiKeyService', () => {
|
||||
|
||||
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
|
||||
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
expiresIn: '100y',
|
||||
@@ -444,14 +436,13 @@ describe('ApiKeyService', () => {
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect(mockJwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: mockWorkspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: mockSecret,
|
||||
expiresIn: expect.any(Number),
|
||||
jwtid: mockApiKeyId,
|
||||
}),
|
||||
|
||||
@@ -147,11 +147,6 @@ export class ApiKeyService {
|
||||
|
||||
await this.validateApiKey(apiKeyId, workspaceId);
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
let expiresIn: string | number;
|
||||
|
||||
if (expiresAt) {
|
||||
@@ -162,14 +157,13 @@ export class ApiKeyService {
|
||||
expiresIn = '100y';
|
||||
}
|
||||
|
||||
const token = this.jwtWrapperService.sign(
|
||||
const token = await this.jwtWrapperService.signAsyncOrThrow(
|
||||
{
|
||||
sub: workspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+12
-12
@@ -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,
|
||||
|
||||
+3
-9
@@ -102,7 +102,7 @@ export class ConnectionProviderOAuthFlowService {
|
||||
|
||||
const codeVerifier = usePkce ? generatePkceVerifier() : null;
|
||||
|
||||
const state = this.signState({
|
||||
const state = await this.signState({
|
||||
sub: connectionProvider.id,
|
||||
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
|
||||
connectionProviderId: connectionProvider.id,
|
||||
@@ -196,14 +196,8 @@ export class ConnectionProviderOAuthFlowService {
|
||||
};
|
||||
}
|
||||
|
||||
private signState(payload: AppOAuthStateJwtPayload): string {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.APP_OAUTH_STATE,
|
||||
payload.workspaceId,
|
||||
);
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
private async signState(payload: AppOAuthStateJwtPayload): Promise<string> {
|
||||
return this.jwtWrapperService.signAsyncOrThrow(payload, {
|
||||
expiresIn: STATE_JWT_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
+9
-7
@@ -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: {
|
||||
|
||||
+6
-5
@@ -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({
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ export class AccessTokenService {
|
||||
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
|
||||
};
|
||||
|
||||
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
|
||||
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
|
||||
+16
-9
@@ -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,
|
||||
|
||||
+23
-24
@@ -91,23 +91,26 @@ export class ApplicationTokenService {
|
||||
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
const applicationAccessToken = this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
});
|
||||
|
||||
const applicationRefreshToken = this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresIn: refreshTokenExpiresIn,
|
||||
});
|
||||
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
|
||||
[
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
}),
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresIn: refreshTokenExpiresIn,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
@@ -229,7 +232,7 @@ export class ApplicationTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
private signApplicationToken({
|
||||
private async signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
@@ -245,7 +248,7 @@ export class ApplicationTokenService {
|
||||
| JwtTokenTypeEnum.APPLICATION_ACCESS
|
||||
| JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
expiresIn: string;
|
||||
}): AuthToken {
|
||||
}): Promise<AuthToken> {
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const jwtPayload:
|
||||
@@ -260,11 +263,7 @@ export class ApplicationTokenService {
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
tokenType,
|
||||
workspaceId,
|
||||
),
|
||||
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
|
||||
+13
-26
@@ -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) },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-7
@@ -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,
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
});
|
||||
|
||||
+6
-8
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-6
@@ -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,
|
||||
|
||||
+36
-20
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-11
@@ -60,11 +60,7 @@ export class WorkspaceAgnosticTokenService {
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
user.id,
|
||||
),
|
||||
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
@@ -73,15 +69,17 @@ export class WorkspaceAgnosticTokenService {
|
||||
|
||||
async validateToken(token: string): Promise<AuthContext> {
|
||||
try {
|
||||
await this.jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
const decoded =
|
||||
this.jwtWrapperService.decode<WorkspaceAgnosticTokenJwtPayload>(token);
|
||||
|
||||
this.jwtWrapperService.verify(token, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
decoded.userId,
|
||||
),
|
||||
});
|
||||
if (decoded.type !== JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
|
||||
throw new AuthException(
|
||||
'Expected a workspace-agnostic token',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: decoded.sub },
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export class FileAiChatService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export class FileEmailAttachmentService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
|
||||
@@ -18,9 +18,9 @@ export class FileUrlService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
signWorkspaceLogoUrl(
|
||||
async signWorkspaceLogoUrl(
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'logoFileId'>,
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
if (!isDefined(workspace.logoFileId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class FileUrlService {
|
||||
});
|
||||
}
|
||||
|
||||
signFileByIdUrl({
|
||||
async signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder,
|
||||
@@ -40,7 +40,7 @@ export class FileUrlService {
|
||||
fileId: string;
|
||||
workspaceId: string;
|
||||
fileFolder: FileFolder;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const fileTokenExpiresIn = this.twentyConfigService.get(
|
||||
'FILE_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
@@ -52,13 +52,7 @@ export class FileUrlService {
|
||||
type: JwtTokenTypeEnum.FILE,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
payload.type,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const token = this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
const token = await this.jwtWrapperService.signAsyncOrThrow(payload, {
|
||||
expiresIn: fileTokenExpiresIn,
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export class FileWorkflowService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.Workflow,
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ export class FilesFieldService {
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
url: await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
JwtService,
|
||||
type JwtSignOptions,
|
||||
type JwtVerifyOptions,
|
||||
} from '@nestjs/jwt';
|
||||
import { JwtService, type JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@@ -28,10 +24,7 @@ import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-k
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util';
|
||||
import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util';
|
||||
import {
|
||||
isAsymmetricJwtHeader,
|
||||
isAsymmetricSigningEligible,
|
||||
} from 'src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util';
|
||||
import { isAsymmetricJwtHeader } from 'src/engine/core-modules/jwt/utils/is-asymmetric-jwt-header.util';
|
||||
|
||||
type ResolvedVerificationKey = {
|
||||
key: string;
|
||||
@@ -54,32 +47,15 @@ export class JwtWrapperService {
|
||||
private readonly jwtKeyManagerService: JwtKeyManagerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link signAsync} for ACCESS / REFRESH tokens (ES256, with
|
||||
* rotatable signing keys). Synchronous HS256 signing remains in place for
|
||||
* token types not yet migrated to asymmetric signing, but new call sites
|
||||
* should not be introduced.
|
||||
*/
|
||||
sign(payload: JwtPayload, options?: JwtSignOptions): string {
|
||||
return this.jwtService.sign(payload, options);
|
||||
}
|
||||
|
||||
async signAsync(
|
||||
async signAsyncOrThrow(
|
||||
payload: JwtPayload,
|
||||
options: { expiresIn: string | number; jwtid?: string },
|
||||
): Promise<string> {
|
||||
if (!isAsymmetricSigningEligible(payload.type)) {
|
||||
throw new AuthException(
|
||||
`signAsync called with non-rotatable token type "${payload.type}"`,
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
const signingKey = await this.jwtKeyManagerService.getCurrentSigningKey();
|
||||
|
||||
if (!isDefined(signingKey)) {
|
||||
throw new AuthException(
|
||||
'No active signing key available to sign ACCESS / REFRESH token',
|
||||
'No active signing key available to sign asymmetric token',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
@@ -111,9 +87,8 @@ export class JwtWrapperService {
|
||||
rawToken: string,
|
||||
): Promise<ResolvedVerificationKey> {
|
||||
const header = decodeJwtHeader(rawToken);
|
||||
const payload = decodeJwtPayload<JwtPayload>(rawToken);
|
||||
|
||||
if (isAsymmetricJwtHeader(header, payload)) {
|
||||
if (isAsymmetricJwtHeader(header)) {
|
||||
const publicKeyPem =
|
||||
await this.jwtKeyManagerService.getValidPublicKeyPemById(header.kid);
|
||||
|
||||
@@ -127,6 +102,8 @@ export class JwtWrapperService {
|
||||
return { key: publicKeyPem, algorithm: JWT_ASYMMETRIC_ALGORITHM };
|
||||
}
|
||||
|
||||
const payload = decodeJwtPayload<JwtPayload>(rawToken);
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
throw new AuthException(
|
||||
'Token invalid.',
|
||||
|
||||
+4
-32
@@ -2,42 +2,14 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type JwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JWT_ASYMMETRIC_ALGORITHM } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
|
||||
|
||||
const ASYMMETRIC_TOKEN_TYPES: ReadonlySet<JwtTokenTypeEnum> = new Set([
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
JwtTokenTypeEnum.REFRESH,
|
||||
]);
|
||||
|
||||
export const isAsymmetricSigningEligible = (type: JwtTokenTypeEnum): boolean =>
|
||||
ASYMMETRIC_TOKEN_TYPES.has(type);
|
||||
|
||||
export const isAsymmetricJwtHeader = (
|
||||
header: jwt.JwtHeader | undefined,
|
||||
payload: JwtPayload | undefined,
|
||||
): header is jwt.JwtHeader & {
|
||||
kid: string;
|
||||
alg: typeof JWT_ASYMMETRIC_ALGORITHM;
|
||||
} => {
|
||||
if (!isDefined(header)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(header.kid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (header.alg !== JWT_ASYMMETRIC_ALGORITHM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isAsymmetricSigningEligible(payload.type);
|
||||
};
|
||||
} =>
|
||||
isDefined(header) &&
|
||||
isNonEmptyString(header.kid) &&
|
||||
header.alg === JWT_ASYMMETRIC_ALGORITHM;
|
||||
|
||||
+6
-3
@@ -13,15 +13,18 @@ type GetRecordImageIdentifierOptions = {
|
||||
record: Record<string, unknown>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
signUrl?: (fileId: string, fileFolder: FileFolder) => string | null;
|
||||
signUrl?: (
|
||||
fileId: string,
|
||||
fileFolder: FileFolder,
|
||||
) => Promise<string | null> | string | null;
|
||||
};
|
||||
|
||||
export const getRecordImageIdentifier = ({
|
||||
export const getRecordImageIdentifier = async ({
|
||||
record,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
signUrl,
|
||||
}: GetRecordImageIdentifierOptions): string | null => {
|
||||
}: GetRecordImageIdentifierOptions): Promise<string | null> => {
|
||||
if (flatObjectMetadata.nameSingular === 'company') {
|
||||
const domainNameObj = record.domainName as
|
||||
| { primaryLinkUrl?: string }
|
||||
|
||||
@@ -71,7 +71,7 @@ export class SearchResolver {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return this.searchService.computeSearchObjectResults({
|
||||
return await this.searchService.computeSearchObjectResults({
|
||||
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId: workspace.id,
|
||||
|
||||
@@ -543,11 +543,11 @@ export class SearchService {
|
||||
return imageIdentifierField.name;
|
||||
}
|
||||
|
||||
private getImageUrlWithToken(
|
||||
private async getImageUrlWithToken(
|
||||
avatarFileId: string,
|
||||
fileFolder: FileFolder,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
return this.fileUrlService.signFileByIdUrl({
|
||||
fileId: avatarFileId,
|
||||
workspaceId,
|
||||
@@ -555,12 +555,12 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
getImageIdentifierValue(
|
||||
async getImageIdentifierValue(
|
||||
record: ObjectRecord,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
const imageIdentifierField = this.getImageIdentifierColumn(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
@@ -648,7 +648,7 @@ export class SearchService {
|
||||
return recordEdges;
|
||||
}
|
||||
|
||||
computeSearchObjectResults({
|
||||
async computeSearchObjectResults({
|
||||
recordsWithObjectMetadataItems,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
@@ -660,10 +660,10 @@ export class SearchService {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
}): SearchResultConnectionDTO {
|
||||
const searchRecords = recordsWithObjectMetadataItems.flatMap(
|
||||
}): Promise<SearchResultConnectionDTO> {
|
||||
const recordPromises = recordsWithObjectMetadataItems.flatMap(
|
||||
({ objectMetadataItem, records }) => {
|
||||
return records.map((record) => {
|
||||
return records.map(async (record) => {
|
||||
return {
|
||||
recordId: record.id,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
@@ -675,7 +675,7 @@ export class SearchService {
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
imageUrl: this.getImageIdentifierValue(
|
||||
imageUrl: await this.getImageIdentifierValue(
|
||||
record,
|
||||
objectMetadataItem,
|
||||
flatFieldMetadataMaps,
|
||||
@@ -687,6 +687,7 @@ export class SearchService {
|
||||
});
|
||||
},
|
||||
);
|
||||
const searchRecords = await Promise.all(recordPromises);
|
||||
|
||||
const sortedRecords = this.sortSearchObjectResults(searchRecords).slice(
|
||||
0,
|
||||
|
||||
+6
-12
@@ -110,7 +110,7 @@ export class CodeInterpreterTool implements Tool {
|
||||
);
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
const sessionToken = this.generateSessionToken(
|
||||
const sessionToken = await this.generateSessionToken(
|
||||
workspaceId,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
@@ -293,16 +293,11 @@ export class CodeInterpreterTool implements Tool {
|
||||
return inputFiles;
|
||||
}
|
||||
|
||||
private generateSessionToken(
|
||||
private async generateSessionToken(
|
||||
workspaceId: string,
|
||||
userId?: string,
|
||||
userWorkspaceId?: string,
|
||||
): string {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
): Promise<string> {
|
||||
const payload: AccessTokenJwtPayload = {
|
||||
sub: userId ?? workspaceId,
|
||||
type: JwtTokenTypeEnum.ACCESS,
|
||||
@@ -312,9 +307,8 @@ export class CodeInterpreterTool implements Tool {
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
};
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: '5m', // Short-lived token for code execution session
|
||||
return this.jwtWrapperService.signAsyncOrThrow(payload, {
|
||||
expiresIn: '5m',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,7 +343,7 @@ export class CodeInterpreterTool implements Tool {
|
||||
},
|
||||
});
|
||||
|
||||
const signedUrl = this.fileUrlService.signFileByIdUrl({
|
||||
const signedUrl = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: savedFile.id,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
|
||||
+39
-32
@@ -536,13 +536,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
});
|
||||
}
|
||||
|
||||
castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
|
||||
async castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
displayName: workspace.displayName,
|
||||
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls(workspace),
|
||||
logo: isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
@@ -583,37 +583,44 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
user: Pick<UserEntity, 'email'>,
|
||||
authProvider: AuthProviderEnum,
|
||||
) {
|
||||
const [availableWorkspacesForSignUp, availableWorkspacesForSignIn] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignUp.map(
|
||||
async ({ workspace, appToken }) => {
|
||||
return {
|
||||
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
|
||||
...(appToken ? { personalInviteToken: appToken.value } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignIn.map(
|
||||
async ({ workspace }) => {
|
||||
return {
|
||||
...(await this.castWorkspaceToAvailableWorkspace(workspace)),
|
||||
loginToken: workspaceValidator.isAuthEnabled(
|
||||
authProvider,
|
||||
workspace,
|
||||
)
|
||||
? (
|
||||
await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
AuthProviderEnum.Password,
|
||||
)
|
||||
).token
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
availableWorkspacesForSignUp:
|
||||
availableWorkspaces.availableWorkspacesForSignUp.map(
|
||||
({ workspace, appToken }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
...(appToken ? { personalInviteToken: appToken.value } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
availableWorkspacesForSignIn: await Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignIn.map(
|
||||
async ({ workspace }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
loginToken: workspaceValidator.isAuthEnabled(
|
||||
authProvider,
|
||||
workspace,
|
||||
)
|
||||
? (
|
||||
await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
AuthProviderEnum.Password,
|
||||
)
|
||||
).token
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
availableWorkspacesForSignUp,
|
||||
availableWorkspacesForSignIn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -141,11 +141,11 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
workspaceMembers.map((member) => [member.userId, member]),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
userIds.map((userId) => {
|
||||
const entries = await Promise.all(
|
||||
userIds.map(async (userId): Promise<[string, string | null]> => {
|
||||
const member = memberByUserId.get(userId);
|
||||
const memberSigned = isDefined(member)
|
||||
? this.workspaceMemberTranspiler.generateSignedAvatarUrl({
|
||||
? await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
|
||||
workspaceId: workspace.id,
|
||||
workspaceMember: member,
|
||||
})
|
||||
@@ -162,7 +162,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
}
|
||||
|
||||
const fallbackSigned =
|
||||
this.workspaceMemberTranspiler.generateSignedAvatarUrl({
|
||||
await this.workspaceMemberTranspiler.generateSignedAvatarUrl({
|
||||
workspaceId: workspace.id,
|
||||
workspaceMember: { avatarUrl: fallbackAvatarUrl, id: userId },
|
||||
});
|
||||
@@ -173,6 +173,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
async loadWorkspaceMembersByUserIds({
|
||||
|
||||
+20
-16
@@ -28,13 +28,13 @@ export type ToWorkspaceMemberDtoArgs = {
|
||||
export class WorkspaceMemberTranspiler {
|
||||
constructor(private readonly fileUrlService: FileUrlService) {}
|
||||
|
||||
generateSignedAvatarUrl({
|
||||
async generateSignedAvatarUrl({
|
||||
workspaceId,
|
||||
workspaceMember,
|
||||
}: {
|
||||
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
|
||||
workspaceId: string;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
if (
|
||||
!isDefined(workspaceMember.avatarUrl) ||
|
||||
!isNonEmptyString(workspaceMember.avatarUrl)
|
||||
@@ -58,11 +58,11 @@ export class WorkspaceMemberTranspiler {
|
||||
});
|
||||
}
|
||||
|
||||
toWorkspaceMemberDto({
|
||||
async toWorkspaceMemberDto({
|
||||
userWorkspace,
|
||||
workspaceMemberEntity,
|
||||
userWorkspaceRoles,
|
||||
}: ToWorkspaceMemberDtoArgs): WorkspaceMemberDTO {
|
||||
}: ToWorkspaceMemberDtoArgs): Promise<WorkspaceMemberDTO> {
|
||||
const {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
@@ -77,7 +77,7 @@ export class WorkspaceMemberTranspiler {
|
||||
numberFormat,
|
||||
} = workspaceMemberEntity;
|
||||
|
||||
const avatarUrl = this.generateSignedAvatarUrl({
|
||||
const avatarUrl = await this.generateSignedAvatarUrl({
|
||||
workspaceId: userWorkspace.workspaceId,
|
||||
workspaceMember: {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
@@ -108,18 +108,20 @@ export class WorkspaceMemberTranspiler {
|
||||
} satisfies WorkspaceMemberDTO;
|
||||
}
|
||||
|
||||
toWorkspaceMemberDtos(
|
||||
async toWorkspaceMemberDtos(
|
||||
allWorkspaceEntitiesBundles: ToWorkspaceMemberDtoArgs[],
|
||||
) {
|
||||
return allWorkspaceEntitiesBundles.map((bundle) =>
|
||||
this.toWorkspaceMemberDto(bundle),
|
||||
): Promise<WorkspaceMemberDTO[]> {
|
||||
return Promise.all(
|
||||
allWorkspaceEntitiesBundles.map((bundle) =>
|
||||
this.toWorkspaceMemberDto(bundle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
toDeletedWorkspaceMemberDto(
|
||||
async toDeletedWorkspaceMemberDto(
|
||||
workspaceMember: WorkspaceMemberWorkspaceEntity,
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMemberDTO {
|
||||
): Promise<DeletedWorkspaceMemberDTO> {
|
||||
const {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
@@ -132,7 +134,7 @@ export class WorkspaceMemberTranspiler {
|
||||
}
|
||||
|
||||
const avatarUrl = userWorkspaceId
|
||||
? this.generateSignedAvatarUrl({
|
||||
? await this.generateSignedAvatarUrl({
|
||||
workspaceId: userWorkspaceId,
|
||||
workspaceMember: {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
@@ -150,12 +152,14 @@ export class WorkspaceMemberTranspiler {
|
||||
} satisfies DeletedWorkspaceMemberDTO;
|
||||
}
|
||||
|
||||
toDeletedWorkspaceMemberDtos(
|
||||
async toDeletedWorkspaceMemberDtos(
|
||||
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMemberDTO[] {
|
||||
return workspaceMembers.map((workspaceMember) =>
|
||||
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
|
||||
): Promise<DeletedWorkspaceMemberDTO[]> {
|
||||
return Promise.all(
|
||||
workspaceMembers.map((workspaceMember) =>
|
||||
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -314,17 +314,19 @@ export class WorkspaceInvitationService {
|
||||
);
|
||||
}
|
||||
|
||||
const logo = isDefined(workspace.logoFileId)
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const emailData = {
|
||||
link: link.toString(),
|
||||
workspace: {
|
||||
name: workspace.displayName,
|
||||
logo: isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
})
|
||||
: undefined,
|
||||
logo,
|
||||
},
|
||||
sender: {
|
||||
email: sender.userEmail,
|
||||
|
||||
@@ -406,7 +406,7 @@ export class WorkspaceResolver {
|
||||
let workspaceLogoWithToken = '';
|
||||
|
||||
if (isDefined(workspace.logoFileId)) {
|
||||
workspaceLogoWithToken = this.fileUrlService.signFileByIdUrl({
|
||||
workspaceLogoWithToken = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
@@ -448,7 +448,7 @@ export class WorkspaceResolver {
|
||||
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
|
||||
|
||||
const logo = isDefined(workspace.logoFileId)
|
||||
? this.fileUrlService.signFileByIdUrl({
|
||||
? await this.fileUrlService.signFileByIdUrl({
|
||||
fileId: workspace.logoFileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
|
||||
Reference in New Issue
Block a user