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
+20 -17
View File
@@ -1,24 +1,27 @@
# ---- Supabase (auth) ----
# Public: safe to expose to the browser. Used for GitHub OAuth sign-in only.
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# ---- GitHub OAuth (app-native auth, server-only) ----
# From your GitHub OAuth App (Settings > Developer settings > OAuth Apps).
# All server-only — NEVER prefix with NEXT_PUBLIC_.
GITHUB_OAUTH_CLIENT_ID=Iv1.your-client-id
GITHUB_OAUTH_CLIENT_SECRET=your-client-secret
# Callback is derived at runtime from the request origin (${origin}/auth/callback)
# — no callback env needed. The OAuth App's registered Authorization callback URL
# must match the host you serve on (prod = https://llmapikey.vercel.app/auth/callback,
# local dev = http://localhost:3000/auth/callback). GitHub OAuth Apps allow only
# ONE callback, so reach the app on its canonical domain.
# Session signing secret (HS256). >=32 bytes. Generate: openssl rand -base64 48
AUTH_SESSION_SECRET=change-me-to-a-32-byte-or-longer-random-string
# ---- Postgres (direct, server-only) ----
# Supabase pooler connection string (Project Settings > Database > Connection
# string > "Transaction" pooler, port 6543). Reaches the unexposed `llmapikey`
# schema. NEVER expose to the client. The connecting role must own/bypass RLS on
# the llmapikey schema (project `postgres` user).
DATABASE_URL=postgresql://postgres.your-ref:password@aws-0-region.pooler.supabase.com:6543/postgres
# Supabase pooler connection string. The Supabase Vercel integration provisions
# this as POSTGRES_URL (Transaction pooler, port 6543). Reaches the unexposed
# `llmapikey` schema. NEVER expose to the client. The connecting role must
# own/bypass RLS on the llmapikey schema (project `postgres` user).
POSTGRES_URL=postgresql://postgres.your-ref:password@aws-0-region.pooler.supabase.com:6543/postgres
# ---- OpenRouter (server-only secret) ----
# Master Provisioning key used to mint per-user keys. NEVER expose to the client.
OPENROUTER_PROVISIONING_KEY=sk-or-v1-provisioning-...
# ---- App config ----
# Public repo URL for the soft star nudge.
NEXT_PUBLIC_REPO_URL=https://github.com/your-org/llmapikey
# Model id shown in /docs and used as the documented target.
NEXT_PUBLIC_OPENROUTER_MODEL=minimax/minimax-m3
# Master management/provisioning key used to mint per-user keys. NEVER expose to
# the client.
OPENROUTER_MANAGEMENT_KEY=sk-or-v1-provisioning-...
# ---- Provisioning controls (server-only) ----
# Feature flag: live key minting is OFF until OpenRouter ToS gate (Phase 1)
+37 -19
View File
@@ -2,7 +2,8 @@
Free, capped OpenRouter API key giveaway — one key per GitHub account.
Next.js (App Router, JS + JSDoc) on Vercel. GitHub sign-in via Supabase Auth.
Next.js (App Router, JS + JSDoc) on Vercel. App-native GitHub OAuth (Arctic +
jose stateless signed-cookie session) — Supabase is the Postgres host only.
Per-user OpenRouter keys are minted from the owner's master Provisioning key,
each capped at a daily USD limit. Key records live in a dedicated, unexposed
`llmapikey` Postgres schema reached only by a server-side direct connection.
@@ -14,17 +15,27 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed
## Stack
- **Next.js 15** App Router, plain JS + JSDoc (no TypeScript)
- **Supabase Auth** — GitHub OAuth provider (sessions via `@supabase/ssr`)
- **App-native GitHub OAuth** — [Arctic](https://arcticjs.dev) for the OAuth2
dance, [jose](https://github.com/panva/jose) for a stateless HS256 signed-cookie
session. No auth provider; GitHub → `/auth/callback` directly. `read:user` scope
only; the access token is read once and discarded (no token storage).
- **Postgres** (`postgres` npm) — direct connection to the unexposed `llmapikey`
schema; the anon role can never reach `api_keys`
schema; the anon role can never reach `api_keys`. (Supabase = DB host only.)
- **OpenRouter Provisioning API** — mints per-user keys
## Architecture notes
- **Identity anchor:** the numeric, immutable GitHub `provider_id` (not the
mutable login). One key per `github_user_id`, enforced by a DB unique constraint.
- **Server-only secrets:** `DATABASE_URL` and `OPENROUTER_PROVISIONING_KEY` are
guarded by the `server-only` package and never reach the client bundle.
- **Server-only secrets:** `POSTGRES_URL`, `OPENROUTER_MANAGEMENT_KEY`,
`GITHUB_OAUTH_CLIENT_SECRET`, and `AUTH_SESSION_SECRET` are guarded by the
`server-only` package and never reach the client bundle (no `NEXT_PUBLIC_`).
- **Stateless session:** signed JWT (HS256) in an httpOnly+Secure+SameSite=Lax
cookie; no DB session table. CSRF handled by a single-use `state` cookie
verified at the callback; `next` redirects pass through a same-origin sanitizer.
- **Schema isolation is structural:** the `llmapikey` schema is unexposed to
PostgREST and reached only via the direct PG client (deny-all RLS as defense in
depth). The app ships no anon DB client, so the isolation holds by construction.
- **Reserve-then-mint:** a `pending` row is inserted (ON CONFLICT DO NOTHING)
before minting, so concurrent double-submits yield exactly one OpenRouter key.
- **Schema isolation:** `llmapikey` is NOT added to PostgREST exposed schemas;
@@ -45,28 +56,32 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed
2. **Environment** — copy `.env.example` to `.env.local` and fill in values.
| Var | Purpose |
|-----|---------|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (public) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anon key (public, auth UI only) |
| `DATABASE_URL` | Supabase **transaction pooler** string (server-only) |
| `OPENROUTER_PROVISIONING_KEY` | Master Provisioning key (server-only) |
| `NEXT_PUBLIC_REPO_URL` | Repo URL for the soft star nudge |
| `NEXT_PUBLIC_OPENROUTER_MODEL` | Model id shown in `/docs` |
| `GITHUB_OAUTH_CLIENT_ID` | GitHub OAuth App client id (server-only) |
| `GITHUB_OAUTH_CLIENT_SECRET` | GitHub OAuth App client secret (server-only) |
| `AUTH_SESSION_SECRET` | Session JWT signing secret, ≥32 bytes (`openssl rand -base64 48`) |
| `POSTGRES_URL` | Supabase **transaction pooler** string (server-only; provisioned by the Supabase Vercel integration) |
| `OPENROUTER_MANAGEMENT_KEY` | Master management/provisioning key (server-only) |
| `PROVISIONING_ENABLED` | `false` until Phase 1 ToS gate clears |
| `MAX_TOTAL_KEYS` | Kill-switch: stop minting past N active keys |
| `KEY_DAILY_LIMIT_USD` | Per-key daily cap sent to OpenRouter |
| `KEY_EXPIRY_DAYS` | Key lifetime (sets `expires_at`) |
| `ADMIN_GITHUB_USER_IDS` | Admin allowlist — numeric GitHub `provider_id`s, CSV (server-only) |
3. **Database** — apply the migration to a **staging branch first**, then prod
(Supabase SQL editor or `psql "$DATABASE_URL" -f ...`):
(Supabase SQL editor or `psql "$POSTGRES_URL" -f ...`):
```bash
psql "$DATABASE_URL" -f supabase/migrations/0001_llmapikey_schema_and_api_keys.up.sql
psql "$POSTGRES_URL" -f supabase/migrations/0001_llmapikey_schema_and_api_keys.up.sql
```
Do NOT add `llmapikey` to the project's PostgREST "Exposed schemas".
Rollback: `...0001_...down.sql`.
4. **GitHub OAuth** — register a GitHub OAuth App; set the client id/secret in
the Supabase dashboard (Auth > Providers > GitHub), callback
`https://<project>.supabase.co/auth/v1/callback`. Add localhost + your Vercel
domain to Supabase Auth redirect URLs.
4. **GitHub OAuth App** — register one at GitHub > Settings > Developer settings >
OAuth Apps. Authorization callback URL points at the **app** (not Supabase):
`https://llmapikey.vercel.app/auth/callback` for prod (use a separate dev app
with `http://localhost:3000/auth/callback` for local). Copy the Client ID +
Secret into `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET`. The callback
is derived from the request origin at runtime, so no callback env is needed —
but reach the app on its **canonical domain** (the one registered above);
GitHub rejects sign-in arriving on any other host (e.g. Vercel deployment-hash
or preview URLs).
5. **Run**
```bash
npm run dev # http://localhost:3000
@@ -75,5 +90,8 @@ each capped at a daily USD limit. Key records live in a dedicated, unexposed
## Deploy (gated)
Auto-build on Vercel is intentionally disabled (see `vercel.json`). Promote
manually once the Phase 1 ToS gate clears and `PROVISIONING_ENABLED=true`.
Git-triggered builds on Vercel are enabled (`vercel.json`
`git.deploymentEnabled: true`). Builds and deploys do NOT start the giveaway:
live key minting is gated independently by `PROVISIONING_ENABLED`. Keep
`PROVISIONING_ENABLED=false` in the Vercel environment until the Phase 1
OpenRouter ToS gate clears; only then set it `true` to begin minting.
+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`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { generateState } from "arctic";
import { getGithubOAuth, GITHUB_SCOPES } from "@/lib/auth/github-oauth";
import { sanitizeNext } from "@/lib/auth/sanitize-next";
/**
* Start GitHub OAuth: create CSRF `state`, stash it + the sanitized `next` in
* short-lived httpOnly cookies, then 302 to GitHub's authorize URL.
*
* @param {Request} request
*/
export async function GET(request) {
const { searchParams, origin } = new URL(request.url);
const next = sanitizeNext(searchParams.get("next"));
const state = generateState();
const oauth = getGithubOAuth(origin);
const url = oauth.createAuthorizationURL(state, GITHUB_SCOPES);
// 10-min, single-use CSRF state + the post-login destination. Lax so they
// survive the top-level redirect back from GitHub. Cookies set via the
// cookies() store attach to the redirect response in route handlers.
const attrs = {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 600,
};
const cookieStore = await cookies();
cookieStore.set("oauth_state", state, attrs);
cookieStore.set("oauth_next", next, attrs);
return NextResponse.redirect(url);
}
+4 -4
View File
@@ -1,14 +1,14 @@
import { NextResponse } from "next/server";
import { createServerAuthClient } from "@/lib/supabase/server-client";
import { clearSession } from "@/lib/auth/session";
/**
* Sign out: clear the Supabase session and redirect home.
* Sign out: clear the session cookie and redirect home. POST (the header uses a
* `method="post"` form); 303 turns the POST into a GET on the redirect.
*
* @param {Request} request
*/
export async function POST(request) {
const supabase = await createServerAuthClient();
await supabase.auth.signOut();
await clearSession();
return NextResponse.redirect(new URL("/", request.url), { status: 303 });
}
+8 -22
View File
@@ -1,31 +1,17 @@
"use client";
import { useState } from "react";
import { createBrowserSupabaseClient } from "@/lib/supabase/browser-client";
import Link from "next/link";
/**
* GitHub sign-in button. Kicks off Supabase OAuth; minimal scope (read:user).
* GitHub sign-in button. A plain link to the server-side OAuth start route
* (`/auth/login`), which creates the CSRF state and redirects to GitHub. No
* client-side auth SDK needed.
*
* @param {{ next?: string, label?: string }} props
*/
export function SignInWithGithubButton({ next = "/dashboard", label = "Sign in with GitHub" }) {
const [loading, setLoading] = useState(false);
async function handleSignIn() {
setLoading(true);
const supabase = createBrowserSupabaseClient();
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: "github",
options: { redirectTo, scopes: "read:user" },
});
if (error) setLoading(false); // on success the browser navigates away
}
const href = `/auth/login?next=${encodeURIComponent(next)}`;
return (
<button className="btn" onClick={handleSignIn} disabled={loading}>
{loading ? "Redirecting…" : label}
</button>
<Link className="btn" href={href}>
{label}
</Link>
);
}
+5 -8
View File
@@ -1,21 +1,18 @@
import Link from "next/link";
import { createServerAuthClient } from "@/lib/supabase/server-client";
import { getCurrentGithubIdentity } from "@/lib/auth/current-github-identity";
/**
* Session-aware header (server component). Shows Dashboard + sign-out when
* authenticated; just Docs otherwise. Display only — `user_name` is fine here.
* authenticated; just Docs otherwise. Display only — the login is fine here.
*/
export async function SiteHeader() {
let username = null;
try {
const supabase = await createServerAuthClient();
const {
data: { user },
} = await supabase.auth.getUser();
username = user?.user_metadata?.user_name ?? null;
const identity = await getCurrentGithubIdentity();
username = identity?.githubUsername ?? null;
} catch {
username = null; // auth not configured (no env) — render signed-out header
username = null; // auth not configured (no secret) — render signed-out header
}
return (
+7 -29
View File
@@ -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();
}
+33
View File
@@ -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`);
}
+12
View File
@@ -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";
}
+62
View File
@@ -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;
}
}
+65
View File
@@ -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);
}
-23
View File
@@ -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);
}
-51
View File
@@ -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;
}
-41
View File
@@ -1,41 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
/**
* Refresh the Supabase auth session on each request so server components and
* actions see a valid token. Reads/writes session cookies via the request and
* response (middleware can't use next/headers cookies()).
*
* @param {import('next/server').NextRequest} request
*/
export async function middleware(request) {
let response = NextResponse.next({ request });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) return response; // no auth configured yet
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
});
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|ico)$).*)"],
};
+68 -123
View File
@@ -8,8 +8,8 @@
"name": "llmapikey",
"version": "0.1.0",
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.45.4",
"arctic": "^3.7.0",
"jose": "^6.2.3",
"next": "^15.1.6",
"postgres": "^3.4.5",
"react": "^19.0.0",
@@ -1001,6 +1001,52 @@
"node": ">=12.4.0"
}
},
"node_modules/@oslojs/asn1": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
"integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==",
"license": "MIT",
"dependencies": {
"@oslojs/binary": "1.0.0"
}
},
"node_modules/@oslojs/binary": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz",
"integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==",
"license": "MIT"
},
"node_modules/@oslojs/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==",
"license": "MIT",
"dependencies": {
"@oslojs/asn1": "1.0.0",
"@oslojs/binary": "1.0.0"
}
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@oslojs/jwt": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz",
"integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==",
"license": "MIT",
"dependencies": {
"@oslojs/encoding": "0.4.1"
}
},
"node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz",
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1015,103 +1061,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.1.tgz",
"integrity": "sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.1.tgz",
"integrity": "sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.1.tgz",
"integrity": "sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.1.tgz",
"integrity": "sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz",
"integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.7.0"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.1.tgz",
"integrity": "sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.108.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.1.tgz",
"integrity": "sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.108.1",
"@supabase/functions-js": "2.108.1",
"@supabase/postgrest-js": "2.108.1",
"@supabase/realtime-js": "2.108.1",
"@supabase/storage-js": "2.108.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1132,12 +1081,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1851,6 +1794,17 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/arctic": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/arctic/-/arctic-3.7.0.tgz",
"integrity": "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==",
"license": "MIT",
"dependencies": {
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@oslojs/jwt": "0.2.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2242,15 +2196,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3445,15 +3390,6 @@
"node": ">= 0.4"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3956,6 +3892,15 @@
"node": ">= 0.4"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+2 -2
View File
@@ -11,8 +11,8 @@
"test": "node --test \"tests/*.test.js\""
},
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.45.4",
"arctic": "^3.7.0",
"jose": "^6.2.3",
"next": "^15.1.6",
"postgres": "^3.4.5",
"react": "^19.0.0",
@@ -0,0 +1,85 @@
---
phase: 1
title: "Session & OAuth Foundation"
status: pending
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Session & OAuth Foundation
## Overview
Add the OAuth client + stateless session primitives the new flow stands on, with no
behavior change yet (current Supabase path keeps working). Pure additive phase.
## Requirements
- Functional: a session module that signs/verifies a JWT cookie carrying
`{ githubUserId, githubUsername }`; a GitHub OAuth client factory (Arctic).
- Non-functional: secrets server-only; cookie httpOnly+Secure+SameSite=Lax; HS256 with
a ≥32-byte secret; numeric-`provider_id` invariant preserved.
## Architecture
- **Session (`lib/auth/session.js`)** — jose `SignJWT`/`jwtVerify`. Cookie name
`llmapikey_session`. Exposes `createSession(identity)`, `readSession()` (returns
`{ githubUserId, githubUsername } | null`, swallows verify errors), `clearSession()`,
and cookie attribute constants. Uses `next/headers` `cookies()`.
- **OAuth client (`lib/auth/github-oauth.js`)** — wraps Arctic `GitHub(clientId,
clientSecret, `${origin}/auth/callback`)`. Exposes a `getGithubOAuth(origin)` factory
(origin-derived callback, no callback env) + the OAuth scope constant `["read:user"]`.
- **Sanitizer (`lib/auth/sanitize-next.js`)** — extracted from the current callback route
so both `/auth/login` and `/auth/callback` share one open-redirect guard (same-origin
relative paths only; default `/dashboard`).
- Secrets read from env: `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`,
`AUTH_SESSION_SECRET`. All server-only (no `NEXT_PUBLIC_`).
## Related Code Files
- Create: `lib/auth/session.js`
- Create: `lib/auth/github-oauth.js`
- Create: `lib/auth/sanitize-next.js`
- Create: `tests/session-roundtrip.test.js`
- Modify: `package.json` (add `arctic`, `jose` — pin Arctic major: API differs across majors)
- Modify: `.env.example` (add the 3 new server-only vars; leave Supabase vars for now)
## Implementation Steps
1. `npm install arctic jose` (runtime deps).
2. `lib/auth/session.js`:
- `import "server-only";`
- `const secret = () => new TextEncoder().encode(process.env.AUTH_SESSION_SECRET)` —
throw if unset/<32 bytes.
- `createSession({ githubUserId, githubUsername })`: `new SignJWT({ login })
.setProtectedHeader({alg:'HS256'}).setSubject(githubUserId).setIssuedAt()
.setExpirationTime('7d').sign(secret())`, then `cookies().set(NAME, jwt, ATTRS)`.
- `readSession()`: read cookie → `jwtVerify` → re-assert `/^\d+$/` on `sub` →
return `{ githubUserId: payload.sub, githubUsername: payload.login }`; any
throw/missing → `null`.
- `clearSession()`: `cookies().delete(NAME)`.
- `ATTRS = { httpOnly:true, secure:true, sameSite:'lax', path:'/', maxAge: 60*60*24*7 }`.
3. `lib/auth/github-oauth.js`: `getGithubOAuth(origin)` returns
`new GitHub(id, secret, `${origin}/auth/callback`)` (throw if id/secret unset);
export `GITHUB_SCOPES`. Also create `lib/auth/sanitize-next.js` exporting `sanitizeNext`
(moved from the callback route).
4. `tests/session-roundtrip.test.js` (node:test): set `AUTH_SESSION_SECRET`, sign an
identity → verify roundtrip; assert tampered token → null; assert non-numeric `sub`
rejected; assert expired token → null.
5. Update `.env.example` with the 3 new vars + comments (server-only).
6. `npm run build` — must compile clean.
## Success Criteria
- [ ] `arctic` + `jose` in `package.json`; `npm install` clean.
- [ ] `lib/auth/session.js` and `lib/auth/github-oauth.js` compile; `server-only` guarded.
- [ ] `npm test` passes including new session roundtrip + tamper/expiry/non-numeric tests.
- [ ] No behavior change — existing Supabase auth still functions (nothing wired yet).
## Risk Assessment
- **Weak/missing `AUTH_SESSION_SECRET`** → `secret()` throws (config error), so a
misconfig 500s any page that reads identity (incl. public landing). Intended fail-fast.
Note the split: `readSession` swallows *token* errors (tamper/expiry → null) but lets
*config* errors propagate. Document 32-byte requirement.
- **jose ESM** — project is `"type":"module"`, so native import is fine.
- **SameSite choice** — `Lax` required so the cookie rides the top-level GET redirect back
from GitHub; `Strict` would drop it. Documented inline.
## Security Considerations
- Secrets never prefixed `NEXT_PUBLIC_`; `server-only` import guard on both modules.
- Preserve numeric-`provider_id` assertion at both mint and read (defense in depth).
@@ -0,0 +1,105 @@
---
phase: 2
title: "OAuth Routes & Identity Swap"
status: pending
priority: P1
effort: "4h"
dependencies: [1]
---
# Phase 2: OAuth Routes & Identity Swap
## Overview
Wire the new flow end-to-end and flip the identity source from Supabase to the signed
cookie. After this phase the app authenticates via its own GitHub OAuth; Supabase auth
code is no longer exercised (deleted in Phase 3).
## Requirements
- Functional: `/auth/login` initiates GitHub OAuth (state cookie + redirect);
`/auth/callback` validates state, exchanges code, fetches `/user`, mints session;
`/auth/sign-out` clears session; `getCurrentGithubIdentity()` reads the cookie.
- Non-functional: open-redirect safe (`next` sanitized); state single-use; identity
contract `{ githubUserId, githubUsername }` unchanged for all callers.
## Architecture
Flow: button → `GET /auth/login?next=/dashboard` (server: create state, set
`oauth_state` httpOnly cookie, 302 to GitHub authorize URL) → GitHub consent → `GET
/auth/callback?code&state` (verify state vs cookie, Arctic `validateAuthorizationCode`,
`GET https://api.github.com/user` with bearer → `{ id, login }``createSession`
clear state cookie → 302 to sanitized `next`).
`getCurrentGithubIdentity()` now delegates to `readSession()` — same return shape, so
`lib/auth/admin-allowlist.js`, `app/actions/generate-key.js`, `app/actions/admin-keys.js`,
`app/dashboard/page.js`, `app/admin/page.js` need **no changes**.
**Exception — `components/site-header.js`** currently calls `createServerAuthClient()`
directly (NOT `getCurrentGithubIdentity()`), so it MUST be migrated: replace the Supabase
`getUser()` block with `const identity = await getCurrentGithubIdentity()` and use
`identity?.githubUsername`.
Middleware no longer refreshes a Supabase session (stateless JWT verified on demand). Keep
a minimal/no-op middleware or remove the file; remove the `@supabase/ssr` import either way.
## Related Code Files
- Create: `app/auth/login/route.js`
- Modify: `app/auth/callback/route.js` (rewrite: state + Arctic + `/user` + session; import shared `sanitizeNext`)
- Modify: `app/auth/sign-out/route.js` (keep `POST` + `{status:303}`; clear cookie instead of `supabase.auth.signOut()`)
- Modify: `lib/auth/current-github-identity.js` (read `readSession()`; keep numeric assert + JSDoc)
- Modify: `components/sign-in-with-github-button.js` (anchor to `/auth/login?next=…`; drop browser supabase client)
- Modify: `components/site-header.js` (use `getCurrentGithubIdentity()`; drop Supabase client)
- Modify: `middleware.js` (delete file — stateless JWT needs no refresh)
## Implementation Steps
1. `app/auth/login/route.js` (GET): derive `origin` from request; `next = sanitizeNext(query.next)`;
`const state = generateState()` (Arctic); `oauth = getGithubOAuth(origin)`;
`url = oauth.createAuthorizationURL(state, GITHUB_SCOPES)`; set `oauth_state` cookie
(httpOnly, secure, lax, maxAge 600) AND `oauth_next` cookie (same attrs) via the
`cookies()` store; `return NextResponse.redirect(url)`. (Cookies set via `cookies()`
attach to the redirect response in route handlers.)
2. Rewrite `app/auth/callback/route.js`:
- read `code`, `state`; read `oauth_state` cookie; if mismatch/missing → redirect `/?auth_error=1`.
- `oauth = getGithubOAuth(origin)` (same origin as login → matching redirect_uri);
`tokens = await oauth.validateAuthorizationCode(code)`; `access = tokens.accessToken()`.
- `fetch('https://api.github.com/user', { headers:{ Authorization:`Bearer ${access}`,
'User-Agent':'llmapikey' }})` → `{ id, login }`; coerce `id` to string, assert numeric.
- `await createSession({ githubUserId:String(id), githubUsername:login })`.
- read `next` from `oauth_next` cookie → `sanitizeNext`; clear `oauth_state` + `oauth_next`;
redirect to `${origin}${next}`. Discard the access token (not persisted).
3. `app/auth/sign-out/route.js` (keep `POST`): `await clearSession(); return
NextResponse.redirect(new URL('/', request.url), { status: 303 })`. Remove Supabase import.
(Caller is a `method="post"` form in `site-header.js`; 303 turns it into a GET.)
4. `lib/auth/current-github-identity.js`: replace Supabase body with `return await
readSession();` Keep the `GithubIdentity` typedef + numeric invariant note. Drop the
Supabase import.
5. `components/sign-in-with-github-button.js`: replace `signInWithOAuth` with a plain
anchor/`<a href={'/auth/login?next='+encodeURIComponent(next)}>` (or `router.push`).
Remove `createBrowserSupabaseClient` import. Keep loading UX minimal.
6. `components/site-header.js`: replace `createServerAuthClient()`/`getUser()` with
`const identity = await getCurrentGithubIdentity()`; `username = identity?.githubUsername`.
Keep the try/catch (DB/config unconfigured → signed-out header). Drop Supabase import.
7. `middleware.js`: delete the file entirely (stateless JWT verified on demand; pages
self-gate via `getCurrentGithubIdentity()`). KISS.
8. `npm run build`; manual local check deferred to Phase 3 (needs real GitHub creds).
## Success Criteria
- [ ] `/auth/login` 302s to `github.com/login/oauth/authorize` with `state` + `read:user`.
- [ ] `/auth/callback` with valid code sets `llmapikey_session` cookie and redirects to `next`.
- [ ] State mismatch/missing → redirect to `/?auth_error=1`, no session set.
- [ ] `getCurrentGithubIdentity()` returns the same shape from the cookie; admin/dashboard/
generate-key compile and behave unchanged.
- [ ] No remaining `@supabase/ssr` import in `middleware.js`, sign-in button, or auth routes.
- [ ] `npm run build` clean.
## Risk Assessment
- **State/next cookie coupling** — keep state and next in httpOnly cookies; never trust
query `next` without `sanitizeNext`. Mitigation: reuse existing sanitizer.
- **GitHub `/user` rate/UA** — GitHub requires a `User-Agent` header; omitting it 403s.
- **Cookie not set on redirect** — set cookies on the `NextResponse` (route handlers can
write); verify `Set-Cookie` present on the 302.
- **Email/primary not needed** — we only read `id`+`login`; no extra `user:email` scope.
## Security Considerations
- CSRF via single-use `state` (httpOnly, 10-min TTL), compared server-side.
- Open-redirect prevented by `sanitizeNext` (same-origin relative only).
- Access token never stored or logged; lives only inside the callback request.
- Session cookie httpOnly+Secure+Lax; numeric `provider_id` re-asserted before minting.
@@ -0,0 +1,85 @@
---
phase: 3
title: "Cleanup Config & Deploy"
status: pending
priority: P1
effort: "2h"
dependencies: [2]
---
# Phase 3: Cleanup Config & Deploy
## Overview
Remove the dead Supabase-auth surface, reconcile config/docs/tests, provision the GitHub
OAuth App + Vercel env, and ship. End state: app authenticates entirely on its own; Supabase
is Postgres-only.
## Requirements
- Functional: no Supabase auth code/deps remain; live GitHub sign-in works end-to-end.
- Non-functional: build+tests green; secrets server-only; docs match reality.
## Architecture
Deletes `lib/supabase/*` (auth clients) and the `@supabase/*` deps. The only Supabase
touchpoint left is `POSTGRES_URL` (DB), consumed by `lib/db/postgres-client.js` — unchanged.
GitHub OAuth App callback now points at the **app** (`/auth/callback`), not Supabase.
## Related Code Files
- Delete: `lib/supabase/server-client.js`, `lib/supabase/browser-client.js` (whole `lib/supabase/`)
- Delete: `tests/rls-deny-all.test.js` (top-level imports `@supabase/supabase-js`; would fail to load once the dep is removed — DECIDED: delete)
- Modify: `package.json` (remove `@supabase/ssr`, `@supabase/supabase-js`)
- Modify: `.env.example` (remove `NEXT_PUBLIC_SUPABASE_URL`/`_ANON_KEY`; finalize new vars)
- Modify: `README.md` (rewrite auth/setup: GitHub OAuth App callback → app domain, new env table)
## Implementation Steps
1. Delete `lib/supabase/` (both clients). Grep-confirm zero remaining imports of
`@/lib/supabase/*`, `@supabase/ssr`, `@supabase/supabase-js` across `app/ lib/ middleware.js`.
2. `npm uninstall @supabase/ssr @supabase/supabase-js`.
3. Delete `tests/rls-deny-all.test.js` (DECIDED). Its top-level `import { createClient }
from "@supabase/supabase-js"` would throw module-not-found once the dep is removed, even
though the test self-skips. Isolation stays structural (unexposed schema + direct PG +
deny-all RLS); add a README note.
4. `.env.example`: drop the two `NEXT_PUBLIC_SUPABASE_*` lines; add `GITHUB_OAUTH_CLIENT_ID`,
`GITHUB_OAUTH_CLIENT_SECRET`, `AUTH_SESSION_SECRET` (callback is origin-derived, no env);
keep `POSTGRES_URL`, `OPENROUTER_MANAGEMENT_KEY`, caps, `ADMIN_GITHUB_USER_IDS`.
5. `README.md`: rewrite Stack + Setup auth steps — GitHub OAuth App with callback
`https://llmapikey.vercel.app/auth/callback`; remove Supabase Auth provider setup; note
Supabase = Postgres only. Update Architecture "Server-only secrets" list.
6. **GitHub OAuth App**: create (or repoint) with Authorization callback
`https://llmapikey.vercel.app/auth/callback`; copy Client ID + Secret.
7. **Vercel env** (Production): add `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`,
`AUTH_SESSION_SECRET` (`openssl rand -base64 48`). Remove `NEXT_PUBLIC_SUPABASE_URL`/
`_ANON_KEY`. (Callback is origin-derived; sign-in works only on the canonical domain
registered in the OAuth App — non-canonical hosts fail at GitHub. Accepted.)
8. `npm run build && npm test` — all green.
9. `vercel deploy --prod --yes`; smoke-test: `/` 200, `/auth/login` 302→github, full
sign-in lands on `/dashboard`, `/admin` 404 anon, sign-out clears cookie.
## Success Criteria
- [ ] No `@supabase/*` deps in `package.json`; `lib/supabase/` gone; zero stale imports.
- [ ] `.env.example` + `README.md` reflect app-native OAuth; no Supabase-auth references.
- [ ] `npm run build` + `npm test` pass.
- [ ] Live: GitHub sign-in completes → `/dashboard`; sign-out works; `/admin` 404 for anon.
- [ ] Vercel has the 3 new vars; Supabase anon vars removed.
## Risk Assessment
- **Cutover invalidates existing Supabase sessions** — users re-login once. Acceptable
(gated/low usage). Communicate if needed.
- **Forgot to repoint GitHub OAuth callback** → `redirect_uri mismatch` at GitHub.
Mitigation: step 6 before deploy; verify exact string.
- **`AUTH_SESSION_SECRET` differs Preview vs Prod** — fine (separate session domains); just
ensure each is set or `readSession` throws.
- **Removing rls test** reduces explicit coverage of schema isolation — mitigate by a note
in README that isolation is structural (unexposed schema + direct PG).
## Security Considerations
- Confirm no secret ever carries `NEXT_PUBLIC_`.
- Verify deployed `Set-Cookie` flags (httpOnly, Secure, SameSite=Lax) via curl.
- Keep `ADMIN_GITHUB_USER_IDS` fail-closed behavior intact (unchanged identity contract).
## Resolved Decisions
1. **rls-deny-all test** — DELETED (top-level supabase-js import breaks once dep removed; isolation is structural).
2. **Redirect URI** — origin-derived (`${origin}/auth/callback`), no callback env; sign-in works only on the canonical registered domain (deployment-hash/preview hosts fail at GitHub — accepted).
3. **middleware.js** — DELETED entirely (stateless JWT, pages self-gate).
## Open Questions
1. **GitHub OAuth App** — register a fresh app dedicated to llmapikey (recommended) vs reuse existing? (deploy-time, not code)
@@ -0,0 +1,59 @@
---
title: "Switch auth: Supabase Auth → app-native GitHub OAuth (Arctic + jose)"
description: "Replace Supabase Auth (GitHub provider) with self-contained GitHub OAuth handled in the Next.js app. Stateless signed-cookie session. Removes shared-Supabase coupling; Supabase becomes Postgres-only."
status: in-progress
priority: P2
branch: "master"
tags: [auth, oauth, github, security, nextjs]
blockedBy: []
blocks: []
created: "2026-06-14T03:52:45.628Z"
createdBy: "ck:plan"
source: skill
---
# Switch auth: Supabase Auth → app-native GitHub OAuth (Arctic + jose)
## Overview
Today auth is brokered by Supabase (GitHub provider → Supabase `/auth/v1/callback`
app). Supabase is used **only** for auth — data is direct Postgres to the unexposed
`llmapikey` schema. This plan replaces Supabase Auth with GitHub OAuth handled in-app
(GitHub → `llmapikey.vercel.app/auth/callback`), using **Arctic** for the OAuth2 dance
and **jose** for a stateless signed-cookie session. Net effect: per-app user isolation,
one fewer redirect hop, no shared `auth.users`/JWT/rate-limit pool, and Supabase
downgraded to "just the DB host".
Identity contract is preserved: `getCurrentGithubIdentity()` keeps returning
`{ githubUserId, githubUsername }` (numeric `provider_id` anchor), so admin allowlist,
key minting, and dashboard are untouched downstream.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Session & OAuth Foundation](./phase-01-session-oauth-foundation.md) | Done |
| 2 | [OAuth Routes & Identity Swap](./phase-02-oauth-routes-identity-swap.md) | Done |
| 3 | [Cleanup Config & Deploy](./phase-03-cleanup-config-deploy.md) | Code done; deploy steps (OAuth App, Vercel env, prod deploy) pending |
## Key Decisions
- **Stateless session** — signed JWT (HS256, jose) in an httpOnly cookie. No DB session
table (app has no user table; identity = `provider_id` + login). Re-login on expiry.
- **No token storage** — `read:user` scope only; fetch `/user` once at callback, derive
id+login, discard the access token. Nothing to refresh.
- **CSRF** — Arctic `state` stored in a short-lived httpOnly cookie, verified at callback.
- **Origin-derived redirect URI** — `${origin}/auth/callback`, built from the request
origin (no callback env). GitHub validates it against the OAuth App's single registered
callback, so sign-in works only when the app is reached on its canonical registered host;
non-canonical hosts (Vercel deployment-hash / preview URLs) fail at GitHub — accepted, we
link only the canonical domain. Login + callback pass the same origin so `redirect_uri`
matches across authorize + token-exchange. The post-login `next` redirect also uses origin.
## Dependencies
- **Supersedes** the auth design in `project:260613-1144-openrouter-key-giveaway-website`
(that plan is effectively built/deployed; only its Supabase-auth choice is replaced here).
No hard block — the live site keeps working through cutover.
- **No impact** on `project:260613-2033-admin-management-crud` (completed) — admin authz
reads the unchanged `getCurrentGithubIdentity()` contract.
-35
View File
@@ -1,35 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
/**
* Real-Supabase verification: the anon role must NOT be able to read api_keys.
* Because `llmapikey` is unexposed to PostgREST, the anon client should get a
* schema/permission error (or, defensively, zero rows) — never real data.
*
* Skips when Supabase env is absent so `npm test` passes in CI/local without
* credentials. Run against a real project to actually exercise the invariant.
*/
test(
"anon client cannot read llmapikey.api_keys",
{ skip: !url || !anonKey ? "Supabase env not set" : false },
async () => {
const supabase = createClient(url, anonKey, {
auth: { persistSession: false },
});
const { data, error } = await supabase
.schema("llmapikey")
.from("api_keys")
.select("id");
// The schema is unexposed to PostgREST, so the anon client MUST get an
// error (e.g. PGRST106). An empty array is NOT acceptable — that would mean
// the schema became reachable, just with no rows visible.
assert.notEqual(error, null, "anon must receive a hard error, not a result set");
assert.equal(data, null, "anon must not receive any data array");
},
);
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
encodeSecret,
signSessionToken,
verifySessionToken,
} from "../lib/auth/session-token.js";
const SECRET = encodeSecret("x".repeat(48));
const IDENTITY = { githubUserId: "12345", githubUsername: "octocat" };
test("session token round-trips identity", async () => {
const token = await signSessionToken(IDENTITY, SECRET);
const decoded = await verifySessionToken(token, SECRET);
assert.deepEqual(decoded, IDENTITY);
});
test("tampered token verifies to null", async () => {
const token = await signSessionToken(IDENTITY, SECRET);
const tampered = token.slice(0, -2) + (token.endsWith("a") ? "bb" : "aa");
assert.equal(await verifySessionToken(tampered, SECRET), null);
});
test("token signed with a different secret verifies to null", async () => {
const token = await signSessionToken(IDENTITY, SECRET);
const otherSecret = encodeSecret("y".repeat(48));
assert.equal(await verifySessionToken(token, otherSecret), null);
});
test("non-numeric subject is rejected", async () => {
const token = await signSessionToken(
{ githubUserId: "octocat", githubUsername: "octocat" },
SECRET,
);
assert.equal(await verifySessionToken(token, SECRET), null);
});
test("expired token verifies to null", async () => {
const { SignJWT } = await import("jose");
const expired = await new SignJWT({ login: "octocat" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("12345")
.setIssuedAt(0)
.setExpirationTime(1) // epoch+1s — long past
.sign(SECRET);
assert.equal(await verifySessionToken(expired, SECRET), null);
});
test("missing token verifies to null", async () => {
assert.equal(await verifySessionToken(undefined, SECRET), null);
assert.equal(await verifySessionToken("", SECRET), null);
});
test("encodeSecret rejects short/missing secrets", () => {
assert.throws(() => encodeSecret(undefined));
assert.throws(() => encodeSecret("tooshort"));
assert.doesNotThrow(() => encodeSecret("z".repeat(32)));
});