From 178a632ed2f917126f85570f35850423bf343dba Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Wed, 25 Feb 2026 14:18:35 +0100 Subject: [PATCH 1/5] Add Discord integration OAuth flow and UI Add Discord as a new platform integration following the same architecture as the existing Slack integration. This includes the OAuth2 flow for adding the bot to a Discord server, storing guild information in the shared platform_integrations table, and full frontend UI for both personal and organization-level integrations. Bot implementation will follow in a subsequent PR. --- .../integrations/IntegrationsPageClient.tsx | 17 +- src/app/(app)/integrations/discord/page.tsx | 56 ++++ .../integrations/IntegrationsPageClient.tsx | 17 +- .../[id]/integrations/discord/page.tsx | 66 ++++ .../integrations/discord/callback/route.ts | 129 ++++++++ .../integrations/DiscordContext.tsx | 76 +++++ .../DiscordIntegrationDetails.tsx | 240 ++++++++++++++ .../integrations/OrgDiscordProvider.tsx | 94 ++++++ .../integrations/UserDiscordProvider.tsx | 46 +++ src/lib/config.server.ts | 6 + src/lib/integrations/core/constants.ts | 1 + src/lib/integrations/discord-service.ts | 302 ++++++++++++++++++ src/lib/integrations/platform-definitions.ts | 15 +- src/routers/discord-router.ts | 63 ++++ .../organization-discord-router.ts | 89 ++++++ .../organizations/organization-router.ts | 2 + src/routers/root-router.ts | 2 + 17 files changed, 1212 insertions(+), 9 deletions(-) create mode 100644 src/app/(app)/integrations/discord/page.tsx create mode 100644 src/app/(app)/organizations/[id]/integrations/discord/page.tsx create mode 100644 src/app/api/integrations/discord/callback/route.ts create mode 100644 src/components/integrations/DiscordContext.tsx create mode 100644 src/components/integrations/DiscordIntegrationDetails.tsx create mode 100644 src/components/integrations/OrgDiscordProvider.tsx create mode 100644 src/components/integrations/UserDiscordProvider.tsx create mode 100644 src/lib/integrations/discord-service.ts create mode 100644 src/routers/discord-router.ts create mode 100644 src/routers/organizations/organization-discord-router.ts diff --git a/src/app/(app)/integrations/IntegrationsPageClient.tsx b/src/app/(app)/integrations/IntegrationsPageClient.tsx index c15abde08..6c2cc6db4 100644 --- a/src/app/(app)/integrations/IntegrationsPageClient.tsx +++ b/src/app/(app)/integrations/IntegrationsPageClient.tsx @@ -11,6 +11,8 @@ import { UserGitHubAppsProvider } from '@/components/integrations/UserGitHubApps import { useGitHubAppsQueries } from '@/components/integrations/GitHubAppsContext'; import { UserSlackProvider } from '@/components/integrations/UserSlackProvider'; import { useSlackQueries } from '@/components/integrations/SlackContext'; +import { UserDiscordProvider } from '@/components/integrations/UserDiscordProvider'; +import { useDiscordQueries } from '@/components/integrations/DiscordContext'; import { UserGitLabProvider } from '@/components/integrations/UserGitLabProvider'; import { useGitLabQueries } from '@/components/integrations/GitLabContext'; import { PageContainer } from '@/components/layouts/PageContainer'; @@ -19,6 +21,7 @@ function IntegrationsPageContent() { const router = useRouter(); const { queries: githubQueries } = useGitHubAppsQueries(); const { queries: slackQueries } = useSlackQueries(); + const { queries: discordQueries } = useDiscordQueries(); const { queries: gitlabQueries } = useGitLabQueries(); // Fetch GitHub App installation status @@ -27,10 +30,13 @@ function IntegrationsPageContent() { // Fetch Slack installation status const { data: slackInstallation, isLoading: slackLoading } = slackQueries.getInstallation(); + // Fetch Discord installation status + const { data: discordInstallation, isLoading: discordLoading } = discordQueries.getInstallation(); + // Fetch GitLab installation status const { data: gitlabInstallation, isLoading: gitlabLoading } = gitlabQueries.getInstallation(); - const isLoading = githubLoading || slackLoading || gitlabLoading; + const isLoading = githubLoading || slackLoading || discordLoading || gitlabLoading; if (isLoading) { return ( @@ -54,6 +60,7 @@ function IntegrationsPageContent() { const platforms = buildPlatformsForPersonal({ github: githubInstallation, slack: slackInstallation, + discord: discordInstallation, gitlab: gitlabInstallation, }); @@ -85,9 +92,11 @@ export function IntegrationsPageClient() { return ( - - - + + + + + ); diff --git a/src/app/(app)/integrations/discord/page.tsx b/src/app/(app)/integrations/discord/page.tsx new file mode 100644 index 000000000..bfaf5bb7d --- /dev/null +++ b/src/app/(app)/integrations/discord/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from 'react'; +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { DiscordIntegrationDetails } from '@/components/integrations/DiscordIntegrationDetails'; +import { UserDiscordProvider } from '@/components/integrations/UserDiscordProvider'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { PageLayout } from '@/components/PageLayout'; + +export default async function UserDiscordIntegrationPage({ + searchParams, +}: { + searchParams: Promise<{ + success?: string; + error?: string; + }>; +}) { + await getUserFromAuthOrRedirect('/users/sign_in'); + const search = await searchParams; + + return ( + + + + } + > + + + +
+
+
+
+ + + } + > + + + + + ); +} diff --git a/src/app/(app)/organizations/[id]/integrations/IntegrationsPageClient.tsx b/src/app/(app)/organizations/[id]/integrations/IntegrationsPageClient.tsx index 418aa476c..63ad6f128 100644 --- a/src/app/(app)/organizations/[id]/integrations/IntegrationsPageClient.tsx +++ b/src/app/(app)/organizations/[id]/integrations/IntegrationsPageClient.tsx @@ -11,6 +11,8 @@ import { OrgGitHubAppsProvider } from '@/components/integrations/OrgGitHubAppsPr import { useGitHubAppsQueries } from '@/components/integrations/GitHubAppsContext'; import { OrgSlackProvider } from '@/components/integrations/OrgSlackProvider'; import { useSlackQueries } from '@/components/integrations/SlackContext'; +import { OrgDiscordProvider } from '@/components/integrations/OrgDiscordProvider'; +import { useDiscordQueries } from '@/components/integrations/DiscordContext'; import { OrgGitLabProvider } from '@/components/integrations/OrgGitLabProvider'; import { useGitLabQueries } from '@/components/integrations/GitLabContext'; @@ -22,6 +24,7 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps const router = useRouter(); const { queries: githubQueries } = useGitHubAppsQueries(); const { queries: slackQueries } = useSlackQueries(); + const { queries: discordQueries } = useDiscordQueries(); const { queries: gitlabQueries } = useGitLabQueries(); // Fetch GitHub App installation status @@ -30,10 +33,13 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps // Fetch Slack installation status const { data: slackInstallation, isLoading: slackLoading } = slackQueries.getInstallation(); + // Fetch Discord installation status + const { data: discordInstallation, isLoading: discordLoading } = discordQueries.getInstallation(); + // Fetch GitLab installation status const { data: gitlabInstallation, isLoading: gitlabLoading } = gitlabQueries.getInstallation(); - const isLoading = githubLoading || slackLoading || gitlabLoading; + const isLoading = githubLoading || slackLoading || discordLoading || gitlabLoading; if (isLoading) { return ( @@ -55,6 +61,7 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps const platforms = buildPlatformsForOrg(organizationId, { github: githubInstallation, slack: slackInstallation, + discord: discordInstallation, gitlab: gitlabInstallation, }); @@ -78,9 +85,11 @@ export function IntegrationsPageClient({ organizationId }: IntegrationsPageClien return ( - - - + + + + + ); diff --git a/src/app/(app)/organizations/[id]/integrations/discord/page.tsx b/src/app/(app)/organizations/[id]/integrations/discord/page.tsx new file mode 100644 index 000000000..411d2935c --- /dev/null +++ b/src/app/(app)/organizations/[id]/integrations/discord/page.tsx @@ -0,0 +1,66 @@ +import { Suspense } from 'react'; +import { DiscordIntegrationDetails } from '@/components/integrations/DiscordIntegrationDetails'; +import { OrgDiscordProvider } from '@/components/integrations/OrgDiscordProvider'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; + +export default async function OrgDiscordIntegrationPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ + success?: string; + error?: string; + }>; +}) { + const search = await searchParams; + + return ( + ( + <> +
+ + + +
+

