feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in
Deploy to route.crispygoat.com / deploy (push) Failing after 2s

After a user signs in with Google, they land on /admin but see
'Your account does not have admin access' because getAdminUser()
only checked the legacy dev_session and rc_auth_uid cookies.

This completes the Auth.js path:

- New src/lib/db.ts: shared pg.Pool singleton (extracted from
  src/lib/auth.ts). The single connection pool for the whole app
  — server actions, API routes, and Auth.js all import from here.

- src/lib/auth.ts: imports the shared pool, signIn event now calls
  the new upsert_admin_user_for_authjs RPC (idempotent) to
  auto-create a platform_admin row on first sign-in.

- New supabase/migrations/209_authjs_auto_create_admin.sql:
  - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for
    can_manage_settings (was likely dashboard-added, not in any
    tracked migration)
  - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID)
    that inserts a platform_admin row with all permissions true
    and ON CONFLICT (user_id) DO NOTHING
  - NOTIFY pgrst to reload PostgREST schema cache

- src/lib/admin-permissions.ts: new Auth.js session check between
  dev_session and rc_auth_uid. Uses auth() from @/lib/auth to
  decrypt the JWT cookie server-side, then getAdminUserFromPool()
  queries admin_users + admin_user_brands via the shared pool.
  Legacy rc_auth_uid path unchanged (deferred).

- src/middleware.ts: recognizes Auth.js session cookies
  (authjs.session-token and __Secure-authjs.session-token) at the
  edge so signed-in users aren't bounced to /login.

Flow after this change:
  Dev/demo:  visit /admin → middleware auto-issues dev_session → in
  Prod:      click Google → Auth.js OAuth → signIn event creates
             admin_users row → redirect to /admin → getAdminUser()
             reads JWT, queries pool, returns platform_admin.
