mirror of
https://github.com/tiennm99/llmapikey.git
synced 2026-08-06 06:24:21 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { createServerAuthClient } from "@/lib/supabase/server-client";
|
||||
import { readSession } from "@/lib/auth/session";
|
||||
|
||||
/**
|
||||
* @typedef {Object} GithubIdentity
|
||||
@@ -9,37 +9,15 @@ import { createServerAuthClient } from "@/lib/supabase/server-client";
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the current GitHub identity from the validated server session.
|
||||
* Resolve the current GitHub identity from the signed session cookie.
|
||||
*
|
||||
* Identity anchor: `provider_id` — the numeric, immutable GitHub id. NOT
|
||||
* `user_name` (the mutable login — a rename could otherwise mint a second key)
|
||||
* and NOT `sub` (the Supabase user UUID). `user_metadata` is end-user-mutable,
|
||||
* so it scopes queries server-side only and is never used as an RLS/auth claim.
|
||||
*
|
||||
* Uses `getUser()` (validates the token with Supabase), not `getSession()`.
|
||||
* Identity anchor: `provider_id` — the numeric, immutable GitHub id. NOT the
|
||||
* mutable login (a rename could otherwise mint a second key). The numeric
|
||||
* invariant is enforced inside `readSession` (defense in depth) and again at
|
||||
* mint time in the OAuth callback. `githubUsername` is display-only.
|
||||
*
|
||||
* @returns {Promise<GithubIdentity | null>} null when unauthenticated.
|
||||
* @throws if a session exists but its GitHub metadata is missing/non-numeric.
|
||||
*/
|
||||
export async function getCurrentGithubIdentity() {
|
||||
const supabase = await createServerAuthClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return null;
|
||||
|
||||
const meta = user.user_metadata ?? {};
|
||||
if (meta.provider_id == null || meta.user_name == null) {
|
||||
throw new Error(
|
||||
"GitHub identity missing provider_id/user_name in user_metadata",
|
||||
);
|
||||
}
|
||||
|
||||
const githubUserId = String(meta.provider_id);
|
||||
// Assert numeric — guards against accidentally anchoring on the mutable login.
|
||||
if (!/^\d+$/.test(githubUserId)) {
|
||||
throw new Error(`Expected numeric GitHub provider_id, got: ${githubUserId}`);
|
||||
}
|
||||
|
||||
return { githubUserId, githubUsername: String(meta.user_name) };
|
||||
return readSession();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { GitHub } from "arctic";
|
||||
|
||||
/**
|
||||
* GitHub OAuth scopes. `read:user` is enough to read the public profile
|
||||
* (`id` + `login`) at the callback; no email scope, no token storage.
|
||||
*/
|
||||
export const GITHUB_SCOPES = ["read:user"];
|
||||
|
||||
/**
|
||||
* Build the Arctic GitHub OAuth client.
|
||||
*
|
||||
* Redirect URI is derived from the request `origin` (`${origin}/auth/callback`),
|
||||
* so no callback env is needed. GitHub validates this against the OAuth App's
|
||||
* registered callback, so requests reaching the app on a non-registered host
|
||||
* (e.g. a Vercel deployment-hash URL) will fail at GitHub — link only the
|
||||
* canonical domain. Login and callback MUST pass the same origin so the
|
||||
* `redirect_uri` matches across the authorize + token-exchange steps.
|
||||
*
|
||||
* @param {string} origin Request origin, e.g. `https://llmapikey.vercel.app`.
|
||||
* @returns {import('arctic').GitHub}
|
||||
*/
|
||||
export function getGithubOAuth(origin) {
|
||||
const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET",
|
||||
);
|
||||
}
|
||||
return new GitHub(clientId, clientSecret, `${origin}/auth/callback`);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Open-redirect guard. Only same-origin relative paths are allowed; anything
|
||||
* else (absolute URLs, protocol-relative `//host`, missing) falls back to
|
||||
* `/dashboard`. Shared by `/auth/login` and `/auth/callback`.
|
||||
*
|
||||
* @param {string | null | undefined} next
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeNext(next) {
|
||||
if (next && next.startsWith("/") && !next.startsWith("//")) return next;
|
||||
return "/dashboard";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Pure JWT sign/verify for the session token — no cookies, no `server-only`, so
|
||||
* it is unit-testable in node:test. `session.js` wraps these with the cookie
|
||||
* store. HS256 over the GitHub identity; subject = numeric `provider_id`.
|
||||
*/
|
||||
|
||||
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
/**
|
||||
* Encode + validate the signing secret. Throws (config error) when missing or
|
||||
* shorter than 32 bytes.
|
||||
*
|
||||
* @param {string | undefined} raw
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function encodeSecret(raw) {
|
||||
if (!raw || Buffer.byteLength(raw, "utf8") < 32) {
|
||||
throw new Error(
|
||||
"AUTH_SESSION_SECRET must be set and at least 32 bytes long",
|
||||
);
|
||||
}
|
||||
return new TextEncoder().encode(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the identity into a JWT.
|
||||
*
|
||||
* @param {{ githubUserId: string, githubUsername: string }} identity
|
||||
* @param {Uint8Array} secret
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export function signSessionToken({ githubUserId, githubUsername }, secret) {
|
||||
return new SignJWT({ login: githubUsername })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(githubUserId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT and extract the identity. Returns null on any token error
|
||||
* (missing/tampered/expired) or when the subject is not a numeric provider_id.
|
||||
*
|
||||
* @param {string | undefined | null} token
|
||||
* @param {Uint8Array} secret
|
||||
* @returns {Promise<{ githubUserId: string, githubUsername: string } | null>}
|
||||
*/
|
||||
export async function verifySessionToken(token, secret) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
const githubUserId = String(payload.sub ?? "");
|
||||
// Re-assert the numeric provider_id invariant (defense in depth).
|
||||
if (!/^\d+$/.test(githubUserId)) return null;
|
||||
return { githubUserId, githubUsername: String(payload.login ?? "") };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import "server-only";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import {
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
encodeSecret,
|
||||
signSessionToken,
|
||||
verifySessionToken,
|
||||
} from "./session-token";
|
||||
|
||||
/**
|
||||
* Stateless signed-cookie session. The app has no user table — identity is just
|
||||
* the GitHub `provider_id` (numeric, immutable) + login, carried in an HS256 JWT
|
||||
* inside an httpOnly cookie. No DB session row; on expiry the user re-logs in.
|
||||
* JWT logic lives in `session-token.js` (testable); this module wires cookies.
|
||||
*/
|
||||
|
||||
const COOKIE_NAME = "llmapikey_session";
|
||||
|
||||
/**
|
||||
* Cookie attributes. SameSite=Lax (not Strict) so the cookie rides the
|
||||
* top-level GET redirect back from GitHub; Strict would drop it.
|
||||
*/
|
||||
const COOKIE_ATTRS = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
};
|
||||
|
||||
/** @returns {Uint8Array} */
|
||||
function secret() {
|
||||
return encodeSecret(process.env.AUTH_SESSION_SECRET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the identity into the session cookie.
|
||||
*
|
||||
* @param {{ githubUserId: string, githubUsername: string }} identity
|
||||
*/
|
||||
export async function createSession(identity) {
|
||||
const jwt = await signSessionToken(identity, secret());
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, jwt, COOKIE_ATTRS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + verify the session cookie. Token errors → null; config errors (bad
|
||||
* `AUTH_SESSION_SECRET`) propagate (intentional fail-fast).
|
||||
*
|
||||
* @returns {Promise<{ githubUserId: string, githubUsername: string } | null>}
|
||||
*/
|
||||
export async function readSession() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
return verifySessionToken(token, secret());
|
||||
}
|
||||
|
||||
/** Clear the session cookie (sign-out). */
|
||||
export async function clearSession() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
/**
|
||||
* Browser Supabase client — anon key only.
|
||||
*
|
||||
* SCOPE: auth UI only (sign-in / sign-out / session). This client MUST NEVER be
|
||||
* used to read or write `llmapikey.api_keys`; that table is server-only and the
|
||||
* anon role is denied by RLS. See lib/supabase/server-client.js.
|
||||
*
|
||||
* @returns {import('@supabase/supabase-js').SupabaseClient}
|
||||
*/
|
||||
export function createBrowserSupabaseClient() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!url || !anonKey) {
|
||||
throw new Error(
|
||||
"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
);
|
||||
}
|
||||
return createBrowserClient(url, anonKey);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* Cookie-based Supabase client (anon key) for reading/refreshing the auth
|
||||
* session on the server. Use this to know WHO the request is, never to touch
|
||||
* `api_keys` — that table lives in the unexposed `llmapikey`
|
||||
* schema and is reached only via the direct Postgres client (lib/db).
|
||||
*
|
||||
* In Server Components cookie writes throw; we swallow that — session refresh
|
||||
* still works in route handlers / server actions where writes are allowed.
|
||||
*
|
||||
* @returns {Promise<import('@supabase/supabase-js').SupabaseClient>}
|
||||
*/
|
||||
export async function createServerAuthClient() {
|
||||
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
|
||||
const anonKey = requireEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(url, anonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
cookieStore.set(name, value, options);
|
||||
}
|
||||
} catch {
|
||||
// Called from a Server Component — cookies are read-only here.
|
||||
// Safe to ignore: middleware/route handlers refresh the session.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user