Behind Coolify's proxy, req.url resolves to 0.0.0.0:3000 which GitHub rejects as an unregistered redirect URI. Prefer NEXTAUTH_URL env var. Made-with: Cursor
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
/**
|
|
* 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 = (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 });
|
|
}
|
|
}
|