diff --git a/.env.example b/.env.example index efc33ca..d1be168 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.md b/README.md index 65de3bf..0cc41e6 100644 --- a/README.md +++ b/README.md @@ -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://.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. diff --git a/app/auth/callback/route.js b/app/auth/callback/route.js index a371db7..fae6dba 100644 --- a/app/auth/callback/route.js +++ b/app/auth/callback/route.js @@ -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`); + } } diff --git a/app/auth/login/route.js b/app/auth/login/route.js new file mode 100644 index 0000000..7af0f9f --- /dev/null +++ b/app/auth/login/route.js @@ -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); +} diff --git a/app/auth/sign-out/route.js b/app/auth/sign-out/route.js index fced70d..385ed6b 100644 --- a/app/auth/sign-out/route.js +++ b/app/auth/sign-out/route.js @@ -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 }); } diff --git a/components/sign-in-with-github-button.js b/components/sign-in-with-github-button.js index cf60f37..2b17853 100644 --- a/components/sign-in-with-github-button.js +++ b/components/sign-in-with-github-button.js @@ -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 ( - + + {label} + ); } diff --git a/components/site-header.js b/components/site-header.js index 3f5d023..0aa0608 100644 --- a/components/site-header.js +++ b/components/site-header.js @@ -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 ( diff --git a/lib/auth/current-github-identity.js b/lib/auth/current-github-identity.js index 94c2b08..e9873b5 100644 --- a/lib/auth/current-github-identity.js +++ b/lib/auth/current-github-identity.js @@ -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} 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(); } diff --git a/lib/auth/github-oauth.js b/lib/auth/github-oauth.js new file mode 100644 index 0000000..e7e0fa4 --- /dev/null +++ b/lib/auth/github-oauth.js @@ -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`); +} diff --git a/lib/auth/sanitize-next.js b/lib/auth/sanitize-next.js new file mode 100644 index 0000000..ae508a7 --- /dev/null +++ b/lib/auth/sanitize-next.js @@ -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"; +} diff --git a/lib/auth/session-token.js b/lib/auth/session-token.js new file mode 100644 index 0000000..fe01ac6 --- /dev/null +++ b/lib/auth/session-token.js @@ -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} + */ +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; + } +} diff --git a/lib/auth/session.js b/lib/auth/session.js new file mode 100644 index 0000000..96f5be6 --- /dev/null +++ b/lib/auth/session.js @@ -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); +} diff --git a/lib/supabase/browser-client.js b/lib/supabase/browser-client.js deleted file mode 100644 index 313f6d8..0000000 --- a/lib/supabase/browser-client.js +++ /dev/null @@ -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); -} diff --git a/lib/supabase/server-client.js b/lib/supabase/server-client.js deleted file mode 100644 index e7007e0..0000000 --- a/lib/supabase/server-client.js +++ /dev/null @@ -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} - */ -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; -} diff --git a/middleware.js b/middleware.js deleted file mode 100644 index 04b28b7..0000000 --- a/middleware.js +++ /dev/null @@ -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)$).*)"], -}; diff --git a/package-lock.json b/package-lock.json index 190bd9a..3429228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4599a32..586b212 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plans/260614-1050-switch-to-app-native-github-oauth/phase-01-session-oauth-foundation.md b/plans/260614-1050-switch-to-app-native-github-oauth/phase-01-session-oauth-foundation.md new file mode 100644 index 0000000..e401a89 --- /dev/null +++ b/plans/260614-1050-switch-to-app-native-github-oauth/phase-01-session-oauth-foundation.md @@ -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). diff --git a/plans/260614-1050-switch-to-app-native-github-oauth/phase-02-oauth-routes-identity-swap.md b/plans/260614-1050-switch-to-app-native-github-oauth/phase-02-oauth-routes-identity-swap.md new file mode 100644 index 0000000..3d5688a --- /dev/null +++ b/plans/260614-1050-switch-to-app-native-github-oauth/phase-02-oauth-routes-identity-swap.md @@ -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/`` (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. diff --git a/plans/260614-1050-switch-to-app-native-github-oauth/phase-03-cleanup-config-deploy.md b/plans/260614-1050-switch-to-app-native-github-oauth/phase-03-cleanup-config-deploy.md new file mode 100644 index 0000000..b11aa77 --- /dev/null +++ b/plans/260614-1050-switch-to-app-native-github-oauth/phase-03-cleanup-config-deploy.md @@ -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) diff --git a/plans/260614-1050-switch-to-app-native-github-oauth/plan.md b/plans/260614-1050-switch-to-app-native-github-oauth/plan.md new file mode 100644 index 0000000..99d1a49 --- /dev/null +++ b/plans/260614-1050-switch-to-app-native-github-oauth/plan.md @@ -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. diff --git a/tests/rls-deny-all.test.js b/tests/rls-deny-all.test.js deleted file mode 100644 index 5580e3c..0000000 --- a/tests/rls-deny-all.test.js +++ /dev/null @@ -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"); - }, -); diff --git a/tests/session-roundtrip.test.js b/tests/session-roundtrip.test.js new file mode 100644 index 0000000..12ef079 --- /dev/null +++ b/tests/session-roundtrip.test.js @@ -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))); +});