@ingram-tech/nk-auth 0.14.1 → 0.15.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
@@ -16,8 +16,8 @@ exactly one Better Auth copy in the app.
16
16
  | `createAuthPool` (`./pool`) | **deprecated** — alias of `createPool` from [`@ingram-tech/nk-db`](../nk-db); inject your app's shared pool instead |
17
17
  | `makeEmailSenders`, `makePasskeyOptions`, `passkeyOptionsForBaseUrl`, `uuidGenerateId` (`./`) | email hooks, passkeys (`passkeyOptionsForBaseUrl` derives `rpID`/`origin` from a single base URL), UUID ids |
18
18
  | `bcryptPassword` (`./`) | **legacy only** — bcrypt verifier for apps with pre-existing bcrypt hashes. New apps omit it (Better Auth defaults to scrypt). See [Migrating bcrypt passwords to scrypt](#migrating-bcrypt-passwords-to-scrypt) |
19
- | `createAuthHelpers`, `safeNext` (`./server`) | validated App Router session helpers (`getSession` / `getUser` / `requireSession` / `requireUser` / `redirectIfAuthenticated`), request-memoized via React `cache()`, with automatic `next` + stale-cookie signalling; `safeNext` validates a `?next=` param |
20
- | `createAuthMiddleware` (`./middleware`) | loop-safe edge middleware: gates unauthenticated users off protected paths, preserves `next`, and clears a stale session cookie so a bad session self-heals |
19
+ | `createAuthHelpers`, `safeNext` (`./server`) | validated App Router session helpers (`getSession` / `getUser` / `requireSession` / `requireUser` / `signInTarget` / `redirectIfAuthenticated`), request-memoized via React `cache()`, with automatic `next` + stale-cookie signalling; `safeNext` validates a `?next=` param |
20
+ | `createAuthMiddleware`, `withAuthPathHeader`, `clearStaleSession` (`./middleware`) | loop-safe edge middleware: gates unauthenticated users off protected paths, preserves `next`, and clears a stale session cookie so a bad session self-heals; the last two are its `next`-header and stale-cookie halves, for a site with its own proxy |
21
21
 
22
22
  > **Data access + RLS** is owned by [`@ingram-tech/nk-db`](../nk-db); see §3.
23
23
  >
@@ -387,9 +387,52 @@ that can touch cookies) deletes the dead Better Auth cookies on the `stale`
387
387
  marker and bounces to a clean `/login?next=…`; signing in returns them to
388
388
  `next`. `next` for the cookie-less case is filled in by middleware directly; for
389
389
  the cookie-present case the guard reads it from the `x-nk-auth-path` header
390
- middleware injects — so the self-heal (next + clearing) needs the middleware.
391
- An app that skips middleware still gets validated gating from the server helpers,
392
- just without automatic `next`/clearing.
390
+ middleware injects.
391
+
392
+ **Your own proxy.** An app that composes its own proxy (locale routing, tenant
393
+ pinning, …) keeps validated gating from the server helpers, but the guards can
394
+ only preserve `next` if something sets that header. Both halves of the
395
+ middleware are exported on their own so a custom proxy adopts them without the
396
+ optimistic gate; outside production the guards warn once when the header is
397
+ missing, and `nk doctor` flags the shape.
398
+
399
+ ```ts
400
+ // proxy.ts — a site with its own proxy
401
+ import { clearStaleSession, withAuthPathHeader } from "@ingram-tech/nk-auth/middleware";
402
+ import { localeProxy } from "@ingram-tech/nk-i18n/next";
403
+
404
+ export function proxy(request: NextRequest) {
405
+ const stale = clearStaleSession(request, { signInPath: "/login" });
406
+ if (stale) return stale; // dead cookie cleared, `next` kept
407
+ const requestHeaders = new Headers(request.headers);
408
+ withAuthPathHeader(request, requestHeaders); // guards can now build `next`
409
+ return localeProxy(routing, request, { requestHeaders });
410
+ }
411
+ ```
412
+
413
+ (`createAuthMiddleware`'s middleware takes the same `{ requestHeaders }` option
414
+ when you want the gate too.)
415
+
416
+ **Your own guard.** A wrapper that layers something over `getUser()` (a profile
417
+ lookup, say) redirects to `await signInTarget()`, the same URL `requireUser`
418
+ would have used, with `next` and `stale` filled in:
419
+
420
+ ```ts
421
+ const { getUser, signInTarget } = createAuthHelpers(auth);
422
+ export async function requireProfile() {
423
+ const user = await getUser();
424
+ if (!user) redirect(await signInTarget());
425
+ return loadProfile(user.id);
426
+ }
427
+ ```
428
+
429
+ **Your own param.** Both `createAuthHelpers` and `createAuthMiddleware` take
430
+ `nextParam` (default `next`) and `isSafeNext` (default `safeNext`, internal
431
+ paths only). A site that already ships `?redirectTo=` with a trusted-origin
432
+ allow-list passes `{ nextParam: "redirectTo", isSafeNext: isTrustedRedirect }`
433
+ to both, and its sign-in page keeps validating with the same function. Whatever
434
+ `isSafeNext` admits is where sign-in will send the user, so keep it as strict as
435
+ `safeNext` on everything except the origins you own.
393
436
 
394
437
  ## 6. Passwords: change, set, and reset
395
438
 
@@ -21,12 +21,32 @@ export declare const NK_AUTH_PATH_HEADER = "x-nk-auth-path";
21
21
  */
22
22
  export declare function safeNextParam(value: string | null | undefined): string | null;
