@ingram-tech/nk-auth 0.13.5 → 0.14.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 +19 -3
- package/dist/jwt.d.ts +9 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +9 -0
- package/dist/jwt.js.map +1 -1
- package/dist/server.d.ts +51 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +36 -27
- package/dist/server.js.map +1 -1
- 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 +117 -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 +245 -0
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
|
+
}
|