원클릭으로
neon-auth-integration
Complete Neon Auth integration setup for AFENDA, addressing gaps in current codebase. Covers provisioning, server SDK, client SDK, bearer token verification, and security module integration.
메뉴
Complete Neon Auth integration setup for AFENDA, addressing gaps in current codebase. Covers provisioning, server SDK, client SDK, bearer token verification, and security module integration.
Enforces correct use of AFENDA design tokens, the L0–L4 premium elevation architecture, semantic lanes, and generous spatial rhythm. Use when editing styles, themes, layouts, or visual states.
Practical NextAuth/Auth.js development and implementation playbook with end-to-end debugging workflows for Next.js apps.
Build AFENDA Treasury as an AP-grade full-stack ERP sub-domain (contracts, db, core, api, worker, web, tests, gates). Use when: treasury module, cash management, bank account, bank statement, reconciliation, liquidity forecast, cash position, payment factory, bank transfer, treasury controls, treasury architecture review, AP-style enterprise workflow.
Quality assurance for web accessibility and usability, particularly for users with disabilities. Use when involved in any web project.
Skill for integrating Better Auth - the comprehensive TypeScript authentication framework.
This skill provides guidance for implementing security features that span across Better Auth, including rate limiting, CSRF protection, session security, trusted origins, secret management, OAuth security, IP tracking, and security auditing. These topics are not covered in individual plugin skills.
| name | neon-auth-integration |
| description | Complete Neon Auth integration setup for AFENDA, addressing gaps in current codebase. Covers provisioning, server SDK, client SDK, bearer token verification, and security module integration. |
The official Neon Auth quickstart is for Vite + React Router (simple client-centric). AFENDA uses Next.js 16 App Router (server + client architecture). This skill adapts the official patterns for Next.js.
| Aspect | Official Quickstart | AFENDA Skill |
|---|---|---|
| Framework | Vite + React Router | Next.js 16 App Router |
| Server SDK | None (client-only) | @neondatabase/auth/next/server ✅ |
| Client SDK | @neondatabase/neon-js | @neondatabase/neon-js ✅ |
| Env var | VITE_NEON_AUTH_URL | NEXT_PUBLIC_NEON_AUTH_URL + NEON_AUTH_BASE_URL |
| Auth Init | createAuthClient() in src/lib/auth.ts | createNeonAuth() in src/lib/auth/server.ts |
| Route Handler | None (Vite doesn't need it) | app/api/auth/[...auth]/route.ts (Next.js pattern) |
| Session API | authClient.useSession() (hook) | auth.api.getSession() (server) |
| UI Components | AuthView, AccountView pre-built | Same + custom Next.js Page wrappers |
| Multi-tenancy | Not in quickstart | ✅ Bridged via toAfendaSession() |
| API Bearer Tokens | Not covered | ✅ Full JWKS + JWT verification |
| RLS Policies | Not covered | ✅ Org isolation patterns |
Key Insight: All official Neon Auth APIs are identical; we just adapt the framework-specific setup for Next.js and add AFENDA's multi-tenancy layer.
Neon Auth is a managed authentication service built on Better Auth. This skill provides:
Status: Migration from NextAuth v5 → Neon Auth COMPLETE. Validated 2026-03-16.
lucky-silence-39754997, neon_auth schema live)apps/web/src/lib/auth/server.ts — 1,640+ lines, 46 exports)apps/web/src/lib/auth/client.ts — createAuthClient() + hooks)apps/api/src/plugins/auth.ts — JWKS + jose.jwtVerify)apps/web/auth.ts — toAuthSession() enrichment)sync_neon_auth_user_to_afenda_identity on neon_auth.user)neon_auth.* tables (managed by Neon — not user-editable)resolvePrincipalContext() call in web layer (API layer does this via JWT)neon_auth schema provisioned with 9 tables (user, session, account, verification, jwks, organization, member, invitation, project_config)@neondatabase/auth v1.4.18 installed, createNeonAuth() in apps/web/src/lib/auth/server.tsapps/web/src/app/api/auth/[...path]/route.ts proxies GET/POST/PUT/DELETE/PATCHcreateAuthClient() from @neondatabase/auth/next in apps/web/src/lib/auth/client.ts with useSession, signIn, signOut, etc.jose.jwtVerify() + createRemoteJWKSet() in apps/api/src/plugins/auth.ts — resolves email → resolvePrincipalContext()apps/web/src/lib/api-client.ts reads session / __Secure-session cookies + Bearer token forwardingresolvePrincipalContext() in packages/core/src/kernel/identity/auth.ts — 7-step principal resolution (API layer)neon_auth.* tables (Neon-managed schema). AFENDA public.* tables have org_isolation policies via current_setting('app.org_id')toAuthSession() in apps/web/auth.ts bridges Neon Auth identity → AFENDA org/role context; auto-assigns single orgapps/web/OWNERS.md references Neon Auth route handler, sign-in page, and auth shimsync_neon_auth_user_to_afenda_identity trigger on neon_auth.user INSERT — creates party, person, iam_principal, party_role, membershipneon_auth.jwks for JWT signing/verificationNEON_AUTH_BASE_URL, NEON_AUTH_COOKIE_SECRET (≥32 chars), NEXT_PUBLIC_NEON_AUTH_URL, NEON_AUTH_JWKS_URL all validated in packages/core/src/kernel/infrastructure/env.tsneonProtectedRouteMiddleware in apps/web/proxy.ts protects /app/*, /admin/*, /finance/*, etc.Important: Neon Auth documentation provides two primary quickstarts:
Vite + React Router (Simple, client-centric)
@neondatabase/neon-jsVITE_NEON_AUTH_URLNext.js App Router (This skill — More control)
@neondatabase/auth/next/server@neondatabase/neon-jsAFENDA uses Next.js, so follow this skill's approach (not the Vite quickstart).
Verify AFENDA env schema accepts Neon Auth variables:
✅ DONE — All env vars are configured in packages/core/src/kernel/infrastructure/env.ts:
NEON_AUTH_BASE_URL — optional, URL-validated (line 116)NEON_AUTH_COOKIE_SECRET — optional, min 32 chars (line 117–119)NEXT_PUBLIC_NEON_AUTH_URL — optional, URL-validated (line 122–124)NEON_AUTH_JWKS_URL — optional, URL-validated (line 120)redactEnv() (line 232)✅ DONE — Verified via Neon MCP 2026-03-16:
lucky-silence-39754997 (name: AFENDA)org-fragrant-lake-90358173 (name: Jack, plan: Launch)aws-ap-southeast-1 (AWS — Neon Auth compatible)production (primary) + v2 (child)neon_auth schema active with 9 tables# Server SDK for Next.js App Router
pnpm add -W @neondatabase/auth
# Unified client SDK (includes UI components)
pnpm add -W @neondatabase/neon-js
# Already installed, but verify
pnpm list zod
pnpm list react react-dom
Compatibility Matrix:
@neondatabase/auth: Latest (Better Auth 1.4.18)@neondatabase/neon-js: Latest (includes @neondatabase/auth/react/ui)✅ PHASE COMPLETE — Validated 2026-03-16 via Neon MCP
neon_auth schema to be created✅ VERIFIED — neon_auth schema contains 9 tables:
| Table | Purpose | Status |
|---|---|---|
neon_auth.user | User identities (id, email, name, role, banned) | ✅ Live (0 rows) |
neon_auth.session | Login sessions (token, userId, activeOrganizationId, impersonatedBy) | ✅ Live (0 active) |
neon_auth.account | OAuth accounts (providerId, accessToken, password) | ✅ Live |
neon_auth.verification | Email/2FA verification codes | ✅ Live |
neon_auth.jwks | JWT signing keys for bearer verification | ✅ Live (1 key pair) |
neon_auth.organization | Neon Auth orgs (id, slug, name) | ✅ Live (0 rows) |
neon_auth.member | Org membership | ✅ Live |
neon_auth.invitation | Org invitations | ✅ Live |
neon_auth.project_config | Auth project settings | ✅ Live |
After Neon Console enables auth, introspect the schema:
pnpm db:generate
# Drizzle will discover neon_auth schema tables:
# - neon_auth.user
# - neon_auth.session
# - neon_auth.account
# - neon_auth.verification
Expected tables in packages/db/src/schema/:
schema/
neon-auth/
neon-auth-accounts.ts (OAuth provider accounts)
neon-auth-sessions.ts (Login sessions)
neon-auth-users.ts (User identities)
neon-auth-verifications.ts (Email/2FA verification codes)
relations.ts (Foreign keys)
Do NOT edit these files. They are auto-generated and controlled by Neon Auth lifecycle.
✅ PHASE COMPLETE —
apps/web/src/lib/auth/server.ts(1,640+ lines, 46 exports)
File: apps/web/src/lib/auth/server.ts (NEW)
This file instantiates the official Neon Auth server SDK per documented pattern.
/**
* Neon Auth Server SDK for Next.js App Router.
*
* Official pattern from https://neon.com/docs/auth/quick-start/nextjs
*
* This is the source of truth for:
* - Route handler at /api/auth/[...auth]/route.ts
* - Server-side session verification
* - Cookie-based session management
*
* Architecture:
* - Neon Auth provides identity (user, session, OAuth)
* - AFENDA auth/* modules provide governance (audit, compliance, incident)
* - `toAfendaSession()` bridges Neon Auth identity → AFENDA context
*/
import { createNeonAuth } from "@neondatabase/auth/next/server";
import type { Session } from "@neondatabase/auth";
// ─────────────────────────────────────────────────────────────────────────────
// Neon Auth Instance
// ─────────────────────────────────────────────────────────────────────────────
/**
* Neon Auth server configuration.
* Credentials read from env at startup.
*/
export const auth = createNeonAuth({
baseUrl: process.env.NEON_AUTH_BASE_URL!,
cookies: {
secret: process.env.NEON_AUTH_COOKIE_SECRET!,
},
});
/**
* Export Neon Auth route handler for [...auth]/route.ts
*/
export const { GET, POST } = auth.handler();
// ─────────────────────────────────────────────────────────────────────────────
// Bridge to AFENDA Context
// ─────────────────────────────────────────────────────────────────────────────
/**
* Neon Auth session shape (from Better Auth).
* Can be queried with `SELECT * FROM neon_auth.user` + `neon_auth.session`.
*/
export type NeonAuthSession = Session | null;
/**
* AFENDA session shape.
* Extends Neon Auth identity with AFENDA org/role context.
*/
export interface AfendaSession {
// From Neon Auth
user: {
id: string; // UUID from neon_auth.user.id
email: string; // From neon_auth.user.email
name: string | null;
image: string | null;
};
// From AFENDA context
affiliation: {
orgId: string; // Resolved from user → party_membership → org
principalId: string; // From auth_principal or party table
roles: string[]; // List of role IDs granted to this principal
permissions: Set<string>; // Expanded from roles
};
// Metadata
expiresAt: Date;
createdAt: Date;
}
/**
* Convert Neon Auth session to AFENDA session.
*
* Prerequisites:
* - Neon Auth user.id must be linked to AFENDA party/principal (via trigger or app logic)
* - User's org membership must exist in party_membership table
* - RLS policies must be active on all auth-* tables
*
* Stub: Future version will query AFENDA auth domain modules to populate:
* - orgId from party_membership
* - principalId from auth_principal or similar
* - roles/permissions from role_assignment + permission registry
*/
export async function toAfendaSession(
neonSession: NeonAuthSession,
): Promise<AfendaSession | null> {
if (!neonSession?.user) return null;
// TODO: Replace with real lookups once DB linking is in place.
// For now, every Neon user gets demo org + admin principal.
return {
user: {
id: neonSession.user.id,
email: neonSession.user.email,
name: neonSession.user.name ?? null,
image: neonSession.user.image ?? null,
},
affiliation: {
orgId: process.env.NODE_ENV === "development"
? "00000000-0000-0000-0000-000000000001" // demo org UUID
: "", // Will fail in prod until DB linking implemented
principalId: neonSession.user.id,
roles: ["admin"],
permissions: new Set(["*"]), // TODO: query actual permissions
},
expiresAt: new Date(neonSession.expiresAt),
createdAt: new Date(neonSession.createdAt ?? Date.now()),
};
}
/**
* Get current user session from cookies/headers.
* Wrapper around Neon Auth + AFENDA bridge.
*
* Usage in Server Components / Route Handlers:
* const session = await getSession();
* if (!session) return <SignInPrompt />;
*/
export async function getSession(): Promise<AfendaSession | null> {
const neonSession = await auth.api.getSession();
return toAfendaSession(neonSession);
}
/**
* Verify that a request has a valid session.
* Middleware / API route pattern.
*/
export async function requireSession(): Promise<AfendaSession> {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized: no session");
}
return session;
}
File: apps/web/src/app/api/auth/[...auth]/route.ts (REPLACE existing)
/**
* Neon Auth routes.
* Replaces the old NextAuth [...nextauth]/route.ts.
*/
import { auth } from "@/lib/auth/server";
export const { GET, POST } = auth.handler();
Old file apps/web/src/app/api/auth/[...nextauth]/route.ts should be deleted.
File: apps/web/middleware.ts (UPDATE)
// ... existing imports ...
import { auth } from "@/lib/auth/server";
export async function middleware(request: NextRequest) {
// Verify Neon Auth session is valid
// (Optional: you can add extra auth checks here)
const session = await auth.api.getSession();
// Let request proceed; protected routes check session in their handlers
return NextResponse.next();
}
export const config = {
// Apply to all routes except static
matcher: ["/((?!_next/static|_next/image|favicon).*)"],
};
✅ PHASE COMPLETE —
apps/web/src/lib/auth/client.ts(250+ lines) withcreateAuthClient(),useSession,signIn,signOut,organization.*,emailOtp.*, capability detection, and error-wrapped facades.
File: apps/web/src/lib/auth/client.ts (NEW)
/**
* Neon Auth Client SDK for React components.
*
* Usage in Client Components:
* const { user, isLoading } = useAuth();
* if (!isLoading && !user) return <SignInButton />;
* return <Dashboard user={user} />;
*/
import { createAuthClient } from "@neondatabase/neon-js/auth";
import type { BetterAuthClientPlugin } from "better-auth/client";
/**
* Neon Auth client instance.
* Endpoint is NEXT_PUBLIC_NEON_AUTH_URL from env.
*/
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_NEON_AUTH_URL ?? "/api/auth",
// Optional: custom plugins for AFENDA domain (audit, compliance, etc.)
plugins: [
// Future: add AFENDA-specific auth plugins (3-factor, anomaly detection, etc.)
],
});
/**
* React hook: Get current user session.
* Triggers re-render on auth state change (login, logout, session refresh).
*
* Example:
* const { user, isLoading, error } = useAuth();
* if (isLoading) return <Skeleton />;
* if (error) return <ErrorAlert error={error} />;
* if (!user) return <SignInForm />;
* return <Dashboard user={user} />;
*/
export const useAuth = () => {
const { data: session, isLoading, error } = authClient.useSession();
return {
user: session?.user ?? null,
session,
isLoading,
error,
};
};
/**
* Sign in with email/password.
*/
export const signIn = authClient.signIn.email;
/**
* Sign in with OAuth provider (Google, GitHub, etc.)
* Neon Auth provides out-of-the-box Google + custom OAuth.
*/
export const signInOAuth = authClient.signIn.social;
/**
* Sign out and clear session cookie.
*/
export const signOut = authClient.signOut;
/**
* Sign up new user with email/password.
*/
export const signUp = authClient.signUp.email;
/**
* Request password reset flow.
*/
export const resetPassword = authClient.forgetPassword;
/**
* Change user profile (name, image, etc.)
* Does NOT change email or password (use separate endpoints).
*/
export const updateProfile = authClient.updateUser;
File: apps/web/src/components/auth-provider.tsx (NEW)
"use client";
import React, { ReactNode } from "react";
import { NeonAuthUIProvider } from "@neondatabase/neon-js/auth/react/ui";
import { authClient } from "@/lib/auth/client";
/**
* Wrap your app with this to enable Neon Auth UI components.
* Use in root layout:
*
* export default function RootLayout({ children }) {
* return (
* <html>
* <body>
* <AuthProvider>{children}</AuthProvider>
* </body>
* </html>
* );
* }
*/
export function AuthProvider({ children }: { children: ReactNode }) {
return (
<NeonAuthUIProvider authClient={authClient}>
{children}
</NeonAuthUIProvider>
);
}
File: apps/web/src/app/layout.tsx (UPDATE)
import { AuthProvider } from "@/components/auth-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
File: apps/web/src/app/(public)/signin/page.tsx (NEW/REPLACE)
Choose one approach:
"use client";
import { AuthView } from "@neondatabase/neon-js/auth/react/ui";
import { authClient } from "@/lib/auth/client";
export default function SignInPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-slate-50">
<div className="w-full max-w-md">
<AuthView
authClient={authClient}
pathname="sign-in"
mode="dark"
/>
</div>
</div>
);
}
"use client";
import { useState } from "react";
import { Button } from "@afenda/ui";
import { Input } from "@afenda/ui";
import { signIn } from "@/lib/auth/client";
export default function SignInPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await signIn({
email,
password,
callbackURL: "/dashboard",
});
} catch (err) {
setError(err instanceof Error ? err.message : "Sign in failed");
}
};
return (
<div className="flex items-center justify-center min-h-screen">
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-4">
<h1 className="text-2xl font-bold">Sign In to AFENDA</h1>
{error && <div className="text-red-600">{error}</div>}
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit">Sign In</Button>
</form>
</div>
);
}
✅ PHASE COMPLETE —
apps/api/src/plugins/auth.tsusesjose.jwtVerify()+createRemoteJWKSet(). JWKS auto-discovered fromNEON_AUTH_JWKS_URLor derived fromNEON_AUTH_BASE_URL/.well-known/jwks.json. Email extracted from JWT payload →resolvePrincipalContext()→ fullRequestContextset onreq.ctx.
File: apps/api/src/plugins/auth.ts (REPLACE bearer stub)
/**
* Fastify plugin: Authentication for Neon Auth bearer tokens.
*
* Expects: Authorization: Bearer <JWK-signed-JWT-from-Neon-Auth>
*
* Neon Auth provides a JWKS endpoint; this plugin:
* 1. Fetches JWKS (cached)
* 2. Verifies JWT signature
* 3. Resolves userId → AFENDA principal context
* 4. Sets req.ctx with org/role/permission context
*/
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import jwtVerify from "@panva/hkdf"; // Or use @noble/ciphers for JWT verification
import { resolvePrincipalContext } from "@afenda/core";
const DEV_HEADER = "x-dev-user-email";
/**
* Placeholder: Fetch Neon Auth JWKS from provisioned auth endpoint.
* In production, implement:
* 1. GET {NEON_AUTH_BASE_URL}/.well-known/jwks.json
* 2. Cache with 1-hour TTL
* 3. Use `jose` lib to verify JWT
*/
async function verifyNeonAuthToken(token: string): Promise<{ sub: string; email: string } | null> {
// TODO: Implement JWKS fetch + JWT verification
// Stub returns null (bearer path disabled)
return null;
}
export const authPlugin = fp(async function authPlugin(app: FastifyInstance) {
app.decorateRequest("ctx", undefined);
app.addHook("onRequest", async (req) => {
// Skip infra endpoints
if (req.url === "/healthz" || req.url === "/readyz") return;
const isDev = process.env.NODE_ENV === "development";
const slug = req.orgSlug ?? "demo";
// ── Dev-mode shortcut ────────────────────────────────────────────────
if (isDev && req.headers[DEV_HEADER]) {
const email = req.headers[DEV_HEADER] as string;
const ctx = await resolvePrincipalContext(app.db, email, slug, req.correlationId);
if (ctx) {
req.ctx = ctx;
if (ctx.activeContext?.orgId) {
req.orgId = ctx.activeContext.orgId;
}
}
return;
}
// ── Neon Auth bearer token ───────────────────────────────────────────
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
const payload = await verifyNeonAuthToken(token);
if (payload?.email) {
const ctx = await resolvePrincipalContext(
app.db,
payload.email,
slug,
req.correlationId,
);
if (ctx) {
req.ctx = ctx;
if (ctx.activeContext?.orgId) {
req.orgId = ctx.activeContext.orgId;
}
}
}
}
// If no session resolved, routes requiring auth will return 401
});
});
# For JWT verification (use jose or @noble/ciphers, NOT @panva/hkdf)
pnpm add -W jose
# Or use @noble/ciphers if you prefer smaller bundle
pnpm add -W @noble/ciphers
Full implementation sketch:
import * as jose from "jose";
let cachedJwks: jose.JSONWebKeySet | null = null;
let jwksCacheTTL = 0;
async function fetchJwks(): Promise<jose.JSONWebKeySet> {
const now = Date.now();
if (cachedJwks && now < jwksCacheTTL) {
return cachedJwks;
}
const baseURL = process.env.NEON_AUTH_BASE_URL;
if (!baseURL) throw new Error("NEON_AUTH_BASE_URL not set");
const res = await fetch(`${baseURL}/.well-known/jwks.json`);
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
cachedJwks = await res.json();
jwksCacheTTL = now + 3600_000; // 1 hour
return cachedJwks;
}
async function verifyNeonAuthToken(
token: string,
): Promise<{ sub: string; email: string } | null> {
const jwks = await fetchJwks();
const secret = jose.importJWKSet(jwks);
try {
const verified = await jose.jwtVerify(token, secret);
const payload = verified.payload as { sub?: string; email?: string };
if (payload.sub && payload.email) {
return { sub: payload.sub, email: payload.email };
}
} catch (err) {
// Invalid or expired token
return null;
}
return null;
}
Validated 2026-03-16 — Sync trigger deployed and operational on live Neon DB.
Deployed trigger: sync_neon_auth_user_to_afenda_identity on neon_auth.user (AFTER INSERT).
On new Neon Auth signup, the trigger automatically provisions:
party record (kind='person', external_key='person:{email}')person record (id=party.id, email, display name) — ON CONFLICT updatesiam_principal record (person_id, kind='user', email) — ON CONFLICT updatesparty_role record (org_id=demo org, party_id, role_type='employee') — ON CONFLICT updatesmembership record (principal_id, party_role_id, status='active') — ON CONFLICT reactivatesFail-safe: Wrapped in EXCEPTION WHEN OTHERS — trigger failure never blocks Neon Auth user creation. Warnings logged via RAISE WARNING.
Live DB state (verified):
public.organization: 2 orgs (Demo Organization slug='demo', Acme Supplies slug='acme-supplies')public.iam_principal: 3 principals (pre-seeded)neon_auth.user: 0 rows (no signups yet — trigger fires on first signup)neon_auth.jwks: 1 key pair for JWT signing/verificationNote: The actual trigger uses AFENDA's domain model (
party,person,iam_principal,party_role,membership) — not the outdated names (auth_principal,party_membership,role_assignment) from the original skill draft.
Already implemented at packages/core/src/kernel/identity/auth.ts (550+ lines):
RequestContextSchemaapps/api/src/plugins/auth.ts) after JWT verificationlistPrincipalContexts() for hat-switching UINote: The web layer (
apps/web/auth.ts) usestoAuthSession()which reads from Neon Auth claims directly — it does NOT callresolvePrincipalContext(). The API layer is the one that does full DB-backed RBAC resolution.
AFENDA public.* tables use current_setting('app.org_id')::uuid via withOrgContext() (ADR-0003 pattern).
neon_auth.* tables have no RLS policies — this is expected because:
neon_auth schema is Neon-managed (not user-editable)public.* layerValidated 2026-03-16 — Dev shim replaced by production Neon Auth integration.
apps/web/auth.ts is now the production session orchestrator (not a dev shim):
toAuthSession() bridges Neon Auth session → AuthSession with AFENDA enrichmentresolveSession() calls getNeonSession() → toAuthSession() → auto-assigns single orgauth() overloaded: zero-arg returns session, one-arg wraps route handlerAll auth imports use the Neon Auth SDK wrappers:
import { auth } from "@/auth" — production auth() backed by Neon Authimport { getNeonSession } from "@/lib/auth/server" — server-side sessionimport { useSession, signIn, signOut } from "@/lib/auth/client" — client-side hooksapps/api/src/plugins/auth.ts still allows x-dev-user-email header bypass in development mode. This is intentional for local API testing without a full browser session. Should be removed or gated before production deployment.
AFENDA's security/governance modules work via the pillar structure (not auth-provider-specific):
kernel/governance/audit — writes audit log on every mutation via writeAuditLog()kernel/identity — resolvePrincipalContext() resolves email → principal → roles → permissionswithOrgContext() sets PostgreSQL GUCs (app.org_id, app.principal_id) for RLSThese modules are identity-provider-agnostic. They work with Neon Auth because:
withOrgContext() for org isolation# Neon Auth endpoint (from console)
NEON_AUTH_BASE_URL=https://your-endpoint.neonauth.region.aws.neon.tech/db/auth
# Cookie secret for session protection (generate with `openssl rand -base64 32`)
NEON_AUTH_COOKIE_SECRET=your-base64-encoded-32-byte-secret
# Web app client-side auth URL (same as above)
NEXT_PUBLIC_NEON_AUTH_URL=https://your-endpoint.neonauth.region.aws.neon.tech/db/auth
# Dev can use localhost or neon endpoint
NEON_AUTH_BASE_URL=http://localhost:5000/db/auth # (local dev)
NEON_AUTH_BASE_URL=https://staging-endpoint.neonauth... # (shared dev)
NEON_AUTH_COOKIE_SECRET=dev-secret-not-used-in-tests
NEXT_PUBLIC_NEON_AUTH_URL=http://localhost:3000/api/auth # (dev)
# GitHub Actions or similar should inject from secrets
NEON_AUTH_BASE_URL=${{ secrets.NEON_AUTH_BASE_URL }}
NEON_AUTH_COOKIE_SECRET=${{ secrets.NEON_AUTH_COOKIE_SECRET }}
NEXT_PUBLIC_NEON_AUTH_URL=${{ env.NEXT_PUBLIC_NEON_AUTH_URL }}
apps/web/src/lib/__vitest_test__/auth.server.facade.test.ts — Server SDK facade testsapps/web/src/lib/__vitest_test__/auth.client.facade.test.ts — Client SDK facade testsapps/api/src/__vitest_test__/auth-flows.test.ts — API auth flows (bearer verification, principal resolution)packages/contracts/src/kernel/identity/__vitest_test__/auth.commands.test.ts — Auth command schemasapps/web/src/app/api/internal/admin/_lib/__vitest_test__/authorization.test.ts — Admin authorizationsync_neon_auth_user_to_afenda_identity creates party/person/principal/role/membershipjose.jwtVerify() → email → resolvePrincipalContext() → req.ctxNeon Auth is the production identity provider. NextAuth has been fully removed. The dev shim in apps/web/auth.ts has been replaced with the production toAuthSession() orchestrator.
If Neon Auth service becomes unavailable:
getNeonSession() returns null → redirect to sign-in page (graceful)No code rollback is needed. The system degrades gracefully when the Neon Auth endpoint is unreachable.
For unsupported features, self-host Better Auth instead.
neon-postgres – Neon fundamentals, branching, connection poolingbetter-auth-best-practices – Better Auth API design, securitynextauth-authentication – Migration path from NextAuth v5fastify-best-practices – Bearer token patterns in FastifyLast updated: March 16, 2026 (validated against live codebase + Neon DB)