23
23
  /**
24
- * Build a sign-in URL: `signInPath`, plus `next` (when safe) so the user returns
25
- * to where they were headed, plus a `stale=1` marker when a session cookie is
26
- * present but invalid — the signal the middleware uses to clear the dead cookie.
24
+ * How a site names and validates its post-login redirect param. Shared by the
25
+ * middleware (which writes it for the cookie-less case) and the server helpers
26
+ * (which write it for the cookie-present-but-invalid case), so both halves agree
27
+ * on the one contract the sign-in page reads.
28
+ */
29
+ export interface NextParamOptions {
30
+ /** Query param carrying the post-login destination. Default `next`. */
31
+ nextParam?: string;
32
+ /**
33
+ * Validate a candidate destination: return the value to redirect to, or null
34
+ * to drop it. Default {@link safeNextParam}, which admits only internal
35
+ * paths. A site with an existing trusted-origin allow-list plugs its own
36
+ * validator in here rather than forking the URL builder; whatever it admits
37
+ * is what its sign-in page will redirect to, so keep it as strict as
38
+ * `safeNextParam` on everything but the origins you own.
39
+ */
40
+ isSafeNext?: (value: string) => string | null;
41
+ }
42
+ /**
43
+ * Build a sign-in URL: `signInPath`, plus the next param (when safe) so the user
44
+ * returns to where they were headed, plus a `stale=1` marker when a session
45
+ * cookie is present but invalid — the signal the middleware uses to clear the
46
+ * dead cookie.
27
47
  */
28
48
  export declare function signInUrl(signInPath: string, opts: {
29
49
  next?: string | null;
30
50
  stale?: boolean;
31
- }): string;
51
+ } & NextParamOptions): string;
32
52
  //# sourceMappingURL=gating-internals.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"gating-internals.d.ts","sourceRoot":"","sources":["../src/gating-internals.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAM7E;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACxB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7C,MAAM,CAOR"}
