@ingram-tech/nk-auth 0.13.5 → 0.13.6
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/package.json +6 -3
- package/src/client.ts +44 -0
- package/src/gating-internals.ts +47 -0
- package/src/id.ts +12 -0
- package/src/index.ts +36 -0
- package/src/jwt.ts +100 -0
- package/src/keys.ts +118 -0
- package/src/middleware.ts +150 -0
- package/src/options.ts +236 -0
- package/src/organization.ts +80 -0
- package/src/password.ts +103 -0
- package/src/paths.ts +19 -0
- package/src/pool.ts +26 -0
- package/src/reset-password.ts +128 -0
- package/src/server.ts +184 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-auth",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.6",
|
|
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",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/**/*.test.tsx",
|
|
17
20
|
"migrations"
|
|
18
21
|
],
|
|
19
22
|
"exports": {
|
|
@@ -65,7 +68,7 @@
|
|
|
65
68
|
"test": "vitest run"
|
|
66
69
|
},
|
|
67
70
|
"dependencies": {
|
|
68
|
-
"@ingram-tech/nk-db": "^
|
|
71
|
+
"@ingram-tech/nk-db": "^2.0.0",
|
|
69
72
|
"bcrypt": "^6.0.0",
|
|
70
73
|
"jose": "^6.2.9",
|
|
71
74
|
"zod": "^4.4.3"
|
|
@@ -93,7 +96,7 @@
|
|
|
93
96
|
},
|
|
94
97
|
"devDependencies": {
|
|
95
98
|
"@better-auth/passkey": "^1.7.0",
|
|
96
|
-
"@ingram-tech/nk-dev": "0.
|
|
99
|
+
"@ingram-tech/nk-dev": "0.12.0",
|
|
97
100
|
"@types/bcrypt": "^6.0.0",
|
|
98
101
|
"@types/node": "^26.2.0",
|
|
99
102
|
"@types/pg": "^8.23.1",
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser auth re-exports, at "@ingram-tech/nk-auth/client" so the server entry
|
|
3
|
+
* never pulls in React. A site builds its own client to preserve full plugin
|
|
4
|
+
* type inference:
|
|
5
|
+
*
|
|
6
|
+
* "use client";
|
|
7
|
+
* import {
|
|
8
|
+
* createAuthClient, jwtClient, passkeyClient,
|
|
9
|
+
* } from "@ingram-tech/nk-auth/client";
|
|
10
|
+
*
|
|
11
|
+
* export const authClient = createAuthClient({
|
|
12
|
+
* baseURL: process.env.NEXT_PUBLIC_SITE_URL,
|
|
13
|
+
* plugins: [jwtClient(), passkeyClient()],
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* This exposes the client auth call surface:
|
|
17
|
+
* signUp.email / signIn.email / signIn.social / signOut / useSession,
|
|
18
|
+
* plus passkey.* (register / authenticate).
|
|
19
|
+
*/
|
|
20
|
+
export { passkeyClient } from "@better-auth/passkey/client";
|
|
21
|
+
export { jwtClient } from "better-auth/client/plugins";
|
|
22
|
+
export { createAuthClient } from "better-auth/react";
|
|
23
|
+
// Password-policy primitives (pure), so a reset form validates against the same
|
|
24
|
+
// bounds the server enforces without a second import.
|
|
25
|
+
export {
|
|
26
|
+
CREDENTIAL_PROVIDER_ID,
|
|
27
|
+
DEFAULT_MAX_PASSWORD_LENGTH,
|
|
28
|
+
DEFAULT_MIN_PASSWORD_LENGTH,
|
|
29
|
+
type PasswordPolicy,
|
|
30
|
+
passwordSchema,
|
|
31
|
+
type ResetPasswordError,
|
|
32
|
+
type ResetPasswordErrorCode,
|
|
33
|
+
validateNewPassword,
|
|
34
|
+
} from "./password.js";
|
|
35
|
+
// Re-exported here too so the client can set `basePath` without a server import.
|
|
36
|
+
export { authBasePath } from "./paths.js";
|
|
37
|
+
// Headless reset-/set-password controller (React hook); the site brings the shell.
|
|
38
|
+
export {
|
|
39
|
+
type ResetPasswordClient,
|
|
40
|
+
type ResetPasswordStatus,
|
|
41
|
+
type UseResetPassword,
|
|
42
|
+
type UseResetPasswordOptions,
|
|
43
|
+
useResetPassword,
|
|
44
|
+
} from "./reset-password.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helpers shared by the server helpers and the middleware. Kept in a
|
|
3
|
+
* zero-`next` module so neither subpath imports the other (server pulls
|
|
4
|
+
* next/navigation, middleware pulls next/server — mixing them breaks the
|
|
5
|
+
* respective runtimes). Not a public export.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Request header the middleware sets to the originally-requested path so a
|
|
10
|
+
* server-component guard — which Next.js otherwise gives no way to learn its own
|
|
11
|
+
* URL — can preserve it as a `next` param when it redirects to sign-in.
|
|
12
|
+
*/
|
|
13
|
+
export const NK_AUTH_PATH_HEADER = "x-nk-auth-path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Accept only an internal, non-protocol-relative path as a post-login redirect,
|
|
17
|
+
* so `next` can never be turned into an open redirect to another origin.
|
|
18
|
+
*
|
|
19
|
+
* Beyond the `//` check, backslashes and ASCII controls must also be rejected:
|
|
20
|
+
* browsers treat `\` as `/` in http(s) URLs (so `/\evil.com` resolves to
|
|
21
|
+
* `https://evil.com/`) and the URL parser strips tab/newline (so an encoded
|
|
22
|
+
* `/\t/evil.com` collapses to `//evil.com`).
|
|
23
|
+
*/
|
|
24
|
+
export function safeNextParam(value: string | null | undefined): string | null {
|
|
25
|
+
if (!value) return null;
|
|
26
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
27
|
+
// oxlint-disable-next-line no-control-regex -- rejecting control chars is the point
|
|
28
|
+
if (/[\\\u0000-\u001f\u007f]/.test(value)) return null;
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a sign-in URL: `signInPath`, plus `next` (when safe) so the user returns
|
|
34
|
+
* to where they were headed, plus a `stale=1` marker when a session cookie is
|
|
35
|
+
* present but invalid — the signal the middleware uses to clear the dead cookie.
|
|
36
|
+
*/
|
|
37
|
+
export function signInUrl(
|
|
38
|
+
signInPath: string,
|
|
39
|
+
opts: { next?: string | null; stale?: boolean },
|
|
40
|
+
): string {
|
|
41
|
+
const params = new URLSearchParams();
|
|
42
|
+
const next = safeNextParam(opts.next);
|
|
43
|
+
if (next) params.set("next", next);
|
|
44
|
+
if (opts.stale) params.set("stale", "1");
|
|
45
|
+
const qs = params.toString();
|
|
46
|
+
return qs ? `${signInPath}?${qs}` : signInPath;
|
|
47
|
+
}
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The Ingram id codec is the standalone `id758` package, re-exported via
|
|
2
|
+
// `@ingram-tech/nk-db/id` — the lowest package in
|
|
3
|
+
// the graph — so a site can mint ids without pulling this auth slice. This
|
|
4
|
+
// re-export keeps `@ingram-tech/nk-auth/id` (and Better Auth's
|
|
5
|
+
// `advanced.database.generateId`, wired in `./options`) working unchanged. For
|
|
6
|
+
// the typed prefix registry (`createIdRegistry`), import from `@ingram-tech/nk-db/id`.
|
|
7
|
+
export {
|
|
8
|
+
base58Id,
|
|
9
|
+
fromPrefixedId,
|
|
10
|
+
toPrefixedId,
|
|
11
|
+
uuidGenerateId,
|
|
12
|
+
} from "@ingram-tech/nk-db/id";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Server entry — the Ingram Better Auth foundation. Browser re-exports live at
|
|
2
|
+
// "@ingram-tech/nk-auth/client" so importing the server presets never pulls in
|
|
3
|
+
// React. Focused subpaths (./jwt, ./organization, ./pool) let a site import
|
|
4
|
+
// only what it needs (e.g. avoid bcrypt when it doesn't use passwords).
|
|
5
|
+
|
|
6
|
+
export { type BackendJwtConfig, backendJwtOptions, verifyBackendJwt } from "./jwt.js";
|
|
7
|
+
export { base58Id, fromPrefixedId, toPrefixedId, uuidGenerateId } from "./id.js";
|
|
8
|
+
export { type AuthEnv, authEnv, authSecret, isConfigured } from "./keys.js";
|
|
9
|
+
export {
|
|
10
|
+
type AuthEmailKind,
|
|
11
|
+
type AuthEmailMessage,
|
|
12
|
+
type AuthEmailUser,
|
|
13
|
+
bcryptPassword,
|
|
14
|
+
makeEmailSenders,
|
|
15
|
+
makePasskeyOptions,
|
|
16
|
+
type PasskeyConfig,
|
|
17
|
+
passkeyOptionsForBaseUrl,
|
|
18
|
+
type SendEmail,
|
|
19
|
+
} from "./options.js";
|
|
20
|
+
export {
|
|
21
|
+
lastActiveOrganizationHooks,
|
|
22
|
+
lastActiveOrganizationUserField,
|
|
23
|
+
nkOrganizationDefaults,
|
|
24
|
+
} from "./organization.js";
|
|
25
|
+
export {
|
|
26
|
+
CREDENTIAL_PROVIDER_ID,
|
|
27
|
+
DEFAULT_MAX_PASSWORD_LENGTH,
|
|
28
|
+
DEFAULT_MIN_PASSWORD_LENGTH,
|
|
29
|
+
type PasswordPolicy,
|
|
30
|
+
passwordSchema,
|
|
31
|
+
type ResetPasswordError,
|
|
32
|
+
type ResetPasswordErrorCode,
|
|
33
|
+
validateNewPassword,
|
|
34
|
+
} from "./password.js";
|
|
35
|
+
export { authBasePath } from "./paths.js";
|
|
36
|
+
export { createAuthPool } from "./pool.js";
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { JwtOptions } from "better-auth/plugins/jwt";
|
|
2
|
+
import {
|
|
3
|
+
createRemoteJWKSet,
|
|
4
|
+
errors as joseErrors,
|
|
5
|
+
type JWTPayload,
|
|
6
|
+
jwtVerify,
|
|
7
|
+
} from "jose";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `jwt` plugin preset for a site's OWN backend API: a short-lived token with a
|
|
11
|
+
* specific `audience`, typically carrying extra claims the site adds via
|
|
12
|
+
* `auth.api.signJWT`. Defaults to EdDSA (Better Auth's default), with the JWKS
|
|
13
|
+
* exposed at `/api/auth/jwks` so the backend can verify it (`verifyBackendJwt`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface BackendJwtConfig {
|
|
17
|
+
/** Expected audience of the site's backend, e.g. "my-app-backend". */
|
|
18
|
+
audience: string;
|
|
19
|
+
/** Token lifetime, e.g. "15m". */
|
|
20
|
+
expirationTime?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Don't auto-attach the JWT to the `set-auth-jwt` response header — set when
|
|
23
|
+
* the site mints tokens on demand via `auth.api.signJWT` instead.
|
|
24
|
+
*/
|
|
25
|
+
disableSettingJwtHeader?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `jwt` plugin options for a backend-API token (custom audience). */
|
|
29
|
+
export const backendJwtOptions = (config: BackendJwtConfig): JwtOptions => ({
|
|
30
|
+
disableSettingJwtHeader: config.disableSettingJwtHeader,
|
|
31
|
+
jwt: {
|
|
32
|
+
audience: config.audience,
|
|
33
|
+
expirationTime: config.expirationTime,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
38
|
+
|
|
39
|
+
// Forced-reload throttle (per JWKS URL). The no-matching-key path is reachable
|
|
40
|
+
// by anyone who can send a request with a made-up `kid`, and `.reload()`
|
|
41
|
+
// deliberately bypasses jose's cooldown — unthrottled, garbage tokens would
|
|
42
|
+
// each cost a fresh fetch against the auth origin.
|
|
43
|
+
const RELOAD_COOLDOWN_MS = 30_000;
|
|
44
|
+
const lastForcedReload = new Map<string, number>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verify a Better-Auth-minted backend JWT (EdDSA) against the issuer's JWKS.
|
|
48
|
+
* For use by the backend that consumes `backendJwtOptions` tokens. Throws on an
|
|
49
|
+
* invalid/expired token or audience/issuer mismatch; returns the claims.
|
|
50
|
+
*
|
|
51
|
+
* Hardened against Better Auth signing-key rotation: jose's `createRemoteJWKSet`
|
|
52
|
+
* refuses to refetch the JWKS for `cooldownDuration` (30s default) after any
|
|
53
|
+
* fetch, so a token signed with a freshly rotated key whose `kid` isn't yet in
|
|
54
|
+
* the cached set would fail for the *whole* cooldown window — a ~30s burst of
|
|
55
|
+
* auth failures on every request that verifies a token. On a no-matching-key
|
|
56
|
+
* miss we force a single `.reload()` (which bypasses the cooldown) and retry, so
|
|
57
|
+
* a rotation costs one extra fetch instead of a brief outage.
|
|
58
|
+
*/
|
|
59
|
+
export const verifyBackendJwt = async (params: {
|
|
60
|
+
token: string;
|
|
61
|
+
/** The issuer's JWKS endpoint, e.g. `${BETTER_AUTH_URL}/api/auth/jwks`. */
|
|
62
|
+
jwksUrl: string | URL;
|
|
63
|
+
audience: string;
|
|
64
|
+
issuer: string;
|
|
65
|
+
}): Promise<JWTPayload> => {
|
|
66
|
+
const url =
|
|
67
|
+
typeof params.jwksUrl === "string" ? new URL(params.jwksUrl) : params.jwksUrl;
|
|
68
|
+
const cacheKey = url.toString();
|
|
69
|
+
let jwks = jwksCache.get(cacheKey);
|
|
70
|
+
if (!jwks) {
|
|
71
|
+
jwks = createRemoteJWKSet(url);
|
|
72
|
+
jwksCache.set(cacheKey, jwks);
|
|
73
|
+
}
|
|
74
|
+
const options = {
|
|
75
|
+
audience: params.audience,
|
|
76
|
+
issuer: params.issuer,
|
|
77
|
+
algorithms: ["EdDSA"],
|
|
78
|
+
// Tolerate a few seconds of clock skew between the minting and verifying
|
|
79
|
+
// hosts; jose's default of 0 fails legitimate tokens at exp/nbf boundaries.
|
|
80
|
+
clockTolerance: 5,
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const { payload } = await jwtVerify(params.token, jwks, options);
|
|
84
|
+
return payload;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!(err instanceof joseErrors.JWKSNoMatchingKey)) throw err;
|
|
87
|
+
// `kid` absent from the cached JWKS — almost always a just-rotated signing
|
|
88
|
+
// key. Force a refresh past the cooldown and retry exactly once, at most
|
|
89
|
+
// once per cooldown window so unknown-`kid` garbage can't hammer the
|
|
90
|
+
// issuer.
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
if (now - (lastForcedReload.get(cacheKey) ?? 0) < RELOAD_COOLDOWN_MS) {
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
lastForcedReload.set(cacheKey, now);
|
|
96
|
+
await jwks.reload();
|
|
97
|
+
const { payload } = await jwtVerify(params.token, jwks, options);
|
|
98
|
+
return payload;
|
|
99
|
+
}
|
|
100
|
+
};
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment contract for @ingram-tech/nk-auth.
|
|
3
|
+
*
|
|
4
|
+
* Following the "each package owns its own env validation" pattern. Env vars are
|
|
5
|
+
* external input, so we parse them with Zod (per docs/code-style.md) rather than
|
|
6
|
+
* reading `process.env` ad hoc. `authEnv()` returns a validated, config-shaped
|
|
7
|
+
* object you can spread straight into your `betterAuth()` call.
|
|
8
|
+
*
|
|
9
|
+
* Required:
|
|
10
|
+
* BETTER_AUTH_SECRET — session/CSRF signing key (`openssl rand -hex 32`)
|
|
11
|
+
* BETTER_AUTH_URL — canonical site origin, e.g. "https://example.com"
|
|
12
|
+
* DATABASE_URL — direct Postgres connection for Better Auth (:5432)
|
|
13
|
+
*
|
|
14
|
+
* Outside production, BETTER_AUTH_SECRET falls back to a well-known insecure
|
|
15
|
+
* placeholder so local dev and tests run without hand-setting it (`nk dev` and
|
|
16
|
+
* plain `next dev` alike). In production it stays strictly required — a missing
|
|
17
|
+
* secret throws at startup rather than silently signing sessions with a value
|
|
18
|
+
* anyone could guess.
|
|
19
|
+
*
|
|
20
|
+
* Sites that derive their own `baseURL` / database connection can take just the
|
|
21
|
+
* secret's prod/dev rule via `authSecret()` instead of the whole `authEnv()`
|
|
22
|
+
* bundle — so that security-sensitive default is never re-implemented app-side.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Well-known, deliberately insecure secret used ONLY when NODE_ENV is not
|
|
29
|
+
* "production". Never applied to a production build — see `secretField`.
|
|
30
|
+
*/
|
|
31
|
+
const DEV_SECRET_PLACEHOLDER = "nk-dev-insecure-placeholder-not-for-production";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* In production the secret is required; everywhere else it defaults to the
|
|
35
|
+
* insecure placeholder. Read NODE_ENV at parse time (not module load) so the
|
|
36
|
+
* same process can be exercised under either mode.
|
|
37
|
+
*/
|
|
38
|
+
const secretField = () =>
|
|
39
|
+
process.env.NODE_ENV === "production"
|
|
40
|
+
? z.string().min(1)
|
|
41
|
+
: z.string().min(1).default(DEV_SECRET_PLACEHOLDER);
|
|
42
|
+
|
|
43
|
+
const buildSchema = () =>
|
|
44
|
+
z.object({
|
|
45
|
+
BETTER_AUTH_SECRET: secretField(),
|
|
46
|
+
BETTER_AUTH_URL: z.string().url(),
|
|
47
|
+
DATABASE_URL: z.string().min(1),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let warnedPlaceholder = false;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Warn once when the insecure dev placeholder is in use, so a missing secret is
|
|
54
|
+
* visible in local logs without nagging on every call. Never fires in
|
|
55
|
+
* production, where `secretField()` makes the secret required.
|
|
56
|
+
*/
|
|
57
|
+
const warnIfPlaceholder = (secret: string): void => {
|
|
58
|
+
if (secret === DEV_SECRET_PLACEHOLDER && !warnedPlaceholder) {
|
|
59
|
+
warnedPlaceholder = true;
|
|
60
|
+
console.warn(
|
|
61
|
+
"@ingram-tech/nk-auth: BETTER_AUTH_SECRET is unset — using an insecure dev placeholder. Set it before deploying.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve just the session-signing secret, applying the same rule `authEnv()`
|
|
68
|
+
* does: strictly required in production, an insecure dev placeholder otherwise.
|
|
69
|
+
*
|
|
70
|
+
* For sites that derive their own `baseURL` and open their own database
|
|
71
|
+
* connection (so they don't want `authEnv()`'s all-or-nothing bundle) but still
|
|
72
|
+
* want nk-auth to own the secret's prod/dev fallback. Reaching for this keeps
|
|
73
|
+
* the security-sensitive default in one place instead of hand-copied into the
|
|
74
|
+
* app. Backed by the same `secretField()` schema as `authEnv()`.
|
|
75
|
+
*/
|
|
76
|
+
export const authSecret = (): string => {
|
|
77
|
+
const result = secretField().safeParse(process.env.BETTER_AUTH_SECRET);
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
// secretField() only rejects a missing/empty secret in production (dev
|
|
80
|
+
// supplies the placeholder default), so that is the sole failure here.
|
|
81
|
+
throw new Error(
|
|
82
|
+
"@ingram-tech/nk-auth: BETTER_AUTH_SECRET is required (an unset secret is only allowed outside production)",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
warnIfPlaceholder(result.data);
|
|
86
|
+
return result.data;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export interface AuthEnv {
|
|
90
|
+
secret: string;
|
|
91
|
+
baseURL: string;
|
|
92
|
+
databaseUrl: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read and validate all nk-auth env vars at once. Throws a single error listing
|
|
97
|
+
* everything missing/invalid, so a misconfigured site fails fast at startup
|
|
98
|
+
* rather than at first sign-in.
|
|
99
|
+
*/
|
|
100
|
+
export const authEnv = (): AuthEnv => {
|
|
101
|
+
const result = buildSchema().safeParse(process.env);
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
const issues = result.error.issues
|
|
104
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
105
|
+
.join(", ");
|
|
106
|
+
throw new Error(`@ingram-tech/nk-auth: invalid environment — ${issues}`);
|
|
107
|
+
}
|
|
108
|
+
const env = result.data;
|
|
109
|
+
warnIfPlaceholder(env.BETTER_AUTH_SECRET);
|
|
110
|
+
return {
|
|
111
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
112
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
113
|
+
databaseUrl: env.DATABASE_URL,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Whether nk-auth is fully configured (lets callers degrade in local/dev). */
|
|
118
|
+
export const isConfigured = (): boolean => buildSchema().safeParse(process.env).success;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loop-safe Next.js auth middleware factory, at
|
|
3
|
+
* "@ingram-tech/nk-auth/middleware". Middleware runs at the edge before a route
|
|
4
|
+
* renders, so it can only afford an **optimistic** check — the presence of a
|
|
5
|
+
* session cookie (`getSessionCookie`), not a database-validated session.
|
|
6
|
+
*
|
|
7
|
+
* That optimism is exactly what makes naive auth middleware loop. The validated
|
|
8
|
+
* server guard (see "@ingram-tech/nk-auth/server") sends an invalid-but-present
|
|
9
|
+
* session to the sign-in page; if the optimistic layer then bounces the sign-in
|
|
10
|
+
* page back ("you have a cookie, go to the app"), a stale cookie ping-pongs
|
|
11
|
+
* forever — and an RSC render can't clear the cookie to break out.
|
|
12
|
+
*
|
|
13
|
+
* So this factory enforces the one safe invariant *at construction*:
|
|
14
|
+
*
|
|
15
|
+
* The sign-in path is never the target of an optimistic redirect.
|
|
16
|
+
*
|
|
17
|
+
* It also owns the two things only middleware can do here:
|
|
18
|
+
*
|
|
19
|
+
* - **Preserve the destination.** When it sends an unauthenticated user to
|
|
20
|
+
* sign-in, it appends `?next=<requested path>`; and it injects an
|
|
21
|
+
* `x-nk-auth-path` request header so the server guards can do the same for
|
|
22
|
+
* the cookie-present-but-invalid case (an RSC can't otherwise learn its URL).
|
|
23
|
+
* - **Clear a stale cookie.** The guard parks an invalid session at
|
|
24
|
+
* `${signInPath}?stale=1`; middleware (the only pre-render place that can set
|
|
25
|
+
* cookies) deletes the dead Better Auth cookies and bounces to a clean
|
|
26
|
+
* sign-in URL, so a bad session self-heals instead of failing every request.
|
|
27
|
+
*/
|
|
28
|
+
import { getSessionCookie } from "better-auth/cookies";
|
|
29
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
30
|
+
import { NK_AUTH_PATH_HEADER, safeNextParam } from "./gating-internals.js";
|
|
31
|
+
|
|
32
|
+
export interface AuthMiddlewareConfig {
|
|
33
|
+
/**
|
|
34
|
+
* Path prefixes that require a session. A request without a session cookie
|
|
35
|
+
* to any of these is redirected to `signInPath`. Matched with `startsWith`.
|
|
36
|
+
*/
|
|
37
|
+
protectedPaths: string[];
|
|
38
|
+
/** Where unauthenticated users are sent. Default `/login`. */
|
|
39
|
+
signInPath?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Optional front-door redirect: when a session cookie is present and the
|
|
42
|
+
* path *exactly* equals one of these, redirect to `signedInRedirect`. Safe
|
|
43
|
+
* because it never targets the sign-in path. Typically `["/"]`.
|
|
44
|
+
*/
|
|
45
|
+
frontDoorPaths?: string[];
|
|
46
|
+
/** Destination for the front-door redirect. Required when `frontDoorPaths` is set. */
|
|
47
|
+
signedInRedirect?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Cookie-name fragment for the cookies cleared on a stale session. Default
|
|
50
|
+
* `better-auth`, which matches `better-auth.session_token` and the
|
|
51
|
+
* `__Secure-…` production variant.
|
|
52
|
+
*/
|
|
53
|
+
sessionCookiePrefix?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createAuthMiddleware(config: AuthMiddlewareConfig) {
|
|
57
|
+
const signInPath = config.signInPath ?? "/login";
|
|
58
|
+
const frontDoorPaths = config.frontDoorPaths ?? [];
|
|
59
|
+
const cookiePrefix = config.sessionCookiePrefix ?? "better-auth";
|
|
60
|
+
|
|
61
|
+
// Segment-boundary match: "/app" gates "/app" and "/app/x" but not
|
|
62
|
+
// "/application". Used for both the construction-time loop check and the
|
|
63
|
+
// per-request gate, so the two can't drift (a broader construction check
|
|
64
|
+
// would reject safe configs like protectedPaths ["/log"] + signInPath
|
|
65
|
+
// "/login", where "/login" is never actually gated).
|
|
66
|
+
const isProtected = (path: string): boolean =>
|
|
67
|
+
config.protectedPaths.some((p) => path === p || path.startsWith(`${p}/`));
|
|
68
|
+
|
|
69
|
+
// Loop-safety, enforced once at construction rather than hoped-for per
|
|
70
|
+
// request. The sign-in path is where the validated guard parks an
|
|
71
|
+
// invalid-but-present session; it must never be the target of an optimistic
|
|
72
|
+
// redirect, or a stale cookie loops.
|
|
73
|
+
if (isProtected(signInPath)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`@ingram-tech/nk-auth: signInPath "${signInPath}" must not fall under protectedPaths — a cookie-less visit would redirect to itself forever.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (frontDoorPaths.includes(signInPath)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`@ingram-tech/nk-auth: signInPath "${signInPath}" must not be a frontDoorPath — it would reintroduce the stale-cookie redirect loop.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (frontDoorPaths.length > 0 && !config.signedInRedirect) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"@ingram-tech/nk-auth: signedInRedirect is required when frontDoorPaths is set.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return function middleware(request: NextRequest): NextResponse {
|
|
90
|
+
const path = request.nextUrl.pathname;
|
|
91
|
+
|
|
92
|
+
// 1. Stale-session handshake. The validated guard sends an invalid session
|
|
93
|
+
// to `${signInPath}?stale=1&next=…`. Clear the dead Better Auth cookies
|
|
94
|
+
// (only middleware can, pre-render) and bounce to a clean sign-in URL
|
|
95
|
+
// that keeps `next`. After this the cookie is gone, so it can't re-arm.
|
|
96
|
+
if (path === signInPath && request.nextUrl.searchParams.get("stale") === "1") {
|
|
97
|
+
const to = request.nextUrl.clone();
|
|
98
|
+
to.searchParams.delete("stale");
|
|
99
|
+
const res = NextResponse.redirect(to);
|
|
100
|
+
for (const cookie of request.cookies.getAll()) {
|
|
101
|
+
if (cookie.name.includes(cookiePrefix)) {
|
|
102
|
+
// `__Secure-`/`__Host-` cookies: browsers reject the deletion
|
|
103
|
+
// Set-Cookie unless it carries the Secure attribute itself, so a
|
|
104
|
+
// bare delete() would silently leave the dead cookie in place on
|
|
105
|
+
// HTTPS and re-run this handshake on every visit.
|
|
106
|
+
res.cookies.delete({
|
|
107
|
+
name: cookie.name,
|
|
108
|
+
path: "/",
|
|
109
|
+
secure:
|
|
110
|
+
cookie.name.startsWith("__Secure-") ||
|
|
111
|
+
cookie.name.startsWith("__Host-"),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hasSessionCookie = !!getSessionCookie(request);
|
|
119
|
+
|
|
120
|
+
// 2. Unauthenticated (no cookie) on a protected path -> sign in, and
|
|
121
|
+
// remember where they were going.
|
|
122
|
+
if (!hasSessionCookie && isProtected(path)) {
|
|
123
|
+
const original = request.nextUrl.pathname + request.nextUrl.search;
|
|
124
|
+
const to = request.nextUrl.clone();
|
|
125
|
+
to.pathname = signInPath;
|
|
126
|
+
to.search = "";
|
|
127
|
+
const next = safeNextParam(original);
|
|
128
|
+
if (next) to.searchParams.set("next", next);
|
|
129
|
+
return NextResponse.redirect(to);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Front door: a cookie-bearing visit to "/" (or configured) -> the app.
|
|
133
|
+
if (
|
|
134
|
+
hasSessionCookie &&
|
|
135
|
+
config.signedInRedirect &&
|
|
136
|
+
frontDoorPaths.includes(path)
|
|
137
|
+
) {
|
|
138
|
+
const to = request.nextUrl.clone();
|
|
139
|
+
to.pathname = config.signedInRedirect;
|
|
140
|
+
to.search = "";
|
|
141
|
+
return NextResponse.redirect(to);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4. Pass through, injecting the requested path so server guards can build
|
|
145
|
+
// `next` for the cookie-present-but-invalid case.
|
|
146
|
+
const requestHeaders = new Headers(request.headers);
|
|
147
|
+
requestHeaders.set(NK_AUTH_PATH_HEADER, path + request.nextUrl.search);
|
|
148
|
+
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { PasskeyOptions } from "@better-auth/passkey";
|
|
2
|
+
import type { BetterAuthOptions } from "better-auth";
|
|
3
|
+
import bcrypt from "bcrypt";
|
|
4
|
+
|
|
5
|
+
// `uuidGenerateId` (the `advanced.database.generateId` UUIDv7 generator) and the
|
|
6
|
+
// base58 skin now live in the dependency-light `./id` module; re-exported here so
|
|
7
|
+
// existing `from "@ingram-tech/nk-auth"` imports keep resolving.
|
|
8
|
+
export { uuidGenerateId } from "./id.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Portable Better Auth building blocks for Ingram sites.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT a `betterAuth()` wrapper: a site assembles its own instance
|
|
14
|
+
* and spreads these presets in. That keeps full plugin type inference at the
|
|
15
|
+
* call site (where `declaration` is off) and respects the prime directive — the
|
|
16
|
+
* site stays plain Better Auth, we just ship the shared config. JWT + org
|
|
17
|
+
* presets live in `./jwt` and `./organization`. See the package README.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `emailAndPassword.password` config that hashes/verifies with bcrypt.
|
|
22
|
+
*
|
|
23
|
+
* @deprecated LEGACY SUPPORT ONLY. Do not wire this into a new site. It exists
|
|
24
|
+
* solely so sites with pre-existing **bcrypt** password hashes keep verifying.
|
|
25
|
+
* Better Auth's default is scrypt — new sites should omit this and use that
|
|
26
|
+
* default. Sites still on bcrypt should migrate hashes to scrypt and drop this;
|
|
27
|
+
* see the nk-auth README (§"Migrating bcrypt passwords to scrypt") for the path.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: bcrypt silently truncates the password at **72 bytes**, so two passwords
|
|
30
|
+
* sharing their first 72 bytes verify as equal — even though the password policy
|
|
31
|
+
* allows up to {@link DEFAULT_MAX_PASSWORD_LENGTH} (128) characters. This is a
|
|
32
|
+
* property of bcrypt, not a bug here, and it's deliberately not length-guarded:
|
|
33
|
+
* a guard would break verification of the legacy long-password hashes this
|
|
34
|
+
* preset exists to support (bcrypt already truncated them at hash time). It is
|
|
35
|
+
* one more reason to migrate off bcrypt — scrypt has no such ceiling.
|
|
36
|
+
*/
|
|
37
|
+
export const bcryptPassword = {
|
|
38
|
+
hash: (password: string): Promise<string> => bcrypt.hash(password, 10),
|
|
39
|
+
verify: ({
|
|
40
|
+
hash,
|
|
41
|
+
password,
|
|
42
|
+
}: {
|
|
43
|
+
hash: string;
|
|
44
|
+
password: string;
|
|
45
|
+
}): Promise<boolean> => bcrypt.compare(password, hash),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface PasskeyConfig {
|
|
49
|
+
/** Relying-party id: the registrable domain, e.g. "example.com". */
|
|
50
|
+
rpId: string;
|
|
51
|
+
/** Relying-party display name. */
|
|
52
|
+
rpName: string;
|
|
53
|
+
/** Expected origin(s), e.g. "https://example.com". */
|
|
54
|
+
origin: string | string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build `passkey` plugin options. Use as `passkey(makePasskeyOptions(cfg))`. */
|
|
58
|
+
export const makePasskeyOptions = (cfg: PasskeyConfig): PasskeyOptions => ({
|
|
59
|
+
rpID: cfg.rpId,
|
|
60
|
+
rpName: cfg.rpName,
|
|
61
|
+
origin: cfg.origin,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive passkey options from the site's single base URL: `rpID` = the URL's
|
|
66
|
+
* hostname (the WebAuthn effective domain — host only, no scheme or port) and
|
|
67
|
+
* `origin` = the URL itself. This is the common case (one sign-in origin), and
|
|
68
|
+
* it keeps the relying-party id and the registered origin in lockstep so they
|
|
69
|
+
* can't drift. Pass the same value the instance signs sessions for, e.g.:
|
|
70
|
+
*
|
|
71
|
+
* passkey(passkeyOptionsForBaseUrl(env.baseURL, "Example"))
|
|
72
|
+
*
|
|
73
|
+
* Reach for `makePasskeyOptions` directly when a site spans multiple origins or
|
|
74
|
+
* must register against a parent registrable domain (e.g. rpID "example.com"
|
|
75
|
+
* for an "app.example.com" origin).
|
|
76
|
+
*/
|
|
77
|
+
export const passkeyOptionsForBaseUrl = (
|
|
78
|
+
baseURL: string,
|
|
79
|
+
rpName: string,
|
|
80
|
+
): PasskeyOptions =>
|
|
81
|
+
makePasskeyOptions({
|
|
82
|
+
rpId: new URL(baseURL).hostname,
|
|
83
|
+
rpName,
|
|
84
|
+
origin: baseURL,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which auth mail is being sent. Switch on this to pick a template — never on
|
|
89
|
+
* `subject`, which is English default copy a site is expected to replace.
|
|
90
|
+
*/
|
|
91
|
+
export type AuthEmailKind = "verify-email" | "reset-password" | "change-email";
|
|
92
|
+
|
|
93
|
+
/** The subset of the Better Auth user these callbacks pass through. */
|
|
94
|
+
export interface AuthEmailUser {
|
|
95
|
+
id: string;
|
|
96
|
+
email: string;
|
|
97
|
+
name?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One outgoing auth mail, handed to the site's sender. */
|
|
101
|
+
export interface AuthEmailMessage {
|
|
102
|
+
/** Discriminator — pick the template off this. */
|
|
103
|
+
kind: AuthEmailKind;
|
|
104
|
+
/**
|
|
105
|
+
* Recipient. For `change-email` this is deliberately the user's CURRENT
|
|
106
|
+
* address: confirming the move from the address that already owns the
|
|
107
|
+
* account is what stops a hijacked session from walking off with it.
|
|
108
|
+
*/
|
|
109
|
+
to: string;
|
|
110
|
+
/**
|
|
111
|
+
* Default English subject, for sites that don't localize. Prefer your own
|
|
112
|
+
* translated copy — see the README.
|
|
113
|
+
*/
|
|
114
|
+
subject: string;
|
|
115
|
+
/** The one-time action link. */
|
|
116
|
+
url: string;
|
|
117
|
+
/** The raw token behind `url`, if you need to build your own link. */
|
|
118
|
+
token: string;
|
|
119
|
+
/** `id` lets you look up a locale/preferences; `name` personalizes copy. */
|
|
120
|
+
user: AuthEmailUser;
|
|
121
|
+
/** `change-email` only: the address the user is moving to. */
|
|
122
|
+
newEmail?: string;
|
|
123
|
+
/** The originating request — `Accept-Language` is a locale source. */
|
|
124
|
+
request?: Request;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Send one transactional email (wire to `@ingram-tech/nk-email`'s `sendEmail`). */
|
|
128
|
+
export type SendEmail = (message: AuthEmailMessage) => Promise<unknown>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Email callbacks for `emailAndPassword.sendResetPassword`,
|
|
132
|
+
* `emailVerification.sendVerificationEmail` and
|
|
133
|
+
* `user.changeEmail.sendChangeEmailConfirmation`, routed through your sender.
|
|
134
|
+
*
|
|
135
|
+
* Spread all three in. Hand-writing them is a trap: `betterAuth()` infers its
|
|
136
|
+
* options generically, so TypeScript does NOT excess-property-check that object
|
|
137
|
+
* literal — a callback under a misremembered name (`sendChangeEmailVerification`
|
|
138
|
+
* is the one people reach for) compiles clean and simply never fires. The
|
|
139
|
+
* `PinnedEmailSenders` assertion below ties these three names to the real Better
|
|
140
|
+
* Auth options, so a rename upstream breaks this build instead of your site.
|
|
141
|
+
*/
|
|
142
|
+
export const makeEmailSenders = (send: SendEmail) => ({
|
|
143
|
+
sendResetPassword: async (
|
|
144
|
+
{ user, url, token }: { user: AuthEmailUser; url: string; token: string },
|
|
145
|
+
request?: Request,
|
|
146
|
+
): Promise<void> => {
|
|
147
|
+
await send({
|
|
148
|
+
kind: "reset-password",
|
|
149
|
+
to: user.email,
|
|
150
|
+
subject: "Reset your password",
|
|
151
|
+
url,
|
|
152
|
+
token,
|
|
153
|
+
user,
|
|
154
|
+
request,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
sendVerificationEmail: async (
|
|
158
|
+
{ user, url, token }: { user: AuthEmailUser; url: string; token: string },
|
|
159
|
+
request?: Request,
|
|
160
|
+
): Promise<void> => {
|
|
161
|
+
await send({
|
|
162
|
+
kind: "verify-email",
|
|
163
|
+
to: user.email,
|
|
164
|
+
subject: "Verify your email",
|
|
165
|
+
url,
|
|
166
|
+
token,
|
|
167
|
+
user,
|
|
168
|
+
request,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
sendChangeEmailConfirmation: async (
|
|
172
|
+
{
|
|
173
|
+
user,
|
|
174
|
+
newEmail,
|
|
175
|
+
url,
|
|
176
|
+
token,
|
|
177
|
+
}: {
|
|
178
|
+
user: AuthEmailUser;
|
|
179
|
+
newEmail: string;
|
|
180
|
+
url: string;
|
|
181
|
+
token: string;
|
|
182
|
+
},
|
|
183
|
+
request?: Request,
|
|
184
|
+
): Promise<void> => {
|
|
185
|
+
await send({
|
|
186
|
+
kind: "change-email",
|
|
187
|
+
to: user.email, // the CURRENT address — see AuthEmailMessage.to
|
|
188
|
+
subject: "Confirm your email change",
|
|
189
|
+
url,
|
|
190
|
+
token,
|
|
191
|
+
user,
|
|
192
|
+
newEmail,
|
|
193
|
+
request,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compile-time pin: each sender above must stay assignable to the Better Auth
|
|
200
|
+
* option slot it is meant to fill, *addressed by that option's real name*.
|
|
201
|
+
*
|
|
202
|
+
* This exists because `betterAuth()` receives its options through a generic, so
|
|
203
|
+
* TypeScript does NOT excess-property-check the object literal a site passes it.
|
|
204
|
+
* A callback under a wrong-but-plausible name — `sendChangeEmailVerification`
|
|
205
|
+
* for `sendChangeEmailConfirmation`, say — therefore compiles cleanly at every
|
|
206
|
+
* call site and simply never fires, which is a silent no-op in production and
|
|
207
|
+
* exactly the bug this pin is here to prevent recurring.
|
|
208
|
+
*
|
|
209
|
+
* Indexing by key is what makes it bite: rename any of the three upstream and
|
|
210
|
+
* `NonNullable<…["theName"]>` stops resolving, breaking this build. It lives in
|
|
211
|
+
* source rather than in a `.test.ts` on purpose — `tsconfig.json` excludes test
|
|
212
|
+
* files, so a type-level assertion in one is never checked by anything.
|
|
213
|
+
*/
|
|
214
|
+
type PinnedEmailSenders = {
|
|
215
|
+
reset: NonNullable<
|
|
216
|
+
NonNullable<BetterAuthOptions["emailAndPassword"]>["sendResetPassword"]
|
|
217
|
+
>;
|
|
218
|
+
verify: NonNullable<
|
|
219
|
+
NonNullable<BetterAuthOptions["emailVerification"]>["sendVerificationEmail"]
|
|
220
|
+
>;
|
|
221
|
+
change: NonNullable<
|
|
222
|
+
NonNullable<
|
|
223
|
+
NonNullable<BetterAuthOptions["user"]>["changeEmail"]
|
|
224
|
+
>["sendChangeEmailConfirmation"]
|
|
225
|
+
>;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const _pinEmailSenders = (send: SendEmail): PinnedEmailSenders => {
|
|
229
|
+
const senders = makeEmailSenders(send);
|
|
230
|
+
return {
|
|
231
|
+
reset: senders.sendResetPassword,
|
|
232
|
+
verify: senders.sendVerificationEmail,
|
|
233
|
+
change: senders.sendChangeEmailConfirmation,
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
void _pinEmailSenders;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Organization-plugin building blocks shared across Ingram sites. The site
|
|
5
|
+
* still calls `organization({ ...nkOrganizationDefaults, ac, roles, ... })`
|
|
6
|
+
* itself — these only supply the generic defaults + the active-org plumbing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Sensible Ingram defaults to spread into the `organization()` plugin. */
|
|
10
|
+
export const nkOrganizationDefaults = {
|
|
11
|
+
/** Invitations valid for one week. */
|
|
12
|
+
invitationExpiresIn: 60 * 60 * 24 * 7,
|
|
13
|
+
/** The user who creates an org is its owner. */
|
|
14
|
+
creatorRole: "owner",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `user.additionalFields` entry that records the user's last active org so it
|
|
19
|
+
* can be restored on the next sign-in. Spread into `user.additionalFields`.
|
|
20
|
+
*/
|
|
21
|
+
export const lastActiveOrganizationUserField = {
|
|
22
|
+
lastActiveOrganizationId: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: false,
|
|
25
|
+
input: false,
|
|
26
|
+
returned: true,
|
|
27
|
+
},
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
interface SessionLike {
|
|
31
|
+
userId: string;
|
|
32
|
+
activeOrganizationId?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `databaseHooks` that restore the user's last active organization on session
|
|
37
|
+
* create (only if they're still a member) and persist it on session update.
|
|
38
|
+
* Requires `lastActiveOrganizationUserField` in `user.additionalFields` and the
|
|
39
|
+
* Better Auth organization tables. Spread as `databaseHooks: lastActiveOrganizationHooks(pool)`.
|
|
40
|
+
*/
|
|
41
|
+
export const lastActiveOrganizationHooks = (pool: Pool) => ({
|
|
42
|
+
session: {
|
|
43
|
+
create: {
|
|
44
|
+
before: async (session: SessionLike) => {
|
|
45
|
+
if (session.activeOrganizationId) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const userResult = await pool.query<{
|
|
49
|
+
lastActiveOrganizationId: string | null;
|
|
50
|
+
}>('SELECT "lastActiveOrganizationId" FROM "user" WHERE id = $1', [
|
|
51
|
+
session.userId,
|
|
52
|
+
]);
|
|
53
|
+
const lastOrgId = userResult.rows[0]?.lastActiveOrganizationId;
|
|
54
|
+
if (!lastOrgId) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const memberResult = await pool.query(
|
|
58
|
+
'SELECT 1 FROM member WHERE "userId" = $1 AND "organizationId" = $2 LIMIT 1',
|
|
59
|
+
[session.userId, lastOrgId],
|
|
60
|
+
);
|
|
61
|
+
if (memberResult.rows.length === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
return { data: { ...session, activeOrganizationId: lastOrgId } };
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
update: {
|
|
68
|
+
after: async (session: SessionLike) => {
|
|
69
|
+
const orgId = session.activeOrganizationId;
|
|
70
|
+
if (typeof orgId !== "string" || !orgId) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await pool.query(
|
|
74
|
+
'UPDATE "user" SET "lastActiveOrganizationId" = $1 WHERE id = $2',
|
|
75
|
+
[orgId, session.userId],
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
package/src/password.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password-policy primitives, shared by both ends of a site so the client form
|
|
3
|
+
* and the server validate against the *same* numbers. Pure (no `next`, `react`,
|
|
4
|
+
* or server imports) so it resolves identically from "@ingram-tech/nk-auth" and
|
|
5
|
+
* "@ingram-tech/nk-auth/client". Import from the "./password" subpath.
|
|
6
|
+
*
|
|
7
|
+
* The values mirror Better Auth's `emailAndPassword` defaults; if a site
|
|
8
|
+
* overrides `minPasswordLength` / `maxPasswordLength` in its `betterAuth()`
|
|
9
|
+
* config, pass the same numbers to `passwordSchema()` so the two stay in step.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The Better Auth `account.providerId` value for an email/password credential
|
|
15
|
+
* (as opposed to a social provider like "google"). This is Better Auth's own
|
|
16
|
+
* table shape, not ours — centralised here so no consuming site hard-codes the
|
|
17
|
+
* string. See {@link import("./server.js").createAuthHelpers}'s
|
|
18
|
+
* `hasCredentialAccount`.
|
|
19
|
+
*/
|
|
20
|
+
export const CREDENTIAL_PROVIDER_ID = "credential";
|
|
21
|
+
|
|
22
|
+
/** Better Auth's default `emailAndPassword.minPasswordLength`. */
|
|
23
|
+
export const DEFAULT_MIN_PASSWORD_LENGTH = 8;
|
|
24
|
+
|
|
25
|
+
/** Better Auth's default `emailAndPassword.maxPasswordLength`. */
|
|
26
|
+
export const DEFAULT_MAX_PASSWORD_LENGTH = 128;
|
|
27
|
+
|
|
28
|
+
export interface PasswordPolicy {
|
|
29
|
+
/** Minimum length. Default {@link DEFAULT_MIN_PASSWORD_LENGTH}. */
|
|
30
|
+
minLength?: number;
|
|
31
|
+
/** Maximum length. Default {@link DEFAULT_MAX_PASSWORD_LENGTH}. */
|
|
32
|
+
maxLength?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Zod schema for a new password, honouring the resolved policy. Use it on both
|
|
37
|
+
* the client (form validation) and the server (before calling Better Auth) so
|
|
38
|
+
* neither side drifts from the other or from the instance's configured bounds.
|
|
39
|
+
*/
|
|
40
|
+
export function passwordSchema(policy: PasswordPolicy = {}): z.ZodString {
|
|
41
|
+
const min = policy.minLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
|
|
42
|
+
const max = policy.maxLength ?? DEFAULT_MAX_PASSWORD_LENGTH;
|
|
43
|
+
return z
|
|
44
|
+
.string()
|
|
45
|
+
.min(min, `Password must be at least ${min} characters.`)
|
|
46
|
+
.max(max, `Password must be at most ${max} characters.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The failure reasons of a "choose a new password + confirm it" form, as a
|
|
51
|
+
* stable `code` (switch on it for i18n) plus an English `message` fallback.
|
|
52
|
+
* `reset_failed` is the server rejecting the reset token (invalid / expired).
|
|
53
|
+
*/
|
|
54
|
+
export type ResetPasswordErrorCode =
|
|
55
|
+
| "password_too_short"
|
|
56
|
+
| "password_too_long"
|
|
57
|
+
| "passwords_do_not_match"
|
|
58
|
+
| "reset_failed";
|
|
59
|
+
|
|
60
|
+
export interface ResetPasswordError {
|
|
61
|
+
code: ResetPasswordErrorCode;
|
|
62
|
+
message: string;
|
|
63
|
+
/** Present on `password_too_short`. */
|
|
64
|
+
minLength?: number;
|
|
65
|
+
/** Present on `password_too_long`. */
|
|
66
|
+
maxLength?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate a new-password pair (the field and its confirmation) against the
|
|
71
|
+
* policy. Returns the first {@link ResetPasswordError}, or null when valid. Pure
|
|
72
|
+
* so it is unit-testable and reusable outside React; {@link
|
|
73
|
+
* import("./client.js").useResetPassword} calls it before hitting the server.
|
|
74
|
+
*/
|
|
75
|
+
export function validateNewPassword(
|
|
76
|
+
newPassword: string,
|
|
77
|
+
confirmPassword: string,
|
|
78
|
+
policy: PasswordPolicy = {},
|
|
79
|
+
): ResetPasswordError | null {
|
|
80
|
+
const min = policy.minLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
|
|
81
|
+
const max = policy.maxLength ?? DEFAULT_MAX_PASSWORD_LENGTH;
|
|
82
|
+
if (newPassword.length < min) {
|
|
83
|
+
return {
|
|
84
|
+
code: "password_too_short",
|
|
85
|
+
message: `Password must be at least ${min} characters.`,
|
|
86
|
+
minLength: min,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (newPassword.length > max) {
|
|
90
|
+
return {
|
|
91
|
+
code: "password_too_long",
|
|
92
|
+
message: `Password must be at most ${max} characters.`,
|
|
93
|
+
maxLength: max,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (newPassword !== confirmPassword) {
|
|
97
|
+
return {
|
|
98
|
+
code: "passwords_do_not_match",
|
|
99
|
+
message: "Passwords do not match.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Better Auth mounts in an Ingram site: **`/auth`**, not the framework
|
|
3
|
+
* default `/api/auth`. Auth is a first-class user-facing surface — sign-in,
|
|
4
|
+
* OAuth callbacks, the JWKS — not an internal machine API, so it lives at the
|
|
5
|
+
* site root alongside the other pages.
|
|
6
|
+
*
|
|
7
|
+
* Use it on both ends so server and client agree:
|
|
8
|
+
*
|
|
9
|
+
* betterAuth({ basePath: authBasePath, ... })
|
|
10
|
+
* createAuthClient({ baseURL, basePath: authBasePath, ... })
|
|
11
|
+
*
|
|
12
|
+
* and mount the catch-all at `app/auth/[...all]/route.ts`. OAuth redirect URIs
|
|
13
|
+
* then become `<site>/auth/callback/<provider>` and the JWKS `<site>/auth/jwks`.
|
|
14
|
+
*
|
|
15
|
+
* Sign-in pages share this namespace, and a page whose path matches a Better
|
|
16
|
+
* Auth endpoint shadows it (static segments beat the catch-all) — `nk doctor`
|
|
17
|
+
* flags such collisions.
|
|
18
|
+
*/
|
|
19
|
+
export const authBasePath = "/auth";
|
package/src/pool.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createPool } from "@ingram-tech/nk-db";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A `pg` Pool for Better Auth's direct database connection.
|
|
6
|
+
*
|
|
7
|
+
* @deprecated Thin alias for `createPool` from `@ingram-tech/nk-db` — the one
|
|
8
|
+
* place pool construction and TLS handling live. Kept so existing call sites
|
|
9
|
+
* (`lib/db.ts` calling `createAuthPool({ connectionString, caCert })`) keep
|
|
10
|
+
* working, and so Better Auth shares the **exact same pool implementation** as
|
|
11
|
+
* app queries — the "one pool per process" rule. Prefer importing `createPool`
|
|
12
|
+
* directly in new code.
|
|
13
|
+
*
|
|
14
|
+
* TLS handling (CA-verified when `caCert` is set; plain-TLS for other managed
|
|
15
|
+
* hosts; no TLS for localhost), and the local `max: 1` cap (for the PGlite
|
|
16
|
+
* socket), all live in `createPool`.
|
|
17
|
+
*/
|
|
18
|
+
export const createAuthPool = (config: {
|
|
19
|
+
connectionString: string;
|
|
20
|
+
/** PEM CA cert; when set, the server cert + hostname are verified. */
|
|
21
|
+
caCert?: string;
|
|
22
|
+
}): Pool =>
|
|
23
|
+
createPool({
|
|
24
|
+
connectionString: config.connectionString,
|
|
25
|
+
caCert: config.caCert,
|
|
26
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless controller for a "set / reset your password" page — the token
|
|
3
|
+
* consumer at the end of Better Auth's forget-password round trip. nk-auth owns
|
|
4
|
+
* the *sender* (`makeEmailSenders.sendResetPassword`) and the endpoint contract;
|
|
5
|
+
* this closes the loop so a site never touches the `resetPassword` endpoint name
|
|
6
|
+
* or hand-rolls the invalid-token / success / validation state machine. The site
|
|
7
|
+
* brings its own shell (inputs, buttons, i18n) and wires it to what this returns.
|
|
8
|
+
*
|
|
9
|
+
* The same page serves both entry points, because both land on the identical URL
|
|
10
|
+
* with a `?token=`: the classic "I forgot my password" flow, and "set a password
|
|
11
|
+
* for my social-only account" (Better Auth's reset endpoint creates the
|
|
12
|
+
* credential when the user has none — see the package README).
|
|
13
|
+
*
|
|
14
|
+
* "use client";
|
|
15
|
+
* const { status, error, submit } = useResetPassword(authClient, { token });
|
|
16
|
+
* if (status === "invalid") return <InvalidLink />;
|
|
17
|
+
* if (status === "success") return <Done />;
|
|
18
|
+
* // <form onSubmit={() => submit(pw, confirm)}> … {error && t(error.code)} …
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useState } from "react";
|
|
21
|
+
import {
|
|
22
|
+
type PasswordPolicy,
|
|
23
|
+
type ResetPasswordError,
|
|
24
|
+
validateNewPassword,
|
|
25
|
+
} from "./password.js";
|
|
26
|
+
|
|
27
|
+
export type ResetPasswordStatus =
|
|
28
|
+
/** No usable token in the URL — show an "invalid or expired link" message. */
|
|
29
|
+
| "invalid"
|
|
30
|
+
/** Token present; awaiting the user's new password. */
|
|
31
|
+
| "idle"
|
|
32
|
+
/** The reset request is in flight. */
|
|
33
|
+
| "submitting"
|
|
34
|
+
/** The password was set; the user can now sign in with it. */
|
|
35
|
+
| "success"
|
|
36
|
+
/** Validation failed, or the server rejected the reset — see `error`. */
|
|
37
|
+
| "error";
|
|
38
|
+
|
|
39
|
+
/** The slice of a Better Auth client this hook calls. */
|
|
40
|
+
export interface ResetPasswordClient {
|
|
41
|
+
resetPassword: (input: {
|
|
42
|
+
newPassword: string;
|
|
43
|
+
token: string;
|
|
44
|
+
}) => Promise<{ error?: { message?: string } | null }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseResetPasswordOptions extends PasswordPolicy {
|
|
48
|
+
/**
|
|
49
|
+
* The reset token from the URL (`?token=`), or null when it is missing — e.g.
|
|
50
|
+
* the link carried `?error=INVALID_TOKEN` instead. Null puts the controller in
|
|
51
|
+
* the `invalid` state before any submit.
|
|
52
|
+
*/
|
|
53
|
+
token: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseResetPassword {
|
|
57
|
+
status: ResetPasswordStatus;
|
|
58
|
+
/** The current failure, or null. `code` is stable for i18n; `message` is an English fallback. */
|
|
59
|
+
error: ResetPasswordError | null;
|
|
60
|
+
/** Validate the pair against the policy, then call the reset endpoint. */
|
|
61
|
+
submit: (newPassword: string, confirmPassword: string) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const RESET_FAILED_FALLBACK = "Could not reset your password.";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drive a reset-password form. Returns the current `status`, the last `error`
|
|
68
|
+
* (with an i18n-friendly `code`), and a `submit(newPassword, confirmPassword)`
|
|
69
|
+
* that runs {@link validateNewPassword} against the policy before hitting the
|
|
70
|
+
* server. Pass the resolved `minLength` / `maxLength` when the site overrides
|
|
71
|
+
* Better Auth's defaults so client and server agree.
|
|
72
|
+
*/
|
|
73
|
+
export function useResetPassword(
|
|
74
|
+
client: ResetPasswordClient,
|
|
75
|
+
options: UseResetPasswordOptions,
|
|
76
|
+
): UseResetPassword {
|
|
77
|
+
const { token, minLength, maxLength } = options;
|
|
78
|
+
const [status, setStatus] = useState<ResetPasswordStatus>(
|
|
79
|
+
token ? "idle" : "invalid",
|
|
80
|
+
);
|
|
81
|
+
const [error, setError] = useState<ResetPasswordError | null>(null);
|
|
82
|
+
|
|
83
|
+
const submit = useCallback(
|
|
84
|
+
async (newPassword: string, confirmPassword: string): Promise<void> => {
|
|
85
|
+
if (!token) {
|
|
86
|
+
setStatus("invalid");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const validation = validateNewPassword(newPassword, confirmPassword, {
|
|
91
|
+
minLength,
|
|
92
|
+
maxLength,
|
|
93
|
+
});
|
|
94
|
+
if (validation) {
|
|
95
|
+
setError(validation);
|
|
96
|
+
setStatus("error");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setError(null);
|
|
101
|
+
setStatus("submitting");
|
|
102
|
+
try {
|
|
103
|
+
const { error: resetError } = await client.resetPassword({
|
|
104
|
+
newPassword,
|
|
105
|
+
token,
|
|
106
|
+
});
|
|
107
|
+
if (resetError) {
|
|
108
|
+
setError({
|
|
109
|
+
code: "reset_failed",
|
|
110
|
+
message: resetError.message ?? RESET_FAILED_FALLBACK,
|
|
111
|
+
});
|
|
112
|
+
setStatus("error");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setStatus("success");
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError({
|
|
118
|
+
code: "reset_failed",
|
|
119
|
+
message: err instanceof Error ? err.message : RESET_FAILED_FALLBACK,
|
|
120
|
+
});
|
|
121
|
+
setStatus("error");
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[token, minLength, maxLength, client],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return { status, error, submit };
|
|
128
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side session helpers for the App Router, at
|
|
3
|
+
* "@ingram-tech/nk-auth/server" so the framework-agnostic core entry never
|
|
4
|
+
* pulls in `next`. A site binds these once to its own Better Auth instance:
|
|
5
|
+
*
|
|
6
|
+
* // lib/auth/session.ts
|
|
7
|
+
* import { createAuthHelpers } from "@ingram-tech/nk-auth/server";
|
|
8
|
+
* import { auth } from "@/lib/auth";
|
|
9
|
+
*
|
|
10
|
+
* export const { getSession, getUser, requireSession, requireUser,
|
|
11
|
+
* redirectIfAuthenticated } = createAuthHelpers(auth);
|
|
12
|
+
*
|
|
13
|
+
* These are the **validated** authority: they resolve through
|
|
14
|
+
* `auth.api.getSession`, which checks the session against the database — not
|
|
15
|
+
* merely the presence of a cookie. The read is memoized per request with React
|
|
16
|
+
* `cache()`, so a render fanning out many `getUser()` calls validates once,
|
|
17
|
+
* not once per call. When a guard finds no valid session it redirects to the sign-in page,
|
|
18
|
+
* preserving the requested path as `next` (read from the header the middleware
|
|
19
|
+
* injects) and, when a session cookie is present-but-invalid, adding `stale=1`
|
|
20
|
+
* so the middleware clears the dead cookie. The optimistic, cookie-presence
|
|
21
|
+
* check belongs in middleware (see "@ingram-tech/nk-auth/middleware"); it may
|
|
22
|
+
* only ever push *unauthenticated* users off protected routes. The decision to
|
|
23
|
+
* send a signed-in user *away* from the sign-in page must run through
|
|
24
|
+
* `redirectIfAuthenticated` here, so a stale cookie resolves to "no session"
|
|
25
|
+
* and falls through to the form instead of ping-ponging forever.
|
|
26
|
+
*/
|
|
27
|
+
import { cookies, headers } from "next/headers";
|
|
28
|
+
import { redirect } from "next/navigation";
|
|
29
|
+
import { cache } from "react";
|
|
30
|
+
import { NK_AUTH_PATH_HEADER, signInUrl } from "./gating-internals.js";
|
|
31
|
+
import { CREDENTIAL_PROVIDER_ID } from "./password.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a `next` redirect param: returns it only if it's an internal,
|
|
35
|
+
* non-protocol-relative path, else null. Use on the login page before honoring
|
|
36
|
+
* `?next=` so it can't become an open redirect.
|
|
37
|
+
*/
|
|
38
|
+
export { safeNextParam as safeNext } from "./gating-internals.js";
|
|
39
|
+
|
|
40
|
+
/** The shape we need from a session: anything carrying a `user`. */
|
|
41
|
+
interface SessionLike {
|
|
42
|
+
user: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The slice of a Better Auth instance these helpers touch. Generic over the
|
|
47
|
+
* site's session type `S`, so `getSession`/`getUser` return the site's fully
|
|
48
|
+
* inferred user shape (additional fields, org id, …) — no `any`, no casts.
|
|
49
|
+
*/
|
|
50
|
+
interface AuthLike<S extends SessionLike> {
|
|
51
|
+
api: {
|
|
52
|
+
getSession: (input: { headers: Headers }) => Promise<S | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Better Auth's `/list-accounts`: the auth methods linked to the current
|
|
55
|
+
* session's user, each `{ providerId }` ("credential" for email/password,
|
|
56
|
+
* or a social provider like "google"). Present on every real `betterAuth()`
|
|
57
|
+
* instance; powers `getLinkedProviders` / `hasCredentialAccount` below.
|
|
58
|
+
*/
|
|
59
|
+
listUserAccounts: (input: {
|
|
60
|
+
headers: Headers;
|
|
61
|
+
}) => Promise<Array<{ providerId: string }>>;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AuthHelpersOptions {
|
|
66
|
+
/** Where guards send unauthenticated users. Default `/login`. */
|
|
67
|
+
signInPath?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Cookie-name fragment that identifies the Better Auth session cookie, used
|
|
70
|
+
* to detect a present-but-invalid (stale) cookie. Default `better-auth`,
|
|
71
|
+
* which matches `better-auth.session_token` and `__Secure-…` in production.
|
|
72
|
+
*/
|
|
73
|
+
sessionCookiePrefix?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createAuthHelpers<S extends SessionLike>(
|
|
77
|
+
auth: AuthLike<S>,
|
|
78
|
+
options: AuthHelpersOptions = {},
|
|
79
|
+
) {
|
|
80
|
+
const signInPath = options.signInPath ?? "/login";
|
|
81
|
+
const cookiePrefix = options.sessionCookiePrefix ?? "better-auth";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The validated session ({ user, session, … }) or null. Memoized per request
|
|
85
|
+
* via React `cache()`: the session cookie is constant within a request, so
|
|
86
|
+
* every helper (and every component calling one) shares a single database
|
|
87
|
+
* validation. Outside a request scope (scripts, tests) `cache()` degrades to
|
|
88
|
+
* an uncached call.
|
|
89
|
+
*
|
|
90
|
+
* Caveat: code that mutates the session mid-request (e.g. a server action
|
|
91
|
+
* updating the user) and then re-reads it through these helpers sees the
|
|
92
|
+
* pre-mutation snapshot; re-read through `auth.api.getSession` directly if
|
|
93
|
+
* that ever matters.
|
|
94
|
+
*/
|
|
95
|
+
const getSession = cache(async (): Promise<S | null> =>
|
|
96
|
+
auth.api.getSession({ headers: await headers() }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** The authenticated user, or null. */
|
|
100
|
+
async function getUser(): Promise<S["user"] | null> {
|
|
101
|
+
const session = await getSession();
|
|
102
|
+
return session?.user ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The sign-in redirect for a request with no valid session: keep the
|
|
107
|
+
* requested path as `next` (from the middleware-injected header), and flag
|
|
108
|
+
* `stale=1` when a session cookie is present-but-invalid so the middleware
|
|
109
|
+
* clears it. The two reads are request-scoped and cached by Next.
|
|
110
|
+
*/
|
|
111
|
+
async function signInTarget(): Promise<string> {
|
|
112
|
+
const [h, c] = await Promise.all([headers(), cookies()]);
|
|
113
|
+
const next = h.get(NK_AUTH_PATH_HEADER);
|
|
114
|
+
const stale = c.getAll().some((ck) => ck.name.includes(cookiePrefix));
|
|
115
|
+
return signInUrl(signInPath, { next, stale });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Require a signed-in user or redirect to sign-in. Returns the user (non-null)
|
|
120
|
+
* so callers keep their `const user = await requireUser()` shape. `redirect()`
|
|
121
|
+
* throws, so control never returns when signed out.
|
|
122
|
+
*/
|
|
123
|
+
async function requireUser(): Promise<S["user"]> {
|
|
124
|
+
const user = await getUser();
|
|
125
|
+
// redirect() is typed `never`, so `user` narrows to non-null below.
|
|
126
|
+
if (!user) redirect(await signInTarget());
|
|
127
|
+
return user;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Require a session or redirect, returning the full validated session (use
|
|
132
|
+
* when the caller needs more than the user — session id, active org, …).
|
|
133
|
+
*/
|
|
134
|
+
async function requireSession(): Promise<S> {
|
|
135
|
+
const session = await getSession();
|
|
136
|
+
if (!session) redirect(await signInTarget());
|
|
137
|
+
return session;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Redirect an already-signed-in user away (e.g. /login -> /dashboard). This
|
|
142
|
+
* is the *correct* place to gate the sign-in page: because it validates the
|
|
143
|
+
* session, a present-but-invalid cookie is treated as signed-out and the
|
|
144
|
+
* page renders instead of looping. Never do this in middleware.
|
|
145
|
+
*/
|
|
146
|
+
async function redirectIfAuthenticated(to: string): Promise<void> {
|
|
147
|
+
if (await getSession()) redirect(to);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `providerId`s linked to the current session's user — "credential" for
|
|
152
|
+
* email/password, plus any social providers ("google", …). Empty when signed
|
|
153
|
+
* out. Reads Better Auth's own `/list-accounts`, so a site never queries the
|
|
154
|
+
* `account` table or hard-codes provider strings itself.
|
|
155
|
+
*/
|
|
156
|
+
async function getLinkedProviders(): Promise<string[]> {
|
|
157
|
+
const accounts = await auth.api.listUserAccounts({
|
|
158
|
+
headers: await headers(),
|
|
159
|
+
});
|
|
160
|
+
return accounts.map((account) => account.providerId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether the current user has an email/password credential. Drives the
|
|
165
|
+
* "Change password" vs "Set password" decision on a security page: a user who
|
|
166
|
+
* signed up purely through a social provider has none until they set one
|
|
167
|
+
* (typically via the reset-password / forgot-password flow — see the
|
|
168
|
+
* `useResetPassword` client hook). Returns false when signed out.
|
|
169
|
+
*/
|
|
170
|
+
async function hasCredentialAccount(): Promise<boolean> {
|
|
171
|
+
const providers = await getLinkedProviders();
|
|
172
|
+
return providers.includes(CREDENTIAL_PROVIDER_ID);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
getSession,
|
|
177
|
+
getUser,
|
|
178
|
+
requireSession,
|
|
179
|
+
requireUser,
|
|
180
|
+
redirectIfAuthenticated,
|
|
181
|
+
getLinkedProviders,
|
|
182
|
+
hasCredentialAccount,
|
|
183
|
+
};
|
|
184
|
+
}
|