/** * 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): 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 = (process.env.NEXTAUTH_URL ?? url.origin).replace(/\/$/, ""); // 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 }); } }