Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions apps/bubble-studio/src/lib/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const SERVICE_LOGOS: Readonly<Record<string, string>> = Object.freeze({
YouTube: '/integrations/youtube.svg',
Instagram: '/integrations/instagram.svg',
Apify: '/integrations/apify.svg',
'X (Twitter)': '/integrations/x-twitter.png',
X: '/integrations/x-twitter.png',
Twitter: '/integrations/x-twitter.png',

// AI models (also used as fallbacks for vendor names)
GPT: '/integrations/gpt.svg',
Expand Down Expand Up @@ -58,6 +61,7 @@ export const INTEGRATIONS: IntegrationLogo[] = [
{ name: 'YouTube', file: SERVICE_LOGOS['YouTube'] },
{ name: 'Instagram', file: SERVICE_LOGOS['Instagram'] },
{ name: 'Apify', file: SERVICE_LOGOS['Apify'] },
{ name: 'X (Twitter)', file: SERVICE_LOGOS['X (Twitter)'] },
];

export const AI_MODELS: IntegrationLogo[] = [
Expand Down Expand Up @@ -97,6 +101,11 @@ const NAME_ALIASES: Readonly<Record<string, string>> = Object.freeze({
youtube: 'YouTube',
instagram: 'Instagram',
apify: 'Apify',
'x-twitter': 'X (Twitter)',
'x-twitter-bubble': 'X (Twitter)',
xtwitter: 'X (Twitter)',
twitter: 'X (Twitter)',
x: 'X (Twitter)',
'research-agent': 'Research Agent',
'research-agent-tool': 'Research Agent',
research: 'Research Agent',
Expand Down Expand Up @@ -174,6 +183,7 @@ export function findLogoForBubble(
[/\byoutube\b/, 'YouTube'],
[/\binstagram\b/, 'Instagram'],
[/\bapify\b/, 'Apify'],
[/\b(x|twitter|x-twitter|xtwitter)\b/, 'X (Twitter)'],
[/\bopenai\b|\bgpt\b/, 'GPT'],
[/\banthropic\b|\bclaude\b/, 'Claude'],
[/\bgemini\b/, 'Gemini'],
Expand Down Expand Up @@ -233,6 +243,7 @@ export function findDocsUrlForBubble(bubble: MinimalBubble): string | null {
slackbubble: 'slack-bubble',
slackformatteragentbubble: 'slack-formatter-agent-bubble',
storagebubble: 'storage-bubble',
xtwitterbubble: 'x-twitter-bubble',
}
);

Expand Down Expand Up @@ -272,6 +283,10 @@ export function findDocsUrlForBubble(bubble: MinimalBubble): string | null {
'slack-formatter-agent': 'slack-formatter-agent-bubble',
slackformatteragent: 'slack-formatter-agent-bubble',
storage: 'storage-bubble',
'x-twitter': 'x-twitter-bubble',
xtwitter: 'x-twitter-bubble',
twitter: 'x-twitter-bubble',
x: 'x-twitter-bubble',
});

// Known tool docs keyed by bubbleName variants
Expand Down
11 changes: 11 additions & 0 deletions apps/bubble-studio/src/pages/CredentialsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ const CREDENTIAL_TYPE_CONFIG: Record<CredentialType, CredentialConfig> = {
ignoreSSL: false,
},
},
[CredentialType.X_TWITTER_CRED]: {
label: 'X (Twitter)',
description:
'OAuth connection to X (Twitter) for posting tweets and reading data',
placeholder: '', // Not used for OAuth
namePlaceholder: 'My X Account',
credentialConfigurations: {
ignoreSSL: false,
},
},
[CredentialType.GOOGLE_SHEETS_CRED]: {
label: 'Google Sheets',
description: 'OAuth connection to Google Sheets for spreadsheet management',
Expand Down Expand Up @@ -201,6 +211,7 @@ const getServiceNameForCredentialType = (
[CredentialType.GMAIL_CRED]: 'Gmail',
[CredentialType.GOOGLE_SHEETS_CRED]: 'Google Sheets',
[CredentialType.GOOGLE_CALENDAR_CRED]: 'Google Calendar',
[CredentialType.X_TWITTER_CRED]: 'X (Twitter)',
};

return typeToServiceMap[credentialType] || credentialType;
Expand Down
4 changes: 4 additions & 0 deletions apps/bubblelab-api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export const env = {
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN,
GOOGLE_OAUTH_CLIENT_ID: process.env.GOOGLE_OAUTH_CLIENT_ID,
GOOGLE_OAUTH_CLIENT_SECRET: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
X_OAUTH_CLIENT_ID: process.env.X_OAUTH_CLIENT_ID,
X_OAUTH_CLIENT_SECRET: process.env.X_OAUTH_CLIENT_SECRET,
isDev:
process.env.BUBBLE_ENV?.toLowerCase() === 'dev' ||
process.env.BUBBLE_ENV?.toLowerCase() === 'test',
Expand Down Expand Up @@ -106,4 +108,6 @@ console.log('🔧 Environment variables loaded:', {
GOOGLE_OAUTH_CLIENT_SECRET: env.GOOGLE_OAUTH_CLIENT_SECRET
? '✅ Set'
: '❌ Missing',
X_OAUTH_CLIENT_ID: env.X_OAUTH_CLIENT_ID ? '✅ Set' : '❌ Missing',
X_OAUTH_CLIENT_SECRET: env.X_OAUTH_CLIENT_SECRET ? '✅ Set' : '❌ Missing',
});
111 changes: 93 additions & 18 deletions apps/bubblelab-api/src/services/oauth-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { OAuth2Client, OAuth2Token } from '@badgateway/oauth2-client';
import {
OAuth2Client,
OAuth2Token,
generateCodeVerifier,
} from '@badgateway/oauth2-client';
import {
CredentialType,
OAUTH_PROVIDERS,
Expand Down Expand Up @@ -44,6 +48,7 @@ export class OAuthService {
credentialName?: string;
timestamp: number;
scopes: string[];
codeVerifier?: string; // For PKCE (required by X/Twitter)
}
> = new Map();

Expand Down Expand Up @@ -75,6 +80,25 @@ export class OAuthService {
'Google OAuth credentials not configured. Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET'
);
}

// X (Twitter) OAuth 2.0 configuration with PKCE
if (env.X_OAUTH_CLIENT_ID && env.X_OAUTH_CLIENT_SECRET) {
this.clients.set(
'x',
new OAuth2Client({
server: 'https://api.twitter.com',
clientId: env.X_OAUTH_CLIENT_ID,
clientSecret: env.X_OAUTH_CLIENT_SECRET,
authorizationEndpoint: 'https://twitter.com/i/oauth2/authorize',
tokenEndpoint: '/2/oauth2/token',
// PKCE is automatically handled by @badgateway/oauth2-client
})
);
} else {
console.warn(
'X OAuth credentials not configured. Set X_OAUTH_CLIENT_ID and X_OAUTH_CLIENT_SECRET'
);
}
}

/**
Expand Down Expand Up @@ -105,6 +129,15 @@ export class OAuthService {
const defaultScopes = this.getDefaultScopes(provider, credentialType);
const requestedScopes = scopes || defaultScopes;

// For X (Twitter), generate PKCE code_verifier using library's helper
let codeVerifier: string | undefined;

if (provider === 'x') {
// Use the library's built-in PKCE code verifier generator
codeVerifier = await generateCodeVerifier();
console.log(`Generated PKCE code_verifier for X OAuth`);
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the PKCE code_verifier generation could expose sensitive authentication flow information in production logs. This log statement should be removed or converted to a debug-level log that's disabled in production.

Suggested change
console.log(`Generated PKCE code_verifier for X OAuth`);

Copilot uses AI. Check for mistakes.
}

// Store state for CSRF protection with requested scopes (expires in 10 minutes)
this.stateStore.set(state, {
userId,
Expand All @@ -113,37 +146,67 @@ export class OAuthService {
credentialName,
timestamp,
scopes: requestedScopes,
codeVerifier, // Store for token exchange
});

try {
// Get provider-specific authorization parameters from centralized config
const providerConfig = OAUTH_PROVIDERS[provider];
const authorizationParams = providerConfig?.authorizationParams || {};

const authUrl = await client.authorizationCode.getAuthorizeUri({
// Build authorization options - library handles PKCE automatically when codeVerifier is provided
const authOptions: {
redirectUri: string;
scope: string[];
state: string;
codeVerifier?: string;
[key: string]: string | string[] | undefined;
} = {
redirectUri,
scope: requestedScopes,
state,
...authorizationParams,
});
};

// Check if our parameters are actually in the URL and manually add if missing
const urlObj = new URL(authUrl);

// If parameters are missing, manually add them
if (
!urlObj.searchParams.has('access_type') &&
authorizationParams.access_type
) {
urlObj.searchParams.set('access_type', authorizationParams.access_type);
// For X, pass codeVerifier - library automatically generates code_challenge and adds PKCE params
if (provider === 'x' && codeVerifier) {
authOptions.codeVerifier = codeVerifier;
}
if (!urlObj.searchParams.has('prompt') && authorizationParams.prompt) {
urlObj.searchParams.set('prompt', authorizationParams.prompt);

const authUrl =
await client.authorizationCode.getAuthorizeUri(authOptions);

// For X (Twitter), ensure scopes are space-separated (X API requirement)
// Library might use comma-separated, so we fix it if needed
const urlObj = new URL(authUrl);
if (provider === 'x') {
const scopeParam = urlObj.searchParams.get('scope');
if (scopeParam && scopeParam.includes(',')) {
const spaceSeparatedScopes = scopeParam
.split(',')
.map((s) => s.trim())
.join(' ');
urlObj.searchParams.set('scope', spaceSeparatedScopes);
}
}

const finalAuthUrl = urlObj.toString();
// For Google, ensure access_type and prompt are present if specified
if (provider === 'google') {
if (
!urlObj.searchParams.has('access_type') &&
authorizationParams.access_type
) {
urlObj.searchParams.set(
'access_type',
authorizationParams.access_type
);
}
if (!urlObj.searchParams.has('prompt') && authorizationParams.prompt) {
urlObj.searchParams.set('prompt', authorizationParams.prompt);
}
}

return { authUrl: finalAuthUrl, state };
return { authUrl: urlObj.toString(), state };
} catch (error) {
// Clean up state on error
this.stateStore.delete(state);
Expand Down Expand Up @@ -186,10 +249,22 @@ export class OAuthService {

try {
// Exchange authorization code for tokens
const token = await client.authorizationCode.getToken({
// Library requires code_verifier for PKCE (X/Twitter) - pass it if we have it
const tokenOptions: {
code: string;
redirectUri: string;
codeVerifier?: string;
} = {
code,
redirectUri,
});
};

// For X (Twitter), include code_verifier for PKCE token exchange
if (provider === 'x' && stateData.codeVerifier) {
tokenOptions.codeVerifier = stateData.codeVerifier;
}

const token = await client.authorizationCode.getToken(tokenOptions);

if (!token.refreshToken) {
console.warn(
Comment on lines 269 to 270
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh token warning check appears to be incomplete in the diff context. Ensure this warning is properly logged for X OAuth, as refresh tokens are critical for the 'offline.access' scope.

Copilot uses AI. Check for mistakes.
Expand Down
5 changes: 5 additions & 0 deletions packages/bubble-core/src/bubble-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export class BubbleFactory {
'instagram-tool',
'linkedin-tool',
'youtube-tool',
'x-twitter',
];
}

Expand Down Expand Up @@ -177,6 +178,9 @@ export class BubbleFactory {
const { GoogleCalendarBubble } = await import(
'./bubbles/service-bubble/google-calendar.js'
);
const { XTwitterBubble } = await import(
'./bubbles/service-bubble/x-twitter.js'
);
const { ApifyBubble } = await import('./bubbles/service-bubble/apify');
const { BubbleFlowGeneratorWorkflow } = await import(
'./bubbles/workflow-bubble/bubbleflow-generator.workflow.js'
Expand Down Expand Up @@ -264,6 +268,7 @@ export class BubbleFactory {
'google-calendar',
GoogleCalendarBubble as BubbleClassWithMetadata
);
this.register('x-twitter', XTwitterBubble as BubbleClassWithMetadata);
this.register('apify', ApifyBubble as BubbleClassWithMetadata);
this.register(
'bubbleflow-generator',
Expand Down
Loading
Loading