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