Skip to content

Commit 113b2df

Browse files
authored
Merge pull request LF-Decentralized-Trust-labs#265 from DarshanCode2005/main
Fix Groups.io integration reliability: cookie refresh, error handling, and webhook validation
2 parents dc83f72 + 68941e2 commit 113b2df

File tree

15 files changed

+679
-67
lines changed

15 files changed

+679
-67
lines changed

backend/src/serverless/integrations/usecases/groupsio/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface GroupsioIntegrationData {
22
email: string
33
token: string
44
groupNames: GroupName[]
5+
password?: string // Optional: if provided, will be encrypted and stored for cookie refresh
56
}
67

78
export interface GroupsioGetToken {

backend/src/services/integrationService.ts

Lines changed: 183 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
GroupsioGetToken,
4949
GroupsioVerifyGroup,
5050
} from '@/serverless/integrations/usecases/groupsio/types'
51+
import { encrypt, decrypt } from '../utils/crypto'
5152
import SearchSyncService from './searchSyncService'
5253

5354
const discordToken = DISCORD_CONFIG.token || DISCORD_CONFIG.token2
@@ -1555,20 +1556,51 @@ export default class IntegrationService {
15551556

15561557
// integration data should have the following fields
15571558
// email, token, array of groups
1558-
// we shouldn't store password and 2FA token in the database
1559-
// user should update them every time thety change something
1559+
// password is optional - if provided, will be encrypted and stored for automatic cookie refresh
15601560

15611561
try {
15621562
this.options.log.info('Creating Groups.io integration!')
1563+
1564+
// Try to get existing integration to preserve encrypted password
1565+
let existingSettings: any = {}
1566+
try {
1567+
const existingIntegration = await IntegrationRepository.findByPlatform(PlatformType.GROUPSIO, {
1568+
...this.options,
1569+
transaction,
1570+
})
1571+
existingSettings = existingIntegration?.settings || {}
1572+
} catch (err) {
1573+
// Integration doesn't exist yet, that's fine
1574+
}
1575+
1576+
// Prepare settings
1577+
const settings: any = {
1578+
email: integrationData.email,
1579+
token: integrationData.token,
1580+
groups: integrationData.groupNames,
1581+
updateMemberAttributes: true,
1582+
lastTokenRefresh: Date.now(),
1583+
}
1584+
1585+
// Encrypt and store password if provided
1586+
if (integrationData.password) {
1587+
const encryptionKey = process.env.GROUPSIO_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
1588+
try {
1589+
settings.encryptedPassword = encrypt(integrationData.password, encryptionKey)
1590+
this.options.log.info('Password encrypted and stored for Groups.io integration')
1591+
} catch (encryptErr) {
1592+
this.options.log.error(encryptErr, 'Failed to encrypt password for Groups.io integration')
1593+
// Continue without storing password - user will need to re-authenticate manually
1594+
}
1595+
} else if (existingSettings?.encryptedPassword) {
1596+
// Preserve existing encrypted password if not updating
1597+
settings.encryptedPassword = existingSettings.encryptedPassword
1598+
}
1599+
15631600
integration = await this.createOrUpdate(
15641601
{
15651602
platform: PlatformType.GROUPSIO,
1566-
settings: {
1567-
email: integrationData.email,
1568-
token: integrationData.token,
1569-
groups: integrationData.groupNames,
1570-
updateMemberAttributes: true,
1571-
},
1603+
settings,
15721604
status: 'in-progress',
15731605
},
15741606
transaction,
@@ -1619,16 +1651,47 @@ export default class IntegrationService {
16191651
response = await axios(config)
16201652

16211653
// we need to get cookie from the response
1654+
if (!response.headers['set-cookie'] || !response.headers['set-cookie'][0]) {
1655+
this.options.log.error({ email: data.email }, 'No set-cookie header in Groups.io login response')
1656+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1657+
}
16221658

16231659
const cookie = response.headers['set-cookie'][0].split(';')[0]
16241660

1661+
if (!cookie) {
1662+
this.options.log.error({ email: data.email }, 'Invalid cookie format in Groups.io login response')
1663+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1664+
}
1665+
16251666
return {
16261667
groupsioCookie: cookie,
16271668
}
16281669
} catch (err) {
1629-
if ('two_factor_required' in response.data) {
1630-
throw new Error400(this.options.language, 'errors.groupsio.twoFactorRequired')
1670+
// Check if it's an axios error with response data
1671+
if (err.response && err.response.data) {
1672+
if ('two_factor_required' in err.response.data) {
1673+
throw new Error400(this.options.language, 'errors.groupsio.twoFactorRequired')
1674+
}
1675+
// Check for other specific error messages
1676+
if (err.response.status === 401 || err.response.status === 403) {
1677+
this.options.log.error(
1678+
{ email: data.email, status: err.response.status },
1679+
'Authentication failed for Groups.io login',
1680+
)
1681+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1682+
}
16311683
}
1684+
1685+
// If it's already an Error400, re-throw it
1686+
if (err instanceof Error400) {
1687+
throw err
1688+
}
1689+
1690+
// For network errors or other unexpected errors
1691+
this.options.log.error(
1692+
{ email: data.email, error: err.message },
1693+
'Unexpected error during Groups.io login',
1694+
)
16321695
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
16331696
}
16341697
}
@@ -1657,4 +1720,114 @@ export default class IntegrationService {
16571720
throw new Error400(this.options.language, 'errors.groupsio.invalidGroup')
16581721
}
16591722
}
1723+
1724+
/**
1725+
* Refreshes the Groups.io cookie for an integration using stored credentials
1726+
* @param integrationId - The integration ID to refresh the cookie for
1727+
* @returns The new cookie string
1728+
* @throws Error400 if credentials are missing, decryption fails, or authentication fails
1729+
*/
1730+
async refreshGroupsioCookie(integrationId: string): Promise<string> {
1731+
this.options.log.info({ integrationId }, 'Refreshing Groups.io cookie')
1732+
1733+
// Get the integration
1734+
const integration = await IntegrationRepository.findById(integrationId, this.options)
1735+
if (!integration) {
1736+
throw new Error404(this.options.language, 'errors.integration.notFound')
1737+
}
1738+
1739+
const settings = integration.settings as any
1740+
if (!settings) {
1741+
throw new Error400(this.options.language, 'errors.groupsio.invalidSettings')
1742+
}
1743+
1744+
// Check if we have encrypted password
1745+
if (!settings.encryptedPassword) {
1746+
this.options.log.error(
1747+
{ integrationId },
1748+
'Cannot refresh Groups.io cookie: no encrypted password stored',
1749+
)
1750+
throw new Error400(
1751+
this.options.language,
1752+
'errors.groupsio.noStoredCredentials',
1753+
'No stored credentials available. Please reconnect the integration.',
1754+
)
1755+
}
1756+
1757+
if (!settings.email) {
1758+
throw new Error400(this.options.language, 'errors.groupsio.missingEmail')
1759+
}
1760+
1761+
// Decrypt password
1762+
const encryptionKey = process.env.GROUPSIO_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
1763+
let password: string
1764+
try {
1765+
password = decrypt(settings.encryptedPassword, encryptionKey)
1766+
} catch (decryptErr) {
1767+
this.options.log.error(decryptErr, { integrationId }, 'Failed to decrypt Groups.io password')
1768+
throw new Error400(
1769+
this.options.language,
1770+
'errors.groupsio.decryptionFailed',
1771+
'Failed to decrypt stored credentials. Please reconnect the integration.',
1772+
)
1773+
}
1774+
1775+
// Get new token
1776+
let newCookie: string
1777+
try {
1778+
const tokenResult = await this.groupsioGetToken({
1779+
email: settings.email,
1780+
password,
1781+
})
1782+
newCookie = tokenResult.groupsioCookie
1783+
} catch (err) {
1784+
// Check if it's a 2FA error
1785+
if (err instanceof Error400 && err.message?.includes('twoFactorRequired')) {
1786+
this.options.log.warn(
1787+
{ integrationId },
1788+
'Groups.io cookie refresh failed: 2FA required. Integration needs manual update.',
1789+
)
1790+
throw new Error400(
1791+
this.options.language,
1792+
'errors.groupsio.twoFactorRequired',
1793+
'Two-factor authentication is required. Please reconnect the integration with your 2FA code.',
1794+
)
1795+
}
1796+
1797+
this.options.log.error(err, { integrationId }, 'Failed to refresh Groups.io cookie')
1798+
throw new Error400(
1799+
this.options.language,
1800+
'errors.groupsio.refreshFailed',
1801+
'Failed to refresh authentication. Please check your credentials and reconnect the integration.',
1802+
)
1803+
}
1804+
1805+
// Update integration settings with new cookie
1806+
const transaction = await SequelizeRepository.createTransaction(this.options)
1807+
try {
1808+
await this.update(
1809+
integrationId,
1810+
{
1811+
settings: {
1812+
...settings,
1813+
token: newCookie,
1814+
lastTokenRefresh: Date.now(),
1815+
},
1816+
},
1817+
transaction,
1818+
)
1819+
await SequelizeRepository.commitTransaction(transaction)
1820+
1821+
this.options.log.info(
1822+
{ integrationId, refreshTime: new Date().toISOString() },
1823+
'Groups.io cookie refreshed successfully',
1824+
)
1825+
} catch (updateErr) {
1826+
await SequelizeRepository.rollbackTransaction(transaction)
1827+
this.options.log.error(updateErr, { integrationId }, 'Failed to update integration with new cookie')
1828+
throw updateErr
1829+
}
1830+
1831+
return newCookie
1832+
}
16601833
}

backend/src/utils/crypto.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,51 @@ export function verifyWebhookSignature(
1919
buffer.Buffer.from(expectedSignature),
2020
)
2121
}
22+
23+
/**
24+
* Encrypts a string using AES-256-GCM
25+
* @param text - The text to encrypt
26+
* @param secretKey - The secret key (should be 32 bytes for AES-256)
27+
* @returns Encrypted string in format: iv:authTag:encryptedData (all base64 encoded)
28+
*/
29+
export function encrypt(text: string, secretKey: string): string {
30+
const algorithm = 'aes-256-gcm'
31+
const key = crypto.scryptSync(secretKey, 'salt', 32)
32+
const iv = crypto.randomBytes(16)
33+
const cipher = crypto.createCipheriv(algorithm, key, iv)
34+
35+
let encrypted = cipher.update(text, 'utf8', 'base64')
36+
encrypted += cipher.final('base64')
37+
const authTag = cipher.getAuthTag()
38+
39+
// Return format: iv:authTag:encryptedData (all base64)
40+
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`
41+
}
42+
43+
/**
44+
* Decrypts a string encrypted with encrypt()
45+
* @param encryptedText - The encrypted text in format: iv:authTag:encryptedData
46+
* @param secretKey - The secret key used for encryption
47+
* @returns Decrypted string
48+
*/
49+
export function decrypt(encryptedText: string, secretKey: string): string {
50+
const algorithm = 'aes-256-gcm'
51+
const key = crypto.scryptSync(secretKey, 'salt', 32)
52+
const parts = encryptedText.split(':')
53+
54+
if (parts.length !== 3) {
55+
throw new Error('Invalid encrypted text format')
56+
}
57+
58+
const iv = buffer.Buffer.from(parts[0], 'base64')
59+
const authTag = buffer.Buffer.from(parts[1], 'base64')
60+
const encrypted = parts[2]
61+
62+
const decipher = crypto.createDecipheriv(algorithm, key, iv)
63+
decipher.setAuthTag(authTag)
64+
65+
let decrypted = decipher.update(encrypted, 'base64', 'utf8')
66+
decrypted += decipher.final('utf8')
67+
68+
return decrypted
69+
}

frontend/components.d.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,50 +9,37 @@ export {}
99

1010
declare module '@vue/runtime-core' {
1111
export interface GlobalComponents {
12+
CommunityAbout: typeof import('./src/components/landing/CommunityAbout.vue')['default']
13+
CommunityAnnouncement: typeof import('./src/components/landing/CommunityAnnouncement.vue')['default']
1214
CommunityFooter: typeof import('./src/components/landing/CommunityFooter.vue')['default']
1315
CommunityHeader: typeof import('./src/components/landing/CommunityHeader.vue')['default']
1416
CommunityHero: typeof import('./src/components/landing/CommunityHero.vue')['default']
1517
CommunityQuickStart: typeof import('./src/components/landing/CommunityQuickStart.vue')['default']
1618
ElAside: typeof import('element-plus/es')['ElAside']
1719
ElAvatar: typeof import('element-plus/es')['ElAvatar']
1820
ElButton: typeof import('element-plus/es')['ElButton']
19-
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
2021
ElCard: typeof import('element-plus/es')['ElCard']
2122
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
2223
ElCollapse: typeof import('element-plus/es')['ElCollapse']
2324
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
2425
ElContainer: typeof import('element-plus/es')['ElContainer']
25-
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
2626
ElDialog: typeof import('element-plus/es')['ElDialog']
2727
ElDivider: typeof import('element-plus/es')['ElDivider']
2828
ElDrawer: typeof import('element-plus/es')['ElDrawer']
2929
ElDropdown: typeof import('element-plus/es')['ElDropdown']
3030
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
31-
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
3231
ElFooter: typeof import('element-plus/es')['ElFooter']
3332
ElForm: typeof import('element-plus/es')['ElForm']
3433
ElFormItem: typeof import('element-plus/es')['ElFormItem']
3534
ElInput: typeof import('element-plus/es')['ElInput']
3635
ElMain: typeof import('element-plus/es')['ElMain']
3736
ElMenu: typeof import('element-plus/es')['ElMenu']
3837
ElOption: typeof import('element-plus/es')['ElOption']
39-
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
4038
ElPagination: typeof import('element-plus/es')['ElPagination']
4139
ElPopover: typeof import('element-plus/es')['ElPopover']
42-
ElRadio: typeof import('element-plus/es')['ElRadio']
43-
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
44-
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
45-
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
4640
ElSelect: typeof import('element-plus/es')['ElSelect']
4741
ElSwitch: typeof import('element-plus/es')['ElSwitch']
48-
ElTable: typeof import('element-plus/es')['ElTable']
49-
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
50-
ElTabPane: typeof import('element-plus/es')['ElTabPane']
51-
ElTabs: typeof import('element-plus/es')['ElTabs']
5242
ElTag: typeof import('element-plus/es')['ElTag']
53-
ElTimeline: typeof import('element-plus/es')['ElTimeline']
54-
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
55-
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
5643
ElTooltip: typeof import('element-plus/es')['ElTooltip']
5744
RouterLink: typeof import('vue-router')['RouterLink']
5845
RouterView: typeof import('vue-router')['RouterView']

services/apps/webhook_api/src/repos/webhooks.repo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ export class WebhooksRepository extends RepositoryBase<WebhooksRepository> {
5757

5858
public async findGroupsIoIntegrationByGroupName(
5959
groupName: string,
60-
): Promise<IDbIntegrationData | null> {
60+
): Promise<(IDbIntegrationData & { settings?: any }) | null> {
6161
const result = await this.db().oneOrNone(
6262
`
63-
select id, "tenantId", platform from integrations
63+
select id, "tenantId", platform, settings from integrations
6464
where platform = $(platform) and "deletedAt" is null
6565
and settings -> 'groups' ? $(groupName)
6666
`,

0 commit comments

Comments
 (0)