Abdul Rahman
2026-05-20 15:51:22 +05:30
committed by GitHub
parent a26fe3bb65
commit c800eccc65
54 changed files with 6572 additions and 53 deletions
@@ -73,7 +73,7 @@ export class CalendarAccountAuthenticationService {
workspaceId,
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
try {
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
return await this.connectedAccountRefreshTokensService.resolveTokens(
connectedAccount,
workspaceId,
);
@@ -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,
);
@@ -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,
);
@@ -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(
@@ -85,7 +85,7 @@ export class MessagingAccountAuthenticationService {
messageChannelId,
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
try {
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
return await this.connectedAccountRefreshTokensService.resolveTokens(
connectedAccount,
workspaceId,
);
@@ -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,