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:
78
app/api/integrations/github/callback/route.ts
Normal file
78
app/api/integrations/github/callback/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /api/integrations/github/callback?code=…&state=…
|
||||
*
|
||||
* Final step of the OAuth dance:
|
||||
* 1. Validate the `state` matches what we stored in the cookie.
|
||||
* 2. Exchange `code` for an access token.
|
||||
* 3. Read the GitHub /user endpoint to confirm + capture login.
|
||||
* 4. Encrypt the token and persist on fs_users.data.integrations.github.
|
||||
* 5. Redirect back to the `returnTo` URL we stashed in the state cookie.
|
||||
*
|
||||
* On any failure we redirect back to /projects with ?gh_error=… so the
|
||||
* UI can surface the message in a toast.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import {
|
||||
exchangeCodeForToken, getAuthenticatedUser, persistGithubIntegration,
|
||||
isGithubOauthConfigured,
|
||||
} from "@/lib/integrations/github";
|
||||
|
||||
const STATE_COOKIE = "gh_oauth_state";
|
||||
|
||||
function bounce(origin: string, returnTo: string, params: Record<string, string>): NextResponse {
|
||||
const dest = new URL(returnTo.startsWith("/") ? returnTo : "/", origin);
|
||||
for (const [k, v] of Object.entries(params)) dest.searchParams.set(k, v);
|
||||
const res = NextResponse.redirect(dest);
|
||||
// Clear the one-shot state cookie so it can't be replayed.
|
||||
res.cookies.set(STATE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const origin = url.origin;
|
||||
|
||||
// Recover the original target from the state cookie *before* any error path.
|
||||
const cookieState = req.headers.get("cookie")
|
||||
?.split(";").map(c => c.trim())
|
||||
.find(c => c.startsWith(`${STATE_COOKIE}=`))
|
||||
?.split("=")[1] ?? "";
|
||||
const [storedState, storedReturnTo = "/"] = decodeURIComponent(cookieState).split(":");
|
||||
|
||||
if (!isGithubOauthConfigured()) {
|
||||
return bounce(origin, storedReturnTo, { gh_error: "GitHub OAuth not configured" });
|
||||
}
|
||||
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return bounce(origin, "/auth", { gh_error: "Sign in first" });
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const errParam = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
||||
|
||||
if (errParam) {
|
||||
return bounce(origin, storedReturnTo, { gh_error: errParam });
|
||||
}
|
||||
if (!code || !state) {
|
||||
return bounce(origin, storedReturnTo, { gh_error: "Missing code or state" });
|
||||
}
|
||||
if (!storedState || storedState !== state) {
|
||||
return bounce(origin, storedReturnTo, { gh_error: "State mismatch (try again)" });
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = `${origin}/api/integrations/github/callback`;
|
||||
const tok = await exchangeCodeForToken(code, callbackUrl);
|
||||
const me = await getAuthenticatedUser(tok.accessToken);
|
||||
await persistGithubIntegration(session.user.email, me.login, tok.accessToken, tok.scope);
|
||||
return bounce(origin, storedReturnTo, { gh_connected: me.login });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "GitHub connect failed";
|
||||
console.error("[github callback]", err);
|
||||
return bounce(origin, storedReturnTo, { gh_error: msg });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user