@ingram-tech/nk-auth 0.14.1 → 0.16.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 +61 -7
- package/dist/gating-internals.d.ts +24 -4
- package/dist/gating-internals.d.ts.map +1 -1
- package/dist/gating-internals.js +7 -5
- package/dist/gating-internals.js.map +1 -1
- package/dist/middleware.d.ts +44 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +77 -32
- package/dist/middleware.js.map +1 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +27 -2
- package/dist/server.js.map +1 -1
- package/migrations/0002_better_auth_1_7.sql +93 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +12 -12
- package/src/gating-internals.ts +28 -6
- package/src/middleware.ts +119 -33
- package/src/server.ts +38 -3
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
|
>
|
|
@@ -114,10 +114,21 @@ const db = await createTestDb({
|
|
|
114
114
|
|
|
115
115
|
**Upgrading better-auth** never means hand-writing a migration in your app: a
|
|
116
116
|
schema-changing upgrade ships as a new file in nk-auth's chain, so you bump the
|
|
117
|
-
dependency and your next migrate applies it.
|
|
118
|
-
|
|
117
|
+
dependency and your next migrate applies it. The chain is kept honest by
|
|
118
|
+
`src/migrations.test.ts`, which diffs the applied chain against the schema the
|
|
119
|
+
pinned `better-auth` asks for (`getAuthTables`) — a bump that adds a column fails
|
|
120
|
+
that test until the delta is shipped as the next `000N_*.sql` (a maintainer task,
|
|
119
121
|
done once in this package).
|
|
120
122
|
|
|
123
|
+
One delta needs a word: `0002_better_auth_1_7` backfills the `account.issuer`
|
|
124
|
+
column Better Auth 1.7 keys accounts on, with the exact value 1.7 writes per
|
|
125
|
+
provider (`local:credential`, `https://accounts.google.com`,
|
|
126
|
+
`local:oauth:<providerId>`, …). A provider whose issuer is per-tenant or
|
|
127
|
+
discovered at runtime (`microsoft`, `cognito`, `paybin`, the generic-oauth
|
|
128
|
+
plugin) cannot be derived, so the migration refuses and names the `providerId`s;
|
|
129
|
+
set `issuer` on those rows by hand and re-run. Nothing to do on a site that only
|
|
130
|
+
has password and built-in social sign-in.
|
|
131
|
+
|
|
121
132
|
> **Adopting from the old copy-in model?** Earlier versions told you to `cp` the
|
|
122
133
|
> baseline into your own `drizzle/` chain. If you already did, keep that file
|
|
123
134
|
> (deleting an applied migration causes journal drift) and just add the auth-chain
|
|
@@ -387,9 +398,52 @@ that can touch cookies) deletes the dead Better Auth cookies on the `stale`
|
|
|
387
398
|
marker and bounces to a clean `/login?next=…`; signing in returns them to
|
|
388
399
|
`next`. `next` for the cookie-less case is filled in by middleware directly; for
|
|
389
400
|
the cookie-present case the guard reads it from the `x-nk-auth-path` header
|
|
390
|
-
middleware injects
|
|
391
|
-
|
|
392
|
-
|
|
401
|
+
middleware injects.
|
|
402
|
+
|
|
403
|
+
**Your own proxy.** An app that composes its own proxy (locale routing, tenant
|
|
404
|
+
pinning, …) keeps validated gating from the server helpers, but the guards can
|
|
405
|
+
only preserve `next` if something sets that header. Both halves of the
|
|
406
|
+
middleware are exported on their own so a custom proxy adopts them without the
|
|
407
|
+
optimistic gate; outside production the guards warn once when the header is
|
|
408
|
+
missing, and `nk doctor` flags the shape.
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
// proxy.ts — a site with its own proxy
|
|
412
|
+
import { clearStaleSession, withAuthPathHeader } from "@ingram-tech/nk-auth/middleware";
|
|
413
|
+
import { localeProxy } from "@ingram-tech/nk-i18n/next";
|
|
414
|
+
|
|
415
|
+
export function proxy(request: NextRequest) {
|
|
416
|
+
const stale = clearStaleSession(request, { signInPath: "/login" });
|
|
417
|
+
if (stale) return stale; // dead cookie cleared, `next` kept
|
|
418
|
+
const requestHeaders = new Headers(request.headers);
|
|
419
|
+
withAuthPathHeader(request, requestHeaders); // guards can now build `next`
|
|
420
|
+
return localeProxy(routing, request, { requestHeaders });
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
(`createAuthMiddleware`'s middleware takes the same `{ requestHeaders }` option
|
|
425
|
+
when you want the gate too.)
|
|
426
|
+
|
|
427
|
+
**Your own guard.** A wrapper that layers something over `getUser()` (a profile
|
|
428
|
+
lookup, say) redirects to `await signInTarget()`, the same URL `requireUser`
|
|
429
|
+
would have used, with `next` and `stale` filled in:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const { getUser, signInTarget } = createAuthHelpers(auth);
|
|
433
|
+
export async function requireProfile() {
|
|
434
|
+
const user = await getUser();
|
|
435
|
+
if (!user) redirect(await signInTarget());
|
|
436
|
+
return loadProfile(user.id);
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Your own param.** Both `createAuthHelpers` and `createAuthMiddleware` take
|
|
441
|
+
`nextParam` (default `next`) and `isSafeNext` (default `safeNext`, internal
|
|
442
|
+
paths only). A site that already ships `?redirectTo=` with a trusted-origin
|
|
443
|
+
allow-list passes `{ nextParam: "redirectTo", isSafeNext: isTrustedRedirect }`
|
|
444
|
+
to both, and its sign-in page keeps validating with the same function. Whatever
|
|
445
|
+
`isSafeNext` admits is where sign-in will send the user, so keep it as strict as
|
|
446
|
+
`safeNext` on everything except the origins you own.
|
|
393
447
|
|
|
394
448
|
## 6. Passwords: change, set, and reset
|
|
395
449
|
|
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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"}
|
package/dist/gating-internals.js
CHANGED
|
@@ -30,15 +30,17 @@ export function safeNextParam(value) {
|
|
|
30
30
|
return value;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* Build a sign-in URL: `signInPath`, plus
|
|
34
|
-
* to where they were headed, plus a `stale=1` marker when a session
|
|
35
|
-
* present but invalid — the signal the middleware uses to clear the
|
|
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
|
|
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;
|
|
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"}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
-
|
|
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
|
package/dist/middleware.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/middleware.js
CHANGED
|
@@ -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,
|
|
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
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/middleware.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA
|
|
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>;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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;
|
|
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, {
|
|
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,
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9B,OAAO,
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- @ingram-tech/nk-auth — Better Auth 1.7 schema delta (better-auth ^1.7.2).
|
|
2
|
+
--
|
|
3
|
+
-- Better Auth 1.7 keys an account on (issuer, accountId) instead of
|
|
4
|
+
-- (providerId, accountId): every account row now carries the identity
|
|
5
|
+
-- namespace it was verified under, and all OAuth sign-in / linking lookups go
|
|
6
|
+
-- through that pair (`findAccountOwnerByKey`, `findAccountByKey`). The `jwt`
|
|
7
|
+
-- plugin also gained key rotation, which needs three nullable columns on `jwks`.
|
|
8
|
+
--
|
|
9
|
+
-- The rows that exist before this file runs were written by 1.6, which never
|
|
10
|
+
-- populated `issuer`, so this delta BACKFILLS it with exactly the value 1.7.2
|
|
11
|
+
-- would have written for that provider — otherwise every returning OAuth user
|
|
12
|
+
-- looks like a new identity. The rules, read off better-auth 1.7.2 itself:
|
|
13
|
+
-- - `credential` (email + password, and the otp / phone / admin plugins) →
|
|
14
|
+
-- `local:credential` (core's createLocalAccountIssuer).
|
|
15
|
+
-- - A built-in social provider that declares a fixed `accountIssuer` → that
|
|
16
|
+
-- issuer verbatim (google, apple, facebook, line).
|
|
17
|
+
-- - Any other built-in social provider → `local:oauth:<providerId>` (core's
|
|
18
|
+
-- createOAuthAccountIssuer; the ids are plain slugs, so percent-encoding is
|
|
19
|
+
-- the identity).
|
|
20
|
+
-- Providers whose issuer is per-tenant or discovered at runtime (microsoft →
|
|
21
|
+
-- the token's `iss`, cognito → the user pool URL, paybin, the generic-oauth
|
|
22
|
+
-- plugin) cannot be derived here, so the migration REFUSES to guess: it raises
|
|
23
|
+
-- naming the unmapped providerIds and rolls back. Backfill those by hand with
|
|
24
|
+
-- the issuer your provider config resolves to, then re-run the migrate.
|
|
25
|
+
--
|
|
26
|
+
-- The unique index is what 1.7 relies on for account identity. If it fails to
|
|
27
|
+
-- build, the database already holds two accounts for one (issuer, accountId)
|
|
28
|
+
-- pair — resolve those duplicates rather than dropping the index.
|
|
29
|
+
--
|
|
30
|
+
-- Append-only: this file is hashed by the runner and must never be edited once
|
|
31
|
+
-- shipped. The next better-auth schema change goes in 0003.
|
|
32
|
+
|
|
33
|
+
alter table "public"."account" add column if not exists "issuer" text;
|
|
34
|
+
--> statement-breakpoint
|
|
35
|
+
update "public"."account" set "issuer" = case "providerId"
|
|
36
|
+
when 'credential' then 'local:credential'
|
|
37
|
+
when 'google' then 'https://accounts.google.com'
|
|
38
|
+
when 'apple' then 'https://appleid.apple.com'
|
|
39
|
+
when 'facebook' then 'https://www.facebook.com'
|
|
40
|
+
when 'line' then 'https://access.line.me'
|
|
41
|
+
when 'atlassian' then 'local:oauth:atlassian'
|
|
42
|
+
when 'discord' then 'local:oauth:discord'
|
|
43
|
+
when 'dropbox' then 'local:oauth:dropbox'
|
|
44
|
+
when 'figma' then 'local:oauth:figma'
|
|
45
|
+
when 'github' then 'local:oauth:github'
|
|
46
|
+
when 'gitlab' then 'local:oauth:gitlab'
|
|
47
|
+
when 'huggingface' then 'local:oauth:huggingface'
|
|
48
|
+
when 'kakao' then 'local:oauth:kakao'
|
|
49
|
+
when 'kick' then 'local:oauth:kick'
|
|
50
|
+
when 'linear' then 'local:oauth:linear'
|
|
51
|
+
when 'linkedin' then 'local:oauth:linkedin'
|
|
52
|
+
when 'naver' then 'local:oauth:naver'
|
|
53
|
+
when 'notion' then 'local:oauth:notion'
|
|
54
|
+
when 'paypal' then 'local:oauth:paypal'
|
|
55
|
+
when 'polar' then 'local:oauth:polar'
|
|
56
|
+
when 'railway' then 'local:oauth:railway'
|
|
57
|
+
when 'reddit' then 'local:oauth:reddit'
|
|
58
|
+
when 'roblox' then 'local:oauth:roblox'
|
|
59
|
+
when 'salesforce' then 'local:oauth:salesforce'
|
|
60
|
+
when 'slack' then 'local:oauth:slack'
|
|
61
|
+
when 'spotify' then 'local:oauth:spotify'
|
|
62
|
+
when 'tiktok' then 'local:oauth:tiktok'
|
|
63
|
+
when 'twitch' then 'local:oauth:twitch'
|
|
64
|
+
when 'twitter' then 'local:oauth:twitter'
|
|
65
|
+
when 'vercel' then 'local:oauth:vercel'
|
|
66
|
+
when 'vk' then 'local:oauth:vk'
|
|
67
|
+
when 'wechat' then 'local:oauth:wechat'
|
|
68
|
+
when 'zoom' then 'local:oauth:zoom'
|
|
69
|
+
end where "issuer" is null;
|
|
70
|
+
--> statement-breakpoint
|
|
71
|
+
do $$
|
|
72
|
+
declare
|
|
73
|
+
unmapped text;
|
|
74
|
+
begin
|
|
75
|
+
select string_agg(distinct "providerId", ', ') into unmapped
|
|
76
|
+
from "public"."account" where "issuer" is null;
|
|
77
|
+
if unmapped is not null then
|
|
78
|
+
raise exception 'nk-auth 0002_better_auth_1_7: cannot derive account.issuer for providerId(s): %. Better Auth 1.7 keys accounts on (issuer, accountId) and these providers use a per-tenant or discovered issuer this migration cannot know. Set "issuer" on those rows by hand (the value your provider config resolves to), then re-run the migrate.', unmapped;
|
|
79
|
+
end if;
|
|
80
|
+
end $$;
|
|
81
|
+
--> statement-breakpoint
|
|
82
|
+
alter table "public"."account" alter column "issuer" set not null;
|
|
83
|
+
--> statement-breakpoint
|
|
84
|
+
create unique index if not exists "account_issuer_accountId_uidx" on "public"."account" ("issuer", "accountId");
|
|
85
|
+
--> statement-breakpoint
|
|
86
|
+
-- `jwt` plugin key rotation (1.7): per-key expiry + the algorithm/curve the
|
|
87
|
+
-- JWKS endpoint advertises. Nullable — a pre-rotation key falls back to the
|
|
88
|
+
-- plugin's configured defaults.
|
|
89
|
+
alter table "public"."jwks" add column if not exists "expiresAt" timestamptz;
|
|
90
|
+
--> statement-breakpoint
|
|
91
|
+
alter table "public"."jwks" add column if not exists "alg" text;
|
|
92
|
+
--> statement-breakpoint
|
|
93
|
+
alter table "public"."jwks" add column if not exists "crv" text;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
"test": "vitest run"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@ingram-tech/nk-db": "^2.1.
|
|
71
|
+
"@ingram-tech/nk-db": "^2.1.2",
|
|
72
72
|
"bcrypt": "^6.0.0",
|
|
73
|
-
"jose": "^6.2.
|
|
74
|
-
"zod": "^4.4
|
|
73
|
+
"jose": "^6.2.10",
|
|
74
|
+
"zod": "^4.5.4"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@better-auth/passkey": "^1.
|
|
78
|
-
"better-auth": "^1.
|
|
77
|
+
"@better-auth/passkey": "^1.7.2",
|
|
78
|
+
"better-auth": "^1.7.2",
|
|
79
79
|
"next": "^15.0.0 || ^16.0.0",
|
|
80
80
|
"pg": "^8.13.0",
|
|
81
81
|
"react": "^18.0.0 || ^19.0.0"
|
|
@@ -95,17 +95,17 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
|
-
"@better-auth/passkey": "^1.7.
|
|
99
|
-
"@ingram-tech/nk-dev": "0.
|
|
98
|
+
"@better-auth/passkey": "^1.7.2",
|
|
99
|
+
"@ingram-tech/nk-dev": "0.14.1",
|
|
100
100
|
"@types/bcrypt": "^6.0.0",
|
|
101
|
-
"@types/node": "^26.
|
|
101
|
+
"@types/node": "^26.4.0",
|
|
102
102
|
"@types/pg": "^8.23.1",
|
|
103
103
|
"@types/react": "^19.2.18",
|
|
104
|
-
"better-auth": "^1.7.
|
|
105
|
-
"next": "^16.3.
|
|
104
|
+
"better-auth": "^1.7.2",
|
|
105
|
+
"next": "^16.3.3",
|
|
106
106
|
"pg": "^8.23.0",
|
|
107
107
|
"react": "^19.2.8",
|
|
108
108
|
"typescript": "^7.0.2",
|
|
109
|
-
"vitest": "^4.1.
|
|
109
|
+
"vitest": "^4.1.11"
|
|
110
110
|
}
|
|
111
111
|
}
|
package/src/gating-internals.ts
CHANGED
|
@@ -30,17 +30,39 @@ export function safeNextParam(value: string | null | undefined): string | null {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
43
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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 {
|
|
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, {
|
|
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,
|