buster/apps/server/src/api/v2/slack/services/slack-oauth-service.ts

433 lines
14 KiB
TypeScript

import { SlackAuthService } from '@buster/slack';
import { z } from 'zod';
import { SLACK_OAUTH_SCOPES } from '../constants';
import * as slackHelpers from './slack-helpers';
import { oauthStateStorage, tokenStorage } from './token-storage';
/**
* Validates if an integration has all required OAuth scopes
*/
function validateScopes(currentScopeString?: string | null): boolean {
if (!currentScopeString) return false;
const currentScopes = currentScopeString.includes(',')
? currentScopeString.split(',').map(s => s.trim())
: currentScopeString.split(' ').map(s => s.trim());
const requiredScopes = [...SLACK_OAUTH_SCOPES];
return requiredScopes.every((scope) => currentScopes.includes(scope));
}
// Environment validation
const SlackEnvSchema = z.object({
SLACK_CLIENT_ID: z.string().min(1),
SLACK_CLIENT_SECRET: z.string().min(1),
SERVER_URL: z.string().url(),
SLACK_INTEGRATION_ENABLED: z
.string()
.default('false')
.transform((val) => val === 'true'),
});
// OAuth metadata schema
const OAuthMetadataSchema = z.object({
returnUrl: z.string().optional(),
source: z.string().optional(),
projectId: z.string().uuid().optional(),
initiatedAt: z.string().datetime().optional(),
ipAddress: z.string().optional(),
});
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export class SlackOAuthService {
private slackAuth: SlackAuthService;
private env: z.infer<typeof SlackEnvSchema>;
constructor() {
try {
// Validate environment variables
this.env = SlackEnvSchema.parse(process.env);
// Initialize Slack auth service with storage implementations
this.slackAuth = new SlackAuthService(
{
clientId: this.env.SLACK_CLIENT_ID,
clientSecret: this.env.SLACK_CLIENT_SECRET,
redirectUri: `${this.env.SERVER_URL}/api/v2/slack/auth/callback`,
scopes: [...SLACK_OAUTH_SCOPES],
},
tokenStorage,
oauthStateStorage
);
} catch (error) {
console.error('Failed to initialize SlackOAuthService:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
environment: {
hasClientId: !!process.env.SLACK_CLIENT_ID,
hasClientSecret: !!process.env.SLACK_CLIENT_SECRET,
hasServerUrl: !!process.env.SERVER_URL,
integrationEnabled: process.env.SLACK_INTEGRATION_ENABLED,
},
});
throw new Error(
`Failed to initialize Slack OAuth service: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Check if Slack integration is enabled
*/
isEnabled(): boolean {
return this.env.SLACK_INTEGRATION_ENABLED;
}
/**
* Initiate OAuth flow
*/
async initiateOAuth(params: {
organizationId: string;
userId: string;
metadata?: OAuthMetadata;
}): Promise<{ authUrl: string; state: string }> {
try {
// Check if integration is enabled
if (!this.isEnabled()) {
throw new Error('Slack integration is not enabled');
}
// Check for existing integration - allow re-installation if scopes don't match
const existing = await slackHelpers.getActiveIntegration(params.organizationId);
if (existing) {
if (validateScopes(existing.scope)) {
throw new Error(
'Organization already has an active Slack integration with current scopes'
);
}
}
// Add system metadata
const metadata = {
...params.metadata,
initiatedAt: new Date().toISOString(),
};
// Generate OAuth URL and state
const { authUrl, state } = await this.slackAuth.generateAuthUrl(metadata);
// Create pending integration
await slackHelpers.createPendingIntegration({
organizationId: params.organizationId,
userId: params.userId,
oauthState: state,
oauthMetadata: metadata,
});
return { authUrl, state };
} catch (error) {
console.error('Failed to initiate OAuth:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
organizationId: params.organizationId,
userId: params.userId,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
integrationEnabled: this.isEnabled(),
});
throw error; // Re-throw to maintain existing error handling in handler
}
}
/**
* Handle OAuth callback
*/
async handleOAuthCallback(params: {
code: string;
state: string;
}): Promise<{
success: boolean;
integrationId: string;
metadata?: OAuthMetadata;
teamName?: string;
error?: string;
}> {
try {
// Validate state and get pending integration
const integration = await slackHelpers.getPendingIntegrationByState(params.state);
if (!integration) {
return {
success: false,
integrationId: '',
error: 'Invalid or expired OAuth state',
};
}
// Exchange code for token - use integration.id as tokenKey
const tokenKey = `slack-token-${integration.id}`;
const tokenResponse = await this.slackAuth.handleCallback(
params.code,
params.state,
tokenKey
);
// Store token vault key and update integration
const updateParams: Parameters<typeof slackHelpers.updateIntegrationAfterOAuth>[1] = {
teamId: tokenResponse.teamId,
teamName: tokenResponse.teamName,
botUserId: tokenResponse.botUserId,
scope: tokenResponse.scope,
tokenVaultKey: tokenKey,
};
// Add optional properties only if they exist
if (tokenResponse.teamDomain !== undefined) {
updateParams.teamDomain = tokenResponse.teamDomain;
}
if (tokenResponse.enterpriseId !== undefined) {
updateParams.enterpriseId = tokenResponse.enterpriseId;
}
if (tokenResponse.installerUserId !== undefined) {
updateParams.installedBySlackUserId = tokenResponse.installerUserId;
}
await slackHelpers.updateIntegrationAfterOAuth(integration.id, updateParams);
return {
success: true,
integrationId: integration.id,
metadata: integration.oauthMetadata as OAuthMetadata,
teamName: tokenResponse.teamName,
};
} catch (error) {
// Enhanced error logging with structured data
console.error('OAuth callback error:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
code: params.code ? '[REDACTED]' : 'missing', // Don't log sensitive data
state: params.state ? `${params.state.substring(0, 8)}...` : 'missing', // Partial state for debugging
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
});
// Try to get integration for cleanup
let integrationId = '';
try {
const integration = await slackHelpers.getPendingIntegrationByState(params.state);
if (integration) {
integrationId = integration.id;
await slackHelpers.markIntegrationAsFailed(
integration.id,
error instanceof Error ? error.message : 'Unknown error'
);
return {
success: false,
integrationId: integration.id,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
} catch (cleanupError) {
console.error('Failed to cleanup after OAuth error:', {
originalError: error instanceof Error ? error.message : String(error),
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
state: params.state ? `${params.state.substring(0, 8)}...` : 'missing',
});
}
return {
success: false,
integrationId,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
/**
* Get integration status
*/
async getIntegrationStatus(organizationId: string): Promise<{
connected: boolean;
status?: 'connected' | 'disconnected' | 're_install_required';
integration?: {
id: string;
teamName: string;
teamDomain?: string;
installedAt: string;
lastUsedAt?: string;
defaultChannel?: {
id: string;
name: string;
};
defaultSharingPermissions?: string;
};
}> {
try {
const integration = await slackHelpers.getActiveIntegration(organizationId);
if (!integration) {
return { connected: false, status: 'disconnected' };
}
// Validate scopes
if (!validateScopes(integration.scope)) {
// Cast defaultChannel to the expected type
const defaultChannel = integration.defaultChannel as
| { id: string; name: string }
| Record<string, never>
| null;
// Check if defaultChannel has content
const hasDefaultChannel =
defaultChannel &&
typeof defaultChannel === 'object' &&
'id' in defaultChannel &&
'name' in defaultChannel;
return {
connected: true,
status: 're_install_required',
integration: {
id: integration.id,
teamName: integration.teamName || '',
...(integration.teamDomain != null && { teamDomain: integration.teamDomain }),
installedAt: integration.installedAt || integration.createdAt,
...(integration.lastUsedAt != null && { lastUsedAt: integration.lastUsedAt }),
...(hasDefaultChannel && {
defaultChannel: {
id: defaultChannel.id,
name: defaultChannel.name,
},
}),
defaultSharingPermissions: integration.defaultSharingPermissions,
},
};
}
// Cast defaultChannel to the expected type
const defaultChannel = integration.defaultChannel as
| { id: string; name: string }
| Record<string, never>
| null;
// Check if defaultChannel has content
const hasDefaultChannel =
defaultChannel &&
typeof defaultChannel === 'object' &&
'id' in defaultChannel &&
'name' in defaultChannel;
return {
connected: true,
status: 'connected',
integration: {
id: integration.id,
teamName: integration.teamName || '',
...(integration.teamDomain != null && { teamDomain: integration.teamDomain }),
installedAt: integration.installedAt || integration.createdAt,
...(integration.lastUsedAt != null && { lastUsedAt: integration.lastUsedAt }),
...(hasDefaultChannel && {
defaultChannel: {
id: defaultChannel.id,
name: defaultChannel.name,
},
}),
defaultSharingPermissions: integration.defaultSharingPermissions,
},
};
} catch (error) {
console.error('Failed to get integration status:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
organizationId,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
});
throw error;
}
}
/**
* Remove integration
*/
async removeIntegration(
organizationId: string,
_userId: string
): Promise<{ success: boolean; error?: string }> {
try {
const integration = await slackHelpers.getActiveIntegration(organizationId);
if (!integration) {
return {
success: false,
error: 'No active Slack integration found',
};
}
// Revoke token from vault
if (integration.tokenVaultKey) {
try {
await tokenStorage.deleteToken(integration.tokenVaultKey);
} catch (error) {
console.error('Failed to delete token from vault:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
tokenVaultKey: integration.tokenVaultKey,
integrationId: integration.id,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
});
// Continue with integration removal even if token deletion fails
}
}
// Soft delete integration
await slackHelpers.softDeleteIntegration(integration.id);
return { success: true };
} catch (error) {
console.error('Failed to remove integration:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
organizationId,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
});
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove integration',
};
}
}
/**
* Get token from vault
*/
async getTokenFromVault(integrationId: string): Promise<string | null> {
try {
const integration = await slackHelpers.getIntegrationById(integrationId);
if (!integration || !integration.tokenVaultKey) {
return null;
}
return await tokenStorage.getToken(integration.tokenVaultKey);
} catch (error) {
console.error('Failed to get token from vault:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
integrationId,
errorType: error instanceof Error ? error.constructor.name : typeof error,
timestamp: new Date().toISOString(),
});
return null;
}
}
}
// Factory function for creating service instances
export function createSlackOAuthService(): SlackOAuthService {
return new SlackOAuthService();
}