import { Body, Controller, Get, HttpException, Param, Post, UseFilters, } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; import { ApiTags } from '@nestjs/swagger'; import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { AuthorizationActions, Sections, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; @ApiTags('Integrations') @Controller('/integrations') export class NoAuthIntegrationsController { constructor( private _integrationManager: IntegrationManager, private _integrationService: IntegrationService, private _refreshIntegrationService: RefreshIntegrationService, private _organizationService: OrganizationService ) {} @Get('/') getIntegrations() { return this._integrationManager.getAllIntegrations(); } @Post('/social-connect/:integration') @CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL]) @UseFilters(new NotEnoughScopesFilter()) async connectSocialMedia( @Param('integration') integration: string, @Body() body: ConnectIntegrationDto ) { if ( !this._integrationManager .getAllowedSocialsIntegrations() .includes(integration) ) { throw new Error('Integration not allowed'); } const integrationProvider = this._integrationManager.getSocialIntegration(integration); const getCodeVerifier = integrationProvider.customFields ? 'none' : await ioRedis.get(`login:${body.state}`); if (!getCodeVerifier) { throw new Error('Invalid state'); } const organization = await ioRedis.get(`organization:${body.state}`); if (!organization) { throw new Error('Organization not found'); } const org = await this._organizationService.getOrgById(organization); if (!integrationProvider.customFields) { await ioRedis.del(`login:${body.state}`); } const details = integrationProvider.externalUrl ? await ioRedis.get(`external:${body.state}`) : undefined; if (details) { await ioRedis.del(`external:${body.state}`); } const refresh = await ioRedis.get(`refresh:${body.state}`); if (refresh) { await ioRedis.del(`refresh:${body.state}`); } const onboarding = await ioRedis.get(`onboarding:${body.state}`); if (onboarding) { await ioRedis.del(`onboarding:${body.state}`); } const { error, accessToken, expiresIn, refreshToken, id, name, picture, username, additionalSettings, // eslint-disable-next-line no-async-promise-executor } = await new Promise(async (res) => { try { const auth = await integrationProvider.authenticate( { code: body.code, codeVerifier: getCodeVerifier, refresh: body.refresh, }, details ? JSON.parse(details) : undefined ); if (typeof auth === 'string') { return res({ error: auth, accessToken: '', id: '', name: '', picture: '', username: '', additionalSettings: [], }); } if (refresh && integrationProvider.reConnect) { console.log('reconnect'); try { const newAuth = await integrationProvider.reConnect( auth.id, refresh, auth.accessToken ); return res({ ...newAuth, refreshToken: body.refresh }); } catch (err: any) { return res({ error: err.message, accessToken: '', id: '', name: '', picture: '', username: '', additionalSettings: [], }); } } return res(auth); } catch (err) { if (err instanceof NotEnoughScopes) { return res({ error: err.message, accessToken: '', id: '', name: '', picture: '', username: '', additionalSettings: [], }); } return res({ error: 'Authentication failed', accessToken: '', id: '', name: '', picture: '', username: '', additionalSettings: [], }); } }); if (error) { throw new NotEnoughScopes(error); } if (!id) { throw new NotEnoughScopes('Invalid API key'); } if (refresh && String(id) !== String(refresh)) { throw new NotEnoughScopes( 'Please refresh the channel that needs to be refreshed' ); } let validName = name; if (!validName) { if (username) { validName = username.split('.')[0] ?? username; } else { validName = `Channel_${String(id).slice(0, 8)}`; } } if ( process.env.STRIPE_PUBLISHABLE_KEY && org.isTrailing && (await this._integrationService.checkPreviousConnections( org.id, String(id) )) ) { throw new HttpException('', 412); } const createUpdate = await this._integrationService.createOrUpdateIntegration( additionalSettings, !!integrationProvider.oneTimeToken, org.id, validName.trim(), picture, 'social', String(id), integration, accessToken, refreshToken, expiresIn, username, refresh ? false : integrationProvider.isBetweenSteps, body.refresh, +body.timezone, details ? AuthService.fixedEncryption(details) : integrationProvider.customFields ? AuthService.fixedEncryption( Buffer.from(body.code, 'base64').toString() ) : integrationProvider.isChromeExtension ? AuthService.fixedEncryption( Buffer.from(body.code, 'base64').toString() ) : undefined ); this._refreshIntegrationService .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider) .catch((err) => { console.log(err); }); // Fetch pages if this is a two-step provider and not a refresh let pages: any[] = []; if (integrationProvider.isBetweenSteps && !refresh) { try { // Check which method the provider uses (pages or companies) const fetchMethod = 'pages' in integrationProvider ? 'pages' : 'companies' in integrationProvider ? 'companies' : null; if (fetchMethod) { // @ts-ignore - dynamic method call pages = await integrationProvider[fetchMethod](accessToken); } } catch (err) { console.log('Failed to fetch pages:', err); } } const webhookUrl = await ioRedis.get(`webhookUrl:${body.state}`); if (webhookUrl) { try { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ params: AuthService.signJWT({ apiKey: org.apiKey, }), }), }); } catch (err) {} await ioRedis.del(`webhookUrl:${body.state}`); } const returnURL = await ioRedis.get(`redirect:${body.state}`); if (returnURL) { await ioRedis.del(`redirect:${body.state}`); } const extensionToken = integrationProvider.isChromeExtension ? AuthService.signJWT({ integrationId: createUpdate.id, organizationId: org.id, internalId: String(id), provider: integration, }) : undefined; // Never leak stored credentials (signed/encrypted secrets) back to the // caller. These columns hold the integration access token, refresh token // and encrypted custom instance details and must stay server-side. const { token: _token, refreshToken: _refreshToken, customInstanceDetails: _customInstanceDetails, ...safeIntegration } = createUpdate as any; return { ...safeIntegration, onboarding: onboarding === 'true', pages, ...(returnURL ? { returnURL } : {}), ...(extensionToken ? { extensionToken } : {}), }; } @Post('/public/provider/:id/connect') async saveProviderPage(@Param('id') id: string, @Body() body: any) { if (!body.state) { throw new Error('Invalid state'); } const organization = await ioRedis.get(`organization:${body.state}`); if (!organization) { throw new Error('Organization not found'); } const org = await this._organizationService.getOrgById(organization); return this._integrationService.saveProviderPage(org.id, id, body); } @Post('/extension-refresh') async extensionRefreshCookies( @Body() body: { jwt: string; cookies: string } ) { let payload: any; try { payload = AuthService.verifyJWT(body.jwt); } catch { throw new HttpException('Invalid token', 401); } const { integrationId, organizationId, internalId, provider } = payload; if (!integrationId || !organizationId || !internalId || !provider) { throw new HttpException('Invalid token payload', 400); } const integration = await this._integrationService.getIntegrationById( organizationId, integrationId ); if (!integration || integration.internalId !== internalId) { throw new HttpException('Integration not found', 404); } const integrationProvider = this._integrationManager.getSocialIntegration(provider); if (!integrationProvider?.isChromeExtension) { throw new HttpException('Not a Chrome extension integration', 400); } const authResult = await integrationProvider.authenticate({ code: body.cookies, codeVerifier: '', }); if (typeof authResult === 'string') { throw new HttpException(authResult, 400); } if (String(authResult.id) !== String(integration.internalId)) { await this._integrationService.refreshNeeded( organizationId, integrationId ); return { success: false, reason: 'account_mismatch' }; } await this._integrationService.createOrUpdateIntegration( undefined, false, organizationId, integration.name, undefined, 'social', integration.internalId, integration.providerIdentifier, authResult.accessToken, '', authResult.expiresIn, undefined, false, undefined, undefined, AuthService.signJWT( JSON.parse(Buffer.from(body.cookies, 'base64').toString()) ) ); return { success: true }; } }