Discord Integration

+

+ Manage Discord integration for {organization.name} +

+
+
+ + + + +
+
+
+
+ + + } + > + + + + + )} + /> + ); +} diff --git a/src/app/api/integrations/discord/callback/route.ts b/src/app/api/integrations/discord/callback/route.ts new file mode 100644 index 000000000..378261f51 --- /dev/null +++ b/src/app/api/integrations/discord/callback/route.ts @@ -0,0 +1,129 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { ensureOrganizationAccess } from '@/routers/organizations/utils'; +import type { Owner } from '@/lib/integrations/core/types'; +import { captureException, captureMessage } from '@sentry/nextjs'; +import { exchangeDiscordCode, upsertDiscordInstallation } from '@/lib/integrations/discord-service'; +import { APP_URL } from '@/lib/constants'; + +const buildDiscordRedirectPath = (state: string | null, queryParam: string): string => { + if (state?.startsWith('org_')) { + return `/organizations/${state.replace('org_', '')}/integrations/discord?${queryParam}`; + } + if (state?.startsWith('user_')) { + return `/integrations/discord?${queryParam}`; + } + return `/integrations?${queryParam}`; +}; + +/** + * Discord OAuth Callback + * + * Called when user completes the Discord OAuth flow + */ +export async function GET(request: NextRequest) { + try { + // 1. Verify user authentication + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) { + return NextResponse.redirect(new URL('/users/sign_in', APP_URL)); + } + + // 2. Extract parameters + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + // Handle OAuth errors from Discord + if (error) { + captureMessage('Discord OAuth error', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { error, state }, + }); + + return NextResponse.redirect( + new URL(buildDiscordRedirectPath(state, `error=${error}`), APP_URL) + ); + } + + // Validate code is present + if (!code) { + captureMessage('Discord callback missing code', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { state, allParams: Object.fromEntries(searchParams.entries()) }, + }); + + return NextResponse.redirect( + new URL(buildDiscordRedirectPath(state, 'error=missing_code'), APP_URL) + ); + } + + // 3. Parse owner from state + let owner: Owner; + let ownerId: string; + + if (state?.startsWith('org_')) { + ownerId = state.replace('org_', ''); + owner = { type: 'org', id: ownerId }; + } else if (state?.startsWith('user_')) { + ownerId = state.replace('user_', ''); + owner = { type: 'user', id: ownerId }; + } else { + captureMessage('Discord callback missing or invalid owner in state', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { code: '***', state, allParams: Object.fromEntries(searchParams.entries()) }, + }); + return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL)); + } + + // 4. Verify user has access to the owner + if (owner.type === 'org') { + await ensureOrganizationAccess({ user }, owner.id); + } else { + // For user-owned integrations, verify it's the same user + if (user.id !== owner.id) { + return NextResponse.redirect(new URL('/integrations?error=unauthorized', APP_URL)); + } + } + + // 5. Exchange code for access token + const oauthData = await exchangeDiscordCode(code); + + // 6. Store installation in database + await upsertDiscordInstallation(owner, oauthData); + + // 7. Redirect to success page + const successPath = + owner.type === 'org' + ? `/organizations/${owner.id}/integrations/discord?success=installed` + : `/integrations/discord?success=installed`; + + return NextResponse.redirect(new URL(successPath, APP_URL)); + } catch (error) { + console.error('Error handling Discord OAuth callback:', error); + + // Capture error to Sentry with context for debugging + const searchParams = request.nextUrl.searchParams; + const state = searchParams.get('state'); + + captureException(error, { + tags: { + endpoint: 'discord/callback', + source: 'discord_oauth', + }, + extra: { + state, + hasCode: !!searchParams.get('code'), + }, + }); + + return NextResponse.redirect( + new URL(buildDiscordRedirectPath(state, 'error=installation_failed'), APP_URL) + ); + } +} diff --git a/src/components/integrations/DiscordContext.tsx b/src/components/integrations/DiscordContext.tsx new file mode 100644 index 000000000..511ea976d --- /dev/null +++ b/src/components/integrations/DiscordContext.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import type { TRPCClientErrorLike } from '@trpc/client'; +import type { AnyRouter } from '@trpc/server'; + +type DiscordError = TRPCClientErrorLike; + +type DiscordInstallation = { + guildId: string | null; + guildName: string | null; + scopes: string[] | null; + installedAt: string; +}; + +type DiscordInstallationResult = { + installed: boolean; + installation: DiscordInstallation | null; +}; + +type DiscordOAuthUrlResult = { + url: string; +}; + +type DiscordTestConnectionResult = { + success: boolean; + error?: string; +}; + +export type DiscordQueries = { + getInstallation: () => UseQueryResult; + getOAuthUrl: () => UseQueryResult; +}; + +export type DiscordMutations = { + uninstallApp: UseMutationResult<{ success: boolean }, DiscordError, void>; + testConnection: UseMutationResult; + devRemoveDbRowOnly: UseMutationResult<{ success: boolean }, DiscordError, void>; +}; + +type DiscordContextValue = { + queries: DiscordQueries; + mutations: DiscordMutations; +}; + +const DiscordContext = createContext(null); + +/** + * Hook to access Discord queries and mutations from context + * Must be used within a DiscordProvider + */ +export function useDiscordQueries() { + const context = useContext(DiscordContext); + if (!context) { + throw new Error('useDiscordQueries must be used within a DiscordProvider'); + } + return context; +} + +/** + * Base provider component that accepts queries and mutations + */ +export function DiscordProvider({ + queries, + mutations, + children, +}: { + queries: DiscordQueries; + mutations: DiscordMutations; + children: ReactNode; +}) { + return ( + {children} + ); +} diff --git a/src/components/integrations/DiscordIntegrationDetails.tsx b/src/components/integrations/DiscordIntegrationDetails.tsx new file mode 100644 index 000000000..2c181dec0 --- /dev/null +++ b/src/components/integrations/DiscordIntegrationDetails.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { CheckCircle2, XCircle, MessageSquare, Settings, ExternalLink, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useEffect } from 'react'; +import { useDiscordQueries } from './DiscordContext'; +import { IS_DEVELOPMENT } from '@/lib/constants'; + +type DiscordIntegrationDetailsProps = { + organizationId?: string; + success?: boolean; + error?: string; +}; + +export function DiscordIntegrationDetails({ success, error }: DiscordIntegrationDetailsProps) { + const { queries, mutations } = useDiscordQueries(); + + // Fetch Discord installation status + const { data: installationData, isLoading, refetch } = queries.getInstallation(); + + // Get OAuth URL for installation + const { data: oauthUrlData } = queries.getOAuthUrl(); + + // Show success/error toasts + useEffect(() => { + if (success) { + toast.success('Discord connected successfully!'); + } + if (error) { + toast.error(`Connection failed: ${error}`); + } + }, [success, error]); + + const handleInstall = () => { + if (oauthUrlData?.url) { + window.location.href = oauthUrlData.url; + } + }; + + const handleUninstall = () => { + if (confirm('Are you sure you want to disconnect Discord?')) { + mutations.uninstallApp.mutate(undefined, { + onSuccess: async () => { + toast.success('Discord disconnected'); + await refetch(); + }, + onError: err => { + toast.error('Failed to disconnect Discord', { + description: err.message, + }); + }, + }); + } + }; + + const handleDevRemoveDbRowOnly = () => { + if ( + confirm( + 'This will remove the database row but keep the Discord bot in the server. Are you sure?' + ) + ) { + mutations.devRemoveDbRowOnly?.mutate(undefined, { + onSuccess: async () => { + toast.success('Database row removed (Discord bot still in server)'); + await refetch(); + }, + onError: err => { + toast.error('Failed to remove database row', { + description: err.message, + }); + }, + }); + } + }; + + const handleTestConnection = () => { + mutations.testConnection.mutate(undefined, { + onSuccess: result => { + if (result.success) { + toast.success('Connection test successful!'); + } else { + toast.error('Connection test failed', { + description: result.error, + }); + } + }, + onError: err => { + toast.error('Connection test failed', { + description: err.message, + }); + }, + }); + }; + + if (isLoading) { + return ( + + +
+
+
+
+ + + ); + } + + const isInstalled = installationData?.installed; + const installation = installationData?.installation; + + return ( +
+ {/* Installation Status Card */} + + +
+
+ + + Discord Integration + + + Create PRs, debug code, ask questions about your repos, etc. directly from Discord + +
+ {isInstalled ? ( + + + Connected + + ) : ( + + + Not Connected + + )} +
+
+ + {isInstalled && installation ? ( + <> + {/* Installation Details */} +
+
+ Server: + {installation.guildName} +
+ {installation.scopes && installation.scopes.length > 0 && ( +
+ Permissions: +
+ {installation.scopes.map((scope: string) => ( + + {scope} + + ))} +
+
+ )} +
+ Connected: + + {installation.installedAt + ? new Date(installation.installedAt).toLocaleDateString() + : 'Unknown'} + +
+
+ + {/* Actions */} +
+ + + + {IS_DEVELOPMENT && ( + + )} +
+ + ) : ( + <> + {/* Not Connected State */} + + + Connect Discord to talk with Kilo directly from your server. + + + +
+

What you'll get:

+
    +
  • ✓ Message Kilo directly from Discord
  • +
+
+ + + + )} +
+
+
+ ); +} diff --git a/src/components/integrations/OrgDiscordProvider.tsx b/src/components/integrations/OrgDiscordProvider.tsx new file mode 100644 index 000000000..42dde3bdb --- /dev/null +++ b/src/components/integrations/OrgDiscordProvider.tsx @@ -0,0 +1,94 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { DiscordProvider, type DiscordQueries, type DiscordMutations } from './DiscordContext'; + +type OrgDiscordProviderProps = { + organizationId: string; + children: ReactNode; +}; + +export function OrgDiscordProvider({ organizationId, children }: OrgDiscordProviderProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const queries: DiscordQueries = { + getInstallation: () => + useQuery(trpc.organizations.discord.getInstallation.queryOptions({ organizationId })), + getOAuthUrl: () => + useQuery(trpc.organizations.discord.getOAuthUrl.queryOptions({ organizationId })), + }; + + const uninstallAppMutation = useMutation( + trpc.organizations.discord.uninstallApp.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.discord.getInstallation.queryKey({ organizationId }), + }); + }, + }) + ); + + const testConnectionMutation = useMutation( + trpc.organizations.discord.testConnection.mutationOptions() + ); + + const devRemoveDbRowOnlyMutation = useMutation( + trpc.organizations.discord.devRemoveDbRowOnly.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.discord.getInstallation.queryKey({ organizationId }), + }); + }, + }) + ); + + const mutations: DiscordMutations = { + uninstallApp: { + ...uninstallAppMutation, + mutate: (_: void, options?: Parameters[1]) => { + uninstallAppMutation.mutate({ organizationId }, options); + }, + mutateAsync: async ( + _: void, + options?: Parameters[1] + ) => { + return uninstallAppMutation.mutateAsync({ organizationId }, options); + }, + } as DiscordMutations['uninstallApp'], + + testConnection: { + ...testConnectionMutation, + mutate: (_: void, options?: Parameters[1]) => { + testConnectionMutation.mutate({ organizationId }, options); + }, + mutateAsync: async ( + _: void, + options?: Parameters[1] + ) => { + return testConnectionMutation.mutateAsync({ organizationId }, options); + }, + } as DiscordMutations['testConnection'], + + devRemoveDbRowOnly: { + ...devRemoveDbRowOnlyMutation, + mutate: (_: void, options?: Parameters[1]) => { + devRemoveDbRowOnlyMutation.mutate({ organizationId }, options); + }, + mutateAsync: async ( + _: void, + options?: Parameters[1] + ) => { + return devRemoveDbRowOnlyMutation.mutateAsync({ organizationId }, options); + }, + } as DiscordMutations['devRemoveDbRowOnly'], + }; + + return ( + + {children} + + ); +} diff --git a/src/components/integrations/UserDiscordProvider.tsx b/src/components/integrations/UserDiscordProvider.tsx new file mode 100644 index 000000000..15eb8bd71 --- /dev/null +++ b/src/components/integrations/UserDiscordProvider.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { DiscordProvider, type DiscordQueries, type DiscordMutations } from './DiscordContext'; + +export function UserDiscordProvider({ children }: { children: ReactNode }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const queries: DiscordQueries = { + getInstallation: () => useQuery(trpc.discord.getInstallation.queryOptions()), + getOAuthUrl: () => useQuery(trpc.discord.getOAuthUrl.queryOptions()), + }; + + const mutations: DiscordMutations = { + uninstallApp: useMutation( + trpc.discord.uninstallApp.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.discord.getInstallation.queryKey(), + }); + }, + }) + ), + + testConnection: useMutation(trpc.discord.testConnection.mutationOptions()), + + devRemoveDbRowOnly: useMutation( + trpc.discord.devRemoveDbRowOnly.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.discord.getInstallation.queryKey(), + }); + }, + }) + ), + }; + + return ( + + {children} + + ); +} diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 3978873be..d5ada5460 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -100,6 +100,12 @@ export const APP_BUILDER_DB_PROXY_AUTH_TOKEN = getEnvVariable('APP_BUILDER_DB_PR export const SLACK_CLIENT_ID = getEnvVariable('SLACK_CLIENT_ID'); export const SLACK_CLIENT_SECRET = getEnvVariable('SLACK_CLIENT_SECRET'); export const SLACK_SIGNING_SECRET = getEnvVariable('SLACK_SIGNING_SECRET'); + +// Discord +export const DISCORD_CLIENT_ID = getEnvVariable('DISCORD_CLIENT_ID'); +export const DISCORD_CLIENT_SECRET = getEnvVariable('DISCORD_CLIENT_SECRET'); +export const DISCORD_BOT_TOKEN = getEnvVariable('DISCORD_BOT_TOKEN'); + // Posts user feedback into a fixed Slack channel in the Kilo workspace. // Expected to be a Slack Incoming Webhook URL. export const SLACK_USER_FEEDBACK_WEBHOOK_URL = getEnvVariable('SLACK_USER_FEEDBACK_WEBHOOK_URL'); diff --git a/src/lib/integrations/core/constants.ts b/src/lib/integrations/core/constants.ts index 1566d266f..8bb08f196 100644 --- a/src/lib/integrations/core/constants.ts +++ b/src/lib/integrations/core/constants.ts @@ -146,6 +146,7 @@ export const PLATFORM = { GITHUB: 'github', GITLAB: 'gitlab', SLACK: 'slack', + DISCORD: 'discord', } as const; /** diff --git a/src/lib/integrations/discord-service.ts b/src/lib/integrations/discord-service.ts new file mode 100644 index 000000000..60df40e87 --- /dev/null +++ b/src/lib/integrations/discord-service.ts @@ -0,0 +1,302 @@ +import 'server-only'; +import { db } from '@/lib/drizzle'; +import type { PlatformIntegration } from '@/db/schema'; +import { platform_integrations } from '@/db/schema'; +import { eq, and, isNull } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; +import type { Owner } from '@/lib/integrations/core/types'; +import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; +import { DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN } from '@/lib/config.server'; +import { APP_URL } from '@/lib/constants'; + +// Discord OAuth2 scopes for the bot integration +// 'bot' scope is needed for the bot to join servers +// 'guilds' scope allows reading basic guild info +const DISCORD_SCOPES = ['bot', 'guilds', 'applications.commands']; + +// Discord bot permissions (bitfield) +// Includes: Send Messages, Read Message History, Add Reactions, Use Slash Commands, Embed Links, Attach Files +const DISCORD_BOT_PERMISSIONS = '277025770560'; + +const DISCORD_REDIRECT_URI = `${APP_URL}/api/integrations/discord/callback`; + +/** + * Discord OAuth2 token response shape + */ +export type DiscordOAuth2Response = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; + guild?: { + id: string; + name: string; + icon: string | null; + }; +}; + +function getOwnershipConditions(owner: Owner) { + return owner.type === 'user' + ? [ + eq(platform_integrations.owned_by_user_id, owner.id), + isNull(platform_integrations.owned_by_organization_id), + ] + : [ + eq(platform_integrations.owned_by_organization_id, owner.id), + isNull(platform_integrations.owned_by_user_id), + ]; +} + +/** + * Get Discord OAuth URL for initiating the OAuth flow + */ +export function getDiscordOAuthUrl(state: string): string { + if (!DISCORD_CLIENT_ID) { + throw new Error('DISCORD_CLIENT_ID is not configured'); + } + + const params = new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + permissions: DISCORD_BOT_PERMISSIONS, + scope: DISCORD_SCOPES.join(' '), + redirect_uri: DISCORD_REDIRECT_URI, + response_type: 'code', + state, + }); + + return `https://discord.com/oauth2/authorize?${params.toString()}`; +} + +/** + * Exchange OAuth code for access token + */ +export async function exchangeDiscordCode(code: string): Promise { + if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) { + throw new Error('Discord OAuth credentials are not configured'); + } + + const response = await fetch('https://discord.com/api/v10/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: DISCORD_REDIRECT_URI, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Discord OAuth error: ${errorText}`); + } + + const data = (await response.json()) as DiscordOAuth2Response; + + if (!data.access_token) { + throw new Error('Discord OAuth error: No access token received'); + } + + return data; +} + +/** + * Get Discord installation for an owner + */ +export async function getInstallation(owner: Owner): Promise { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and(...getOwnershipConditions(owner), eq(platform_integrations.platform, PLATFORM.DISCORD)) + ) + .limit(1); + + return integration || null; +} + +/** + * Get Discord installation by guild ID + * Used to identify which Kilo Code user/org owns the installation when receiving Discord events + */ +export async function getInstallationByGuildId( + guildId: string +): Promise { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and( + eq(platform_integrations.platform, PLATFORM.DISCORD), + eq(platform_integrations.platform_installation_id, guildId) + ) + ) + .limit(1); + + return integration || null; +} + +/** + * Get the owner information from a Discord installation + */ +export function getOwnerFromInstallation(integration: PlatformIntegration): Owner | null { + if (integration.owned_by_organization_id) { + return { type: 'org', id: integration.owned_by_organization_id }; + } + if (integration.owned_by_user_id) { + return { type: 'user', id: integration.owned_by_user_id }; + } + return null; +} + +/** + * Create or update Discord installation from OAuth response + */ +export async function upsertDiscordInstallation( + owner: Owner, + oauthResponse: DiscordOAuth2Response +): Promise { + if (!oauthResponse.guild?.id) { + throw new Error( + 'Discord OAuth response did not include guild information. The bot must be added to a server during authorization.' + ); + } + + const existing = await getInstallation(owner); + + const guildId = oauthResponse.guild.id; + const guildName = oauthResponse.guild.name || 'Unknown Server'; + const scopes = oauthResponse.scope?.split(' ') || null; + + // Note: We intentionally do NOT store the OAuth2 access_token or refresh_token. + // Discord's OAuth2 user tokens are short-lived and not used for bot operations. + // All bot API calls use the DISCORD_BOT_TOKEN env var instead. + const metadata = { + guild_icon: oauthResponse.guild.icon, + }; + + if (existing) { + const [updated] = await db + .update(platform_integrations) + .set({ + platform_account_id: guildId, + platform_account_login: guildName, + scopes, + integration_status: INTEGRATION_STATUS.ACTIVE, + metadata, + updated_at: new Date().toISOString(), + }) + .where(eq(platform_integrations.id, existing.id)) + .returning(); + + return updated; + } + + const [created] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: owner.type === 'user' ? owner.id : null, + owned_by_organization_id: owner.type === 'org' ? owner.id : null, + platform: PLATFORM.DISCORD, + integration_type: 'oauth', + platform_installation_id: guildId, + platform_account_id: guildId, + platform_account_login: guildName, + scopes, + integration_status: INTEGRATION_STATUS.ACTIVE, + metadata, + installed_at: new Date().toISOString(), + }) + .returning(); + + return created; +} + +/** + * Uninstall Discord integration for an owner + */ +export async function uninstallApp(owner: Owner) { + const integration = await getInstallation(owner); + + if (!integration || integration.integration_status !== INTEGRATION_STATUS.ACTIVE) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Discord installation not found', + }); + } + + await db.delete(platform_integrations).where(eq(platform_integrations.id, integration.id)); + + return { success: true }; +} + +/** + * Remove only the database row for a Discord integration without revoking the token. + * Useful for development when you want to re-test the OAuth flow. + */ +export async function removeDbRowOnly(owner: Owner) { + const integration = await getInstallation(owner); + + if (!integration) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Discord installation not found', + }); + } + + await db.delete(platform_integrations).where(eq(platform_integrations.id, integration.id)); + + return { success: true }; +} + +/** + * Test Discord connection by verifying the bot can access the guild. + * Uses the Bot Token (not the OAuth2 user token) since bot operations + * require `Bot` authorization, and the OAuth2 token from the install + * flow is short-lived. + */ +export async function testConnection(owner: Owner): Promise<{ success: boolean; error?: string }> { + const integration = await getInstallation(owner); + + if (!integration) { + return { success: false, error: 'No Discord installation found' }; + } + + if (!DISCORD_BOT_TOKEN) { + return { success: false, error: 'DISCORD_BOT_TOKEN is not configured' }; + } + + const guildId = integration.platform_account_id; + if (!guildId) { + return { success: false, error: 'No guild ID found for this installation' }; + } + + try { + // Verify the bot can access this guild + const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, { + headers: { + Authorization: `Bot ${DISCORD_BOT_TOKEN}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 403 || response.status === 404) { + return { + success: false, + error: 'Bot does not have access to this server. It may have been removed.', + }; + } + return { success: false, error: `Discord API error: ${errorText}` }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } +} diff --git a/src/lib/integrations/platform-definitions.ts b/src/lib/integrations/platform-definitions.ts index 7aeddaecf..43b4dd748 100644 --- a/src/lib/integrations/platform-definitions.ts +++ b/src/lib/integrations/platform-definitions.ts @@ -1,6 +1,6 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; -export type PlatformType = 'github' | 'gitlab' | 'bitbucket' | 'slack'; +export type PlatformType = 'github' | 'gitlab' | 'bitbucket' | 'slack' | 'discord'; export type PlatformStatus = 'installed' | 'not_installed' | 'coming_soon'; @@ -48,6 +48,15 @@ export const PLATFORM_DEFINITIONS: PlatformDefinition[] = [ personalRoute: '/integrations/gitlab', orgRoute: organizationId => `/organizations/${organizationId}/integrations/gitlab`, }, + { + id: 'discord', + name: 'Discord', + description: + 'Create PRs, debug code, ask questions about your repos, etc. directly from Discord', + enabled: true, + personalRoute: '/integrations/discord', + orgRoute: organizationId => `/organizations/${organizationId}/integrations/discord`, + }, { id: 'bitbucket', name: 'Bitbucket', @@ -60,6 +69,7 @@ type InstallationStatus = { github?: { installed: boolean }; slack?: { installed: boolean }; gitlab?: { installed: boolean }; + discord?: { installed: boolean }; }; function getStatus(id: PlatformType, installations: InstallationStatus): PlatformStatus { @@ -72,6 +82,9 @@ function getStatus(id: PlatformType, installations: InstallationStatus): Platfor if (id === PLATFORM.GITLAB) { return installations.gitlab?.installed ? 'installed' : 'not_installed'; } + if (id === PLATFORM.DISCORD) { + return installations.discord?.installed ? 'installed' : 'not_installed'; + } return 'coming_soon'; } diff --git a/src/routers/discord-router.ts b/src/routers/discord-router.ts new file mode 100644 index 000000000..5a7192813 --- /dev/null +++ b/src/routers/discord-router.ts @@ -0,0 +1,63 @@ +import 'server-only'; +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import * as discordService from '@/lib/integrations/discord-service'; +import { TRPCError } from '@trpc/server'; + +export const discordRouter = createTRPCRouter({ + // Get Discord installation status for the current user + getInstallation: baseProcedure.query(async ({ ctx }) => { + const integration = await discordService.getInstallation({ + type: 'user', + id: ctx.user.id, + }); + + if (!integration) { + return { + installed: false, + installation: null, + }; + } + + // Only return installed: true if the integration status is 'active' + const isInstalled = integration.integration_status === 'active'; + + return { + installed: isInstalled, + installation: { + guildId: integration.platform_account_id, + guildName: integration.platform_account_login, + scopes: integration.scopes, + installedAt: integration.installed_at, + }, + }; + }), + + // Get OAuth URL for initiating Discord OAuth flow + getOAuthUrl: baseProcedure.query(({ ctx }) => { + const state = `user_${ctx.user.id}`; + return { + url: discordService.getDiscordOAuthUrl(state), + }; + }), + + // Uninstall Discord integration for the current user + uninstallApp: baseProcedure.mutation(async ({ ctx }) => { + return discordService.uninstallApp({ type: 'user', id: ctx.user.id }); + }), + + // Test Discord connection + testConnection: baseProcedure.mutation(async ({ ctx }) => { + return discordService.testConnection({ type: 'user', id: ctx.user.id }); + }), + + // Dev-only: Remove only the database row without revoking the Discord token + devRemoveDbRowOnly: baseProcedure.mutation(async ({ ctx }) => { + if (process.env.NODE_ENV !== 'development') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This endpoint is only available in development mode', + }); + } + return discordService.removeDbRowOnly({ type: 'user', id: ctx.user.id }); + }), +}); diff --git a/src/routers/organizations/organization-discord-router.ts b/src/routers/organizations/organization-discord-router.ts new file mode 100644 index 000000000..d1c12539a --- /dev/null +++ b/src/routers/organizations/organization-discord-router.ts @@ -0,0 +1,89 @@ +import { createTRPCRouter } from '@/lib/trpc/init'; +import { organizationMemberProcedure, organizationOwnerProcedure } from './utils'; +import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; +import * as discordService from '@/lib/integrations/discord-service'; +import { TRPCError } from '@trpc/server'; + +export const organizationDiscordRouter = createTRPCRouter({ + /** + * Gets the Discord installation status for an organization + */ + getInstallation: organizationMemberProcedure.query(async ({ input }) => { + const integration = await discordService.getInstallation({ + type: 'org', + id: input.organizationId, + }); + + if (!integration) { + return { + installed: false, + installation: null, + }; + } + + // Only return installed: true if the integration status is 'active' + const isInstalled = integration.integration_status === 'active'; + + return { + installed: isInstalled, + installation: { + guildId: integration.platform_account_id, + guildName: integration.platform_account_login, + scopes: integration.scopes, + installedAt: integration.installed_at, + }, + }; + }), + + /** + * Get OAuth URL for initiating Discord OAuth flow + */ + getOAuthUrl: organizationMemberProcedure.query(({ input }) => { + const state = `org_${input.organizationId}`; + return { + url: discordService.getDiscordOAuthUrl(state), + }; + }), + + /** + * Uninstalls the Discord integration for an organization + */ + uninstallApp: organizationOwnerProcedure.mutation(async ({ input, ctx }) => { + const result = await discordService.uninstallApp({ + type: 'org', + id: input.organizationId, + }); + + // Audit log + await createAuditLog({ + organization_id: input.organizationId, + action: 'organization.settings.change', + actor_id: ctx.user.id, + actor_email: ctx.user.google_user_email, + actor_name: ctx.user.google_user_name, + message: 'Disconnected Discord integration', + }); + + return result; + }), + + /** + * Test Discord connection + */ + testConnection: organizationMemberProcedure.mutation(async ({ input }) => { + return discordService.testConnection({ type: 'org', id: input.organizationId }); + }), + + /** + * Dev-only: Remove only the database row without revoking the Discord token + */ + devRemoveDbRowOnly: organizationOwnerProcedure.mutation(async ({ input }) => { + if (process.env.NODE_ENV !== 'development') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This endpoint is only available in development mode', + }); + } + return discordService.removeDbRowOnly({ type: 'org', id: input.organizationId }); + }), +}); diff --git a/src/routers/organizations/organization-router.ts b/src/routers/organizations/organization-router.ts index 4b4559a3c..f3896d103 100644 --- a/src/routers/organizations/organization-router.ts +++ b/src/routers/organizations/organization-router.ts @@ -53,6 +53,7 @@ import { organizationAppBuilderRouter } from '@/routers/organizations/organizati import { organizationSecurityAgentRouter } from '@/routers/organizations/organization-security-agent-router'; import { organizationSecurityAuditLogRouter } from '@/routers/organizations/organization-security-audit-log-router'; import { organizationSlackRouter } from '@/routers/organizations/organization-slack-router'; +import { organizationDiscordRouter } from '@/routers/organizations/organization-discord-router'; import { organizationAutoTriageRouter } from '@/routers/organizations/organization-auto-triage-router'; import { organizationAutoFixRouter } from '@/routers/organizations/organization-auto-fix-router'; import { organizationAutoTopUpRouter } from '@/routers/organizations/organization-auto-top-up-router'; @@ -109,6 +110,7 @@ export const organizationsRouter = createTRPCRouter({ securityAgent: organizationSecurityAgentRouter, securityAuditLog: organizationSecurityAuditLogRouter, slack: organizationSlackRouter, + discord: organizationDiscordRouter, autoTriage: organizationAutoTriageRouter, autoFix: organizationAutoFixRouter, autoTopUp: organizationAutoTopUpRouter, diff --git a/src/routers/root-router.ts b/src/routers/root-router.ts index f077168fb..3d5a41dc7 100644 --- a/src/routers/root-router.ts +++ b/src/routers/root-router.ts @@ -14,6 +14,7 @@ import { cloudAgentNextRouter } from '@/routers/cloud-agent-next-router'; import { githubAppsRouter } from '@/routers/github-apps-router'; import { gitlabRouter } from '@/routers/gitlab-router'; import { slackRouter } from '@/routers/slack-router'; +import { discordRouter } from '@/routers/discord-router'; import { codeReviewRouter } from '@/routers/code-reviews/code-reviews-router'; import { personalReviewAgentRouter } from '@/routers/code-reviews-router'; import { byokRouter } from '@/routers/byok-router'; @@ -47,6 +48,7 @@ export const rootRouter = createTRPCRouter({ githubApps: githubAppsRouter, gitlab: gitlabRouter, slack: slackRouter, + discord: discordRouter, cloudAgent: cloudAgentRouter, cloudAgentNext: cloudAgentNextRouter, codeReviews: codeReviewRouter, From 766c381fcd7aca36eaa04d16d1629198fffeea87 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 26 Feb 2026 10:46:06 +0100 Subject: [PATCH 2/5] Fix CSRF in Discord OAuth flow by HMAC-signing the state parameter The state parameter was a plain, guessable string (user_id/org_id), allowing authorization code injection attacks. Sign it with HMAC-SHA256 using NEXTAUTH_SECRET and embed the initiating user's ID so the callback can verify the flow wasn't tampered with. --- .../integrations/discord/callback/route.ts | 55 +++++++++--- src/lib/integrations/oauth-state.ts | 86 +++++++++++++++++++ src/routers/discord-router.ts | 3 +- .../organization-discord-router.ts | 5 +- 4 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 src/lib/integrations/oauth-state.ts diff --git a/src/app/api/integrations/discord/callback/route.ts b/src/app/api/integrations/discord/callback/route.ts index 378261f51..e7fe9b547 100644 --- a/src/app/api/integrations/discord/callback/route.ts +++ b/src/app/api/integrations/discord/callback/route.ts @@ -5,13 +5,19 @@ import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import type { Owner } from '@/lib/integrations/core/types'; import { captureException, captureMessage } from '@sentry/nextjs'; import { exchangeDiscordCode, upsertDiscordInstallation } from '@/lib/integrations/discord-service'; +import { verifyOAuthState } from '@/lib/integrations/oauth-state'; import { APP_URL } from '@/lib/constants'; const buildDiscordRedirectPath = (state: string | null, queryParam: string): string => { - if (state?.startsWith('org_')) { - return `/organizations/${state.replace('org_', '')}/integrations/discord?${queryParam}`; + // Try to extract the owner from a signed state for best-effort redirects on error paths. + // We use verifyOAuthState so we don't trust unsigned/tampered values for routing. + const verified = state ? verifyOAuthState(state) : null; + const owner = verified?.owner; + + if (owner?.startsWith('org_')) { + return `/organizations/${owner.replace('org_', '')}/integrations/discord?${queryParam}`; } - if (state?.startsWith('user_')) { + if (owner?.startsWith('user_')) { return `/integrations/discord?${queryParam}`; } return `/integrations?${queryParam}`; @@ -62,26 +68,47 @@ export async function GET(request: NextRequest) { ); } - // 3. Parse owner from state + // 3. Verify signed state (CSRF protection) + const verified = verifyOAuthState(state); + if (!verified) { + captureMessage('Discord callback invalid or tampered state signature', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { code: '***', state, allParams: Object.fromEntries(searchParams.entries()) }, + }); + return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL)); + } + + // 4. Verify the user completing the flow is the same user who initiated it + if (verified.userId !== user.id) { + captureMessage('Discord callback user mismatch (possible CSRF)', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { stateUserId: verified.userId, sessionUserId: user.id }, + }); + return NextResponse.redirect(new URL('/integrations?error=unauthorized', APP_URL)); + } + + // 5. Parse owner from verified state payload let owner: Owner; - let ownerId: string; + const ownerStr = verified.owner; - if (state?.startsWith('org_')) { - ownerId = state.replace('org_', ''); + if (ownerStr.startsWith('org_')) { + const ownerId = ownerStr.replace('org_', ''); owner = { type: 'org', id: ownerId }; - } else if (state?.startsWith('user_')) { - ownerId = state.replace('user_', ''); + } else if (ownerStr.startsWith('user_')) { + const ownerId = ownerStr.replace('user_', ''); owner = { type: 'user', id: ownerId }; } else { captureMessage('Discord callback missing or invalid owner in state', { level: 'warning', tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, - extra: { code: '***', state, allParams: Object.fromEntries(searchParams.entries()) }, + extra: { code: '***', owner: ownerStr }, }); return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL)); } - // 4. Verify user has access to the owner + // 6. Verify user has access to the owner if (owner.type === 'org') { await ensureOrganizationAccess({ user }, owner.id); } else { @@ -91,13 +118,13 @@ export async function GET(request: NextRequest) { } } - // 5. Exchange code for access token + // 7. Exchange code for access token const oauthData = await exchangeDiscordCode(code); - // 6. Store installation in database + // 8. Store installation in database await upsertDiscordInstallation(owner, oauthData); - // 7. Redirect to success page + // 9. Redirect to success page const successPath = owner.type === 'org' ? `/organizations/${owner.id}/integrations/discord?success=installed` diff --git a/src/lib/integrations/oauth-state.ts b/src/lib/integrations/oauth-state.ts new file mode 100644 index 000000000..dec46eba3 --- /dev/null +++ b/src/lib/integrations/oauth-state.ts @@ -0,0 +1,86 @@ +import 'server-only'; +import crypto from 'node:crypto'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; + +/** + * HMAC-signed OAuth state parameter. + * + * The plain owner string (`user_` / `org_`) that was previously used + * as the OAuth `state` is guessable and does not bind the flow to the user + * who initiated it, leaving the callback vulnerable to CSRF / authorization- + * code injection. + * + * This module produces a state value of the form: + * + * base64url({ owner, uid }) . HMAC-SHA256(payload, secret) + * + * where `owner` is the original owner string and `uid` is the ID of the + * authenticated user who started the flow. On the callback we: + * + * 1. Verify the HMAC (state was created by us, not forged). + * 2. Extract `uid` and confirm it matches the session user (same user + * who initiated the flow is completing it). + * 3. Return the `owner` string so the rest of the callback logic is + * unchanged. + */ + +const HMAC_ALGORITHM = 'sha256'; + +function sign(data: string): string { + return crypto.createHmac(HMAC_ALGORITHM, NEXTAUTH_SECRET).update(data).digest('base64url'); +} + +/** + * Build a signed OAuth state parameter. + * + * @param owner – owner string, e.g. `user_abc123` or `org_xyz789` + * @param userId – the ID of the currently-authenticated user initiating the flow + */ +export function createOAuthState(owner: string, userId: string): string { + const payload = Buffer.from(JSON.stringify({ owner, uid: userId })).toString('base64url'); + const signature = sign(payload); + return `${payload}.${signature}`; +} + +export type VerifiedOAuthState = { + /** The original owner string (`user_` or `org_`) */ + owner: string; + /** The user ID that initiated the OAuth flow */ + userId: string; +}; + +/** + * Verify a signed OAuth state parameter and return the embedded payload. + * + * Returns `null` if the state is missing, malformed, or the signature is + * invalid. + */ +export function verifyOAuthState(state: string | null): VerifiedOAuthState | null { + if (!state) return null; + + const dotIndex = state.indexOf('.'); + if (dotIndex === -1) return null; + + const payload = state.slice(0, dotIndex); + const providedSig = state.slice(dotIndex + 1); + + // Constant-time comparison to prevent timing attacks + const expectedSig = sign(payload); + if ( + providedSig.length !== expectedSig.length || + !crypto.timingSafeEqual(Buffer.from(providedSig), Buffer.from(expectedSig)) + ) { + return null; + } + + try { + const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { + owner?: string; + uid?: string; + }; + if (typeof data.owner !== 'string' || typeof data.uid !== 'string') return null; + return { owner: data.owner, userId: data.uid }; + } catch { + return null; + } +} diff --git a/src/routers/discord-router.ts b/src/routers/discord-router.ts index 5a7192813..4d5f11536 100644 --- a/src/routers/discord-router.ts +++ b/src/routers/discord-router.ts @@ -1,6 +1,7 @@ import 'server-only'; import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; import * as discordService from '@/lib/integrations/discord-service'; +import { createOAuthState } from '@/lib/integrations/oauth-state'; import { TRPCError } from '@trpc/server'; export const discordRouter = createTRPCRouter({ @@ -34,7 +35,7 @@ export const discordRouter = createTRPCRouter({ // Get OAuth URL for initiating Discord OAuth flow getOAuthUrl: baseProcedure.query(({ ctx }) => { - const state = `user_${ctx.user.id}`; + const state = createOAuthState(`user_${ctx.user.id}`, ctx.user.id); return { url: discordService.getDiscordOAuthUrl(state), }; diff --git a/src/routers/organizations/organization-discord-router.ts b/src/routers/organizations/organization-discord-router.ts index d1c12539a..d7535a7ea 100644 --- a/src/routers/organizations/organization-discord-router.ts +++ b/src/routers/organizations/organization-discord-router.ts @@ -2,6 +2,7 @@ import { createTRPCRouter } from '@/lib/trpc/init'; import { organizationMemberProcedure, organizationOwnerProcedure } from './utils'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; import * as discordService from '@/lib/integrations/discord-service'; +import { createOAuthState } from '@/lib/integrations/oauth-state'; import { TRPCError } from '@trpc/server'; export const organizationDiscordRouter = createTRPCRouter({ @@ -38,8 +39,8 @@ export const organizationDiscordRouter = createTRPCRouter({ /** * Get OAuth URL for initiating Discord OAuth flow */ - getOAuthUrl: organizationMemberProcedure.query(({ input }) => { - const state = `org_${input.organizationId}`; + getOAuthUrl: organizationMemberProcedure.query(({ input, ctx }) => { + const state = createOAuthState(`org_${input.organizationId}`, ctx.user.id); return { url: discordService.getDiscordOAuthUrl(state), }; From 3ad963a699c10a1a684526d62ca7c10ffcfdcc64 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 26 Feb 2026 11:11:01 +0100 Subject: [PATCH 3/5] fix: add TTL and nonce to OAuth state to prevent replay attacks The signed OAuth state payload previously only contained { owner, uid }, making it deterministic and valid indefinitely. A leaked state token (via logs, referrer headers, browser history) could be replayed at any time by the same authenticated user context. Add an issued-at timestamp (iat) with a 10-minute TTL and a 128-bit random nonce to each state token. Verification now rejects tokens that are expired, have future timestamps, or lack the new fields. --- src/lib/integrations/oauth-state.ts | 41 +++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/integrations/oauth-state.ts b/src/lib/integrations/oauth-state.ts index dec46eba3..1d30f2c39 100644 --- a/src/lib/integrations/oauth-state.ts +++ b/src/lib/integrations/oauth-state.ts @@ -12,20 +12,30 @@ import { NEXTAUTH_SECRET } from '@/lib/config.server'; * * This module produces a state value of the form: * - * base64url({ owner, uid }) . HMAC-SHA256(payload, secret) + * base64url({ owner, uid, iat, nonce }) . HMAC-SHA256(payload, secret) * - * where `owner` is the original owner string and `uid` is the ID of the - * authenticated user who started the flow. On the callback we: + * where `owner` is the original owner string, `uid` is the ID of the + * authenticated user who started the flow, `iat` is the issued-at timestamp + * (seconds since epoch), and `nonce` is random bytes to ensure uniqueness. + * + * On the callback we: * * 1. Verify the HMAC (state was created by us, not forged). - * 2. Extract `uid` and confirm it matches the session user (same user + * 2. Check `iat` is within the allowed TTL window (default 10 minutes). + * 3. Extract `uid` and confirm it matches the session user (same user * who initiated the flow is completing it). - * 3. Return the `owner` string so the rest of the callback logic is + * 4. Return the `owner` string so the rest of the callback logic is * unchanged. */ const HMAC_ALGORITHM = 'sha256'; +/** Maximum age of a state token in seconds (10 minutes). */ +const STATE_TTL_SECONDS = 10 * 60; + +/** Number of random bytes for the nonce (16 bytes = 128 bits). */ +const NONCE_BYTES = 16; + function sign(data: string): string { return crypto.createHmac(HMAC_ALGORITHM, NEXTAUTH_SECRET).update(data).digest('base64url'); } @@ -37,7 +47,11 @@ function sign(data: string): string { * @param userId – the ID of the currently-authenticated user initiating the flow */ export function createOAuthState(owner: string, userId: string): string { - const payload = Buffer.from(JSON.stringify({ owner, uid: userId })).toString('base64url'); + const iat = Math.floor(Date.now() / 1000); + const nonce = crypto.randomBytes(NONCE_BYTES).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ owner, uid: userId, iat, nonce })).toString( + 'base64url' + ); const signature = sign(payload); return `${payload}.${signature}`; } @@ -52,8 +66,8 @@ export type VerifiedOAuthState = { /** * Verify a signed OAuth state parameter and return the embedded payload. * - * Returns `null` if the state is missing, malformed, or the signature is - * invalid. + * Returns `null` if the state is missing, malformed, the signature is + * invalid, or the token has expired. */ export function verifyOAuthState(state: string | null): VerifiedOAuthState | null { if (!state) return null; @@ -77,8 +91,19 @@ export function verifyOAuthState(state: string | null): VerifiedOAuthState | nul const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { owner?: string; uid?: string; + iat?: number; + nonce?: string; }; if (typeof data.owner !== 'string' || typeof data.uid !== 'string') return null; + + // Enforce TTL: reject tokens that are too old or have no timestamp + if (typeof data.iat !== 'number') return null; + const ageSeconds = Math.floor(Date.now() / 1000) - data.iat; + if (ageSeconds < 0 || ageSeconds > STATE_TTL_SECONDS) return null; + + // Require nonce to be present (guards against old-format tokens) + if (typeof data.nonce !== 'string' || data.nonce.length === 0) return null; + return { owner: data.owner, userId: data.uid }; } catch { return null; From 9e1ea6a3fd2e3b395312f262de0eb963695109e3 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 26 Feb 2026 13:16:45 +0100 Subject: [PATCH 4/5] Fix potential URL encoding error --- src/app/api/integrations/discord/callback/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/integrations/discord/callback/route.ts b/src/app/api/integrations/discord/callback/route.ts index e7fe9b547..01b118bdc 100644 --- a/src/app/api/integrations/discord/callback/route.ts +++ b/src/app/api/integrations/discord/callback/route.ts @@ -51,7 +51,7 @@ export async function GET(request: NextRequest) { }); return NextResponse.redirect( - new URL(buildDiscordRedirectPath(state, `error=${error}`), APP_URL) + new URL(buildDiscordRedirectPath(state, `error=${encodeURIComponent(error)}`), APP_URL) ); } From e7db164afecb1455955006f55a1d408b015df302 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 26 Feb 2026 13:18:39 +0100 Subject: [PATCH 5/5] Open with noopener,noreferrer --- src/components/integrations/DiscordIntegrationDetails.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/integrations/DiscordIntegrationDetails.tsx b/src/components/integrations/DiscordIntegrationDetails.tsx index 2c181dec0..a836bd542 100644 --- a/src/components/integrations/DiscordIntegrationDetails.tsx +++ b/src/components/integrations/DiscordIntegrationDetails.tsx @@ -183,7 +183,11 @@ export function DiscordIntegrationDetails({ success, error }: DiscordIntegration