-
Notifications
You must be signed in to change notification settings - Fork 6
Add Discord integration (OAuth flow + UI) #558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
178a632
715eff5
0b8e8ed
cb96a47
766c381
3ad963a
9e1ea6a
e7db164
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <PageLayout | ||
| title="Discord Integration" | ||
| subtitle="Connect your Discord server to interact with Kilo" | ||
| headerActions={ | ||
| <Link href="/integrations"> | ||
| <Button variant="ghost" size="sm" className="gap-2"> | ||
| <ArrowLeft className="h-4 w-4" /> | ||
| Back to Integrations | ||
| </Button> | ||
| </Link> | ||
| } | ||
| > | ||
| <UserDiscordProvider> | ||
| <Suspense | ||
| fallback={ | ||
| <Card> | ||
| <CardContent className="pt-6"> | ||
| <div className="animate-pulse space-y-4"> | ||
| <div className="bg-muted h-20 rounded" /> | ||
| <div className="bg-muted h-32 rounded" /> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| } | ||
| > | ||
| <DiscordIntegrationDetails | ||
| success={search.success === 'installed'} | ||
| error={search.error} | ||
| /> | ||
| </Suspense> | ||
| </UserDiscordProvider> | ||
| </PageLayout> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <OrganizationByPageLayout | ||
| params={params} | ||
| render={({ organization }) => ( | ||
| <> | ||
| <div className="space-y-4"> | ||
| <Link href={`/organizations/${organization.id}/integrations`}> | ||
| <Button variant="ghost" size="sm" className="gap-2"> | ||
| <ArrowLeft className="h-4 w-4" /> | ||
| Back to Integrations | ||
| </Button> | ||
| </Link> | ||
| <div> | ||
| <h1 className="text-3xl font-bold">Discord Integration</h1> | ||
| <p className="text-muted-foreground mt-2"> | ||
| Manage Discord integration for {organization.name} | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <OrgDiscordProvider organizationId={organization.id}> | ||
| <Suspense | ||
| fallback={ | ||
| <Card> | ||
| <CardContent className="pt-6"> | ||
| <div className="animate-pulse space-y-4"> | ||
| <div className="bg-muted h-20 rounded" /> | ||
| <div className="bg-muted h-32 rounded" /> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| } | ||
| > | ||
| <DiscordIntegrationDetails | ||
| organizationId={organization.id} | ||
| success={search.success === 'installed'} | ||
| error={search.error} | ||
| /> | ||
| </Suspense> | ||
| </OrgDiscordProvider> | ||
| </> | ||
| )} | ||
| /> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| 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 { verifyOAuthState } from '@/lib/integrations/oauth-state'; | ||
| import { APP_URL } from '@/lib/constants'; | ||
|
|
||
| const buildDiscordRedirectPath = (state: string | null, queryParam: string): string => { | ||
| // 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 (owner?.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)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Auth redirect drops OAuth callback context, causing non-recoverable install failures for signed-out users. When |
||
| } | ||
|
|
||
| // 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=${encodeURIComponent(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. 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; | ||
| const ownerStr = verified.owner; | ||
|
|
||
| if (ownerStr.startsWith('org_')) { | ||
| const ownerId = ownerStr.replace('org_', ''); | ||
| owner = { type: 'org', id: ownerId }; | ||
| } 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: '***', owner: ownerStr }, | ||
| }); | ||
| return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL)); | ||
| } | ||
|
|
||
| // 6. 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)); | ||
| } | ||
| } | ||
|
|
||
| // 7. Exchange code for access token | ||
| const oauthData = await exchangeDiscordCode(code); | ||
|
|
||
| // 8. Store installation in database | ||
| await upsertDiscordInstallation(owner, oauthData); | ||
|
|
||
| // 9. 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) | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.