feat(github): OAuth integration + repo picker for Import flow
User can now click "Connect GitHub" inside the Import-existing-code
flow, sign in via GitHub, and pick a repo from a searchable list of
their own + collaborator + org repos. Both public and private repos
work — the encrypted access token on the user's account is auto-
attached when the create endpoint runs the agent-runner mirror.
OAuth flow:
- GET /api/integrations/github/connect — generates state, sets
a 10-min httpOnly cookie, 302s to GitHub authorize.
- GET /api/integrations/github/callback — verifies state,
exchanges code for token, fetches /user, encrypts the
token with secret-box (AES-256-GCM, VIBN_SECRETS_KEY) and
persists it on fs_users.data.integrations.github.
Bounces back to ?gh_connected=login or ?gh_error=msg.
- GET /api/integrations/github/repos — server-side fetches
the connected user's repos (per_page=100, sort=pushed,
affiliation=owner+collaborator+org_member). Returns the
GitHub login + a stripped repo summary; never the token.
- POST /api/integrations/github/disconnect — drops the integration
from fs_users (does NOT revoke on github.com).
Scopes requested: repo, read:user.
Token storage:
- Encrypted at rest with secret-box (lib/auth/secret-box.ts) using
VIBN_SECRETS_KEY. Tokens never leave the server.
- One token per fs_users row, keyed by email.
ImportSetup UI:
- On mount, fires /repos to detect connection state.
- If connected: shows a connected-as-@login chip with disconnect
link, a search-as-you-type repo picker (max 220px scroll, badges
for Private / language), and a "paste a different URL instead"
escape hatch.
- If not connected: shows a Connect GitHub card with a public-URL
fallback inline.
- On return from OAuth (?gh_connected=… or ?gh_error=…), surfaces
a toast and silently refreshes the repo list.
- Selected repo carries default_branch + repo id into the create
payload so we can store them on the project for later UI hints.
/api/projects/create:
- When a githubRepoUrl is mirrored, falls back to the user's
OAuth-linked token if no PAT is explicitly passed. Means the
flow "just works" for private repos once GitHub is connected.
Required env (already set in production):
- GITHUB_CLIENT_ID
- GITHUB_CLIENT_SECRET
Made-with: Cursor
This commit is contained in:
189
lib/integrations/github.ts
Normal file
189
lib/integrations/github.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GitHub OAuth integration helpers.
|
||||
*
|
||||
* Storage layout — fs_users.data.integrations.github:
|
||||
* {
|
||||
* login: "octocat",
|
||||
* accessToken: <encryptSecret(token)>, // encrypted at rest
|
||||
* scope: "repo,read:user",
|
||||
* connectedAt: ISO,
|
||||
* }
|
||||
*
|
||||
* Tokens never leave the server. All API calls happen server-side; the
|
||||
* UI only ever sees the GitHub `login` and the list of repos.
|
||||
*
|
||||
* Scopes requested: `repo` (private+public read+write so we can mirror)
|
||||
* `read:user` (so we can show the connected username)
|
||||
*/
|
||||
|
||||
import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
|
||||
const SCOPES = "repo,read:user";
|
||||
|
||||
export function isGithubOauthConfigured(): boolean {
|
||||
return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0;
|
||||
}
|
||||
|
||||
/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */
|
||||
export function buildAuthorizeUrl(state: string, callbackUrl: string): string {
|
||||
const u = new URL("https://github.com/login/oauth/authorize");
|
||||
u.searchParams.set("client_id", CLIENT_ID);
|
||||
u.searchParams.set("redirect_uri", callbackUrl);
|
||||
u.searchParams.set("scope", SCOPES);
|
||||
u.searchParams.set("state", state);
|
||||
u.searchParams.set("allow_signup", "true");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
interface TokenExchangeResult {
|
||||
accessToken: string;
|
||||
scope: string;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/** POST /login/oauth/access_token — exchange auth code for an access token. */
|
||||
export async function exchangeCodeForToken(
|
||||
code: string, callbackUrl: string,
|
||||
): Promise<TokenExchangeResult> {
|
||||
const res = await fetch("https://github.com/login/oauth/access_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: callbackUrl,
|
||||
}),
|
||||
});
|
||||
const body = await res.json() as {
|
||||
access_token?: string; scope?: string; token_type?: string;
|
||||
error?: string; error_description?: string;
|
||||
};
|
||||
if (!res.ok || !body.access_token) {
|
||||
throw new Error(body.error_description || body.error || "GitHub token exchange failed");
|
||||
}
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
scope: body.scope ?? "",
|
||||
tokenType: body.token_type ?? "bearer",
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /user — fetch the authenticated user's login + name. */
|
||||
export async function getAuthenticatedUser(token: string) {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>;
|
||||
}
|
||||
|
||||
interface GithubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
default_branch: string;
|
||||
html_url: string;
|
||||
pushed_at: string | null;
|
||||
language: string | null;
|
||||
fork: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /user/repos — list repos the authenticated user can access.
|
||||
* Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't
|
||||
* paginate further. The picker UI does client-side filter on top.
|
||||
*/
|
||||
export async function listUserRepos(token: string, perPage = 100): Promise<GithubRepo[]> {
|
||||
const u = new URL("https://api.github.com/user/repos");
|
||||
u.searchParams.set("per_page", String(perPage));
|
||||
u.searchParams.set("sort", "pushed");
|
||||
u.searchParams.set("affiliation", "owner,collaborator,organization_member");
|
||||
const res = await fetch(u.toString(), {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<GithubRepo[]>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// fs_users persistence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoredGithubIntegration {
|
||||
login: string;
|
||||
accessToken: string; // encrypted
|
||||
scope: string;
|
||||
connectedAt: string;
|
||||
}
|
||||
|
||||
/** Write the integration to fs_users.data.integrations.github. */
|
||||
export async function persistGithubIntegration(
|
||||
email: string, login: string, plainToken: string, scope: string,
|
||||
): Promise<void> {
|
||||
const blob: StoredGithubIntegration = {
|
||||
login,
|
||||
accessToken: encryptSecret(plainToken),
|
||||
scope,
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = jsonb_set(
|
||||
jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true),
|
||||
'{integrations,github}', $2::jsonb, true
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE data->>'email' = $1`,
|
||||
[email, JSON.stringify(blob)],
|
||||
);
|
||||
}
|
||||
|
||||
/** Read + decrypt the GitHub access token for a user, if any. */
|
||||
export async function loadGithubIntegration(
|
||||
email: string,
|
||||
): Promise<{ login: string; token: string; scope: string } | null> {
|
||||
const rows = await query<{ blob: StoredGithubIntegration | null }>(
|
||||
`SELECT data->'integrations'->'github' AS blob
|
||||
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
const blob = rows[0]?.blob;
|
||||
if (!blob || !blob.accessToken) return null;
|
||||
try {
|
||||
return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the stored integration. */
|
||||
export async function disconnectGithubIntegration(email: string): Promise<void> {
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = data #- '{integrations,github}',
|
||||
updated_at = NOW()
|
||||
WHERE data->>'email' = $1`,
|
||||
[email],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user