1
+ {"version":3,"file":"gating-internals.d.ts","sourceRoot":"","sources":["../src/gating-internals.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAM7E;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAChC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAC9C;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CACxB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAChE,MAAM,CAQR"}
@@ -30,15 +30,17 @@ export function safeNextParam(value) {
30
30
  return value;
31
31
  }
32
32
  /**
33
- * Build a sign-in URL: `signInPath`, plus `next` (when safe) so the user returns
34
- * to where they were headed, plus a `stale=1` marker when a session cookie is
35
- * present but invalid — the signal the middleware uses to clear the dead cookie.
33
+ * Build a sign-in URL: `signInPath`, plus the next param (when safe) so the user
34
+ * returns to where they were headed, plus a `stale=1` marker when a session
35
+ * cookie is present but invalid — the signal the middleware uses to clear the
36
+ * dead cookie.
36
37
  */
37
38
  export function signInUrl(signInPath, opts) {
38
39
  const params = new URLSearchParams();
39
- const next = safeNextParam(opts.next);
40
+ const validate = opts.isSafeNext ?? safeNextParam;
41
+ const next = opts.next ? validate(opts.next) : null;
40
42
  if (next)
41
- params.set("next", next);
43
+ params.set(opts.nextParam ?? "next", next);
42
44
  if (opts.stale)
43
45
  params.set("stale", "1");
44
46
  const qs = params.toString();
@@ -1 +1 @@
1
- {"version":3,"file":"gating-internals.js","sourceRoot":"","sources":["../src/gating-internals.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,KAAgC;IAC7D,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAClE,oFAAoF;IACpF,IAAI,yBAAyB,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACvD,OAAO,KAAK,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CACxB,UAAkB,EAClB,IAA+C;IAE/C,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAC;IACrC,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,IAAI;QAAE,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACnC,IAAI,IAAI,CAAC,KAAK;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACzC,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;IAC7B,OAAO,EAAE,CAAC,CAAC,CAAC,GAAG,UAAU,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"gating-internals.js","sourceRoot":"","sources":["../src/gating-internals.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,KAAgC;IAC7D,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAClE,oFAAoF;IACpF,IAAI,yBAAyB,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACvD,OAAO,KAAK,CAAC;AACd,CAAC;AAsBD;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CACxB,UAAkB,EAClB,IAAkE;IAElE,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAC;IACrC,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,IAAI,aAAa,CAAC;IAClD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACpD,IAAI,IAAI;QAAE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,MAAM,EAAE,IAAI,CAAC,CAAC;IACrD,IAAI,IAAI,CAAC,KAAK;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACzC,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;IAC7B,OAAO,EAAE,CAAC,CAAC,CAAC,GAAG,UAAU,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC;AAChD,CAAC"}
@@ -1,5 +1,47 @@
1
1
  import { type NextRequest, NextResponse } from "next/server";
2
- export interface AuthMiddlewareConfig {
2
+ import { type NextParamOptions } from "./gating-internals.js";
3
+ export type { NextParamOptions } from "./gating-internals.js";
4
+ /**
5
+ * Forward the requested path + query to the app as the `x-nk-auth-path` request
6
+ * header, so a server guard can build `next` when it redirects to sign-in. The
7
+ * one line a custom proxy needs for `next` preservation:
8
+ *
9
+ * const requestHeaders = new Headers(request.headers);
10
+ * withAuthPathHeader(request, requestHeaders);
11
+ * return localeProxy(routing, request, { requestHeaders });
12
+ *
13
+ * Set, never passed through: the header is ours to mint, so a client that sends
14
+ * one of its own can't choose where sign-in returns to.
15
+ */
16
+ export declare function withAuthPathHeader(request: NextRequest, requestHeaders: Headers): void;
17
+ export interface StaleSessionConfig {
18
+ /** The sign-in path the server guards redirect to. Default `/login`. */
19
+ signInPath?: string;
20
+ /** Cookie-name fragment of the cookies to clear. Default `better-auth`. */
21
+ sessionCookiePrefix?: string;
22
+ }
23
+ /**
24
+ * The stale-session handshake, on its own for a custom proxy. The validated
25
+ * guard sends a present-but-invalid session to `${signInPath}?stale=1&next=…`;
26
+ * this clears the dead Better Auth cookies (only middleware can, pre-render) and
27
+ * returns a redirect to the same URL minus `stale`, which keeps `next`. Returns
28
+ * null on every other request, so a proxy can short-circuit on it:
29
+ *
30
+ * const stale = clearStaleSession(request, { signInPath: "/login" });
31
+ * if (stale) return stale;
32
+ *
33
+ * After the redirect the cookie is gone, so the handshake can't re-arm.
34
+ */
35
+ export declare function clearStaleSession(request: NextRequest, config?: StaleSessionConfig): NextResponse | null;
36
+ export interface AuthMiddlewareOptions {
37
+ /**
38
+ * Headers to forward to the app, if the middleware has its own to add. A
39
+ * fresh copy of the request's headers is used when omitted; pass your own
40
+ * when you also set things like a tenant or locale header.
41
+ */
42
+ requestHeaders?: Headers;
43
+ }
44
+ export interface AuthMiddlewareConfig extends NextParamOptions {
3
45
  /**
4
46
  * Path prefixes that require a session. A request without a session cookie
5
47
  * to any of these is redirected to `signInPath`. Matched with `startsWith`.
@@ -22,5 +64,5 @@ export interface AuthMiddlewareConfig {
22
64
  */
23
65
  sessionCookiePrefix?: string;
24
66
  }
25
- export declare function createAuthMiddleware(config: AuthMiddlewareConfig): (request: NextRequest) => NextResponse;
67
+ export declare function createAuthMiddleware(config: AuthMiddlewareConfig): (request: NextRequest, options?: AuthMiddlewareOptions) => NextResponse;
26
68
  //# sourceMappingURL=middleware.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG7D,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;IAC1B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,aAiC5B,WAAW,KAAG,YAAY,CA6D9D"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAEN,KAAK,gBAAgB,EAErB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,WAAW,EACpB,cAAc,EAAE,OAAO,GACrB,IAAI,CAKN;AAED,MAAM,WAAW,kBAAkB;IAClC,wEAAwE;IACxE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,WAAW,EACpB,MAAM,GAAE,kBAAuB,GAC7B,YAAY,GAAG,IAAI,CA4BrB;AAED,MAAM,WAAW,qBAAqB;IACrC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,oBAAqB,SAAQ,gBAAgB;IAC7D;;;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;IAC1B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,aAkCtD,WAAW,YACX,qBAAqB,KAC5B,YAAY,CAgDf"}
@@ -24,10 +24,70 @@
24
24
  * `${signInPath}?stale=1`; middleware (the only pre-render place that can set
25
25
  * cookies) deletes the dead Better Auth cookies and bounces to a clean
26
26
  * sign-in URL, so a bad session self-heals instead of failing every request.
27
+ *
28
+ * Both halves are also exported on their own — {@link withAuthPathHeader} and
29
+ * {@link clearStaleSession} — for a site whose proxy composes several concerns
30
+ * (locale routing, tenant pinning, …) and does not want the optimistic gate.
31
+ * Without the header the server guards cannot preserve `next` at all, and they
32
+ * say so (a one-time warning outside production); wiring one line into your own
33
+ * proxy is enough to fix that.
27
34
  */
28
35
  import { getSessionCookie } from "better-auth/cookies";
29
36
  import { NextResponse } from "next/server";
30
- import { NK_AUTH_PATH_HEADER, safeNextParam } from "./gating-internals.js";
37
+ import { NK_AUTH_PATH_HEADER, signInUrl, } from "./gating-internals.js";
38
+ /**
39
+ * Forward the requested path + query to the app as the `x-nk-auth-path` request
40
+ * header, so a server guard can build `next` when it redirects to sign-in. The
41
+ * one line a custom proxy needs for `next` preservation:
42
+ *
43
+ * const requestHeaders = new Headers(request.headers);
44
+ * withAuthPathHeader(request, requestHeaders);
45
+ * return localeProxy(routing, request, { requestHeaders });
46
+ *
47
+ * Set, never passed through: the header is ours to mint, so a client that sends
48
+ * one of its own can't choose where sign-in returns to.
49
+ */
50
+ export function withAuthPathHeader(request, requestHeaders) {
51
+ requestHeaders.set(NK_AUTH_PATH_HEADER, request.nextUrl.pathname + request.nextUrl.search);
52
+ }
53
+ /**
54
+ * The stale-session handshake, on its own for a custom proxy. The validated
55
+ * guard sends a present-but-invalid session to `${signInPath}?stale=1&next=…`;
56
+ * this clears the dead Better Auth cookies (only middleware can, pre-render) and
57
+ * returns a redirect to the same URL minus `stale`, which keeps `next`. Returns
58
+ * null on every other request, so a proxy can short-circuit on it:
59
+ *
60
+ * const stale = clearStaleSession(request, { signInPath: "/login" });
61
+ * if (stale) return stale;
62
+ *
63
+ * After the redirect the cookie is gone, so the handshake can't re-arm.
64
+ */
65
+ export function clearStaleSession(request, config = {}) {
66
+ const signInPath = config.signInPath ?? "/login";
67
+ const cookiePrefix = config.sessionCookiePrefix ?? "better-auth";
68
+ if (request.nextUrl.pathname !== signInPath ||
69
+ request.nextUrl.searchParams.get("stale") !== "1") {
70
+ return null;
71
+ }
72
+ const to = request.nextUrl.clone();
73
+ to.searchParams.delete("stale");
74
+ const res = NextResponse.redirect(to);
75
+ for (const cookie of request.cookies.getAll()) {
76
+ if (cookie.name.includes(cookiePrefix)) {
77
+ // `__Secure-`/`__Host-` cookies: browsers reject the deletion
78
+ // Set-Cookie unless it carries the Secure attribute itself, so a
79
+ // bare delete() would silently leave the dead cookie in place on
80
+ // HTTPS and re-run this handshake on every visit.
81
+ res.cookies.delete({
82
+ name: cookie.name,
83
+ path: "/",
84
+ secure: cookie.name.startsWith("__Secure-") ||
85
+ cookie.name.startsWith("__Host-"),
86
+ });
87
+ }
88
+ }
89
+ return res;
90
+ }
31
91
  export function createAuthMiddleware(config) {
32
92
  const signInPath = config.signInPath ?? "/login";
33
93
  const frontDoorPaths = config.frontDoorPaths ?? [];
@@ -51,32 +111,16 @@ export function createAuthMiddleware(config) {
51
111
  if (frontDoorPaths.length > 0 && !config.signedInRedirect) {
52
112
  throw new Error("@ingram-tech/nk-auth: signedInRedirect is required when frontDoorPaths is set.");
53
113
  }
54
- return function middleware(request) {
114
+ return function middleware(request, options = {}) {
55
115
  const path = request.nextUrl.pathname;
56
- // 1. Stale-session handshake. The validated guard sends an invalid session
57
- // to `${signInPath}?stale=1&next=…`. Clear the dead Better Auth cookies
58
- // (only middleware can, pre-render) and bounce to a clean sign-in URL
59
- // that keeps `next`. After this the cookie is gone, so it can't re-arm.
60
- if (path === signInPath && request.nextUrl.searchParams.get("stale") === "1") {
61
- const to = request.nextUrl.clone();
62
- to.searchParams.delete("stale");
63
- const res = NextResponse.redirect(to);
64
- for (const cookie of request.cookies.getAll()) {
65
- if (cookie.name.includes(cookiePrefix)) {
66
- // `__Secure-`/`__Host-` cookies: browsers reject the deletion
67
- // Set-Cookie unless it carries the Secure attribute itself, so a
68
- // bare delete() would silently leave the dead cookie in place on
69
- // HTTPS and re-run this handshake on every visit.
70
- res.cookies.delete({
71
- name: cookie.name,
72
- path: "/",
73
- secure: cookie.name.startsWith("__Secure-") ||
74
- cookie.name.startsWith("__Host-"),
75
- });
76
- }
77
- }
78
- return res;
79
- }
116
+ // 1. Stale-session handshake: clear the dead cookies and bounce to a clean
117
+ // sign-in URL that keeps `next`.
118
+ const stale = clearStaleSession(request, {
119
+ signInPath,
120
+ sessionCookiePrefix: cookiePrefix,
121
+ });
122
+ if (stale)
123
+ return stale;
80
124
  const hasSessionCookie = !!getSessionCookie(request);
81
125
  // 2. Unauthenticated (no cookie) on a protected path -> sign in, and
82
126
  // remember where they were going.
@@ -84,10 +128,11 @@ export function createAuthMiddleware(config) {
84
128
  const original = request.nextUrl.pathname + request.nextUrl.search;
85
129
  const to = request.nextUrl.clone();
86
130
  to.pathname = signInPath;
87
- to.search = "";
88
- const next = safeNextParam(original);
89
- if (next)
90
- to.searchParams.set("next", next);
131
+ to.search = new URL(signInUrl(signInPath, {
132
+ next: original,
133
+ nextParam: config.nextParam,
134
+ isSafeNext: config.isSafeNext,
135
+ }), request.nextUrl).search;
91
136
  return NextResponse.redirect(to);
92
137
  }
93
138
  // 3. Front door: a cookie-bearing visit to "/" (or configured) -> the app.
@@ -101,8 +146,8 @@ export function createAuthMiddleware(config) {
101
146
  }
102
147
  // 4. Pass through, injecting the requested path so server guards can build
103
148
  // `next` for the cookie-present-but-invalid case.
104
- const requestHeaders = new Headers(request.headers);
105
- requestHeaders.set(NK_AUTH_PATH_HEADER, path + request.nextUrl.search);
149
+ const requestHeaders = options.requestHeaders ?? new Headers(request.headers);
150
+ withAuthPathHeader(request, requestHeaders);
106
151
  return NextResponse.next({ request: { headers: requestHeaders } });
107
152
  };
108
153
  }
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -25,12 +25,14 @@
25
25
  * and falls through to the form instead of ping-ponging forever.
26
26
  */
27
27
  import type { IdHelper } from "@ingram-tech/nk-db/id";
28
+ import { type NextParamOptions } from "./gating-internals.js";
28
29
  /**
29
30
  * Validate a `next` redirect param: returns it only if it's an internal,
30
31
  * non-protocol-relative path, else null. Use on the login page before honoring
31
32
  * `?next=` so it can't become an open redirect.
32
33
  */
33
34
  export { safeNextParam as safeNext } from "./gating-internals.js";
35
+ export type { NextParamOptions } from "./gating-internals.js";
34
36
  /** The shape we need from a session: anything carrying a `user`. */
35
37
  interface SessionLike {
36
38
  user: unknown;
@@ -69,7 +71,7 @@ export interface SessionIds {
69
71
  user: IdHelper;
70
72
  organization?: IdHelper;
71
73
  }
72
- export interface AuthHelpersOptions {
74
+ export interface AuthHelpersOptions extends NextParamOptions {
73
75
  /** Where guards send unauthenticated users. Default `/login`. */
74
76
  signInPath?: string;
75
77
  /**
@@ -97,6 +99,7 @@ export declare function createAuthHelpers<S extends SessionLike>(auth: AuthLike<
97
99
  getUser: () => Promise<S["user"] | null>;
98
100
  requireSession: () => Promise<S>;
99
101
  requireUser: () => Promise<S["user"]>;
102
+ signInTarget: () => Promise<string>;
100
103
  redirectIfAuthenticated: (to: string) => Promise<void>;
101
104
  getLinkedProviders: () => Promise<string[]>;
102
105
  hasCredentialAccount: () => Promise<boolean>;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAOtD;;;;GAIG;AACH,OAAO,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAElE,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;QAC/D;;;;;WAKG;QACH,gBAAgB,EAAE,CAAC,KAAK,EAAE;YACzB,OAAO,EAAE,OAAO,CAAC;SACjB,KAAK,OAAO,CAAC,KAAK,CAAC;YAAE,UAAU,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC,CAAC;KAC7C,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,YAAY,CAAC,EAAE,QAAQ,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IAClC,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,WAAW,EACrD,OAAO,EAAE,CAAC,EACV,GAAG,EAAE,UAAU,GACb,CAAC,CAyBH;AAED,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,WAAW,EACtD,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EACjB,OAAO,GAAE,kBAAuB;sBAiBG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;mBAQ1B,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;0BAkClB,OAAO,CAAC,CAAC,CAAC;uBAXb,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;kCAuBL,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;8BAU5B,OAAO,CAAC,MAAM,EAAE,CAAC;gCAcf,OAAO,CAAC,OAAO,CAAC;EAcvD"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAItD,OAAO,EAEN,KAAK,gBAAgB,EAErB,MAAM,uBAAuB,CAAC;AAG/B;;;;GAIG;AACH,OAAO,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAClE,YAAY,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,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;QAC/D;;;;;WAKG;QACH,gBAAgB,EAAE,CAAC,KAAK,EAAE;YACzB,OAAO,EAAE,OAAO,CAAC;SACjB,KAAK,OAAO,CAAC,KAAK,CAAC;YAAE,UAAU,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC,CAAC;KAC7C,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,YAAY,CAAC,EAAE,QAAQ,CAAC;CACxB;AAED,MAAM,WAAW,kBAAmB,SAAQ,gBAAgB;IAC3D,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,WAAW,EACrD,OAAO,EAAE,CAAC,EACV,GAAG,EAAE,UAAU,GACb,CAAC,CAyBH;AAED,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,WAAW,EACtD,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EACjB,OAAO,GAAE,kBAAuB;sBAiBG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;mBAQ1B,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;0BA+DlB,OAAO,CAAC,CAAC,CAAC;uBAXb,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;wBA3BjB,OAAO,CAAC,MAAM,CAAC;kCAkDH,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;8BAU5B,OAAO,CAAC,MAAM,EAAE,CAAC;gCAcf,OAAO,CAAC,OAAO,CAAC;EAevD"}
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
3
  import { cache } from "react";
4
- import { NK_AUTH_PATH_HEADER, signInUrl } from "./gating-internals.js";
4
+ import { NK_AUTH_PATH_HEADER, signInUrl, } from "./gating-internals.js";
5
5
  import { CREDENTIAL_PROVIDER_ID } from "./password.js";
6
6
  /**
7
7
  * Validate a `next` redirect param: returns it only if it's an internal,
@@ -65,17 +65,41 @@ export function createAuthHelpers(auth, options = {}) {
65
65
  const session = await getSession();
66
66
  return session?.user ?? null;
67
67
  }
68
+ // The missing-header warning fires once per helpers instance, not once per
69
+ // request: it is a wiring mistake, and a wiring mistake does not get less
70
+ // wrong the second time.
71
+ let warnedMissingHeader = false;
68
72
  /**
69
73
  * The sign-in redirect for a request with no valid session: keep the
70
74
  * requested path as `next` (from the middleware-injected header), and flag
71
75
  * `stale=1` when a session cookie is present-but-invalid so the middleware
72
76
  * clears it. The two reads are request-scoped and cached by Next.
77
+ *
78
+ * Exported for a site that wraps the guards in its own (a profile lookup on
79
+ * top of `getUser()`, say): `redirect(await signInTarget())` gets `next` and
80
+ * `stale` right without re-deriving either.
81
+ *
82
+ * `next` depends on the `x-nk-auth-path` header, which only middleware can
83
+ * set. When it is absent the destination is silently lost — the user signs in
84
+ * and lands on the default page instead of the one they asked for — so
85
+ * outside production this says so once, naming the two ways to wire it.
73
86
  */
74
87
  async function signInTarget() {
75
88
  const [h, c] = await Promise.all([headers(), cookies()]);
76
89
  const next = h.get(NK_AUTH_PATH_HEADER);
90
+ if (next === null &&
91
+ !warnedMissingHeader &&
92
+ process.env.NODE_ENV !== "production") {
93
+ warnedMissingHeader = true;
94
+ console.warn(`@ingram-tech/nk-auth: \`next\` not preserved — no ${NK_AUTH_PATH_HEADER} request header. Mount createAuthMiddleware, or call withAuthPathHeader(request, requestHeaders) from "@ingram-tech/nk-auth/middleware" in your own proxy.`);
95
+ }
77
96
  const stale = c.getAll().some((ck) => ck.name.includes(cookiePrefix));
78
- return signInUrl(signInPath, { next, stale });
97
+ return signInUrl(signInPath, {
98
+ next,
99
+ stale,
100
+ nextParam: options.nextParam,
101
+ isSafeNext: options.isSafeNext,
102
+ });
79
103
  }
80
104
  /**
81
105
  * Require a signed-in user or redirect to sign-in. Returns the user (non-null)
@@ -137,6 +161,7 @@ export function createAuthHelpers(auth, options = {}) {
137
161
  getUser,
138
162
  requireSession,
139
163
  requireUser,
164
+ signInTarget,
140
165
  redirectIfAuthenticated,
141
166
  getLinkedProviders,
142
167
  hasCredentialAccount,
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-auth",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "The Ingram Better Auth foundation: composable presets (org, dual-shape JWT, active-org hooks, pg pool) for Next.js sites.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -96,7 +96,7 @@
96
96
  },
97
97
  "devDependencies": {
98
98
  "@better-auth/passkey": "^1.7.0",
99
- "@ingram-tech/nk-dev": "0.13.1",
99
+ "@ingram-tech/nk-dev": "0.14.0",
100
100
  "@types/bcrypt": "^6.0.0",
101
101
  "@types/node": "^26.2.0",
102
102
  "@types/pg": "^8.23.1",
@@ -30,17 +30,39 @@ export function safeNextParam(value: string | null | undefined): string | null {
30
30
  }
31
31
 
32
32
  /**
33
- * Build a sign-in URL: `signInPath`, plus `next` (when safe) so the user returns
34
- * to where they were headed, plus a `stale=1` marker when a session cookie is
35
- * present but invalid — the signal the middleware uses to clear the dead cookie.
33
+ * How a site names and validates its post-login redirect param. Shared by the
34
+ * middleware (which writes it for the cookie-less case) and the server helpers
35
+ * (which write it for the cookie-present-but-invalid case), so both halves agree
36
+ * on the one contract the sign-in page reads.
37
+ */
38
+ export interface NextParamOptions {
39
+ /** Query param carrying the post-login destination. Default `next`. */
40
+ nextParam?: string;
41
+ /**
42
+ * Validate a candidate destination: return the value to redirect to, or null
43
+ * to drop it. Default {@link safeNextParam}, which admits only internal
44
+ * paths. A site with an existing trusted-origin allow-list plugs its own
45
+ * validator in here rather than forking the URL builder; whatever it admits
46
+ * is what its sign-in page will redirect to, so keep it as strict as
47
+ * `safeNextParam` on everything but the origins you own.
48
+ */
49
+ isSafeNext?: (value: string) => string | null;
50
+ }
51
+
52
+ /**
53
+ * Build a sign-in URL: `signInPath`, plus the next param (when safe) so the user
54
+ * returns to where they were headed, plus a `stale=1` marker when a session
55
+ * cookie is present but invalid — the signal the middleware uses to clear the
56
+ * dead cookie.
36
57
  */
37
58
  export function signInUrl(
38
59
  signInPath: string,
39
- opts: { next?: string | null; stale?: boolean },
60
+ opts: { next?: string | null; stale?: boolean } & NextParamOptions,
40
61
  ): string {
41
62
  const params = new URLSearchParams();
42
- const next = safeNextParam(opts.next);
43
- if (next) params.set("next", next);
63
+ const validate = opts.isSafeNext ?? safeNextParam;
64
+ const next = opts.next ? validate(opts.next) : null;
65
+ if (next) params.set(opts.nextParam ?? "next", next);
44
66
  if (opts.stale) params.set("stale", "1");
45
67
  const qs = params.toString();
46
68
  return qs ? `${signInPath}?${qs}` : signInPath;
package/src/middleware.ts CHANGED
@@ -24,12 +24,108 @@
24
24
  * `${signInPath}?stale=1`; middleware (the only pre-render place that can set
25
25
  * cookies) deletes the dead Better Auth cookies and bounces to a clean
26
26
  * sign-in URL, so a bad session self-heals instead of failing every request.
27
+ *
28
+ * Both halves are also exported on their own — {@link withAuthPathHeader} and
29
+ * {@link clearStaleSession} — for a site whose proxy composes several concerns
30
+ * (locale routing, tenant pinning, …) and does not want the optimistic gate.
31
+ * Without the header the server guards cannot preserve `next` at all, and they
32
+ * say so (a one-time warning outside production); wiring one line into your own
33
+ * proxy is enough to fix that.
27
34
  */
28
35
  import { getSessionCookie } from "better-auth/cookies";
29
36
  import { type NextRequest, NextResponse } from "next/server";
30
- import { NK_AUTH_PATH_HEADER, safeNextParam } from "./gating-internals.js";
37
+ import {
38
+ NK_AUTH_PATH_HEADER,
39
+ type NextParamOptions,
40
+ signInUrl,
41
+ } from "./gating-internals.js";
42
+
43
+ export type { NextParamOptions } from "./gating-internals.js";
44
+
45
+ /**
46
+ * Forward the requested path + query to the app as the `x-nk-auth-path` request
47
+ * header, so a server guard can build `next` when it redirects to sign-in. The
48
+ * one line a custom proxy needs for `next` preservation:
49
+ *
50
+ * const requestHeaders = new Headers(request.headers);
51
+ * withAuthPathHeader(request, requestHeaders);
52
+ * return localeProxy(routing, request, { requestHeaders });
53
+ *
54
+ * Set, never passed through: the header is ours to mint, so a client that sends
55
+ * one of its own can't choose where sign-in returns to.
56
+ */
57
+ export function withAuthPathHeader(
58
+ request: NextRequest,
59
+ requestHeaders: Headers,
60
+ ): void {
61
+ requestHeaders.set(
62
+ NK_AUTH_PATH_HEADER,
63
+ request.nextUrl.pathname + request.nextUrl.search,
64
+ );
65
+ }
66
+
67
+ export interface StaleSessionConfig {
68
+ /** The sign-in path the server guards redirect to. Default `/login`. */
69
+ signInPath?: string;
70
+ /** Cookie-name fragment of the cookies to clear. Default `better-auth`. */
71
+ sessionCookiePrefix?: string;
72
+ }
73
+
74
+ /**
75
+ * The stale-session handshake, on its own for a custom proxy. The validated
76
+ * guard sends a present-but-invalid session to `${signInPath}?stale=1&next=…`;
77
+ * this clears the dead Better Auth cookies (only middleware can, pre-render) and
78
+ * returns a redirect to the same URL minus `stale`, which keeps `next`. Returns
79
+ * null on every other request, so a proxy can short-circuit on it:
80
+ *
81
+ * const stale = clearStaleSession(request, { signInPath: "/login" });
82
+ * if (stale) return stale;
83
+ *
84
+ * After the redirect the cookie is gone, so the handshake can't re-arm.
85
+ */
86
+ export function clearStaleSession(
87
+ request: NextRequest,
88
+ config: StaleSessionConfig = {},
89
+ ): NextResponse | null {
90
+ const signInPath = config.signInPath ?? "/login";
91
+ const cookiePrefix = config.sessionCookiePrefix ?? "better-auth";
92
+ if (
93
+ request.nextUrl.pathname !== signInPath ||
94
+ request.nextUrl.searchParams.get("stale") !== "1"
95
+ ) {
96
+ return null;
97
+ }
98
+ const to = request.nextUrl.clone();
99
+ to.searchParams.delete("stale");
100
+ const res = NextResponse.redirect(to);
101
+ for (const cookie of request.cookies.getAll()) {
102
+ if (cookie.name.includes(cookiePrefix)) {
103
+ // `__Secure-`/`__Host-` cookies: browsers reject the deletion
104
+ // Set-Cookie unless it carries the Secure attribute itself, so a
105
+ // bare delete() would silently leave the dead cookie in place on
106
+ // HTTPS and re-run this handshake on every visit.
107
+ res.cookies.delete({
108
+ name: cookie.name,
109
+ path: "/",
110
+ secure:
111
+ cookie.name.startsWith("__Secure-") ||
112
+ cookie.name.startsWith("__Host-"),
113
+ });
114
+ }
115
+ }
116
+ return res;
117
+ }
31
118
 
32
- export interface AuthMiddlewareConfig {
119
+ export interface AuthMiddlewareOptions {
120
+ /**
121
+ * Headers to forward to the app, if the middleware has its own to add. A
122
+ * fresh copy of the request's headers is used when omitted; pass your own
123
+ * when you also set things like a tenant or locale header.
124
+ */
125
+ requestHeaders?: Headers;
126
+ }
127
+
128
+ export interface AuthMiddlewareConfig extends NextParamOptions {
33
129
  /**
34
130
  * Path prefixes that require a session. A request without a session cookie
35
131
  * to any of these is redirected to `signInPath`. Matched with `startsWith`.
@@ -86,34 +182,19 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig) {
86
182
  );
87
183
  }
88
184
 
89
- return function middleware(request: NextRequest): NextResponse {
185
+ return function middleware(
186
+ request: NextRequest,
187
+ options: AuthMiddlewareOptions = {},
188
+ ): NextResponse {
90
189
  const path = request.nextUrl.pathname;
91
190
 
92
- // 1. Stale-session handshake. The validated guard sends an invalid session
93
- // to `${signInPath}?stale=1&next=…`. Clear the dead Better Auth cookies
94
- // (only middleware can, pre-render) and bounce to a clean sign-in URL
95
- // that keeps `next`. After this the cookie is gone, so it can't re-arm.
96
- if (path === signInPath && request.nextUrl.searchParams.get("stale") === "1") {
97
- const to = request.nextUrl.clone();
98
- to.searchParams.delete("stale");
99
- const res = NextResponse.redirect(to);
100
- for (const cookie of request.cookies.getAll()) {
101
- if (cookie.name.includes(cookiePrefix)) {
102
- // `__Secure-`/`__Host-` cookies: browsers reject the deletion
103
- // Set-Cookie unless it carries the Secure attribute itself, so a
104
- // bare delete() would silently leave the dead cookie in place on
105
- // HTTPS and re-run this handshake on every visit.
106
- res.cookies.delete({
107
- name: cookie.name,
108
- path: "/",
109
- secure:
110
- cookie.name.startsWith("__Secure-") ||
111
- cookie.name.startsWith("__Host-"),
112
- });
113
- }
114
- }
115
- return res;
116
- }
191
+ // 1. Stale-session handshake: clear the dead cookies and bounce to a clean
192
+ // sign-in URL that keeps `next`.
193
+ const stale = clearStaleSession(request, {
194
+ signInPath,
195
+ sessionCookiePrefix: cookiePrefix,
196
+ });
197
+ if (stale) return stale;
117
198
 
118
199
  const hasSessionCookie = !!getSessionCookie(request);
119
200
 
@@ -123,9 +204,14 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig) {
123
204
  const original = request.nextUrl.pathname + request.nextUrl.search;
124
205
  const to = request.nextUrl.clone();
125
206
  to.pathname = signInPath;
126
- to.search = "";
127
- const next = safeNextParam(original);
128
- if (next) to.searchParams.set("next", next);
207
+ to.search = new URL(
208
+ signInUrl(signInPath, {
209
+ next: original,
210
+ nextParam: config.nextParam,
211
+ isSafeNext: config.isSafeNext,
212
+ }),
213
+ request.nextUrl,
214
+ ).search;
129
215
  return NextResponse.redirect(to);
130
216
  }
131
217
 
@@ -143,8 +229,8 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig) {
143
229
 
144
230
  // 4. Pass through, injecting the requested path so server guards can build
145
231
  // `next` for the cookie-present-but-invalid case.
146
- const requestHeaders = new Headers(request.headers);
147
- requestHeaders.set(NK_AUTH_PATH_HEADER, path + request.nextUrl.search);
232
+ const requestHeaders = options.requestHeaders ?? new Headers(request.headers);
233
+ withAuthPathHeader(request, requestHeaders);
148
234
  return NextResponse.next({ request: { headers: requestHeaders } });
149
235
  };
150
236
  }
package/src/server.ts CHANGED
@@ -28,7 +28,11 @@ import type { IdHelper } from "@ingram-tech/nk-db/id";
28
28
  import { cookies, headers } from "next/headers";
29
29
  import { redirect } from "next/navigation";
30
30
  import { cache } from "react";
31
- import { NK_AUTH_PATH_HEADER, signInUrl } from "./gating-internals.js";
31
+ import {
32
+ NK_AUTH_PATH_HEADER,
33
+ type NextParamOptions,
34
+ signInUrl,
35
+ } from "./gating-internals.js";
32
36
  import { CREDENTIAL_PROVIDER_ID } from "./password.js";
33
37
 
34
38
  /**
@@ -37,6 +41,7 @@ import { CREDENTIAL_PROVIDER_ID } from "./password.js";
37
41
  * `?next=` so it can't become an open redirect.
38
42
  */
39
43
  export { safeNextParam as safeNext } from "./gating-internals.js";
44
+ export type { NextParamOptions } from "./gating-internals.js";
40
45
 
41
46
  /** The shape we need from a session: anything carrying a `user`. */
42
47
  interface SessionLike {
@@ -75,7 +80,7 @@ export interface SessionIds {
75
80
  organization?: IdHelper;
76
81
  }
77
82
 
78
- export interface AuthHelpersOptions {
83
+ export interface AuthHelpersOptions extends NextParamOptions {
79
84
  /** Where guards send unauthenticated users. Default `/login`. */
80
85
  signInPath?: string;
81
86
  /**
@@ -163,17 +168,46 @@ export function createAuthHelpers<S extends SessionLike>(
163
168
  return session?.user ?? null;
164
169
  }
165
170
 
171
+ // The missing-header warning fires once per helpers instance, not once per
172
+ // request: it is a wiring mistake, and a wiring mistake does not get less
173
+ // wrong the second time.
174
+ let warnedMissingHeader = false;
175
+
166
176
  /**
167
177
  * The sign-in redirect for a request with no valid session: keep the
168
178
  * requested path as `next` (from the middleware-injected header), and flag
169
179
  * `stale=1` when a session cookie is present-but-invalid so the middleware
170
180
  * clears it. The two reads are request-scoped and cached by Next.
181
+ *
182
+ * Exported for a site that wraps the guards in its own (a profile lookup on
183
+ * top of `getUser()`, say): `redirect(await signInTarget())` gets `next` and
184
+ * `stale` right without re-deriving either.
185
+ *
186
+ * `next` depends on the `x-nk-auth-path` header, which only middleware can
187
+ * set. When it is absent the destination is silently lost — the user signs in
188
+ * and lands on the default page instead of the one they asked for — so
189
+ * outside production this says so once, naming the two ways to wire it.
171
190
  */
172
191
  async function signInTarget(): Promise<string> {
173
192
  const [h, c] = await Promise.all([headers(), cookies()]);
174
193
  const next = h.get(NK_AUTH_PATH_HEADER);
194
+ if (
195
+ next === null &&
196
+ !warnedMissingHeader &&
197
+ process.env.NODE_ENV !== "production"
198
+ ) {
199
+ warnedMissingHeader = true;
200
+ console.warn(
201
+ `@ingram-tech/nk-auth: \`next\` not preserved — no ${NK_AUTH_PATH_HEADER} request header. Mount createAuthMiddleware, or call withAuthPathHeader(request, requestHeaders) from "@ingram-tech/nk-auth/middleware" in your own proxy.`,
202
+ );
203
+ }
175
204
  const stale = c.getAll().some((ck) => ck.name.includes(cookiePrefix));
176
- return signInUrl(signInPath, { next, stale });
205
+ return signInUrl(signInPath, {
206
+ next,
207
+ stale,
208
+ nextParam: options.nextParam,
209
+ isSafeNext: options.isSafeNext,
210
+ });
177
211
  }
178
212
 
179
213
  /**
@@ -238,6 +272,7 @@ export function createAuthHelpers<S extends SessionLike>(
238
272
  getUser,
239
273
  requireSession,
240
274
  requireUser,
275
+ signInTarget,
241
276
  redirectIfAuthenticated,
242
277
  getLinkedProviders,
243
278
  hasCredentialAccount,