@dbx-tools/auth 0.6.161 → 0.6.167

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/src/auth.ts DELETED
@@ -1,265 +0,0 @@
1
- /**
2
- * Better Auth passwordless runtime with email OTP and passkeys.
3
- *
4
- * Callers provide authorization, delivery, storage, origin, and secret. Better
5
- * Auth owns users, OTP verification records, sessions, rate limits, passkey
6
- * credentials, and their native HTTP routes.
7
- *
8
- * @module
9
- */
10
-
11
- import { passkey } from "@better-auth/passkey";
12
- import { type AuthStatus, SESSION_COOKIE_NAME } from "@dbx-tools/shared-auth";
13
- import { log } from "@dbx-tools/shared-core";
14
- import { APIError, betterAuth, type BetterAuthOptions } from "better-auth";
15
- import { emailOTP } from "better-auth/plugins";
16
-
17
- import type { AuthStorage } from "./storage.ts";
18
- import { migrateAuth } from "./storage.ts";
19
-
20
- const logger = log.logger("auth");
21
-
22
- export type AuthorizeIdentity = (email: string) => boolean | Promise<boolean>;
23
-
24
- export interface AuthEmailOptions {
25
- subject: string;
26
- brandName: string;
27
- message: string;
28
- codeTtlSeconds: number;
29
- }
30
-
31
- export interface PasswordlessAuthOptions {
32
- storage: AuthStorage;
33
- baseURL: string;
34
- basePath?: string;
35
- appName: string;
36
- secret: string;
37
- sessionCookieName?: string;
38
- /** Same-origin path returned after logout. Defaults to `/`. */
39
- logoutRedirectPath?: string;
40
- sessionTtlSeconds: number;
41
- sessionCutoffMs?: number;
42
- codeTtlSeconds: number;
43
- maxAttempts: number;
44
- authorizeIdentity: AuthorizeIdentity;
45
- sendCode(email: string, code: string, options: AuthEmailOptions): Promise<void>;
46
- subject?: string;
47
- message?: string;
48
- }
49
-
50
- export interface PasswordlessAuthRuntime {
51
- readonly basePath: string;
52
- readonly passkeysEnabled: boolean;
53
- handler(request: Request): Promise<Response>;
54
- session(headers: Headers): Promise<string | undefined>;
55
- status(headers: Headers): Promise<AuthStatus>;
56
- close(): Promise<void>;
57
- }
58
-
59
- export async function createPasswordlessAuth(
60
- config: PasswordlessAuthOptions,
61
- ): Promise<PasswordlessAuthRuntime> {
62
- const origin = new URL(config.baseURL).origin;
63
- const rpID = new URL(origin).hostname;
64
- const basePath = config.basePath ?? "/api/auth";
65
- const logoutRedirectPath = normalizeLogoutRedirectPath(config.logoutRedirectPath);
66
- const emailOptions: AuthEmailOptions = {
67
- subject: config.subject ?? "Your verification code",
68
- brandName: config.appName,
69
- message: config.message ?? "Your verification code is:",
70
- codeTtlSeconds: config.codeTtlSeconds,
71
- };
72
-
73
- const options = {
74
- appName: config.appName,
75
- baseURL: origin,
76
- basePath,
77
- database: config.storage.database,
78
- secret: config.secret,
79
- // The gate fronts the app on whatever interface address the tunnel binds
80
- // (an overlay/LAN IP, localhost, or a public domain) — not just `baseURL`.
81
- // Better Auth's origin check would reject every other host with
82
- // INVALID_ORIGIN and block sign-in. Trust the request's own origin here:
83
- // this endpoint sits behind the OTP gate and the tunnel preserves the
84
- // browser's Host, so the fixed-origin CSRF check adds nothing while breaking
85
- // legitimate access. Same-origin requests (no Origin header) are allowed too.
86
- trustedOrigins: (request?: Request) => {
87
- const requestOrigin = request?.headers.get("origin");
88
- return requestOrigin ? [requestOrigin, origin] : [origin];
89
- },
90
- session: {
91
- expiresIn: config.sessionTtlSeconds,
92
- updateAge: Math.min(24 * 60 * 60, config.sessionTtlSeconds),
93
- },
94
- verification: {
95
- storeIdentifier: "hashed",
96
- storeInDatabase: true,
97
- },
98
- rateLimit: {
99
- enabled: true,
100
- window: 15 * 60,
101
- max: 10,
102
- customRules: {
103
- "/email-otp/send-verification-otp": { window: 15 * 60, max: 5 },
104
- "/sign-in/email-otp": { window: 15 * 60, max: 10 },
105
- },
106
- },
107
- advanced: {
108
- useSecureCookies: origin.startsWith("https://"),
109
- ipAddress: {
110
- ipAddressHeaders: ["x-real-ip"],
111
- disableIpTracking: false,
112
- },
113
- cookies: {
114
- session_token: {
115
- name: config.sessionCookieName ?? SESSION_COOKIE_NAME,
116
- attributes: {
117
- httpOnly: true,
118
- sameSite: "lax",
119
- secure: origin.startsWith("https://"),
120
- path: "/",
121
- },
122
- },
123
- },
124
- },
125
- databaseHooks: {
126
- user: {
127
- create: {
128
- before: async (user: { email: string }) => {
129
- if (!(await config.authorizeIdentity(normalizeEmail(user.email)))) {
130
- throw new APIError("FORBIDDEN", { message: "Identity is not authorized" });
131
- }
132
- return { data: user };
133
- },
134
- },
135
- },
136
- },
137
- plugins: [
138
- emailOTP({
139
- otpLength: 6,
140
- expiresIn: config.codeTtlSeconds,
141
- allowedAttempts: config.maxAttempts,
142
- storeOTP: "hashed",
143
- async sendVerificationOTP({ email, otp, type }) {
144
- if (type !== "sign-in") return;
145
- const address = normalizeEmail(email);
146
- if (!(await config.authorizeIdentity(address))) {
147
- // Accept any address and fail SILENTLY for one not on the allow-list:
148
- // the request still returns 200 (so the gate never reveals who is
149
- // allowed), and no code is sent. Logged at debug for diagnostics, not
150
- // as a warning — an unknown address hitting the login page is normal.
151
- logger.debug("verification code suppressed: identity not authorized", {
152
- email: address,
153
- });
154
- return;
155
- }
156
- logger.info("sending verification code", { email: address });
157
- void config.sendCode(address, otp, emailOptions).catch((error: unknown) => {
158
- logger.error("verification email failed", { email: address, error });
159
- });
160
- },
161
- }),
162
- passkey({
163
- rpID,
164
- rpName: config.appName,
165
- origin,
166
- authenticatorSelection: {
167
- residentKey: "preferred",
168
- userVerification: "preferred",
169
- },
170
- registration: { requireSession: true },
171
- }),
172
- ] as const,
173
- } satisfies BetterAuthOptions;
174
-
175
- await migrateAuth(options, config.storage);
176
- const auth = betterAuth(options);
177
-
178
- const session = async (headers: Headers): Promise<string | undefined> => {
179
- const current = await auth.api.getSession({ headers });
180
- const email = normalizeEmail(current?.user.email);
181
- const createdAt = current?.session.createdAt;
182
- const createdAtMs = createdAt ? new Date(createdAt).getTime() : Number.NaN;
183
- if (config.sessionCutoffMs && !(createdAtMs >= config.sessionCutoffMs)) return undefined;
184
- if (!email || !(await config.authorizeIdentity(email))) return undefined;
185
- return email;
186
- };
187
-
188
- const handleCompatibilityRoute = async (request: Request): Promise<Response | undefined> => {
189
- const path = new URL(request.url).pathname;
190
- if (path === `${basePath}/status`) {
191
- const email = await session(request.headers);
192
- return jsonResponse({
193
- authenticated: Boolean(email),
194
- ...(email ? { email } : {}),
195
- enabled: true,
196
- passkeysEnabled: true,
197
- });
198
- }
199
- // OTP send + verify go straight to better-auth's native emailOTP endpoints
200
- // (`/email-otp/send-verification-otp`, `/sign-in/email-otp`); there is no
201
- // compatibility wrapper for them. Failures there are logged by the plugin's
202
- // sendVerificationOTP hook, not swallowed behind an always-ok response.
203
- if (path === `${basePath}/logout` && request.method === "POST") {
204
- const response = await auth.api.signOut({
205
- headers: request.headers,
206
- asResponse: true,
207
- });
208
- return compatibilityResponse(response, { ok: response.ok, redirectTo: logoutRedirectPath });
209
- }
210
- if (path === `${basePath}/logout` && request.method === "GET") {
211
- const response = await auth.api.signOut({
212
- headers: request.headers,
213
- asResponse: true,
214
- });
215
- return redirectResponse(response, logoutRedirectPath);
216
- }
217
- return undefined;
218
- };
219
-
220
- return {
221
- basePath,
222
- passkeysEnabled: true,
223
- handler: async (request) => (await handleCompatibilityRoute(request)) ?? auth.handler(request),
224
- session,
225
- status: async (headers) => {
226
- const email = await session(headers);
227
- return {
228
- authenticated: Boolean(email),
229
- ...(email ? { email } : {}),
230
- enabled: true,
231
- passkeysEnabled: true,
232
- };
233
- },
234
- close: () => config.storage.close(),
235
- };
236
- }
237
-
238
- /** Restrict logout redirects to one same-origin application path. */
239
- export function normalizeLogoutRedirectPath(value: string | undefined): string {
240
- const path = value?.trim();
241
- return path?.startsWith("/") && !path.startsWith("//") && !path.includes("\\") ? path : "/";
242
- }
243
-
244
- function normalizeEmail(email: string | undefined): string {
245
- return email?.trim().toLowerCase() ?? "";
246
- }
247
-
248
- function jsonResponse(body: unknown, status = 200): Response {
249
- return new Response(JSON.stringify(body), {
250
- status,
251
- headers: { "content-type": "application/json" },
252
- });
253
- }
254
-
255
- function compatibilityResponse(response: Response, body: unknown): Response {
256
- const headers = new Headers({ "content-type": "application/json" });
257
- for (const cookie of response.headers.getSetCookie()) headers.append("set-cookie", cookie);
258
- return new Response(JSON.stringify(body), { status: 200, headers });
259
- }
260
-
261
- function redirectResponse(response: Response, location: string): Response {
262
- const headers = new Headers({ location });
263
- for (const cookie of response.headers.getSetCookie()) headers.append("set-cookie", cookie);
264
- return new Response(null, { status: 303, headers });
265
- }
package/src/storage.ts DELETED
@@ -1,161 +0,0 @@
1
- /**
2
- * Better Auth database selection and migration locking.
3
- *
4
- * Callers pass a native AppKit Lakebase pool when available. Otherwise auth
5
- * uses SQLite in the operating system's application-data directory.
6
- *
7
- * @module
8
- */
9
-
10
- import { mkdirSync } from "node:fs";
11
- import { dirname, resolve } from "node:path";
12
- import { fileLock } from "@dbx-tools/core";
13
- import { advisoryLock, type PgPoolLike } from "@dbx-tools/postgres";
14
- import { log } from "@dbx-tools/shared-core";
15
- import type { BetterAuthOptions } from "better-auth";
16
- import envPaths from "env-paths";
17
-
18
- const logger = log.logger("auth:storage");
19
-
20
- export type AuthStorageMode = "auto" | "lakebase" | "sqlite";
21
-
22
- export interface AuthStorageConfig {
23
- storage?: AuthStorageMode;
24
- sqlitePath?: string;
25
- }
26
-
27
- export interface ResolvedAuthStorageConfig {
28
- mode: AuthStorageMode;
29
- sqlitePath: string;
30
- }
31
-
32
- export type AuthDatabase = NonNullable<BetterAuthOptions["database"]>;
33
-
34
- export interface AuthStorage {
35
- kind: "lakebase" | "sqlite" | "memory";
36
- database: AuthDatabase;
37
- pool?: PgPoolLike;
38
- path?: string;
39
- close(): Promise<void>;
40
- }
41
-
42
- type MigrationModule = {
43
- getMigrations(options: BetterAuthOptions): Promise<{
44
- runMigrations(): Promise<void>;
45
- }>;
46
- };
47
-
48
- interface SqliteDatabase {
49
- exec(sql: string): unknown;
50
- close(): void;
51
- }
52
-
53
- interface BunSqliteModule {
54
- Database: new (path: string, options?: { create?: boolean; strict?: boolean }) => SqliteDatabase;
55
- }
56
-
57
- const MIGRATION_LOCK = ["auth", "better-auth", "migrations"] as const;
58
-
59
- export function resolveAuthStorageConfig(
60
- config: AuthStorageConfig = {},
61
- ): ResolvedAuthStorageConfig {
62
- const mode = config.storage ?? "auto";
63
- if (mode !== "auto" && mode !== "lakebase" && mode !== "sqlite") {
64
- throw new TypeError('auth storage must be "auto", "lakebase", or "sqlite"');
65
- }
66
- const dataDirectory = envPaths("dbx-tools", { suffix: "" }).data;
67
- return {
68
- mode,
69
- sqlitePath: resolve(config.sqlitePath ?? resolve(dataDirectory, "auth", "auth.sqlite")),
70
- };
71
- }
72
-
73
- export function shouldUseLakebase(config: AuthStorageConfig = {}): boolean {
74
- const resolved = resolveAuthStorageConfig(config);
75
- if (resolved.mode === "lakebase") return true;
76
- if (resolved.mode === "sqlite") return false;
77
- return Boolean(process.env.LAKEBASE_ENDPOINT ?? process.env.PGHOST);
78
- }
79
-
80
- export async function createAuthStorage(
81
- config: AuthStorageConfig,
82
- pool?: PgPoolLike,
83
- ): Promise<AuthStorage> {
84
- const resolved = resolveAuthStorageConfig(config);
85
- if (pool && resolved.mode !== "sqlite") {
86
- return {
87
- kind: "lakebase",
88
- database: pool,
89
- pool,
90
- close: async () => undefined,
91
- };
92
- }
93
- if (resolved.mode === "lakebase") {
94
- throw new Error("auth storage is lakebase but no Lakebase pool was supplied");
95
- }
96
-
97
- // Prefer SQLite (durable, survives restarts) when a SQLite binding is present
98
- // — bun:sqlite in Bun, node:sqlite in Node. Both are optional runtime
99
- // features, so fall back to an in-memory adapter when neither can be opened
100
- // rather than failing the whole gate. Memory loses sessions/OTPs on restart
101
- // but keeps sign-in working; an explicit `--auth-storage sqlite` still errors
102
- // if SQLite is genuinely unavailable, so the fallback is auto-mode only.
103
- try {
104
- mkdirSync(dirname(resolved.sqlitePath), { recursive: true });
105
- const database = await openSqlite(resolved.sqlitePath);
106
- return {
107
- kind: "sqlite",
108
- database,
109
- path: resolved.sqlitePath,
110
- close: async () => {
111
- database.close();
112
- },
113
- };
114
- } catch (error) {
115
- if (resolved.mode === "sqlite") throw error;
116
- logger.warn("sqlite unavailable for auth storage; using in-memory adapter", { error });
117
- const { memoryAdapter } = await import("better-auth/adapters/memory");
118
- return {
119
- kind: "memory",
120
- database: memoryAdapter({}) as unknown as AuthDatabase,
121
- close: async () => undefined,
122
- };
123
- }
124
- }
125
-
126
- export async function migrateAuth(options: BetterAuthOptions, storage: AuthStorage): Promise<void> {
127
- // The in-memory adapter builds its schema in memory on init — there is no
128
- // database to migrate.
129
- if (storage.kind === "memory") return;
130
-
131
- const run = async (): Promise<void> => {
132
- const module = (await import(migrationModuleUrl())) as MigrationModule;
133
- const migrations = await module.getMigrations(options);
134
- await migrations.runMigrations();
135
- };
136
-
137
- if (storage.kind === "lakebase" && storage.pool) {
138
- await advisoryLock.withAdvisoryLock(storage.pool, MIGRATION_LOCK, run);
139
- return;
140
- }
141
- await fileLock.withFileLock(MIGRATION_LOCK, run);
142
- }
143
-
144
- async function openSqlite(path: string): Promise<AuthDatabase & { close(): void }> {
145
- if (process.versions.bun) {
146
- const specifier = "bun:sqlite";
147
- const { Database } = (await import(specifier)) as BunSqliteModule;
148
- const database = new Database(path, { create: true, strict: true });
149
- database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON");
150
- return database;
151
- }
152
- const { DatabaseSync } = await import("node:sqlite");
153
- const database = new DatabaseSync(path);
154
- database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON");
155
- return database;
156
- }
157
-
158
- function migrationModuleUrl(): string {
159
- const entry = import.meta.resolve("better-auth");
160
- return new URL("./db/get-migration.mjs", entry).href;
161
- }