From 21f63ffc1027df36b81454123c434cb5cc20e524 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Mon, 2 Mar 2026 15:32:23 +0100 Subject: [PATCH 1/4] Add Discord account-link auth flow for team members Route unlinked Discord users through a secure link button flow that enforces Kilo sign-in and owner/org access before redirecting to Discord OAuth. This lets org members authorize themselves without falling back to an internal bot user. --- .../(app)/integrations/discord/link/route.ts | 75 ++++++++++ src/app/(app)/integrations/discord/page.tsx | 5 +- .../[id]/integrations/discord/page.tsx | 2 +- .../integrations/discord/callback/route.ts | 38 ++++- src/app/discord/webhook/route.ts | 6 + .../DiscordIntegrationDetails.tsx | 10 ++ src/lib/discord-bot.ts | 30 +++- src/lib/discord/auth.test.ts | 136 +++++++++++++++++ src/lib/discord/auth.ts | 78 +++++++--- src/lib/discord/authorized-users.test.ts | 44 ++++++ src/lib/discord/authorized-users.ts | 50 +++++++ src/lib/integrations/discord-service.ts | 137 ++++++++++++++++-- src/routers/discord-router.ts | 11 ++ 13 files changed, 579 insertions(+), 43 deletions(-) create mode 100644 src/app/(app)/integrations/discord/link/route.ts create mode 100644 src/lib/discord/auth.test.ts create mode 100644 src/lib/discord/authorized-users.test.ts create mode 100644 src/lib/discord/authorized-users.ts diff --git a/src/app/(app)/integrations/discord/link/route.ts b/src/app/(app)/integrations/discord/link/route.ts new file mode 100644 index 000000000..bcbe6e56d --- /dev/null +++ b/src/app/(app)/integrations/discord/link/route.ts @@ -0,0 +1,75 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import * as z from 'zod'; +import { APP_URL } from '@/lib/constants'; +import { getUserFromAuth } from '@/lib/user.server'; +import { createOAuthState } from '@/lib/integrations/oauth-state'; +import { getDiscordUserLinkOAuthUrl, getInstallation } from '@/lib/integrations/discord-service'; +import { isOrganizationMember } from '@/lib/organizations/organizations'; +import type { Owner } from '@/lib/integrations/core/types'; + +const LinkRequestSchema = z.discriminatedUnion('ownerType', [ + z.object({ ownerType: z.literal('org'), ownerId: z.uuid() }), + z.object({ ownerType: z.literal('user'), ownerId: z.string().min(1) }), +]); + +function buildIntegrationPath(owner: Owner, queryParam?: string): string { + const basePath = + owner.type === 'org' + ? `/organizations/${owner.id}/integrations/discord` + : '/integrations/discord'; + + return queryParam ? `${basePath}?${queryParam}` : basePath; +} + +function buildSignInPath(callbackPath: string): string { + return `/users/sign_in?callbackPath=${encodeURIComponent(callbackPath)}`; +} + +export async function GET(request: NextRequest) { + const parsed = LinkRequestSchema.safeParse({ + ownerType: request.nextUrl.searchParams.get('ownerType'), + ownerId: request.nextUrl.searchParams.get('ownerId'), + }); + + if (!parsed.success) { + return NextResponse.redirect(new URL('/integrations/discord?error=invalid_link', APP_URL)); + } + + const owner: Owner = + parsed.data.ownerType === 'org' + ? { type: 'org', id: parsed.data.ownerId } + : { type: 'user', id: parsed.data.ownerId }; + + const callbackPath = `${request.nextUrl.pathname}${request.nextUrl.search}`; + const authResult = await getUserFromAuth({ adminOnly: false }); + if (!authResult.user) { + return NextResponse.redirect(new URL(buildSignInPath(callbackPath), APP_URL)); + } + + if (owner.type === 'org' && !authResult.user.is_admin) { + const isMember = await isOrganizationMember(owner.id, authResult.user.id); + if (!isMember) { + return NextResponse.redirect( + new URL(buildIntegrationPath(owner, 'error=unauthorized'), APP_URL) + ); + } + } + + if (owner.type === 'user' && authResult.user.id !== owner.id) { + return NextResponse.redirect( + new URL(buildIntegrationPath(owner, 'error=unauthorized'), APP_URL) + ); + } + + const installation = await getInstallation(owner); + if (!installation) { + return NextResponse.redirect( + new URL(buildIntegrationPath(owner, 'error=installation_missing'), APP_URL) + ); + } + + const statePrefix = owner.type === 'org' ? `org_${owner.id}` : `user_${owner.id}`; + const state = createOAuthState(statePrefix, authResult.user.id); + return NextResponse.redirect(getDiscordUserLinkOAuthUrl(state)); +} diff --git a/src/app/(app)/integrations/discord/page.tsx b/src/app/(app)/integrations/discord/page.tsx index 8a2e204ef..31f133970 100644 --- a/src/app/(app)/integrations/discord/page.tsx +++ b/src/app/(app)/integrations/discord/page.tsx @@ -43,7 +43,10 @@ export default async function UserDiscordIntegrationPage({ } > - + ); diff --git a/src/app/(app)/organizations/[id]/integrations/discord/page.tsx b/src/app/(app)/organizations/[id]/integrations/discord/page.tsx index 09ab72889..be834c9cc 100644 --- a/src/app/(app)/organizations/[id]/integrations/discord/page.tsx +++ b/src/app/(app)/organizations/[id]/integrations/discord/page.tsx @@ -52,7 +52,7 @@ export default async function OrgDiscordIntegrationPage({ > diff --git a/src/app/api/integrations/discord/callback/route.ts b/src/app/api/integrations/discord/callback/route.ts index 01b118bdc..2265de799 100644 --- a/src/app/api/integrations/discord/callback/route.ts +++ b/src/app/api/integrations/discord/callback/route.ts @@ -4,7 +4,12 @@ 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 { + exchangeDiscordCode, + getDiscordOAuthUserId, + linkDiscordRequesterToOwner, + upsertDiscordInstallation, +} from '@/lib/integrations/discord-service'; import { verifyOAuthState } from '@/lib/integrations/oauth-state'; import { APP_URL } from '@/lib/constants'; @@ -121,14 +126,37 @@ export async function GET(request: NextRequest) { // 7. Exchange code for access token const oauthData = await exchangeDiscordCode(code); - // 8. Store installation in database - await upsertDiscordInstallation(owner, oauthData); + // 8. Resolve the Discord requester identity and persist authorization mapping + const discordUserId = await getDiscordOAuthUserId(oauthData.access_token); + const authorizedRequester = { + kiloUserId: user.id, + discordUserId, + }; + + const isInstallFlow = Boolean(oauthData.guild?.id); + if (isInstallFlow) { + await upsertDiscordInstallation(owner, oauthData, authorizedRequester); + } else { + const linked = await linkDiscordRequesterToOwner(owner, authorizedRequester); + if (!linked) { + captureMessage('Discord user link callback without an existing installation', { + level: 'warning', + tags: { endpoint: 'discord/callback', source: 'discord_oauth' }, + extra: { owner, userId: user.id }, + }); + + return NextResponse.redirect( + new URL(buildDiscordRedirectPath(state, 'error=installation_missing'), APP_URL) + ); + } + } // 9. Redirect to success page + const successQuery = isInstallFlow ? 'success=installed' : 'success=linked_user'; const successPath = owner.type === 'org' - ? `/organizations/${owner.id}/integrations/discord?success=installed` - : `/integrations/discord?success=installed`; + ? `/organizations/${owner.id}/integrations/discord?${successQuery}` + : `/integrations/discord?${successQuery}`; return NextResponse.redirect(new URL(successPath, APP_URL)); } catch (error) { diff --git a/src/app/discord/webhook/route.ts b/src/app/discord/webhook/route.ts index 7b80b678f..6521d09ee 100644 --- a/src/app/discord/webhook/route.ts +++ b/src/app/discord/webhook/route.ts @@ -171,6 +171,12 @@ async function processGatewayMessage(event: ForwardedGatewayEvent) { const responseText = truncateForDiscord(responseWithDevInfo); const postResult = await postDiscordMessage(channelId, responseText, { messageReference: { message_id: messageId }, + linkButton: result.linkDiscordAccountUrl + ? { + label: 'Link My Discord Account', + url: result.linkDiscordAccountUrl, + } + : undefined, }); console.log( diff --git a/src/components/integrations/DiscordIntegrationDetails.tsx b/src/components/integrations/DiscordIntegrationDetails.tsx index 0eb6d7230..adb1b72bd 100644 --- a/src/components/integrations/DiscordIntegrationDetails.tsx +++ b/src/components/integrations/DiscordIntegrationDetails.tsx @@ -274,7 +274,17 @@ export function DiscordIntegrationDetails({ {/* Actions */} + + + Each team member who wants to use Kilo in Discord must link their own Discord + account. + + +
+