@ingram-tech/nk-auth 0.14.0 → 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 +84 -47
- 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/package.json +3 -3
- package/src/gating-internals.ts +28 -6
- package/src/middleware.ts +119 -33
- package/src/server.ts +38 -3
package/README.md
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
# @ingram-tech/nk-auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
only what you need from focused subpaths.
|
|
3
|
+
Composable [Better Auth](https://better-auth.com) presets for Next.js apps, spread
|
|
4
|
+
into your *own* `betterAuth()` call. This is not a `betterAuth()` wrapper: your app
|
|
5
|
+
stays plain Better Auth, so plugin type inference survives. Import only what you
|
|
6
|
+
need from focused subpaths.
|
|
8
7
|
|
|
9
|
-
`better-auth`, `pg`, `@better-auth/passkey` are
|
|
8
|
+
`better-auth`, `pg`, `@better-auth/passkey` are peer dependencies, so there is
|
|
10
9
|
exactly one Better Auth copy in the app.
|
|
11
10
|
|
|
12
11
|
| Export (subpath) | For |
|
|
13
12
|
| --- | --- |
|
|
14
|
-
| `authEnv`, `authSecret`, `isConfigured` (`./`) | env validation — `authEnv()` returns the full `{ secret, baseURL, databaseUrl }` bundle; `authSecret()` resolves just the secret (with the prod/dev rule) for
|
|
15
|
-
| `backendJwtOptions` / `verifyBackendJwt` (`./jwt`) | a JWT for
|
|
13
|
+
| `authEnv`, `authSecret`, `isConfigured` (`./`) | env validation — `authEnv()` returns the full `{ secret, baseURL, databaseUrl }` bundle; `authSecret()` resolves just the secret (with the prod/dev rule) for apps that derive their own `baseURL` / DB connection |
|
|
14
|
+
| `backendJwtOptions` / `verifyBackendJwt` (`./jwt`) | a JWT for your own backend API (custom `audience`; `ids` mints `sub` as the public user id) |
|
|
16
15
|
| `nkOrganizationDefaults`, `lastActiveOrganizationHooks`, `lastActiveOrganizationUserField` (`./organization`) | org-plugin defaults + active-org restore/persist |
|
|
17
|
-
| `createAuthPool` (`./pool`) | **deprecated** — alias of `createPool` from [`@ingram-tech/nk-db`](../nk-db); inject
|
|
16
|
+
| `createAuthPool` (`./pool`) | **deprecated** — alias of `createPool` from [`@ingram-tech/nk-db`](../nk-db); inject your app's shared pool instead |
|
|
18
17
|
| `makeEmailSenders`, `makePasskeyOptions`, `passkeyOptionsForBaseUrl`, `uuidGenerateId` (`./`) | email hooks, passkeys (`passkeyOptionsForBaseUrl` derives `rpID`/`origin` from a single base URL), UUID ids |
|
|
19
|
-
| `bcryptPassword` (`./`) | **legacy only** — bcrypt verifier for
|
|
20
|
-
| `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 |
|
|
21
|
-
| `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 |
|
|
22
|
-
|
|
23
|
-
> **Data access + RLS** is owned by [`@ingram-tech/nk-db`](../nk-db)
|
|
24
|
-
> direct `pg` connection and enforce per-request Row Level Security with
|
|
25
|
-
> `withRls` / `withRlsTransaction` (claims taken from the Better Auth session — no
|
|
26
|
-
> JWT minting, no REST proxy).
|
|
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` / `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
|
+
|
|
22
|
+
> **Data access + RLS** is owned by [`@ingram-tech/nk-db`](../nk-db); see §3.
|
|
27
23
|
>
|
|
28
|
-
> **Backend-JWT + org sites** (a backend API plus the org plugin): compose
|
|
29
|
-
>
|
|
24
|
+
> **Backend-JWT + org sites** (a backend API plus the org plugin): compose your
|
|
25
|
+
> app's shared nk-db pool, `backendJwtOptions({ audience })`,
|
|
30
26
|
> `nkOrganizationDefaults`, and `lastActiveOrganizationHooks(pool)` in your
|
|
31
27
|
> `betterAuth()`; verify backend tokens with `verifyBackendJwt`. Keep
|
|
32
28
|
> app-specific bits (SSO restrictions, permissions/roles, connectors) in the app.
|
|
@@ -49,7 +45,7 @@ Outside production, `BETTER_AUTH_SECRET` falls back to a well-known insecure
|
|
|
49
45
|
placeholder, so local dev and tests run without setting it (a warning is logged).
|
|
50
46
|
In production it stays required — a missing secret throws at startup.
|
|
51
47
|
|
|
52
|
-
If your
|
|
48
|
+
If your app derives its own `baseURL` and opens its own database connection, it
|
|
53
49
|
may not want `authEnv()`'s all-or-nothing bundle (which also requires
|
|
54
50
|
`BETTER_AUTH_URL` and `DATABASE_URL`). Take just the secret — same prod/dev rule —
|
|
55
51
|
with `authSecret()`:
|
|
@@ -116,14 +112,14 @@ const db = await createTestDb({
|
|
|
116
112
|
});
|
|
117
113
|
```
|
|
118
114
|
|
|
119
|
-
**Upgrading better-auth** never means hand-writing a migration in your
|
|
115
|
+
**Upgrading better-auth** never means hand-writing a migration in your app: a
|
|
120
116
|
schema-changing upgrade ships as a new file in nk-auth's chain, so you bump the
|
|
121
117
|
dependency and your next migrate applies it. Reconcile the shipped schema against
|
|
122
|
-
a pinned `better-auth` with `npx @better-auth/cli generate` (a
|
|
123
|
-
|
|
118
|
+
a pinned `better-auth` with `npx @better-auth/cli generate` (a maintainer task,
|
|
119
|
+
done once in this package).
|
|
124
120
|
|
|
125
|
-
> **Adopting from the old copy-in model?** Earlier
|
|
126
|
-
> baseline into
|
|
121
|
+
> **Adopting from the old copy-in model?** Earlier versions told you to `cp` the
|
|
122
|
+
> baseline into your own `drizzle/` chain. If you already did, keep that file
|
|
127
123
|
> (deleting an applied migration causes journal drift) and just add the auth-chain
|
|
128
124
|
> invocation above: its DDL is all `… if not exists`, so it no-ops against your
|
|
129
125
|
> existing tables and simply records the nk-auth journal. New auth migrations then
|
|
@@ -131,10 +127,8 @@ task, done once here — not per site).
|
|
|
131
127
|
|
|
132
128
|
## 2. Configure the server
|
|
133
129
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
the call site (so `auth.api.*` stays typed) and keeps the site plain Better Auth,
|
|
137
|
-
per the prime directive.
|
|
130
|
+
Your app calls `betterAuth` itself and spreads in the presets, which keeps full
|
|
131
|
+
Better Auth type inference at the call site, so `auth.api.*` stays typed.
|
|
138
132
|
|
|
139
133
|
```ts
|
|
140
134
|
// lib/auth.ts
|
|
@@ -166,13 +160,13 @@ export const auth = betterAuth({
|
|
|
166
160
|
basePath: authBasePath, // mount at /auth, not the framework default /api/auth
|
|
167
161
|
advanced: { database: { generateId: uuidGenerateId } }, // UUIDv7 ids
|
|
168
162
|
// ^ stored as hyphenated UUIDv7 (uuid columns / RLS policies stay valid).
|
|
169
|
-
// To show those same ids as prefixed base58 on the wire/UI — `team_…`,
|
|
170
|
-
//
|
|
163
|
+
// To show those same ids as prefixed base58 on the wire/UI — `team_…`, the
|
|
164
|
+
// way an API exposes prefixed ids like `agt_`/`smt_` — skin them with
|
|
171
165
|
// `toPrefixedId(uuid, "team")` / recover with `fromPrefixedId`. `base58Id`
|
|
172
|
-
// mints a fresh one directly for text-id
|
|
166
|
+
// mints a fresh one directly for text-id apps. All from `@ingram-tech/nk-auth`.
|
|
173
167
|
emailAndPassword: {
|
|
174
168
|
enabled: true,
|
|
175
|
-
// Better Auth hashes with scrypt by default. Only
|
|
169
|
+
// Better Auth hashes with scrypt by default. Only apps carrying
|
|
176
170
|
// pre-existing bcrypt hashes set `password: bcryptPassword` (legacy).
|
|
177
171
|
sendResetPassword: email.sendResetPassword,
|
|
178
172
|
},
|
|
@@ -232,11 +226,11 @@ has any stored preference.
|
|
|
232
226
|
mail a user ever gets from you; a bare `<a href=url>url</a>` reads as phishing
|
|
233
227
|
and trains people to distrust your domain. Take the
|
|
234
228
|
[`registry`](https://github.com/ingram-technologies/registry) email components
|
|
235
|
-
(`shadcn add email-verification email-password-reset`)
|
|
236
|
-
`body` / `ctaLabel` / `preview` overrides
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
229
|
+
(`shadcn add email-verification email-password-reset`), which accept `heading` /
|
|
230
|
+
`body` / `ctaLabel` / `preview` overrides so you can pass translated copy, or
|
|
231
|
+
write your own. Send auth links from the default `notifications` local part
|
|
232
|
+
(`fromAddress("Example")`), **not** `no-reply`: this is the mail users are most
|
|
233
|
+
likely to reply to, and dropping that reply is hostile. See
|
|
240
234
|
[transactional-email.md](../../docs/transactional-email.md).
|
|
241
235
|
|
|
242
236
|
**Spread all three in; do not hand-write them.** `betterAuth()` takes its
|
|
@@ -274,8 +268,8 @@ claims GUC, so a policy's `auth.uid()` still compares to a `uuid` column.
|
|
|
274
268
|
|
|
275
269
|
## 4. Client
|
|
276
270
|
|
|
277
|
-
Assemble the client in a `"use client"` module (
|
|
278
|
-
|
|
271
|
+
Assemble the client in a `"use client"` module (plugin inference is preserved
|
|
272
|
+
here too):
|
|
279
273
|
|
|
280
274
|
```tsx
|
|
281
275
|
"use client";
|
|
@@ -393,14 +387,57 @@ that can touch cookies) deletes the dead Better Auth cookies on the `stale`
|
|
|
393
387
|
marker and bounces to a clean `/login?next=…`; signing in returns them to
|
|
394
388
|
`next`. `next` for the cookie-less case is filled in by middleware directly; for
|
|
395
389
|
the cookie-present case the guard reads it from the `x-nk-auth-path` header
|
|
396
|
-
middleware injects
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
399
436
|
|
|
400
437
|
## 6. Passwords: change, set, and reset
|
|
401
438
|
|
|
402
439
|
nk-auth owns the reset **sender** (`makeEmailSenders.sendResetPassword`, §2) and
|
|
403
|
-
|
|
440
|
+
closes the loop so your app never touches Better Auth's `account` table or
|
|
404
441
|
endpoint names directly. Three pieces:
|
|
405
442
|
|
|
406
443
|
**Detect what login the user has.** A social-only account (Google, …) has no
|
|
@@ -478,11 +515,11 @@ async redirects() {
|
|
|
478
515
|
## Migrating bcrypt passwords to scrypt
|
|
479
516
|
|
|
480
517
|
`bcryptPassword` is **legacy support only** (see its `@deprecated` note). It
|
|
481
|
-
exists so
|
|
518
|
+
exists so apps whose `account.password` hashes are bcrypt keep verifying:
|
|
482
519
|
that is all nk-auth ships for the bcrypt case.
|
|
483
520
|
|
|
484
|
-
The path to move
|
|
485
|
-
|
|
521
|
+
The path to move fully onto scrypt is a **dual-format verifier**, wired in your
|
|
522
|
+
app (nk-auth does not ship it). Better Auth's default hasher is scrypt
|
|
486
523
|
(`<salt-hex>:<key-hex>`) and bcrypt hashes are trivially distinguishable (they
|
|
487
524
|
start with `$2a$`/`$2b$`/`$2y$`), so override only
|
|
488
525
|
`emailAndPassword.password.verify` to branch on `hash.startsWith("$2")` →
|
|
@@ -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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-auth",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"test": "vitest run"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@ingram-tech/nk-db": "^2.1.
|
|
71
|
+
"@ingram-tech/nk-db": "^2.1.1",
|
|
72
72
|
"bcrypt": "^6.0.0",
|
|
73
73
|
"jose": "^6.2.9",
|
|
74
74
|
"zod": "^4.4.3"
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@better-auth/passkey": "^1.7.0",
|
|
99
|
-
"@ingram-tech/nk-dev": "0.
|
|
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",
|
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,
|