import { WebClient } from "@slack/web-api"; import type { IntegrationService, Organization, OrganizationIntegration, SecretReference, } from "@trigger.dev/database"; import { z } from "zod"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { slackSecretLogFields } from "./safeIntegrationLog"; import { slackAccessResultLogFields } from "./slackOAuthResultLog"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; const SlackSecretSchema = z.object({ botAccessToken: z.string(), userAccessToken: z.string().optional(), expiresIn: z.number().optional(), refreshToken: z.string().optional(), botScopes: z.array(z.string()).optional(), userScopes: z.array(z.string()).optional(), raw: z.record(z.any()).optional(), }); type SlackSecret = z.infer; const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth"; export type OrganizationIntegrationForService = Omit< AuthenticatableIntegration, "service" > & { service: TService; }; type AuthenticatedClientOptions = TService extends "SLACK" ? { forceBotToken?: boolean; } : undefined; type AuthenticatedClientForIntegration = TService extends "SLACK" ? InstanceType : never; export type AuthenticatableIntegration = OrganizationIntegration & { tokenReference: SecretReference; }; export function isIntegrationForService( integration: AuthenticatableIntegration, service: TService ): integration is OrganizationIntegrationForService { return (integration.service satisfies IntegrationService) === service; } export class OrgIntegrationRepository { static async getAuthenticatedClientForIntegration( integration: OrganizationIntegrationForService, options?: AuthenticatedClientOptions ): Promise> { const secretStore = getSecretStore(integration.tokenReference.provider); switch (integration.service) { case "SLACK": { const secret = await secretStore.getSecret( SlackSecretSchema, integration.tokenReference.key ); if (!secret) { throw new Error("Failed to get access token"); } // TODO refresh access token here return new WebClient( options?.forceBotToken ? secret.botAccessToken : (secret.userAccessToken ?? secret.botAccessToken), { retryConfig: { retries: 2, randomize: true, maxTimeout: 5000, maxRetryTime: 10000, }, } ) as AuthenticatedClientForIntegration; } default: { throw new Error(`Unsupported service ${integration.service}`); } } } static isSlackSupported = !!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET; static isVercelSupported = !!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET && !!env.VERCEL_INTEGRATION_APP_SLUG; /** * Generate the URL to install the Vercel integration. * Users are redirected to Vercel's marketplace to complete the installation. * * @param state - Base64-encoded state containing org/project info for the callback */ static vercelInstallUrl(state: string): string { // The user goes to Vercel's marketplace to install the integration // After installation, Vercel redirects to our callback with the authorization code const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/vercel/callback`); const encodedState = encodeURIComponent(state); return `https://vercel.com/integrations/${env.VERCEL_INTEGRATION_APP_SLUG}/new?state=${encodedState}&redirect_uri=${redirectUri}`; } static slackAuthorizationUrl( state: string, scopes: string[] = [ "channels:read", "groups:read", "im:read", "mpim:read", "chat:write", "chat:write.public", ], userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"] ) { return `https://slack.com/oauth/v2/authorize?client_id=${ env.ORG_SLACK_INTEGRATION_CLIENT_ID }&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${ env.APP_ORIGIN }/integrations/slack/callback`; } static async redirectToAuthService( service: IntegrationService, state: string, request: Request, redirectTo: string ) { const session = await getUserSession(request); session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo); const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined; if (!authUrl) { throw new Response("Unsupported service", { status: 400 }); } logger.debug("Redirecting to auth service", { service, authUrl, redirectTo, }); return new Response(null, { status: 302, headers: { location: authUrl, "Set-Cookie": await commitSession(session), }, }); } static async redirectAfterAuth(request: Request) { const session = await getUserSession(request); logger.debug("Redirecting back after auth", { sessionData: session.data, }); const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY); if (!redirectTo) { throw new Response("Invalid redirect", { status: 400 }); } session.unset(REDIRECT_AFTER_AUTH_KEY); return new Response(null, { status: 302, headers: { location: redirectTo, "Set-Cookie": await commitSession(session), }, }); } static async createOrgIntegration(serviceName: string, code: string, org: Organization) { switch (serviceName) { case "slack": { if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) { throw new Error("Slack integration not configured"); } const client = new WebClient(); const result = await client.oauth.v2.access({ client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID, client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET, code, redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`, }); if (result.ok) { // `result` carries Slack tokens; log only non-secret diagnostics. logger.debug("Received slack access token", slackAccessResultLogFields(result)); if (!result.access_token) { throw new Error("Failed to get access token"); } return await $transaction(prisma, async (tx) => { const secretStore = getSecretStore("DATABASE", { prismaClient: tx, }); const integrationFriendlyId = generateFriendlyId("org_integration"); const secretValue: SlackSecret = { botAccessToken: result.access_token!, userAccessToken: result.authed_user ? result.authed_user.access_token : undefined, expiresIn: result.expires_in, refreshToken: result.refresh_token, botScopes: result.scope ? result.scope.split(",") : [], userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [], raw: result, }; // `secretValue` carries the tokens encrypted below; log only // non-secret fields. logger.debug( "Setting secret", slackSecretLogFields(integrationFriendlyId, secretValue) ); await secretStore.setSecret(integrationFriendlyId, secretValue); const reference = await tx.secretReference.create({ data: { provider: "DATABASE", key: integrationFriendlyId, }, }); return await tx.organizationIntegration.create({ data: { friendlyId: integrationFriendlyId, organizationId: org.id, service: "SLACK", tokenReferenceId: reference.id, integrationData: { team: result.team, user: result.authed_user ? { id: result.authed_user.id, } : undefined, } as any, }, }); }); } } default: { throw new Error(`Service ${serviceName} not supported`); } } } }