Skip to content

Commit 68941e2

Browse files
DCO Sign
Signed-off-by: Darshan Thakare <143271270+DarshanCode2005@users.noreply.github.com>
1 parent baf42fd commit 68941e2

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
@@ -1507,20 +1508,51 @@ export default class IntegrationService {
15071508

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

15131513
try {
15141514
this.options.log.info('Creating Groups.io integration!')
1515+
1516+
// Try to get existing integration to preserve encrypted password
1517+
let existingSettings: any = {}
1518+
try {
1519+
const existingIntegration = await IntegrationRepository.findByPlatform(PlatformType.GROUPSIO, {
1520+
...this.options,
1521+
transaction,
1522+
})
1523+
existingSettings = existingIntegration?.settings || {}
1524+
} catch (err) {
1525+
// Integration doesn't exist yet, that's fine
1526+
}
1527+
1528+
// Prepare settings
1529+
const settings: any = {
1530+
email: integrationData.email,
1531+
token: integrationData.token,
1532+
groups: integrationData.groupNames,
1533+
updateMemberAttributes: true,
1534+
lastTokenRefresh: Date.now(),
1535+
}
1536+
1537+
// Encrypt and store password if provided
1538+
if (integrationData.password) {
1539+
const encryptionKey = process.env.GROUPSIO_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
1540+
try {
1541+
settings.encryptedPassword = encrypt(integrationData.password, encryptionKey)
1542+
this.options.log.info('Password encrypted and stored for Groups.io integration')
1543+
} catch (encryptErr) {
1544+
this.options.log.error(encryptErr, 'Failed to encrypt password for Groups.io integration')
1545+
// Continue without storing password - user will need to re-authenticate manually
1546+
}
1547+
} else if (existingSettings?.encryptedPassword) {
1548+
// Preserve existing encrypted password if not updating
1549+
settings.encryptedPassword = existingSettings.encryptedPassword
1550+
}
1551+
15151552
integration = await this.createOrUpdate(
15161553
{
15171554
platform: PlatformType.GROUPSIO,
1518-
settings: {
1519-
email: integrationData.email,
1520-
token: integrationData.token,
1521-
groups: integrationData.groupNames,
1522-
updateMemberAttributes: true,
1523-
},
1555+
settings,
15241556
status: 'in-progress',
15251557
},
15261558
transaction,
@@ -1567,16 +1599,47 @@ export default class IntegrationService {
15671599
response = await axios(config)
15681600

15691601
// we need to get cookie from the response
1602+
if (!response.headers['set-cookie'] || !response.headers['set-cookie'][0]) {
1603+
this.options.log.error({ email: data.email }, 'No set-cookie header in Groups.io login response')
1604+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1605+
}
15701606

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

1609+
if (!cookie) {
1610+
this.options.log.error({ email: data.email }, 'Invalid cookie format in Groups.io login response')
1611+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1612+
}
1613+
15731614
return {
15741615
groupsioCookie: cookie,
15751616
}
15761617
} catch (err) {
1577-
if ('two_factor_required' in response.data) {
1578-
throw new Error400(this.options.language, 'errors.groupsio.twoFactorRequired')
1618+
// Check if it's an axios error with response data
1619+
if (err.response && err.response.data) {
1620+
if ('two_factor_required' in err.response.data) {
1621+
throw new Error400(this.options.language, 'errors.groupsio.twoFactorRequired')
1622+
}
1623+
// Check for other specific error messages
1624+
if (err.response.status === 401 || err.response.status === 403) {
1625+
this.options.log.error(
1626+
{ email: data.email, status: err.response.status },
1627+
'Authentication failed for Groups.io login',
1628+
)
1629+
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
1630+
}
15791631
}
1632+
1633+
// If it's already an Error400, re-throw it
1634+
if (err instanceof Error400) {
1635+
throw err
1636+
}
1637+
1638+
// For network errors or other unexpected errors
1639+
this.options.log.error(
1640+
{ email: data.email, error: err.message },
1641+
'Unexpected error during Groups.io login',
1642+
)
15801643
throw new Error400(this.options.language, 'errors.groupsio.invalidCredentials')
15811644
}
15821645
}
@@ -1605,4 +1668,114 @@ export default class IntegrationService {
16051668
throw new Error400(this.options.language, 'errors.groupsio.invalidGroup')
16061669
}
16071670
}
1671+
1672+
/**
1673+
* Refreshes the Groups.io cookie for an integration using stored credentials
1674+
* @param integrationId - The integration ID to refresh the cookie for
1675+
* @returns The new cookie string
1676+
* @throws Error400 if credentials are missing, decryption fails, or authentication fails
1677+
*/
1678+
async refreshGroupsioCookie(integrationId: string): Promise<string> {
1679+
this.options.log.info({ integrationId }, 'Refreshing Groups.io cookie')
1680+
1681+
// Get the integration
1682+
const integration = await IntegrationRepository.findById(integrationId, this.options)
1683+
if (!integration) {
1684+
throw new Error404(this.options.language, 'errors.integration.notFound')
1685+
}
1686+
1687+
const settings = integration.settings as any
1688+
if (!settings) {
1689+
throw new Error400(this.options.language, 'errors.groupsio.invalidSettings')
1690+
}
1691+
1692+
// Check if we have encrypted password
1693+
if (!settings.encryptedPassword) {
1694+
this.options.log.error(
1695+
{ integrationId },
1696+
'Cannot refresh Groups.io cookie: no encrypted password stored',
1697+
)
1698+
throw new Error400(
1699+
this.options.language,
1700+
'errors.groupsio.noStoredCredentials',
1701+
'No stored credentials available. Please reconnect the integration.',
1702+
)
1703+
}
1704+
1705+
if (!settings.email) {
1706+
throw new Error400(this.options.language, 'errors.groupsio.missingEmail')
1707+
}
1708+
1709+
// Decrypt password
1710+
const encryptionKey = process.env.GROUPSIO_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
1711+
let password: string
1712+
try {
1713+
password = decrypt(settings.encryptedPassword, encryptionKey)
1714+
} catch (decryptErr) {
1715+
this.options.log.error(decryptErr, { integrationId }, 'Failed to decrypt Groups.io password')
1716+
throw new Error400(
1717+
this.options.language,
1718+
'errors.groupsio.decryptionFailed',
1719+
'Failed to decrypt stored credentials. Please reconnect the integration.',
1720+
)
1721+
}
1722+
1723+
// Get new token
1724+
let newCookie: string
1725+
try {
1726+
const tokenResult = await this.groupsioGetToken({
1727+
email: settings.email,
1728+
password,
1729+
})
1730+
newCookie = tokenResult.groupsioCookie
1731+
} catch (err) {
1732+
// Check if it's a 2FA error
1733+
if (err instanceof Error400 && err.message?.includes('twoFactorRequired')) {
1734+
this.options.log.warn(
1735+
{ integrationId },
1736+
'Groups.io cookie refresh failed: 2FA required. Integration needs manual update.',
1737+
)
1738+
throw new Error400(
1739+
this.options.language,
1740+
'errors.groupsio.twoFactorRequired',
1741+
'Two-factor authentication is required. Please reconnect the integration with your 2FA code.',
1742+
)
1743+
}
1744+
1745+
this.options.log.error(err, { integrationId }, 'Failed to refresh Groups.io cookie')
1746+
throw new Error400(
1747+
this.options.language,
1748+
'errors.groupsio.refreshFailed',
1749+
'Failed to refresh authentication. Please check your credentials and reconnect the integration.',
1750+
)
1751+
}
1752+
1753+
// Update integration settings with new cookie
1754+
const transaction = await SequelizeRepository.createTransaction(this.options)
1755+
try {
1756+
await this.update(
1757+
integrationId,
1758+
{
1759+
settings: {
1760+
...settings,
1761+
token: newCookie,
1762+
lastTokenRefresh: Date.now(),
1763+
},
1764+
},
1765+
transaction,
1766+
)
1767+
await SequelizeRepository.commitTransaction(transaction)
1768+
1769+
this.options.log.info(
1770+
{ integrationId, refreshTime: new Date().toISOString() },
1771+
'Groups.io cookie refreshed successfully',
1772+
)
1773+
} catch (updateErr) {
1774+
await SequelizeRepository.rollbackTransaction(transaction)
1775+
this.options.log.error(updateErr, { integrationId }, 'Failed to update integration with new cookie')
1776+
throw updateErr
1777+
}
1778+
1779+
return newCookie
1780+
}
16081781
}

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)