@ingram-tech/nk-auth 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,8 @@ only what you need from focused subpaths.
17
17
  | `createAuthPool` (`./pool`) | `pg` Pool with optional SSL CA verification |
18
18
  | `bcryptPassword`, `makeEmailSenders`, `makePasskeyOptions`, `uuidGenerateId` (`./`) | password migration, email hooks, passkeys, UUID ids |
19
19
  | `createServerSupabase` (`./`) | RLS-aware supabase-js client (attaches the session JWT) |
20
+ | `createAuthHelpers` (`./server`) | validated App Router session helpers: `getSession` / `getUser` / `requireSession` / `requireUser` / `redirectIfAuthenticated` |
21
+ | `createAuthMiddleware` (`./middleware`) | loop-safe edge middleware that only gates *unauthenticated* users off protected paths |
20
22
 
21
23
  > **Supabase Auth → Better Auth + RLS migration?** Read
22
24
  > [`docs/better-auth-migration.md`](../../docs/better-auth-migration.md) — the
@@ -176,6 +178,74 @@ export const authClient = createAuthClient({
176
178
  // authClient.signIn.email(...), signIn.social(...), useSession(), passkey.*
177
179
  ```
178
180
 
181
+ ## 5. Gate routes (without the redirect loop)
182
+
183
+ Two layers, **one rule that keeps them from fighting**: only the *validated*
184
+ layer may redirect a request *away from* the sign-in page.
185
+
186
+ The validated layer (server helpers) — bind once to your instance:
187
+
188
+ ```ts
189
+ // lib/auth/session.ts
190
+ import { createAuthHelpers } from "@ingram-tech/nk-auth/server";
191
+ import { auth } from "@/lib/auth";
192
+
193
+ export const {
194
+ getSession,
195
+ getUser,
196
+ requireSession,
197
+ requireUser,
198
+ redirectIfAuthenticated,
199
+ } = createAuthHelpers(auth);
200
+ ```
201
+
202
+ ```tsx
203
+ // app/dashboard/page.tsx — gate a protected page (validated, DB-backed).
204
+ import { requireUser } from "@/lib/auth/session";
205
+ export default async function Dashboard() {
206
+ const user = await requireUser(); // -> /login when signed out
207
+ return <main>Hi {user.email}</main>;
208
+ }
209
+
210
+ // app/login/page.tsx — gate the sign-in page HERE, never in middleware.
211
+ import { redirectIfAuthenticated } from "@/lib/auth/session";
212
+ import { LoginForm } from "./login-form";
213
+ export default async function Login() {
214
+ await redirectIfAuthenticated("/dashboard"); // validated: a stale cookie
215
+ return <LoginForm />; // resolves to "signed out" and falls through to the form
216
+ }
217
+ ```
218
+
219
+ The optimistic layer (middleware) is a fast edge short-circuit on cookie
220
+ *presence*. It can save a render for users with no cookie at all — but it must
221
+ never decide the sign-in page, because a present-but-invalid cookie there is
222
+ exactly what loops. `createAuthMiddleware` enforces that **at construction**: it
223
+ throws if you try to protect or front-door the sign-in path.
224
+
225
+ ```ts
226
+ // middleware.ts
227
+ import { createAuthMiddleware } from "@ingram-tech/nk-auth/middleware";
228
+
229
+ export const middleware = createAuthMiddleware({
230
+ protectedPaths: ["/dashboard", "/memory"], // cookie-less -> signInPath
231
+ signInPath: "/login",
232
+ frontDoorPaths: ["/"], // optional: cookie-bearing "/" -> signedInRedirect
233
+ signedInRedirect: "/dashboard",
234
+ });
235
+
236
+ export const config = {
237
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)"],
238
+ };
239
+ ```
240
+
241
+ Why the split: middleware runs before render and can't afford a DB lookup, so it
242
+ can only trust the cookie *exists*. The server helpers hit `auth.api.getSession`
243
+ and check the session *contents*. When those disagree (revoked session, rotated
244
+ secret, wiped DB) the validated layer wins and parks the user on `/login` — and
245
+ because middleware refuses to bounce `/login`, the form renders instead of
246
+ ping-ponging. Middleware is optional; sites that prefer one source of truth can
247
+ use the server helpers alone.
248
+
179
249
  ## RLS bridge (the important part)
180
250
 
181
251
  `auth.uid()` reads the `sub` claim of the JWT PostgREST receives. The `jwt`
@@ -0,0 +1,20 @@
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+ export interface AuthMiddlewareConfig {
3
+ /**
4
+ * Path prefixes that require a session. A request without a session cookie
5
+ * to any of these is redirected to `signInPath`. Matched with `startsWith`.
6
+ */
7
+ protectedPaths: string[];
8
+ /** Where unauthenticated users are sent. Default `/login`. */
9
+ signInPath?: string;
10
+ /**
11
+ * Optional front-door redirect: when a session cookie is present and the
12
+ * path *exactly* equals one of these, redirect to `signedInRedirect`. Safe
13
+ * because it never targets the sign-in path. Typically `["/"]`.
14
+ */
15
+ frontDoorPaths?: string[];
16
+ /** Destination for the front-door redirect. Required when `frontDoorPaths` is set. */
17
+ signedInRedirect?: string;
18
+ }
19
+ export declare function createAuthMiddleware(config: AuthMiddlewareConfig): (request: NextRequest) => NextResponse;
20
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,MAAM,WAAW,oBAAoB;IACpC;;;OAGG;IACH,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,sFAAsF;IACtF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,IAwBrC,SAAS,WAAW,KAAG,YAAY,CAyB9D"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A loop-safe Next.js auth middleware factory, at
3
+ * "@ingram-tech/nk-auth/middleware". Middleware runs at the edge before a route
4
+ * renders, so it can only afford an **optimistic** check — the presence of a
5
+ * session cookie (`getSessionCookie`), not a database-validated session.
6
+ *
7
+ * That optimism is exactly what makes naive auth middleware loop. The validated
8
+ * server guard (see "@ingram-tech/nk-auth/server") sends an invalid-but-present
9
+ * session to the sign-in page; if the optimistic layer then bounces the sign-in
10
+ * page back ("you have a cookie, go to the app"), a stale cookie ping-pongs
11
+ * forever — and an RSC render can't clear the cookie to break out.
12
+ *
13
+ * So this factory enforces the one safe invariant *at construction*:
14
+ *
15
+ * The sign-in path is never the target of an optimistic redirect.
16
+ *
17
+ * It will only ever (a) push *cookie-less* requests off `protectedPaths`, and
18
+ * optionally (b) push *cookie-bearing* requests off a front door to the app. It
19
+ * refuses to protect or front-door the sign-in path, because either would
20
+ * reintroduce the loop. Sending a *signed-in* user away from /login is the job
21
+ * of `redirectIfAuthenticated` in the server helpers, which validates first.
22
+ */
23
+ import { getSessionCookie } from "better-auth/cookies";
24
+ import { NextResponse } from "next/server";
25
+ export function createAuthMiddleware(config) {
26
+ const signInPath = config.signInPath ?? "/login";
27
+ const frontDoorPaths = config.frontDoorPaths ?? [];
28
+ // Loop-safety, enforced once at construction rather than hoped-for per
29
+ // request. The sign-in path is where the validated guard parks an
30
+ // invalid-but-present session; it must never be the target of an optimistic
31
+ // redirect, or a stale cookie loops.
32
+ if (config.protectedPaths.some((p) => signInPath.startsWith(p))) {
33
+ throw new Error(`@ingram-tech/nk-auth: signInPath "${signInPath}" must not fall under protectedPaths — a cookie-less visit would redirect to itself forever.`);
34
+ }
35
+ if (frontDoorPaths.includes(signInPath)) {
36
+ throw new Error(`@ingram-tech/nk-auth: signInPath "${signInPath}" must not be a frontDoorPath — it would reintroduce the stale-cookie redirect loop.`);
37
+ }
38
+ if (frontDoorPaths.length > 0 && !config.signedInRedirect) {
39
+ throw new Error("@ingram-tech/nk-auth: signedInRedirect is required when frontDoorPaths is set.");
40
+ }
41
+ return function middleware(request) {
42
+ const hasSessionCookie = !!getSessionCookie(request);
43
+ const path = request.nextUrl.pathname;
44
+ if (!hasSessionCookie &&
45
+ config.protectedPaths.some((p) => path.startsWith(p))) {
46
+ const url = request.nextUrl.clone();
47
+ url.pathname = signInPath;
48
+ return NextResponse.redirect(url);
49
+ }
50
+ if (hasSessionCookie &&
51
+ config.signedInRedirect &&
52
+ frontDoorPaths.includes(path)) {
53
+ const url = request.nextUrl.clone();
54
+ url.pathname = config.signedInRedirect;
55
+ return NextResponse.redirect(url);
56
+ }
57
+ return NextResponse.next();
58
+ };
59
+ }
60
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAoB,YAAY,EAAE,MAAM,aAAa,CAAC;AAoB7D,MAAM,UAAU,oBAAoB,CAAC,MAA4B;IAChE,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,IAAI,QAAQ,CAAC;IACjD,MAAM,cAAc,GAAG,MAAM,CAAC,cAAc,IAAI,EAAE,CAAC;IAEnD,uEAAuE;IACvE,kEAAkE;IAClE,4EAA4E;IAC5E,qCAAqC;IACrC,IAAI,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CACd,qCAAqC,UAAU,8FAA8F,CAC7I,CAAC;IACH,CAAC;IACD,IAAI,cAAc,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,KAAK,CACd,qCAAqC,UAAU,sFAAsF,CACrI,CAAC;IACH,CAAC;IACD,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC;QAC3D,MAAM,IAAI,KAAK,CACd,gFAAgF,CAChF,CAAC;IACH,CAAC;IAED,OAAO,SAAS,UAAU,CAAC,OAAoB;QAC9C,MAAM,gBAAgB,GAAG,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC;QACrD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;QAEtC,IACC,CAAC,gBAAgB;YACjB,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EACpD,CAAC;YACF,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACpC,GAAG,CAAC,QAAQ,GAAG,UAAU,CAAC;YAC1B,OAAO,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QAED,IACC,gBAAgB;YAChB,MAAM,CAAC,gBAAgB;YACvB,cAAc,CAAC,QAAQ,CAAC,IAAI,CAAC,EAC5B,CAAC;YACF,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACpC,GAAG,CAAC,QAAQ,GAAG,MAAM,CAAC,gBAAgB,CAAC;YACvC,OAAO,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QAED,OAAO,YAAY,CAAC,IAAI,EAAE,CAAC;IAC5B,CAAC,CAAC;AACH,CAAC"}
@@ -0,0 +1,25 @@
1
+ /** The shape we need from a session: anything carrying a `user`. */
2
+ interface SessionLike {
3
+ user: unknown;
4
+ }
5
+ /**
6
+ * The slice of a Better Auth instance these helpers touch. Generic over the
7
+ * site's session type `S`, so `getSession`/`getUser` return the site's fully
8
+ * inferred user shape (additional fields, org id, …) — no `any`, no casts.
9
+ */
10
+ interface AuthLike<S extends SessionLike> {
11
+ api: {
12
+ getSession: (input: {
13
+ headers: Headers;
14
+ }) => Promise<S | null>;
15
+ };
16
+ }
17
+ export declare function createAuthHelpers<S extends SessionLike>(auth: AuthLike<S>): {
18
+ getSession: () => Promise<S | null>;
19
+ getUser: () => Promise<S["user"] | null>;
20
+ requireSession: (redirectTo?: string) => Promise<S>;
21
+ requireUser: (redirectTo?: string) => Promise<S["user"]>;
22
+ redirectIfAuthenticated: (to: string) => Promise<void>;
23
+ };
24
+ export {};
25
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAwBA,oEAAoE;AACpE,UAAU,WAAW;IACpB,IAAI,EAAE,OAAO,CAAC;CACd;AAED;;;;GAIG;AACH,UAAU,QAAQ,CAAC,CAAC,SAAS,WAAW;IACvC,GAAG,EAAE;QACJ,UAAU,EAAE,CAAC,KAAK,EAAE;YAAE,OAAO,EAAE,OAAO,CAAA;SAAE,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;KAC/D,CAAC;CACF;AAED,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,WAAW,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;sBAE5C,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;mBAgBpB,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;6CARG,OAAO,CAAC,CAAC,CAAC;0CAkBb,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;kCAa1B,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;EAWjE"}
package/dist/server.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Server-side session helpers for the App Router, at
3
+ * "@ingram-tech/nk-auth/server" so the framework-agnostic core entry never
4
+ * pulls in `next`. A site binds these once to its own Better Auth instance:
5
+ *
6
+ * // lib/auth/session.ts
7
+ * import { createAuthHelpers } from "@ingram-tech/nk-auth/server";
8
+ * import { auth } from "@/lib/auth";
9
+ *
10
+ * export const { getSession, getUser, requireUser, redirectIfAuthenticated } =
11
+ * createAuthHelpers(auth);
12
+ *
13
+ * These are the **validated** authority: every call hits `auth.api.getSession`,
14
+ * which checks the session against the database — not merely the presence of a
15
+ * cookie. That distinction is the whole point. The optimistic, cookie-presence
16
+ * check belongs in middleware (see "@ingram-tech/nk-auth/middleware"); it may
17
+ * only ever push *unauthenticated* users off protected routes. The decision to
18
+ * send a signed-in user *away* from the sign-in page must run through
19
+ * `redirectIfAuthenticated` here, so a stale or revoked cookie resolves to "no
20
+ * session" and falls through to the form instead of ping-ponging forever.
21
+ */
22
+ import { headers } from "next/headers";
23
+ import { redirect } from "next/navigation";
24
+ export function createAuthHelpers(auth) {
25
+ /** The validated session ({ user, session, … }) or null. */
26
+ async function getSession() {
27
+ return auth.api.getSession({ headers: await headers() });
28
+ }
29
+ /**
30
+ * Require a session or redirect, returning the full validated session (use
31
+ * when the caller needs more than the user — session id, active org, …).
32
+ */
33
+ async function requireSession(redirectTo = "/login") {
34
+ const session = await getSession();
35
+ // redirect() is typed `never`, so `session` narrows to non-null below.
36
+ if (!session)
37
+ redirect(redirectTo);
38
+ return session;
39
+ }
40
+ /** The authenticated user, or null. */
41
+ async function getUser() {
42
+ const session = await getSession();
43
+ return session?.user ?? null;
44
+ }
45
+ /**
46
+ * Require a signed-in user or redirect. Returns the user (non-null) so
47
+ * callers keep their `const user = await requireUser()` shape. `redirect()`
48
+ * throws, so control never returns when signed out.
49
+ */
50
+ async function requireUser(redirectTo = "/login") {
51
+ const user = await getUser();
52
+ // redirect() is typed `never`, so `user` narrows to non-null below.
53
+ if (!user)
54
+ redirect(redirectTo);
55
+ return user;
56
+ }
57
+ /**
58
+ * Redirect an already-signed-in user away (e.g. /login -> /dashboard). This
59
+ * is the *correct* place to gate the sign-in page: because it validates the
60
+ * session, a present-but-invalid cookie is treated as signed-out and the
61
+ * page renders instead of looping. Never do this in middleware.
62
+ */
63
+ async function redirectIfAuthenticated(to) {
64
+ if (await getSession())
65
+ redirect(to);
66
+ }
67
+ return {
68
+ getSession,
69
+ getUser,
70
+ requireSession,
71
+ requireUser,
72
+ redirectIfAuthenticated,
73
+ };
74
+ }
75
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAkB3C,MAAM,UAAU,iBAAiB,CAAwB,IAAiB;IACzE,4DAA4D;IAC5D,KAAK,UAAU,UAAU;QACxB,OAAO,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,EAAE,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED;;;OAGG;IACH,KAAK,UAAU,cAAc,CAAC,UAAU,GAAG,QAAQ;QAClD,MAAM,OAAO,GAAG,MAAM,UAAU,EAAE,CAAC;QACnC,uEAAuE;QACvE,IAAI,CAAC,OAAO;YAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;QACnC,OAAO,OAAO,CAAC;IAChB,CAAC;IAED,uCAAuC;IACvC,KAAK,UAAU,OAAO;QACrB,MAAM,OAAO,GAAG,MAAM,UAAU,EAAE,CAAC;QACnC,OAAO,OAAO,EAAE,IAAI,IAAI,IAAI,CAAC;IAC9B,CAAC;IAED;;;;OAIG;IACH,KAAK,UAAU,WAAW,CAAC,UAAU,GAAG,QAAQ;QAC/C,MAAM,IAAI,GAAG,MAAM,OAAO,EAAE,CAAC;QAC7B,oEAAoE;QACpE,IAAI,CAAC,IAAI;YAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;QAChC,OAAO,IAAI,CAAC;IACb,CAAC;IAED;;;;;OAKG;IACH,KAAK,UAAU,uBAAuB,CAAC,EAAU;QAChD,IAAI,MAAM,UAAU,EAAE;YAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;IACtC,CAAC;IAED,OAAO;QACN,UAAU;QACV,OAAO;QACP,cAAc;QACd,WAAW;QACX,uBAAuB;KACvB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-auth",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "The Ingram Better Auth foundation: composable presets (org, dual-shape JWT, Supabase RLS bridge, active-org hooks, pg pool) for Next.js sites.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,6 +41,14 @@
41
41
  "types": "./dist/paths.d.ts",
42
42
  "import": "./dist/paths.js"
43
43
  },
44
+ "./server": {
45
+ "types": "./dist/server.d.ts",
46
+ "import": "./dist/server.js"
47
+ },
48
+ "./middleware": {
49
+ "types": "./dist/middleware.d.ts",
50
+ "import": "./dist/middleware.js"
51
+ },
44
52
  "./client": {
45
53
  "types": "./dist/client.d.ts",
46
54
  "import": "./dist/client.js"
@@ -62,6 +70,7 @@
62
70
  "@better-auth/passkey": "^1.6.0",
63
71
  "@supabase/supabase-js": ">=2.0.0",
64
72
  "better-auth": "^1.6.0",
73
+ "next": "^15.0.0 || ^16.0.0",
65
74
  "pg": "^8.13.0",
66
75
  "react": "^18.0.0 || ^19.0.0"
67
76
  },
@@ -72,6 +81,9 @@
72
81
  "@supabase/supabase-js": {
73
82
  "optional": true
74
83
  },
84
+ "next": {
85
+ "optional": true
86
+ },
75
87
  "pg": {
76
88
  "optional": true
77
89
  },
@@ -88,6 +100,7 @@
88
100
  "@types/pg": "^8.11.0",
89
101
  "@types/react": "^19.0.0",
90
102
  "better-auth": "^1.6.0",
103
+ "next": "^16.2.9",
91
104
  "pg": "^8.13.0",
92
105
  "react": "^19.0.0",
93
106
  "typescript": "^6.0.3",