From 3f08e81b934ba49d10af48a558c654fb662e2c9a Mon Sep 17 00:00:00 2001 From: David Anyatonwu Date: Sat, 11 Jul 2026 13:37:42 +0100 Subject: [PATCH] feat(x): support OAuth 2.0 authentication --- .env.example | 2 + .../src/api/routes/enterprise.controller.ts | 20 +- .../src/api/routes/integrations.controller.ts | 21 +- .../routes/no.auth.integrations.controller.ts | 12 +- .../v1/public.integrations.controller.ts | 20 +- .../launches/add.provider.component.tsx | 175 ++++++++-- .../launches/continue.integration.tsx | 2 +- docker-compose.yaml | 2 + .../src/integrations/integration.manager.ts | 43 ++- .../src/integrations/provider.capabilities.ts | 32 ++ .../src/integrations/social/x.provider.ts | 304 +++++++++++++----- 11 files changed, 509 insertions(+), 124 deletions(-) create mode 100644 libraries/nestjs-libraries/src/integrations/provider.capabilities.ts diff --git a/.env.example b/.env.example index b6f600ddac..864e5111c3 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ STORAGE_PROVIDER="local" X_URL="" X_API_KEY="" X_API_SECRET="" +X_CLIENT_ID="" +X_CLIENT_SECRET="" LINKEDIN_CLIENT_ID="" LINKEDIN_CLIENT_SECRET="" REDDIT_CLIENT_ID="" diff --git a/apps/backend/src/api/routes/enterprise.controller.ts b/apps/backend/src/api/routes/enterprise.controller.ts index 5e9bbdc806..e4ae6c0995 100644 --- a/apps/backend/src/api/routes/enterprise.controller.ts +++ b/apps/backend/src/api/routes/enterprise.controller.ts @@ -51,6 +51,7 @@ export class EnterpriseController { refreshId?: string; provider: string; webhookUrl: string; + authMode?: string; }; if (!load || !load.redirectUrl || !load.apiKey || !load.provider) { @@ -75,8 +76,25 @@ export class EnterpriseController { load.provider ); + const selectableAuthProvider = + this._integrationManager.getSelectableAuthProvider(load.provider); + const previousIntegration = + selectableAuthProvider?.getAuthMode && load.refreshId + ? (await this._integrationService.getIntegrationsList(org.id)).find( + (item) => item.internalId === load.refreshId + ) + : undefined; + const resolvedAuthMode = + load.authMode || + (previousIntegration + ? selectableAuthProvider?.getAuthMode?.(previousIntegration) + : undefined); const { codeVerifier, state, url } = - await integrationProvider.generateAuthUrl(); + selectableAuthProvider && resolvedAuthMode + ? await selectableAuthProvider.generateAuthUrlForMode( + resolvedAuthMode + ) + : await integrationProvider.generateAuthUrl(); if (load.refreshId) { await ioRedis.set(`refresh:${state}`, load.refreshId, 'EX', 3600); diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 024a890432..62ea69f48b 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -198,6 +198,7 @@ export class IntegrationsController { @Query('externalUrl') externalUrl: string, @Query('redirectUrl') redirectUrl: string, @Query('onboarding') onboarding: string, + @Query('authMode') authMode: string, @GetOrgFromRequest() org: Organization ) { if ( @@ -223,8 +224,26 @@ export class IntegrationsController { } : undefined; + const selectableAuthProvider = + this._integrationManager.getSelectableAuthProvider(integration); + const previousIntegration = + selectableAuthProvider?.getAuthMode && refresh + ? (await this._integrationService.getIntegrationsList(org.id)).find( + (item) => item.internalId === refresh + ) + : undefined; + const resolvedAuthMode = + authMode || + (previousIntegration + ? selectableAuthProvider?.getAuthMode?.(previousIntegration) + : undefined); const { codeVerifier, state, url } = - await integrationProvider.generateAuthUrl(getExternalUrl); + selectableAuthProvider && resolvedAuthMode + ? await selectableAuthProvider.generateAuthUrlForMode( + resolvedAuthMode, + getExternalUrl + ) + : await integrationProvider.generateAuthUrl(getExternalUrl); if (refresh) { await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600); diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 3f048937ce..a2628f6ebc 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -239,11 +239,13 @@ export class NoAuthIntegrationsController { : undefined ); - this._refreshIntegrationService - .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider) - .catch((err) => { - console.log(err); - }); + if (refreshToken) { + 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[] = []; diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 5cb53b90d3..c07fe8733d 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -328,6 +328,7 @@ export class PublicIntegrationsController { async getIntegrationUrl( @Param('integration') integration: string, @Query('refresh') refresh: string, + @Query('authMode') authMode: string, @GetOrgFromRequest() org: Organization ) { Sentry.metrics.count('public_api-request', 1); @@ -352,8 +353,25 @@ export class PublicIntegrationsController { } try { + const selectableAuthProvider = + this._integrationManager.getSelectableAuthProvider(integration); + const previousIntegration = + selectableAuthProvider?.getAuthMode && refresh + ? (await this._integrationService.getIntegrationsList(org.id)).find( + (item) => item.internalId === refresh + ) + : undefined; + const resolvedAuthMode = + authMode || + (previousIntegration + ? selectableAuthProvider?.getAuthMode?.(previousIntegration) + : undefined); const { codeVerifier, state, url } = - await integrationProvider.generateAuthUrl(); + selectableAuthProvider && resolvedAuthMode + ? await selectableAuthProvider.generateAuthUrlForMode( + resolvedAuthMode + ) + : await integrationProvider.generateAuthUrl(); if (refresh) { await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600); diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 9ff651acb7..b076fa62cc 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -1,7 +1,7 @@ 'use client'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { Input } from '@gitroom/react/form/input'; import { FieldValues, FormProvider, useForm } from 'react-hook-form'; @@ -21,6 +21,35 @@ import copy from 'copy-to-clipboard'; import { capitalize } from 'lodash'; const resolver = classValidatorResolver(ApiKeyDto); +type ProviderAuthOption = { + id: string; + title: string; + description: string; + capabilities: string[]; + recommended?: boolean; +}; + +type SocialProviderItem = { + identifier: string; + name: string; + toolTip?: string; + isExternal: boolean; + isWeb3: boolean; + isChromeExtension?: boolean; + extensionCookies?: Array<{ + name: string; + domain: string; + }>; + authOptions?: ProviderAuthOption[]; + customFields?: Array<{ + key: string; + label: string; + validation: string; + type: 'text' | 'password'; + hint?: string; + }>; +}; + export const useAddProvider = (update?: () => void, invite?: boolean) => { const modal = useModals(); const fetch = useFetch(); @@ -377,25 +406,7 @@ const ChromeExtensionWarning: FC<{ }; export const AddProviderComponent: FC<{ - social: Array<{ - identifier: string; - name: string; - toolTip?: string; - isExternal: boolean; - isWeb3: boolean; - isChromeExtension?: boolean; - extensionCookies?: Array<{ - name: string; - domain: string; - }>; - customFields?: Array<{ - key: string; - label: string; - validation: string; - type: 'text' | 'password'; - hint?: string; - }>; - }>; + social: SocialProviderItem[]; article: Array<{ identifier: string; name: string; @@ -411,6 +422,12 @@ export const AddProviderComponent: FC<{ const router = useRouter(); const fetch = useFetch(); const modal = useModals(); + const t = useT(); + const [authSelection, setAuthSelection] = useState<{ + invite: boolean; + provider: SocialProviderItem; + }>(); + const [selectedAuthMode, setSelectedAuthMode] = useState(''); const getSocialLink = useCallback( ( invite: boolean, @@ -425,9 +442,25 @@ export const AddProviderComponent: FC<{ defaultValue?: string; type: 'text' | 'password'; hint?: string; - }> + }>, + authOptions?: ProviderAuthOption[], + authMode?: string ) => async () => { + if (authOptions && authOptions.length > 1 && !authMode) { + const provider = social.find( + (item) => item.identifier === identifier + )!; + setSelectedAuthMode( + authOptions.find((option) => option.recommended)?.id || + authOptions[0].id + ); + setAuthSelection({ invite, provider }); + return; + } + + const resolvedAuthMode = + authMode || (authOptions?.length === 1 ? authOptions[0].id : ''); const onboardingParam = onboarding ? 'onboarding=true' : ''; const openWeb3 = async () => { const { component: Web3Providers } = web3List.find( @@ -475,6 +508,9 @@ export const AddProviderComponent: FC<{ isMobile ? `redirectUrl=${encodeURIComponent('postiz://integrations')}` : '', + resolvedAuthMode + ? `authMode=${encodeURIComponent(resolvedAuthMode)}` + : '', ] .filter(Boolean) .join('&'); @@ -664,10 +700,100 @@ export const AddProviderComponent: FC<{ } await gotoIntegration(); }, - [onboarding] + [onboarding, social] ); - const t = useT(); + if (authSelection) { + const { provider, invite } = authSelection; + return ( +
+
+ +
+
+ {t('connect_provider', 'Connect {{provider}}', { + provider: provider.name, + })} +
+
+ {t( + 'choose_authentication_method', + 'Choose how this channel should connect.' + )} +
+
+
+ +
+ {provider.authOptions!.map((option) => { + const selected = selectedAuthMode === option.id; + return ( + + ); + })} +
+ +
+ + +
+
+ ); + } return (
@@ -702,7 +828,8 @@ export const AddProviderComponent: FC<{ item.isExternal, item.isWeb3, item.isChromeExtension, - item.customFields + item.customFields, + item.authOptions )} {...(!!item.toolTip ? { diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx index 0240dc2d41..05210b5844 100644 --- a/apps/frontend/src/components/launches/continue.integration.tsx +++ b/apps/frontend/src/components/launches/continue.integration.tsx @@ -64,7 +64,7 @@ export const ContinueIntegration: FC<{ refresh: searchParams.refresh || '', }; } - if (provider === 'x') { + if (provider === 'x' && searchParams.oauth_token) { return { state: searchParams.oauth_token || '', code: searchParams.oauth_verifier || '', diff --git a/docker-compose.yaml b/docker-compose.yaml index 6d7d57e6f9..6f6016022c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,8 @@ services: X_URL: '' X_API_KEY: '' X_API_SECRET: '' + X_CLIENT_ID: '' + X_CLIENT_SECRET: '' LINKEDIN_CLIENT_ID: '' LINKEDIN_CLIENT_SECRET: '' REDDIT_CLIENT_ID: '' diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 9229e019a1..02c3b91ec0 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -37,6 +37,10 @@ import { SkoolProvider } from '@gitroom/nestjs-libraries/integrations/social/sko import { WhopProvider } from '@gitroom/nestjs-libraries/integrations/social/whop.provider'; import { MeweProvider } from '@gitroom/nestjs-libraries/integrations/social/mewe.provider'; import { TumblrProvider } from '@gitroom/nestjs-libraries/integrations/social/tumblr.provider'; +import { + CapableSocialProvider, + supportsSelectableAuth, +} from '@gitroom/nestjs-libraries/integrations/provider.capabilities'; export const socialIntegrationList: Array = [ new XProvider(), @@ -81,19 +85,25 @@ export class IntegrationManager { async getAllIntegrations() { return { social: await Promise.all( - socialIntegrationList.map(async (p) => ({ - name: p.name, - identifier: p.identifier, - toolTip: p.toolTip, - editor: p.editor, - isExternal: !!p.externalUrl, - isWeb3: !!p.isWeb3, - isChromeExtension: !!p.isChromeExtension, - ...(p.extensionCookies - ? { extensionCookies: p.extensionCookies } - : {}), - ...(p.customFields ? { customFields: await p.customFields() } : {}), - })) + socialIntegrationList.map(async (p) => { + const provider = p as CapableSocialProvider; + return { + name: p.name, + identifier: p.identifier, + toolTip: p.toolTip, + editor: p.editor, + isExternal: !!p.externalUrl, + isWeb3: !!p.isWeb3, + isChromeExtension: !!p.isChromeExtension, + ...(p.extensionCookies + ? { extensionCookies: p.extensionCookies } + : {}), + ...(p.customFields ? { customFields: await p.customFields() } : {}), + ...(supportsSelectableAuth(provider) + ? { authOptions: provider.authOptions } + : {}), + }; + }) ), article: [] as any[], }; @@ -174,4 +184,11 @@ export class IntegrationManager { getSocialIntegration(integration: string): SocialProvider { return socialIntegrationList.find((i) => i.identifier === integration)!; } + + getSelectableAuthProvider(integration: string) { + const provider = socialIntegrationList.find( + (item) => item.identifier === integration + ) as CapableSocialProvider | undefined; + return provider && supportsSelectableAuth(provider) ? provider : undefined; + } } diff --git a/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts b/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts new file mode 100644 index 0000000000..85b2c81372 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts @@ -0,0 +1,32 @@ +import { Integration } from '@prisma/client'; +import { + ClientInformation, + GenerateAuthUrlResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; + +export type ProviderAuthOption = { + id: string; + title: string; + description: string; + capabilities: string[]; + recommended?: boolean; +}; + +export interface SelectableAuthProvider { + authOptions: ProviderAuthOption[]; + getAuthMode?(integration: Integration): string; + generateAuthUrlForMode( + authMode: string, + clientInformation?: ClientInformation + ): Promise; +} + +export type CapableSocialProvider = SocialProvider & + Partial; + +export const supportsSelectableAuth = ( + provider: CapableSocialProvider +): provider is CapableSocialProvider & SelectableAuthProvider => + Array.isArray(provider.authOptions) && + typeof provider.generateAuthUrlForMode === 'function'; diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 22a85f73d4..36d4ffc2fb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -22,6 +22,16 @@ import { stripLinks as removeLinks } from '@gitroom/helpers/utils/strip.links'; import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto'; import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { ProviderAuthOption } from '@gitroom/nestjs-libraries/integrations/provider.capabilities'; + +const X_OAUTH2_PREFIX = 'oauth2:'; +const X_OAUTH2_SCOPES = [ + 'tweet.read', + 'tweet.write', + 'users.read', + 'media.write', + 'offline.access', +] as const; @Rules( `X can have maximum 4 pictures, or maximum one video, it can also be without attachments ${ @@ -34,6 +44,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { identifier = 'x'; name = 'X'; isBetweenSteps = false; + refreshCron = true; scopes = [] as string[]; stripLinks = () => !!process.env.STRIP_LINKS_FROM_X_POSTS; override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours) @@ -43,6 +54,36 @@ export class XProvider extends SocialAbstract implements SocialProvider { editor = 'normal' as const; dto = XDto; + get authOptions(): ProviderAuthOption[] { + const oauth1: ProviderAuthOption = { + id: 'oauth1', + title: 'Legacy publishing', + description: + 'Connect with OAuth 1.0a for publishing and existing X features.', + capabilities: ['publishing', 'analytics'], + }; + + const oauth2: ProviderAuthOption = { + id: 'oauth2', + title: 'OAuth 2.0', + description: + 'Connect with OAuth 2.0 for publishing and refreshable access.', + capabilities: ['publishing', 'analytics'], + recommended: true, + }; + + return [ + ...(process.env.X_CLIENT_ID && process.env.X_CLIENT_SECRET + ? [oauth2] + : []), + ...(process.env.X_API_KEY && process.env.X_API_SECRET ? [oauth1] : []), + ]; + } + + getAuthMode(integration: Integration) { + return integration.token.startsWith(X_OAUTH2_PREFIX) ? 'oauth2' : 'oauth1'; + } + maxLength(additionalSettings?: any) { // Accepts either the parsed additionalSettings array (from validation) or a // plain boolean (legacy callers). "Verified" => premium => higher limit. @@ -94,7 +135,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { return { type: 'bad-body', value: 'You are not allowed to create a post with duplicate content', - } + }; } if (body.includes('usage-capped')) { @@ -121,8 +162,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { if (body.includes('Your account is not permitted to access this feature')) { return { type: 'bad-body', - value: - 'X blocked your request', + value: 'X blocked your request', }; } if (body.includes('The Tweet contains an invalid URL.')) { @@ -168,15 +208,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { id: string, fields: { likesAmount: string } ) { - // @ts-ignore - // eslint-disable-next-line prefer-rest-params - const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(integration.token); if ( (await client.v2.tweetLikedBy(id)).meta.result_count >= @@ -203,13 +235,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { postId: string, information: any ) { - const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(integration.token); const { data: { id }, @@ -252,15 +278,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { id: string, fields: { likesAmount: string; post: string } ) { - // @ts-ignore - // eslint-disable-next-line prefer-rest-params - const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(integration.token); if ( (await client.v2.tweetLikedBy(id)).meta.result_count >= @@ -279,33 +297,62 @@ export class XProvider extends SocialAbstract implements SocialProvider { return false; } - async refreshToken(): Promise { - return { - id: '', - name: '', - accessToken: '', - refreshToken: '', - expiresIn: 0, - picture: '', - username: '', - }; + async generateAuthUrl() { + const options = this.authOptions; + const defaultAuthMode = + options.find((option) => option.id === 'oauth1')?.id || options[0]?.id; + + if (!defaultAuthMode) { + throw new Error('X authentication is not configured'); + } + + return this.generateAuthUrlForMode(defaultAuthMode); } - async generateAuthUrl() { + async generateAuthUrlForMode(authMode: string) { + const callbackUrl = this.getCallbackUrl(); + + if (authMode === 'oauth2') { + if (!process.env.X_CLIENT_ID || !process.env.X_CLIENT_SECRET) { + throw new Error('X OAuth 2.0 is not configured'); + } + + const client = new TwitterApi({ + clientId: process.env.X_CLIENT_ID, + clientSecret: process.env.X_CLIENT_SECRET, + }); + const { url, codeVerifier, state } = client.generateOAuth2AuthLink( + callbackUrl, + { + scope: [...X_OAUTH2_SCOPES], + } + ); + + return { + url, + state, + codeVerifier: X_OAUTH2_PREFIX + codeVerifier, + }; + } + + if (authMode !== 'oauth1') { + throw new Error('Unsupported X authentication mode'); + } + + if (!process.env.X_API_KEY || !process.env.X_API_SECRET) { + throw new Error('X OAuth 1.0a is not configured'); + } + const client = new TwitterApi({ appKey: process.env.X_API_KEY!, appSecret: process.env.X_API_SECRET!, }); const { url, oauth_token, oauth_token_secret } = - await client.generateAuthLink( - (process.env.X_URL || process.env.FRONTEND_URL) + - `/integrations/social/x`, - { - authAccessType: 'write', - linkMode: 'authenticate', - forceLogin: false, - } - ); + await client.generateAuthLink(callbackUrl, { + authAccessType: 'write', + linkMode: 'authenticate', + forceLogin: false, + }); return { url, codeVerifier: oauth_token + ':' + oauth_token_secret, @@ -315,6 +362,55 @@ export class XProvider extends SocialAbstract implements SocialProvider { async authenticate(params: { code: string; codeVerifier: string }) { const { code, codeVerifier } = params; + + if (codeVerifier.startsWith(X_OAUTH2_PREFIX)) { + const startingClient = new TwitterApi({ + clientId: process.env.X_CLIENT_ID!, + clientSecret: process.env.X_CLIENT_SECRET!, + }); + const { accessToken, refreshToken, expiresIn, client, scope } = + await startingClient.loginWithOAuth2({ + code, + codeVerifier: codeVerifier.slice(X_OAUTH2_PREFIX.length), + redirectUri: this.getCallbackUrl(), + }); + + this.checkScopes([...X_OAUTH2_SCOPES], scope); + + const { + data: { username, verified, profile_image_url, name, id }, + } = await client.v2.me({ + 'user.fields': [ + 'username', + 'verified', + 'verified_type', + 'profile_image_url', + 'name', + ], + }); + if (!refreshToken) { + throw new Error('X did not return an OAuth 2.0 refresh token'); + } + + return { + id: String(id), + accessToken: X_OAUTH2_PREFIX + accessToken, + name, + refreshToken: X_OAUTH2_PREFIX + refreshToken, + expiresIn, + picture: profile_image_url || '', + username, + additionalSettings: [ + { + title: 'Verified', + description: 'Is this a verified user? (Premium)', + type: 'checkbox' as const, + value: verified, + }, + ], + }; + } + const [oauth_token, oauth_token_secret] = codeVerifier.split(':'); const startingClient = new TwitterApi({ @@ -359,7 +455,57 @@ export class XProvider extends SocialAbstract implements SocialProvider { }; } + async refreshToken(refreshToken: string): Promise { + if (!refreshToken?.startsWith(X_OAUTH2_PREFIX)) { + return { + id: '', + name: '', + accessToken: '', + refreshToken: '', + expiresIn: 0, + picture: '', + username: '', + }; + } + + const startingClient = new TwitterApi({ + clientId: process.env.X_CLIENT_ID!, + clientSecret: process.env.X_CLIENT_SECRET!, + }); + const { + accessToken, + refreshToken: nextRefreshToken, + expiresIn, + client, + scope, + } = await startingClient.refreshOAuth2Token( + refreshToken.slice(X_OAUTH2_PREFIX.length) + ); + this.checkScopes([...X_OAUTH2_SCOPES], scope); + const { + data: { username, profile_image_url, name, id }, + } = await client.v2.me({ + 'user.fields': ['username', 'profile_image_url', 'name'], + }); + + return { + id: String(id), + name, + accessToken: X_OAUTH2_PREFIX + accessToken, + refreshToken: + X_OAUTH2_PREFIX + + (nextRefreshToken || refreshToken.slice(X_OAUTH2_PREFIX.length)), + expiresIn, + picture: profile_image_url || '', + username, + }; + } + private async getClient(accessToken: string) { + if (accessToken.startsWith(X_OAUTH2_PREFIX)) { + return new TwitterApi(accessToken.slice(X_OAUTH2_PREFIX.length)); + } + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); return new TwitterApi({ appKey: process.env.X_API_KEY!, @@ -369,6 +515,28 @@ export class XProvider extends SocialAbstract implements SocialProvider { }); } + private getCallbackUrl() { + const baseUrl = process.env.X_URL || process.env.FRONTEND_URL; + if (!baseUrl) { + throw new Error('X callback URL is not configured'); + } + + return `${baseUrl.replace(/\/$/, '')}/integrations/social/x`; + } + + private getAuthorizationHeader( + method: string, + url: string, + accessToken: string + ) { + if (accessToken.startsWith(X_OAUTH2_PREFIX)) { + return `Bearer ${accessToken.slice(X_OAUTH2_PREFIX.length)}`; + } + + const [token, secret] = accessToken.split(':'); + return this.signOAuth1(method, url, token, secret); + } + private signOAuth1( method: string, url: string, @@ -480,7 +648,6 @@ export class XProvider extends SocialAbstract implements SocialProvider { }>[], integration: Integration ): Promise { - const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); const client = await this.getClient(accessToken); const [firstPost] = postDetails; @@ -510,17 +677,18 @@ export class XProvider extends SocialAbstract implements SocialProvider { : firstPost.message, ...(media_ids.length ? { media: { media_ids } } : {}), made_with_ai: this.assetBoolean(firstPost?.settings?.made_with_ai), - paid_partnership: this.assetBoolean(firstPost?.settings?.paid_partnership), + paid_partnership: this.assetBoolean( + firstPost?.settings?.paid_partnership + ), }; const tweetResponse = await this.fetch(tweetUrl, { method: 'POST', headers: { - Authorization: this.signOAuth1( + Authorization: this.getAuthorizationHeader( 'POST', tweetUrl, - accessTokenSplit, - accessSecretSplit + accessToken ), 'Content-Type': 'application/json', }, @@ -553,7 +721,6 @@ export class XProvider extends SocialAbstract implements SocialProvider { }>[], integration: Integration ): Promise { - const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); const client = await this.getClient(accessToken); const [commentPost] = postDetails; @@ -580,11 +747,10 @@ export class XProvider extends SocialAbstract implements SocialProvider { const tweetResponse = await this.fetch(tweetUrl, { method: 'POST', headers: { - Authorization: this.signOAuth1( + Authorization: this.getAuthorizationHeader( 'POST', tweetUrl, - accessTokenSplit, - accessSecretSplit + accessToken ), 'Content-Type': 'application/json', }, @@ -650,13 +816,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { const until = dayjs().endOf('day'); const since = dayjs().subtract(date > 100 ? 100 : date, 'day'); - const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(accessToken); try { const tweets = uniqBy( @@ -740,13 +900,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { const today = dayjs().format('YYYY-MM-DD'); - const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(accessToken); try { // Fetch the specific tweet with public metrics @@ -819,13 +973,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { } override async mention(token: string, d: { query: string }) { - const [accessTokenSplit, accessSecretSplit] = token.split(':'); - const client = new TwitterApi({ - appKey: process.env.X_API_KEY!, - appSecret: process.env.X_API_SECRET!, - accessToken: accessTokenSplit, - accessSecret: accessSecretSplit, - }); + const client = await this.getClient(token); try { const data = await client.v2.userByUsername(d.query, {