Slack workflow connector (#20427)
https://github.com/user-attachments/assets/5a746414-988b-473c-9401-b8863a3e1c15 https://github.com/user-attachments/assets/0cdebdb1-f7c8-43cb-beef-f279387b6ce9 https://github.com/user-attachments/assets/df31c631-0781-42d8-8e6e-e5a16573ee3b https://github.com/user-attachments/assets/6adaeae4-f3c9-4a5f-b0df-50c1f9a78428 --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
+1
-1
@@ -73,7 +73,7 @@ export class CalendarAccountAuthenticationService {
|
||||
workspaceId,
|
||||
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
|
||||
try {
|
||||
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
||||
return await this.connectedAccountRefreshTokensService.resolveTokens(
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+42
-12
@@ -148,7 +148,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('refreshAndSaveTokens', () => {
|
||||
describe('resolveTokens', () => {
|
||||
it('should reuse the cached token, decrypt before returning to the caller, and skip the refresh call entirely', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
@@ -158,7 +158,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
||||
} as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -203,7 +203,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -240,7 +240,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -277,7 +277,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -296,7 +296,37 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when refresh token is missing', async () => {
|
||||
it('should return decrypted access token and null refresh token when access token is valid but no refresh token exists', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.APP,
|
||||
accessToken: mockEncryptedAccessToken,
|
||||
refreshToken: null,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000),
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: mockAccessTokenPlaintext,
|
||||
refreshToken: null,
|
||||
});
|
||||
expect(
|
||||
connectedAccountTokenEncryptionService.decrypt,
|
||||
).toHaveBeenCalledWith({
|
||||
ciphertext: mockEncryptedAccessToken,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
expect(
|
||||
connectedAccountTokenEncryptionService.decrypt,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when refresh token is missing and access token is expired', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
@@ -306,7 +336,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toThrow(
|
||||
new ConnectedAccountRefreshAccessTokenException(
|
||||
`No refresh token found for connected account ${mockConnectedAccountId} in workspace ${mockWorkspaceId}`,
|
||||
@@ -334,7 +364,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.mockRejectedValue(invalidGrantError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
'Microsoft OAuth error: invalid_grant - Token has been revoked',
|
||||
@@ -362,7 +392,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR,
|
||||
});
|
||||
@@ -443,7 +473,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAndSaveTokens - OIDC/SAML', () => {
|
||||
describe('resolveTokens - OIDC/SAML', () => {
|
||||
it('should decrypt and return existing tokens for OIDC without attempting a refresh', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
@@ -453,7 +483,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -480,7 +510,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
+58
-34
@@ -19,7 +19,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc
|
||||
|
||||
export type ConnectedAccountTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60;
|
||||
@@ -40,14 +40,22 @@ export class ConnectedAccountRefreshTokensService {
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
) {}
|
||||
|
||||
async refreshAndSaveTokens(
|
||||
async resolveTokens(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
const {
|
||||
refreshToken: encryptedRefreshToken,
|
||||
accessToken: encryptedAccessToken,
|
||||
} = connectedAccount;
|
||||
const isAccessTokenValid =
|
||||
await this.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
if (isAccessTokenValid) {
|
||||
this.logger.debug(
|
||||
`Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
|
||||
);
|
||||
|
||||
return this.decryptExistingTokens(connectedAccount, workspaceId);
|
||||
}
|
||||
|
||||
const encryptedRefreshToken = connectedAccount.refreshToken;
|
||||
|
||||
if (!isDefined(encryptedRefreshToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
@@ -56,37 +64,53 @@ export class ConnectedAccountRefreshTokensService {
|
||||
);
|
||||
}
|
||||
|
||||
const isAccessTokenValid =
|
||||
await this.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
if (isAccessTokenValid) {
|
||||
this.logger.debug(
|
||||
`Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
|
||||
);
|
||||
if (!isDefined(encryptedAccessToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedAccessToken,
|
||||
workspaceId,
|
||||
}),
|
||||
refreshToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Access token expired for connected account ${connectedAccount.id} in workspace ${workspaceId}, refreshing...`,
|
||||
);
|
||||
|
||||
const decryptedRefreshTokenForRefreshCall =
|
||||
return this.performRefreshAndSave(
|
||||
connectedAccount,
|
||||
encryptedRefreshToken,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private decryptExistingTokens(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
workspaceId: string,
|
||||
): ConnectedAccountTokens {
|
||||
const {
|
||||
accessToken: encryptedAccessToken,
|
||||
refreshToken: encryptedRefreshToken,
|
||||
} = connectedAccount;
|
||||
|
||||
if (!isDefined(encryptedAccessToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedAccessToken,
|
||||
workspaceId,
|
||||
}),
|
||||
refreshToken: isDefined(encryptedRefreshToken)
|
||||
? this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async performRefreshAndSave(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
encryptedRefreshToken: string,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
const decryptedRefreshToken =
|
||||
this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
@@ -94,7 +118,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
const connectedAccountTokens = await this.refreshTokens(
|
||||
connectedAccount,
|
||||
decryptedRefreshTokenForRefreshCall,
|
||||
decryptedRefreshToken,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ describe('MessagingMessagesImportService', () => {
|
||||
{
|
||||
provide: ConnectedAccountRefreshTokensService,
|
||||
useValue: {
|
||||
refreshAndSaveTokens: jest.fn().mockResolvedValue({
|
||||
resolveTokens: jest.fn().mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
}),
|
||||
@@ -266,7 +266,7 @@ describe('MessagingMessagesImportService', () => {
|
||||
).toHaveBeenCalledWith([mockMessageChannel.id], workspaceId);
|
||||
|
||||
expect(
|
||||
connectedAccountRefreshTokensService.refreshAndSaveTokens,
|
||||
connectedAccountRefreshTokensService.resolveTokens,
|
||||
).toHaveBeenCalledWith(mockConnectedAccount, workspaceId);
|
||||
|
||||
expect(
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export class MessagingAccountAuthenticationService {
|
||||
messageChannelId,
|
||||
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
|
||||
try {
|
||||
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
||||
return await this.connectedAccountRefreshTokensService.resolveTokens(
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+2
-1
@@ -449,7 +449,8 @@ export class WorkflowSchemaWorkspaceService {
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.CODE:
|
||||
case WorkflowActionType.HTTP_REQUEST: {
|
||||
case WorkflowActionType.HTTP_REQUEST:
|
||||
case WorkflowActionType.LOGIC_FUNCTION: {
|
||||
const propertyPath = extractPropertyPathFromVariable(items);
|
||||
const schemaNode = navigateOutputSchemaProperty({
|
||||
schema: step.settings.outputSchema as BaseOutputSchemaV2,
|
||||
|
||||
Reference in New Issue
Block a user