Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/.env.development.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ STRIPE_KILOCLAW_2026_03_19_COMMIT_PRICE_ID=price_test_kiloclaw_2026_03_19_commit
# Fresh current checkout uses recurring Standard immediately; no current Standard intro price is required.
STRIPE_KILOCLAW_2026_05_10_STANDARD_PRICE_ID=price_test_kiloclaw_2026_05_10_standard
STRIPE_KILOCLAW_2026_05_10_COMMIT_PRICE_ID=price_test_kiloclaw_2026_05_10_commit

# Encryption key for GitHub App user-to-server tokens (AES-256-GCM, base64-encoded 32 bytes)
USER_GH_APP_TOKEN_ENCRYPTION_KEY=
1 change: 1 addition & 0 deletions apps/web/src/app/(app)/integrations/github/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default async function UserGitHubIntegrationPage({
error={search.error}
pendingApproval={search.pending_approval === 'true'}
existingPendingOrg={search.org}
enableUserTokens={process.env.ENABLE_GITHUB_USER_TOKENS === 'true'}
/>
</Suspense>
</PageLayout>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest } from 'next/server';
import { GET } from './route';
import { db, cleanupDbForTest } from '@/lib/drizzle';
import { user_github_app_tokens } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';

jest.mock('@/lib/integrations/platforms/github/app-selector', () => ({
getGitHubAppCredentials: jest.fn(() => ({
clientId: 'client-id',
clientSecret: 'client-secret',
})),
}));

jest.mock('@/lib/integrations/platforms/github/oauth-state', () => ({
verifyOAuthState: jest.fn(),
safeReturnTo: jest.fn((path: string | undefined) => path ?? '/account/integrations'),
}));

jest.mock('@octokit/oauth-methods', () => ({
exchangeWebFlowCode: jest.fn(),
}));

jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => ({
rest: {
users: {
getAuthenticated: jest.fn(async () => ({ data: { id: 123, login: 'alice' } })),
listEmailsForAuthenticatedUser: jest.fn(async () => ({
data: [{ email: 'alice@example.com', primary: true }],
})),
},
},
})),
}));

// Key is 32 bytes: base64("12345678901234567890123456789012")
jest.mock('@/lib/config.server', () => ({
USER_GH_APP_TOKEN_ENCRYPTION_KEY: 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=',
}));

const { verifyOAuthState } = jest.requireMock('@/lib/integrations/platforms/github/oauth-state');
const { exchangeWebFlowCode } = jest.requireMock('@octokit/oauth-methods');

function makeRequest(url: string) {
return new NextRequest(new URL(url, 'http://localhost:3000'));
}

const TEST_USER_ID = 'user-123';

