From 76163abd90d5cc3c28e7886fc1e1fce202a76693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 12 Feb 2026 15:53:29 -0300 Subject: [PATCH 1/3] feat: add GitHub integration endpoints for token exchange and installation checks - Add POST /api/integrations/github/exchange-token for OIDC token exchange - Add POST /api/integrations/github/exchange-token-with-pat for PAT token exchange - Add GET /api/integrations/github/check-installation for CLI polling - Implement OIDC JWT verification using GitHub's JWKS endpoint - Add findGitHubIntegrationByAccountLogin() helper function - Support both production (OIDC) and development (PAT) authentication flows --- .gitignore | 3 + .../github/check-installation/route.ts | 24 +++++++ .../github/exchange-token-with-pat/route.ts | 64 +++++++++++++++++++ .../github/exchange-token/route.ts | 48 ++++++++++++++ .../integrations/db/platform-integrations.ts | 19 ++++++ src/lib/integrations/platforms/github/oidc.ts | 47 ++++++++++++++ 6 files changed, 205 insertions(+) create mode 100644 src/app/api/integrations/github/check-installation/route.ts create mode 100644 src/app/api/integrations/github/exchange-token-with-pat/route.ts create mode 100644 src/app/api/integrations/github/exchange-token/route.ts create mode 100644 src/lib/integrations/platforms/github/oidc.ts 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..960153fc2 --- /dev/null +++ b/src/app/api/integrations/github/exchange-token-with-pat/route.ts @@ -0,0 +1,64 @@ +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 }); + + try { + await octokit.rest.repos.get({ owner, repo }); + } catch (error) { + console.error('PAT validation failed:', error); + return NextResponse.json( + { error: 'Invalid PAT or no access to repository' }, + { status: 401 } + ); + } + + 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 } + ); + } + + const { token } = await generateGitHubInstallationToken( + integration.platform_installation_id, + integration.github_app_type || 'standard' + ); + + 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..a6997240b --- /dev/null +++ b/src/app/api/integrations/github/exchange-token/route.ts @@ -0,0 +1,48 @@ +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 * 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 integration = await findGitHubIntegrationByAccountLogin(repositoryOwner); + + if (!integration || !integration.platform_installation_id) { + return NextResponse.json( + { error: `No GitHub App installation found for owner ${repositoryOwner}` }, + { status: 404 } + ); + } + + const { token } = await generateGitHubInstallationToken( + integration.platform_installation_id, + integration.github_app_type || 'standard' + ); + + 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..b0dd65975 100644 --- a/src/lib/integrations/db/platform-integrations.ts +++ b/src/lib/integrations/db/platform-integrations.ts @@ -525,6 +525,25 @@ 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) + */ +export async function findGitHubIntegrationByAccountLogin(accountLogin: string) { + const [integration] = await db + .select() + .from(platform_integrations) + .where( + and( + eq(platform_integrations.platform, PLATFORM.GITHUB), + eq(platform_integrations.platform_account_login, 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/oidc.ts b/src/lib/integrations/platforms/github/oidc.ts new file mode 100644 index 000000000..f9f7f1835 --- /dev/null +++ b/src/lib/integrations/platforms/github/oidc.ts @@ -0,0 +1,47 @@ +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; + 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'); + } +} From cc437de849c9a3fa646911b5215510cf37a5ae6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 12 Feb 2026 16:47:14 -0300 Subject: [PATCH 2/3] security: harden GitHub token exchange endpoints - Fix case-insensitive account lookup in findGitHubIntegrationByAccountLogin - Add repository_id and repository_visibility to OIDC token payload - Add repository-scoped token generation to generateGitHubInstallationToken - Validate OIDC repository_owner_id matches stored platform_account_id - Validate OIDC repository is in installation scope (for selected repos) - Scope OIDC tokens to requesting repository only - Verify PAT has write permissions (not just read) before token exchange - Scope PAT tokens to validated repository only (fixes privilege escalation) - Add audit logging for all token exchanges Fixes high-severity privilege escalation where read-only PAT with access to one repo could obtain write access to all repos in the installation. --- .../github/exchange-token-with-pat/route.ts | 21 ++++++++-- .../github/exchange-token/route.ts | 40 ++++++++++++++++++- .../integrations/db/platform-integrations.ts | 3 +- .../integrations/platforms/github/adapter.ts | 14 ++++++- src/lib/integrations/platforms/github/oidc.ts | 2 + 5 files changed, 72 insertions(+), 8 deletions(-) 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 index 960153fc2..94259e6db 100644 --- a/src/app/api/integrations/github/exchange-token-with-pat/route.ts +++ b/src/app/api/integrations/github/exchange-token-with-pat/route.ts @@ -27,13 +27,19 @@ export async function POST(request: NextRequest) { const octokit = new Octokit({ auth: pat }); + let repoData; try { - await octokit.rest.repos.get({ owner, repo }); + 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: 'Invalid PAT or no access to repository' }, - { status: 401 } + { error: 'PAT owner does not have write access to repository' }, + { status: 403 } ); } @@ -46,9 +52,16 @@ export async function POST(request: NextRequest) { ); } + 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' + integration.github_app_type || 'standard', + [`${owner}/${repo}`] ); return NextResponse.json({ token }); diff --git a/src/app/api/integrations/github/exchange-token/route.ts b/src/app/api/integrations/github/exchange-token/route.ts index a6997240b..8c66ce21b 100644 --- a/src/app/api/integrations/github/exchange-token/route.ts +++ b/src/app/api/integrations/github/exchange-token/route.ts @@ -2,6 +2,7 @@ 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) { @@ -16,6 +17,8 @@ export async function POST(request: NextRequest) { 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); @@ -26,9 +29,44 @@ export async function POST(request: NextRequest) { ); } + if (integration.platform_account_id && integration.platform_account_id !== 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' + integration.github_app_type || 'standard', + [repository] ); return NextResponse.json({ token }); diff --git a/src/lib/integrations/db/platform-integrations.ts b/src/lib/integrations/db/platform-integrations.ts index b0dd65975..cf00d7de4 100644 --- a/src/lib/integrations/db/platform-integrations.ts +++ b/src/lib/integrations/db/platform-integrations.ts @@ -527,6 +527,7 @@ export async function unsuspendIntegrationForOwner(owner: Owner, platform: strin /** * 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 @@ -535,7 +536,7 @@ export async function findGitHubIntegrationByAccountLogin(accountLogin: string) .where( and( eq(platform_integrations.platform, PLATFORM.GITHUB), - eq(platform_integrations.platform_account_login, accountLogin), + sql`LOWER(${platform_integrations.platform_account_login}) = LOWER(${accountLogin})`, eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE) ) ) 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 index f9f7f1835..e0221dcde 100644 --- a/src/lib/integrations/platforms/github/oidc.ts +++ b/src/lib/integrations/platforms/github/oidc.ts @@ -10,6 +10,8 @@ export type GitHubOIDCTokenPayload = { repository: string; repository_owner: string; repository_owner_id: string; + repository_id: string; + repository_visibility: string; run_id: string; run_number: string; run_attempt: string; From e184c9631ccbecd8e9bcfe1c82ac30351b8b0950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 12 Feb 2026 16:51:06 -0300 Subject: [PATCH 3/3] fix: normalize repository_owner_id to string for comparison GitHub OIDC claims may be parsed as numbers by jose, but platform_account_id is stored as text in the database. Convert repositoryOwnerId to string to prevent false 403 rejections due to type mismatch (e.g., 12345 !== '12345'). --- src/app/api/integrations/github/exchange-token/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/integrations/github/exchange-token/route.ts b/src/app/api/integrations/github/exchange-token/route.ts index 8c66ce21b..7d2d8aa7c 100644 --- a/src/app/api/integrations/github/exchange-token/route.ts +++ b/src/app/api/integrations/github/exchange-token/route.ts @@ -29,7 +29,10 @@ export async function POST(request: NextRequest) { ); } - if (integration.platform_account_id && integration.platform_account_id !== repositoryOwnerId) { + if ( + integration.platform_account_id && + integration.platform_account_id !== String(repositoryOwnerId) + ) { Sentry.captureMessage('OIDC token owner ID mismatch', { level: 'warning', extra: {