diff --git a/.gitignore b/.gitignore index 3fad18229..95b040ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ run-milvus-test.sh # Cloudflare .wrangler/ .env*.local + +# Kilo Telemetry +telemetry-id \ No newline at end of file diff --git a/src/app/api/integrations/github/check-installation/route.ts b/src/app/api/integrations/github/check-installation/route.ts new file mode 100644 index 000000000..956216f65 --- /dev/null +++ b/src/app/api/integrations/github/check-installation/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { findGitHubIntegrationByAccountLogin } from '@/lib/integrations/db/platform-integrations'; +import * as Sentry from '@sentry/nextjs'; + +export async function GET(request: NextRequest) { + try { + const owner = request.nextUrl.searchParams.get('owner'); + if (!owner) { + return NextResponse.json({ error: 'Missing owner parameter' }, { status: 400 }); + } + + const integration = await findGitHubIntegrationByAccountLogin(owner); + const installed = !!(integration && integration.platform_installation_id); + + return NextResponse.json({ installation: installed }); + } catch (error) { + console.error('GitHub installation check failed:', error); + Sentry.captureException(error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/github/exchange-token-with-pat/route.ts b/src/app/api/integrations/github/exchange-token-with-pat/route.ts new file mode 100644 index 000000000..94259e6db --- /dev/null +++ b/src/app/api/integrations/github/exchange-token-with-pat/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Octokit } from '@octokit/rest'; +import { findGitHubIntegrationByAccountLogin } from '@/lib/integrations/db/platform-integrations'; +import { generateGitHubInstallationToken } from '@/lib/integrations/platforms/github/adapter'; +import * as Sentry from '@sentry/nextjs'; + +export async function POST(request: NextRequest) { + try { + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Missing authorization header' }, { status: 400 }); + } + + const pat = authHeader.substring(7); + + let body: { owner?: string; repo?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { owner, repo } = body; + if (!owner || !repo) { + return NextResponse.json({ error: 'Missing owner or repo in request body' }, { status: 400 }); + } + + const octokit = new Octokit({ auth: pat }); + + let repoData; + try { + const response = await octokit.rest.repos.get({ owner, repo }); + repoData = response.data; + } catch (error) { + console.error('PAT validation failed:', error); + return NextResponse.json({ error: 'Invalid PAT or no access to repository' }, { status: 401 }); + } + + if (!repoData.permissions?.push && !repoData.permissions?.admin) { + return NextResponse.json( + { error: 'PAT owner does not have write access to repository' }, + { status: 403 } + ); + } + + const integration = await findGitHubIntegrationByAccountLogin(owner); + + if (!integration || !integration.platform_installation_id) { + return NextResponse.json( + { error: `No GitHub App installation found for owner ${owner}` }, + { status: 404 } + ); + } + + console.log('PAT token exchange', { + owner, + repo, + installationId: integration.platform_installation_id, + }); + + const { token } = await generateGitHubInstallationToken( + integration.platform_installation_id, + integration.github_app_type || 'standard', + [`${owner}/${repo}`] + ); + + return NextResponse.json({ token }); + } catch (error) { + console.error('GitHub token exchange with PAT failed:', error); + Sentry.captureException(error); + + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/github/exchange-token/route.ts b/src/app/api/integrations/github/exchange-token/route.ts new file mode 100644 index 000000000..7d2d8aa7c --- /dev/null +++ b/src/app/api/integrations/github/exchange-token/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyGitHubOIDCToken } from '@/lib/integrations/platforms/github/oidc'; +import { findGitHubIntegrationByAccountLogin } from '@/lib/integrations/db/platform-integrations'; +import { generateGitHubInstallationToken } from '@/lib/integrations/platforms/github/adapter'; +import type { PlatformRepository } from '@/lib/integrations/core/types'; +import * as Sentry from '@sentry/nextjs'; + +export async function POST(request: NextRequest) { + try { + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Missing authorization header' }, { status: 400 }); + } + + const oidcToken = authHeader.substring(7); + + const payload = await verifyGitHubOIDCToken(oidcToken, 'kilo-github-action'); + + const repositoryOwner = payload.repository_owner; + const repositoryOwnerId = payload.repository_owner_id; + const repository = payload.repository; + + const integration = await findGitHubIntegrationByAccountLogin(repositoryOwner); + + if (!integration || !integration.platform_installation_id) { + return NextResponse.json( + { error: `No GitHub App installation found for owner ${repositoryOwner}` }, + { status: 404 } + ); + } + + if ( + integration.platform_account_id && + integration.platform_account_id !== String(repositoryOwnerId) + ) { + Sentry.captureMessage('OIDC token owner ID mismatch', { + level: 'warning', + extra: { + repositoryOwner, + repositoryOwnerId, + integrationAccountId: integration.platform_account_id, + }, + }); + return NextResponse.json({ error: 'Installation owner mismatch' }, { status: 403 }); + } + + if ( + integration.repository_access === 'selected' && + integration.repositories && + Array.isArray(integration.repositories) + ) { + const repos = integration.repositories as PlatformRepository[]; + const hasAccess = repos.some((r) => r.full_name === repository); + if (!hasAccess) { + return NextResponse.json( + { error: 'Repository not in installation scope' }, + { status: 403 } + ); + } + } + + console.log('OIDC token exchange', { + repository, + repositoryOwner, + repositoryOwnerId, + installationId: integration.platform_installation_id, + }); + + const { token } = await generateGitHubInstallationToken( + integration.platform_installation_id, + integration.github_app_type || 'standard', + [repository] + ); + + return NextResponse.json({ token }); + } catch (error) { + console.error('GitHub token exchange failed:', error); + Sentry.captureException(error); + + if (error instanceof Error && error.message.includes('OIDC token verification failed')) { + return NextResponse.json({ error: 'Invalid OIDC token' }, { status: 401 }); + } + + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/lib/integrations/db/platform-integrations.ts b/src/lib/integrations/db/platform-integrations.ts index c2c7b54b1..cf00d7de4 100644 --- a/src/lib/integrations/db/platform-integrations.ts +++ b/src/lib/integrations/db/platform-integrations.ts @@ -525,6 +525,26 @@ export async function unsuspendIntegrationForOwner(owner: Owner, platform: strin .where(and(ownershipCondition, eq(platform_integrations.platform, platform))); } +/** + * Finds an active GitHub integration by account login (username or org name) + * Uses case-insensitive comparison since GitHub usernames/org names are case-insensitive + */ +export async function findGitHubIntegrationByAccountLogin(accountLogin: string) { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and( + eq(platform_integrations.platform, PLATFORM.GITHUB), + sql`LOWER(${platform_integrations.platform_account_login}) = LOWER(${accountLogin})`, + eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE) + ) + ) + .limit(1); + + return integration || null; +} + /** * Owner-aware upsert for platform integrations * Supports both user and organization ownership diff --git a/src/lib/integrations/platforms/github/adapter.ts b/src/lib/integrations/platforms/github/adapter.ts index e02bea3d8..5bd155128 100644 --- a/src/lib/integrations/platforms/github/adapter.ts +++ b/src/lib/integrations/platforms/github/adapter.ts @@ -32,10 +32,12 @@ export function verifyGitHubWebhookSignature( /** * Generates GitHub App installation token * @param appType - The type of GitHub App to use (defaults to 'standard') + * @param repositoryNames - Optional list of repository names to scope the token to (e.g., ["owner/repo"]) */ export async function generateGitHubInstallationToken( installationId: string, - appType: GitHubAppType = 'standard' + appType: GitHubAppType = 'standard', + repositoryNames?: string[] ): Promise { const credentials = getGitHubAppCredentials(appType); @@ -49,7 +51,15 @@ export async function generateGitHubInstallationToken( installationId, }); - const authResult = await auth({ type: 'installation' }); + const authOptions: { type: 'installation'; repositoryNames?: string[] } = { + type: 'installation', + }; + + if (repositoryNames && repositoryNames.length > 0) { + authOptions.repositoryNames = repositoryNames; + } + + const authResult = await auth(authOptions); return { token: authResult.token, diff --git a/src/lib/integrations/platforms/github/oidc.ts b/src/lib/integrations/platforms/github/oidc.ts new file mode 100644 index 000000000..e0221dcde --- /dev/null +++ b/src/lib/integrations/platforms/github/oidc.ts @@ -0,0 +1,49 @@ +import { jwtVerify, createRemoteJWKSet } from 'jose'; + +const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; +const GITHUB_JWKS_URL = `${GITHUB_OIDC_ISSUER}/.well-known/jwks`; + +const jwks = createRemoteJWKSet(new URL(GITHUB_JWKS_URL)); + +export type GitHubOIDCTokenPayload = { + sub: string; + repository: string; + repository_owner: string; + repository_owner_id: string; + repository_id: string; + repository_visibility: string; + run_id: string; + run_number: string; + run_attempt: string; + actor: string; + actor_id: string; + workflow: string; + ref: string; + ref_type: string; + environment?: string; + job_workflow_ref: string; + iss: string; + aud: string; + exp: number; + iat: number; + jti: string; +}; + +export async function verifyGitHubOIDCToken( + token: string, + expectedAudience: string +): Promise { + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: GITHUB_OIDC_ISSUER, + audience: expectedAudience, + }); + + return payload as GitHubOIDCTokenPayload; + } catch (error) { + if (error instanceof Error) { + throw new Error(`OIDC token verification failed: ${error.message}`); + } + throw new Error('OIDC token verification failed'); + } +}