This commit is contained in:
2026-06-06 20:30:11 +00:00
parent 53d995fc99
commit 1e9f9c0414
5 changed files with 203 additions and 69 deletions
+60 -2
View File
@@ -1,4 +1,6 @@
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";
import { pool } from "@/lib/db";
import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types";
@@ -8,7 +10,9 @@ export type { AdminUser } from "./admin-permissions-types";
* Resolution order:
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
* 3. Auth.js v5 session (JWT cookie) → look up `admin_users` by the
* Auth.js user id (the `users.id` UUID managed by @auth/pg-adapter).
* 4. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
*
* `brand_id` is the active brand; `brand_ids` is the full membership list.
* For dev sessions without a real DB, `brand_ids` is populated by:
@@ -24,12 +28,23 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return buildDevAdmin("platform_admin");
}
// ── Dev session bypass (enabled for testing on all envs) ──────────────
// ── Dev session bypass (enabled for testing on all envs) ──────────
const dev = cookieStore.get("dev_session")?.value;
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
return buildDevAdmin(dev);
}
// ── Auth.js v5 session (JWT) ─────────────────────────────────────
// After Google sign-in, the encrypted JWT cookie is set. `auth()`
// decrypts it server-side and returns the session — no DB call here,
// just cookie decryption. Then we look up the admin row by the
// Auth.js `users.id` UUID (same ID space as `admin_users.user_id`).
const session = await auth();
if (session?.user?.id) {
const admin = await getAdminUserFromPool(session.user.id);
if (admin) return admin;
}
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) return null;
@@ -91,6 +106,49 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return buildAdminUser(admin, brandIds);
}
/**
* Look up an admin user by the Auth.js `users.id` UUID using the shared
* `pg` pool. Returns `null` if no active row exists.
*
* The `admin_users.user_id` column is UUID (see 028_fix_caller_uid_type.sql).
* The Auth.js `users.id` is also UUID (see 204_authjs_tables.sql:18). The
* @auth/pg-adapter auto-generates a fresh UUID per new user on first
* sign-in; the Google `sub` claim is stored separately in
* `accounts."providerAccountId"`. So both IDs are in the same UUID space.
*/
async function getAdminUserFromPool(userId: string): Promise<AdminUser | null> {
try {
const { rows } = await pool.query<Record<string, unknown>>(
"SELECT * FROM admin_users WHERE user_id = $1 AND active = true LIMIT 1",
[userId]
);
if (rows.length === 0) return null;
const admin = rows[0];
const brandIds = await fetchAdminUserBrandIdsFromPool(admin.id as string);
return buildAdminUser(admin, brandIds);
} catch (e) {
console.warn("[admin-permissions] getAdminUserFromPool error:", e);
return null;
}
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given
* admin row id, via the shared `pg` pool. Returns an empty array on any
* failure.
*/
async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise<string[]> {
try {
const { rows } = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM admin_user_brands WHERE admin_user_id = $1",
[adminRowId]
);
return rows.map((r) => r.brand_id).filter((id): id is string => typeof id === "string");
} catch {
return [];
}
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
* Returns an empty array on any failure (e.g. before migration 207 is applied).
+12 -59
View File
@@ -1,11 +1,8 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
import { pool } from "@/lib/db";
import { authConfig, isDevLoginEnabled } from "@/auth.config";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
@@ -39,51 +36,11 @@ function buildDevCredentialsProvider() {
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client — see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 1. The Postgres database adapter (uses the shared `pool` from @/lib/db)
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
@@ -96,7 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
adapter: PostgresAdapter(pool),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
@@ -108,27 +65,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
* `admin_users` keyed to this Auth.js user id. The RPC is
* idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops.
*
* This is the seam between the new Auth.js auth layer and the
* existing admin authorization model. After this fires, the user
* is recognized by `getAdminUser()` in `src/lib/admin-permissions.ts`.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
"SELECT * FROM upsert_admin_user_for_authjs($1)",
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// eslint-disable-next-line no-console
// Don't block sign-in on a missing admin_users row.
console.warn("[auth] signIn event error (non-fatal):", e);
}
},
+49
View File
@@ -0,0 +1,49 @@
import { Pool } from "pg";
/**
* Shared `pg.Pool` for direct Postgres access.
*
* This is the single connection pool for the entire app — server actions,
* API routes, and Auth.js all import `pool` from here. No more ad-hoc pools
* in individual files.
*
* Replaces the Supabase JS client (see CLAUDE.md "Supabase is being
* removed in favor of a direct Postgres connection"). SECURITY DEFINER
* RPCs are the recommended way to do reads/writes; this pool is the
* transport.
*
* Connection resolution:
* 1. `DATABASE_URL` — preferred, single connection string
* 2. `SUPABASE_DB_URL` — legacy, for projects still on Supabase
* 3. `POSTGRES_URL` — alternative
*
* Singleton pattern: `globalThis.__pgPool` so Next.js hot reload doesn't
* open a new pool on every request.
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function createPool(): Pool {
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The deploy workflow and `.env.example` document
// the required vars.
console.warn(
"[db] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — pg pool will fail on first query."
);
}
return new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
});
}
export const pool: Pool =
globalForPool.__pgPool ?? (globalForPool.__pgPool = createPool());
+16 -8
View File
@@ -3,18 +3,18 @@ import { NextResponse, type NextRequest } from "next/server";
/**
* Middleware for /admin/* and /login routes.
*
* Two responsibilities:
* 1. Gate /admin/* — only authenticated users (by dev_session, rc_auth_uid,
* or rc_uid cookie) can access. Unauthenticated users are redirected
* to /login.
* 2. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by
* Recognises three auth sources at the edge:
* 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-`
* variant in prod) — set after Google sign-in.
* 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies.
* 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by
* default in non-prod) and the user has no auth cookie, automatically
* issue `dev_session=platform_admin` so visiting /admin just works.
* No buttons, no demo page, no client-side cookie games.
*
* This is the single source of truth for "am I allowed in?" at the edge.
* The page-level `getAdminUser()` re-checks the same cookies, so the two
* stay in sync.
* The page-level `getAdminUser()` re-checks the same cookies (and reads
* the Auth.js JWT) to resolve the role.
*/
export function middleware(request: NextRequest) {
const { nextUrl } = request;
@@ -25,12 +25,20 @@ export function middleware(request: NextRequest) {
const dev = request.cookies.get("dev_session")?.value;
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
const rcUid = request.cookies.get("rc_uid")?.value;
// Auth.js v5 sets an encrypted JWT cookie. Names differ by transport:
// `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token`
// (prod / HTTPS). The presence of either cookie is sufficient to
// consider the request authenticated at the edge — the page-level
// `getAdminUser()` re-reads the JWT and resolves the role.
const authJsSessionToken = request.cookies.get("authjs.session-token")?.value;
const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value;
const hasDevSession =
dev === "platform_admin" ||
dev === "brand_admin" ||
dev === "store_employee";
const hasRealAuth = Boolean(rcAuthUid || rcUid);
const isAuthenticated = hasDevSession || hasRealAuth;
const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken);
const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession;
// ── /admin/* ──────────────────────────────────────────────────────
if (isOnAdmin) {