@marlinjai/auth-brain-nextjs
v0.8.0
Published
Next.js integration for the Lumitra auth-brain identity service: middleware, session helpers, and fail-closed permission guards
Maintainers
Readme
@marlinjai/auth-brain-nextjs
Next.js integration for the Lumitra auth-brain identity service: middleware, server-component session helpers, and fail-closed permission guards. Extracted from the battle-tested lumitra-studio integration; the semantics (Authorization-header precedence, fail-closed can(), dual-accept SERVICE_TOKEN rotation, dev bypass) are preserved exactly.
Setup
// src/lib/auth.ts — the single config entry point
import { createAuthBrainNextjs } from '@marlinjai/auth-brain-nextjs';
export const auth = createAuthBrainNextjs({
appName: 'receipts',
// Multi-workspace mode: every session workspace whose slug starts with the
// prefix is a membership; the active one is a validated cookie selection.
workspaces: { slugPrefix: 'receipts-' },
activeWorkspaceCookie: 'receipts_ws',
// Single-workspace mode instead: { slug: 'lumitra-studio', slugEnvVar: 'STUDIO_WORKSPACE_SLUG' }
permissions: {
'receipts.upload': 'workspace.member',
'receipts.row.write': 'workspace.member',
},
publicPaths: ['/api/health'],
publicUrl: 'https://receipts.lumitra.co',
});// src/middleware.ts
import { auth } from '@/lib/auth';
export const runtime = 'nodejs'; // timingSafeEqual is not available on Edge
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt).*)'],
};
export default auth.createAuthMiddleware();Pages: const session = await auth.requireSession('/dashboard'). Mutating routes/actions: const { denied, auth: principal } = await auth.guardMutation(req, 'receipts.upload'); if (denied) return denied; (in server actions, which have no NextRequest, use await auth.requireAction('receipts.upload', resourceWorkspaceId) — it throws AuthBrainActionError on deny). Workspace switcher: wrap auth.setActiveWorkspace(id) in a 'use server' action.
Load-bearing requirements in the consuming middleware file (the package cannot enforce these for you):
export const runtime = 'nodejs'— the constant-time token compare usesnode:cryptotimingSafeEqual, which does not exist on the Edge runtime. Omitting this silently breaks the service-token gate.- The
config.matchermust cover every page and/api/*route except Next internals/static assets. A route outside the matcher is UNGUARDED.
Multi-workspace (slugPrefix) consumers MUST scope mutations to the resource's true owner. guardMutation/requireAction default the can() target to the session's ACTIVE workspace when resourceWorkspaceId is omitted — correct only when the resource genuinely belongs to the active workspace. For anything addressed by an opaque id (a row, a table), resolve the OWNING workspace server-side and pass it as resourceWorkspaceId, and check it against session.memberships for reads. Otherwise a member of several workspaces can touch workspace B's data while the check runs against workspace A.
Behavior contract
- An
Authorizationheader always means a machine caller: bearer compare (constant-time, dual-acceptSERVICE_TOKEN/SERVICE_TOKEN_NEXT), never a fallthrough to the session cookie. - No header: the
lumitra_sessioncookie is verified against auth-brain (30s cache); workspace membership (matched by slug/prefix) IS the gate, re-checked every request. - A verified session with zero matched workspaces gets
/no-access(pages) or JSON 401 (/api/*) — never a login loop. - The active-workspace cookie is a selector into the verified membership set, never a credential: invalid/absent falls back to the first membership (sorted by slug).
can()(OpenFGA) errors andfalseare equally a DENY (fail-closed). An un-provisioned OpenFGA never silently grants.- All env is read lazily;
next buildneeds no runtime secrets. Credentials are never logged.
Env
AUTH_BRAIN_URL (default https://auth.lumitra.co), OPENFGA_API_URL, OPENFGA_STORE_ID, OPENFGA_AUTHORIZATION_MODEL_ID, OPENFGA_API_TOKEN, SERVICE_TOKEN (+ SERVICE_TOKEN_NEXT during rotation), AUTH_DEV_USER_EMAIL (development only — bypasses auth for local UI work).
oidc mode (apps on their own domain)
The shared-cookie setup above needs the app on *.lumitra.co. For any other
domain, configure oidc mode: the app logs people in through auth-brain's
OpenID Connect authorization-code flow and keeps its own session cookie on its
own hostname.
// src/lib/auth.ts
export const auth = createAuthBrainNextjs({
appName: 'opuntia-studio',
mode: { oidc: { requireAmr: ['mfa'] } }, // all other fields have defaults
workspaces: { appGrant: { app: 'opuntia-studio' } },
permissions: { 'opuntia-studio.admin.access': 'workspace.member' },
publicPaths: ['/api/health'],
publicUrl: 'https://opuntiagatherings.com',
});
// src/app/api/auth/[action]/route.ts
import { auth } from '@/lib/auth';
const handlers = auth.oidcHandlers();
export const runtime = 'nodejs';
export async function GET(req: NextRequest, { params }: { params: { action: string } }) {
const h = handlers[params.action as keyof typeof handlers];
return h ? h(req) : new Response(null, { status: 404 });
}Register the client on auth-brain (/admin/oauth-clients, or the machine
API) with redirect_uri exactly https://<app>/api/auth/callback, and set
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and AUTH_SESSION_SECRET (32+ chars)
in the app's environment. The middleware, getSession, requireSession,
guardMutation and requireAction behave exactly as in cookie mode; an
unauthenticated page navigation goes to /api/auth/login?return_to=<path>,
which starts the flow, and a signed-in person without the app grant lands on
/no-access. Node runtime is required, as before.
Branded sign-in landing and silent sign-in
Set signInPath (e.g. '/admin/sign-in') to a public page that renders the
SignInWithLumitra button (@marlinjai/auth-brain-nextjs/signin) with
href={auth.loginUrl(returnTo)}. An unauthenticated page load then first tries
a silent sign-in (/api/auth/login?prompt=none): someone already signed in at
auth.lumitra.co gets straight in with zero clicks, and only a cold visitor is
sent to the landing (the provider answers login_required, the callback
redirects to signInPath?return_to=<path>).
- Signing out of the app sets a
<appName>_oidc_nosilentmarker cookie, so a still-warm provider session cannot silently sign the person back in. A failed silent attempt sets it for five minutes, so a cold visitor is not bounced through the provider on every page. The next successful sign-in clears it. - Only full page loads try silently; Next.js prefetches and client-side router fetches go straight to the landing.
silentSignIn: falseturns the silent attempt off and always shows the landing.
Popup sign-in
<SignInWithLumitra href={auth.loginUrl(returnTo)} mode="popup" /> runs the
sign-in in a separate window, the way "Sign in with Google" does. The app's
callback answers that window with a small page that reports the destination
back to the waiting tab (over a same-origin BroadcastChannel, which survives
the Google step severing window.opener, with postMessage as a second
channel) and closes itself; the tab then navigates with its new session.
- A blocked popup, a modified click (new tab or window) and no JavaScript all fall back to the plain link.
- While waiting, the button reads "Continue in the sign-in window" (a second click focuses that window) and a "continue in this tab" link stays available.
- Errors are shown inside the popup; no entitlement reports the no-access page as the destination.
- The completion page carries a strict Content-Security-Policy pinned to its one inline script's hash. No provider or client registration change is needed: the popup id never leaves the app.