describe('GitHub user-connect callback', () => {
beforeEach(async () => {
await cleanupDbForTest();
jest.clearAllMocks();
await insertTestUser({ id: TEST_USER_ID });
});

test('redirects with invalid_state when state verification fails', async () => {
verifyOAuthState.mockReturnValue(null);
const request = makeRequest(
'/api/integrations/github/user-connect/callback?code=abc&state=bad'
);
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('gh_error=invalid_state');
});

test('redirects with access_denied when GitHub returns error', async () => {
verifyOAuthState.mockReturnValue({
kilo_user_id: 'user-123',
app_type: 'standard',
return_to: '/account/integrations',
nonce: 'nonce',
expires_at: Date.now() / 1000 + 600,
});
const request = makeRequest(
'/api/integrations/github/user-connect/callback?error=access_denied&state=valid'
);
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('gh_error=access_denied');
});

test('persists encrypted token on happy path', async () => {
verifyOAuthState.mockReturnValue({
kilo_user_id: 'user-123',
app_type: 'standard',
return_to: '/account/integrations',
nonce: 'nonce',
expires_at: Date.now() / 1000 + 600,
});

exchangeWebFlowCode.mockResolvedValue({
authentication: {
token: 'ghu_abc123',
expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
},
});

const request = makeRequest(
'/api/integrations/github/user-connect/callback?code=valid-code&state=valid'
);
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/account/integrations');

const rows = await db
.select()
.from(user_github_app_tokens)
.where(eq(user_github_app_tokens.kilo_user_id, 'user-123'));
expect(rows).toHaveLength(1);
expect(rows[0].github_login).toBe('alice');
expect(rows[0].github_user_id).toBe('123');
expect(rows[0].github_email).toBe('alice@example.com');
expect(rows[0].access_token_encrypted).not.toBe('ghu_abc123');
expect(rows[0].revoked_at).toBeNull();
});

test('onConflictDoUpdate overwrites existing row and resets revoked_at', async () => {
const encryptionKey = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=';
const { encryptWithSymmetricKey } = await import('@/lib/encryption');
await db.insert(user_github_app_tokens).values({
kilo_user_id: 'user-123',
github_app_type: 'standard',
github_user_id: '999',
github_login: 'old-login',
access_token_encrypted: encryptWithSymmetricKey('old-token', encryptionKey),
access_token_expires_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
revoked_at: new Date().toISOString(),
revocation_reason: 'user_revoked',
});

verifyOAuthState.mockReturnValue({
kilo_user_id: 'user-123',
app_type: 'standard',
return_to: '/account/integrations',
nonce: 'nonce',
expires_at: Date.now() / 1000 + 600,
});

exchangeWebFlowCode.mockResolvedValue({
authentication: {
token: 'ghu_newtoken',
expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
},
});

const request = makeRequest(
'/api/integrations/github/user-connect/callback?code=valid-code&state=valid'
);
await GET(request);

const rows = await db
.select()
.from(user_github_app_tokens)
.where(eq(user_github_app_tokens.kilo_user_id, 'user-123'));
expect(rows).toHaveLength(1);
expect(rows[0].github_login).toBe('alice');
expect(rows[0].revoked_at).toBeNull();
expect(rows[0].revocation_reason).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { Octokit } from '@octokit/rest';
import { exchangeWebFlowCode } from '@octokit/oauth-methods';
import { getGitHubAppCredentials } from '@/lib/integrations/platforms/github/app-selector';
import { verifyOAuthState, safeReturnTo } from '@/lib/integrations/platforms/github/oauth-state';
import { db } from '@/lib/drizzle';
import { user_github_app_tokens } from '@kilocode/db/schema';
import { USER_GH_APP_TOKEN_ENCRYPTION_KEY } from '@/lib/config.server';
import { encryptWithSymmetricKey } from '@/lib/encryption';
import { captureException } from '@sentry/nextjs';

type GitHubConnectError =
| 'exchange_failed'
| 'access_denied'
| 'expired_state'
| 'invalid_state'
| 'unknown';

function errorRedirect(error: GitHubConnectError, returnTo: string, requestUrl: string): Response {
const url = new URL(safeReturnTo(returnTo), requestUrl);
url.searchParams.set('gh_error', error);
return NextResponse.redirect(url);
}

function sanitizeForLog(value: string | null): string | null {
if (value === null) return null;
// Truncate and strip control characters to prevent log injection
// eslint-disable-next-line no-control-regex
return value.slice(0, 200).replace(/[\u0000-\u001f\u007f]/g, '');
}

function mapGitHubError(error: string | null, errorDescription: string | null): GitHubConnectError {
if (error === 'access_denied') return 'access_denied';
console.error('GitHub OAuth error:', {
error: sanitizeForLog(error),
errorDescription: sanitizeForLog(errorDescription),
});
return 'unknown';
}

export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');

// 1. Verify state first so we have a return path even on error
const decoded = verifyOAuthState(state);
const returnTo = decoded?.return_to ?? '/account/integrations';

if (error) {
const mapped = mapGitHubError(error, errorDescription);
return errorRedirect(mapped, returnTo, request.url);
}

if (!decoded) {
return errorRedirect('invalid_state', returnTo, request.url);
}

if (!code) {
return errorRedirect('exchange_failed', returnTo, request.url);
}

const { kilo_user_id, app_type } = decoded;

// 2. Exchange code for token
const credentials = getGitHubAppCredentials(app_type);
const exchangeResult = await exchangeWebFlowCode({
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
clientType: 'github-app',
code,
});

if (!exchangeResult.authentication?.token) {
return errorRedirect('exchange_failed', returnTo, request.url);
}

const auth = exchangeResult.authentication;
const accessToken = auth.token;
// MVP: ignore refresh token
const accessTokenExpiresAt =
'expiresAt' in auth && auth.expiresAt
? new Date(auth.expiresAt)
: new Date(Date.now() + 8 * 60 * 60 * 1000);

// 3. Resolve identity
const octokit = new Octokit({ auth: accessToken });
const { data: githubUser } = await octokit.rest.users.getAuthenticated();

let primaryEmail: string | null = null;
try {
const { data: emails } = await octokit.rest.users.listEmailsForAuthenticatedUser();
primaryEmail = emails.find(e => e.primary)?.email ?? null;
} catch {
// 404 if user keeps emails private — store null
primaryEmail = null;
}

// 4. Encrypt and persist
const encryptedToken = encryptWithSymmetricKey(accessToken, USER_GH_APP_TOKEN_ENCRYPTION_KEY);

await db
.insert(user_github_app_tokens)
.values({
kilo_user_id,
github_app_type: app_type,
github_user_id: String(githubUser.id),
github_login: githubUser.login,
github_email: primaryEmail,
access_token_encrypted: encryptedToken,
access_token_expires_at: accessTokenExpiresAt.toISOString(),
revoked_at: null,
revocation_reason: null,
})
.onConflictDoUpdate({
target: [user_github_app_tokens.kilo_user_id, user_github_app_tokens.github_app_type],
set: {
github_user_id: String(githubUser.id),
github_login: githubUser.login,
github_email: primaryEmail,
access_token_encrypted: encryptedToken,
access_token_expires_at: accessTokenExpiresAt.toISOString(),
revoked_at: null,
revocation_reason: null,
updated_at: new Date().toISOString(),
},
});

// 5. Redirect back
const redirectUrl = new URL(safeReturnTo(returnTo), request.url);
return NextResponse.redirect(redirectUrl);
} catch (err) {
console.error('GitHub user-connect callback error:', err);
captureException(err, {
tags: { endpoint: 'github/user-connect/callback' },
});
// Fallback redirect on unexpected error
const fallback = new URL('/account/integrations', request.url);
fallback.searchParams.set('gh_error', 'unknown');
return NextResponse.redirect(fallback);
}
}
Loading