-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add GitHub integration endpoints for token exchange and installation checks #162
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -80,3 +80,6 @@ run-milvus-test.sh | |
| # Cloudflare | ||
| .wrangler/ | ||
| .env*.local | ||
|
|
||
| # Kilo Telemetry | ||
| telemetry-id | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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'); | ||||||
|
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: Unauthenticated endpoint can be used to enumerate GitHub App installations This |
||||||
| 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' }, | ||||||
|
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: Returning raw exception messages can leak internals This response exposes
Suggested change
|
||||||
| { status: 500 } | ||||||
| ); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 }); | ||||||
|
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. CRITICAL: PAT-based token exchange needs stronger guardrails This endpoint lets any caller who can present any GitHub PAT (with repo access) mint a GitHub App installation token for the owner. That is a large privilege escalation surface if the route is accessible outside a tightly controlled environment. Strongly consider enforcing Kilo-side auth + authorization, restricting to server-to-server usage, and/or limiting which repos/owners/workflows are allowed to exchange. |
||||||
|
|
||||||
| let repoData; | ||||||
| try { | ||||||
| const response = await octokit.rest.repos.get({ owner, repo }); | ||||||
| repoData = response.data; | ||||||
| } catch (error) { | ||||||
| console.error('PAT validation failed:', error); | ||||||
|
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: Logging raw Octokit errors may leak sensitive request details
|
||||||
| 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}`] | ||||||
|
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: Scope installation token using canonical repository name
Suggested change
|
||||||
| ); | ||||||
|
|
||||||
| 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' }, | ||||||
|
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: Returning raw exception messages can leak internals This 500 response exposes
Suggested change
|
||||||
| { status: 500 } | ||||||
| ); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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; | ||||||
|
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: OIDC payload is used without runtime validation/normalization
|
||||||
| const repositoryOwnerId = payload.repository_owner_id; | ||||||
|
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: GitHub OIDC claims like
Suggested change
|
||||||
| 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' }, | ||||||
|
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: Returning raw exception messages can leak internals This 500 response exposes
Suggested change
|
||||||
| { status: 500 } | ||||||
| ); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GitHubOIDCTokenPayload> { | ||
| try { | ||
| const { payload } = await jwtVerify(token, jwks, { | ||
| issuer: GITHUB_OIDC_ISSUER, | ||
| audience: expectedAudience, | ||
| }); | ||
|
|
||
| return payload as GitHubOIDCTokenPayload; | ||
|
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: Unsafe cast of JWT payload can hide missing/incorrect claims
|
||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| throw new Error(`OIDC token verification failed: ${error.message}`); | ||
| } | ||
| throw new Error('OIDC token verification failed'); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you give a little more info on:
Shouldn't an authenticated user be required here? And then we can simply query their own `platform_integrations?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jrf0110 It's to support the
kilo github installandkilo github runcommands of the CLI, so the CLI needs an endpoint to check if the installation ends to continue with the generation of the .github/workflow files.