feat(auth): replace Supabase Auth with app-native GitHub OAuth

Self-contained GitHub OAuth (Arctic) with a stateless HS256 signed-cookie
session (jose); Supabase is downgraded to the Postgres host only.

- Origin-derived callback (no redirect-uri env); read:user scope; access
  token read once at callback and discarded (no token storage).
- CSRF via single-use state cookie; open-redirect guard on next.
- getCurrentGithubIdentity() now reads the session cookie, preserving the
  numeric provider_id identity contract for admin/dashboard/mint.
- Remove @supabase/ssr + @supabase/supabase-js, middleware, and the
  supabase-dependent rls test; delete lib/supabase clients.
This commit is contained in:
2026-06-14 12:19:40 +07:00
parent 616f133989
commit 559bac8104
23 changed files with 797 additions and 395 deletions
+44 -21
View File
@@ -1,35 +1,58 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { createServerAuthClient } from "@/lib/supabase/server-client";
import { getGithubOAuth } from "@/lib/auth/github-oauth";
import { createSession } from "@/lib/auth/session";
import { sanitizeNext } from "@/lib/auth/sanitize-next";
/**
* OAuth callback: exchange the GitHub auth code for a Supabase session cookie,
* then redirect to `next` (default /dashboard).
* GitHub OAuth callback. Verify CSRF state, exchange the code, read the public
* profile once (`id` + `login`), mint the session cookie, then redirect to the
* stashed `next`. The access token is never stored.
*
* @param {Request} request
*/
export async function GET(request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = sanitizeNext(searchParams.get("next"));
const state = searchParams.get("state");
if (code) {
const supabase = await createServerAuthClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
const cookieStore = await cookies();
const storedState = cookieStore.get("oauth_state")?.value;
const next = sanitizeNext(cookieStore.get("oauth_next")?.value);
// Clear the transient cookies regardless of outcome.
cookieStore.delete("oauth_state");
cookieStore.delete("oauth_next");
if (!code || !state || !storedState || state !== storedState) {
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
/**
* Only allow same-origin relative paths to avoid open-redirect.
*
* @param {string | null} next
* @returns {string}
*/
function sanitizeNext(next) {
if (next && next.startsWith("/") && !next.startsWith("//")) return next;
return "/dashboard";
try {
const oauth = getGithubOAuth(origin);
const tokens = await oauth.validateAuthorizationCode(code);
const accessToken = tokens.accessToken();
const res = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "llmapikey",
Accept: "application/vnd.github+json",
},
});
if (!res.ok) return NextResponse.redirect(`${origin}/?auth_error=1`);
const profile = await res.json();
const githubUserId = String(profile.id);
// Anchor on the numeric provider_id, never the mutable login.
if (!/^\d+$/.test(githubUserId)) {
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
await createSession({ githubUserId, githubUsername: String(profile.login) });
return NextResponse.redirect(`${origin}${next}`);
} catch {
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
}