@neondatabase/auth 0.1.0-beta.1

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.
@@ -0,0 +1,494 @@
1
+ import { getGlobalBroadcastChannel } from "better-auth/client";
2
+ import { adminClient, anonymousClient, emailOTPClient, jwtClient, magicLinkClient, organizationClient, phoneNumberClient } from "better-auth/client/plugins";
3
+
4
+ //#region src/core/in-flight-request-manager.ts
5
+ /**
6
+ * Generic in-flight request deduplication manager.
7
+ *
8
+ * Prevents thundering herd by tracking Promises by key.
9
+ * Multiple concurrent calls with the same key await the same Promise
10
+ * instead of making N identical requests.
11
+ *
12
+ * Example:
13
+ * ```typescript
14
+ * const manager = new InFlightRequestManager();
15
+ *
16
+ * // 10 concurrent calls deduplicate to 1 actual fetch
17
+ * const results = await Promise.all([
18
+ * manager.deduplicate('user:123', () => fetchUser(123)),
19
+ * manager.deduplicate('user:123', () => fetchUser(123)),
20
+ * // ... 8 more calls
21
+ * ]);
22
+ * // Result: 1 fetch call, 10 identical results
23
+ * ```
24
+ *
25
+ * Thread Safety: JavaScript is single-threaded, no race conditions possible
26
+ */
27
+ var InFlightRequestManager = class {
28
+ /**
29
+ * Map of request keys to in-flight Promises.
30
+ * Automatically cleared after Promise resolution (success or error).
31
+ */
32
+ inFlightRequests = /* @__PURE__ */ new Map();
33
+ /**
34
+ * Execute function with deduplication.
35
+ *
36
+ * If request with same key is in-flight, returns existing Promise.
37
+ * Otherwise, executes fn and tracks the Promise.
38
+ *
39
+ * @param key - Unique identifier for this request (e.g., "getSession")
40
+ * @param fn - Async function to execute (only called if no in-flight request exists)
41
+ * @returns Promise that resolves to the function result
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * // First call: Executes fetchSession(), tracks Promise
46
+ * const result1 = await manager.deduplicate('getSession', fetchSession);
47
+ *
48
+ * // Concurrent call: Returns existing Promise (no fetchSession() call)
49
+ * const result2 = await manager.deduplicate('getSession', fetchSession);
50
+ *
51
+ * // Both results are identical (same object reference)
52
+ * console.log(result1 === result2); // true
53
+ * ```
54
+ */
55
+ async deduplicate(key, fn) {
56
+ const existing = this.inFlightRequests.get(key);
57
+ if (existing) return existing;
58
+ const promise = fn().finally(() => {
59
+ this.inFlightRequests.delete(key);
60
+ });
61
+ this.inFlightRequests.set(key, promise);
62
+ return promise;
63
+ }
64
+ /**
65
+ * Clear specific in-flight request.
66
+ *
67
+ * Useful for forced refresh or cache invalidation scenarios.
68
+ * Next call with same key will execute fresh request.
69
+ *
70
+ * @param key - Request key to clear
71
+ */
72
+ clear(key) {
73
+ this.inFlightRequests.delete(key);
74
+ }
75
+ /**
76
+ * Clear all in-flight requests.
77
+ *
78
+ * Useful for cleanup on sign-out or reset scenarios.
79
+ */
80
+ clearAll() {
81
+ this.inFlightRequests.clear();
82
+ }
83
+ /**
84
+ * Check if request is in-flight.
85
+ *
86
+ * @param key - Request key to check
87
+ * @returns True if request is currently in-flight
88
+ */
89
+ has(key) {
90
+ return this.inFlightRequests.has(key);
91
+ }
92
+ /**
93
+ * Get count of in-flight requests (for debugging/testing).
94
+ *
95
+ * @returns Number of currently tracked requests
96
+ */
97
+ size() {
98
+ return this.inFlightRequests.size;
99
+ }
100
+ };
101
+
102
+ //#endregion
103
+ //#region src/core/constants.ts
104
+ /**
105
+ * Session caching configuration constants
106
+ *
107
+ * Uses industry-standard 60s cache TTL (common across auth providers).
108
+ *
109
+ * Note: Token refresh detection is now automatic via Better Auth's
110
+ * fetchOptions.onSuccess callback. No polling is needed.
111
+ */
112
+ /** Session cache TTL in milliseconds (60 seconds) */
113
+ const SESSION_CACHE_TTL_MS = 6e4;
114
+ /** Clock skew buffer for token expiration checks in milliseconds (10 seconds) */
115
+ const CLOCK_SKEW_BUFFER_MS = 1e4;
116
+ /** Default session expiry duration in milliseconds (1 hour) */
117
+ const DEFAULT_SESSION_EXPIRY_MS = 36e5;
118
+ /** Name of the session verifier parameter in the URL, used for the OAUTH flow */
119
+ const NEON_AUTH_SESSION_VERIFIER_PARAM_NAME = "neon_auth_session_verifier";
120
+
121
+ //#endregion
122
+ //#region src/utils/jwt.ts
123
+ /**
124
+ * Extract expiration timestamp from JWT payload
125
+ * @param jwt - The JWT token string
126
+ * @returns Expiration timestamp in seconds (Unix time) or null if invalid
127
+ */
128
+ function getJwtExpiration(jwt) {
129
+ try {
130
+ const parts = jwt.split(".");
131
+ if (parts.length !== 3) return null;
132
+ const exp = JSON.parse(atob(parts[1])).exp;
133
+ return typeof exp === "number" ? exp : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ //#endregion
140
+ //#region src/core/session-cache-manager.ts
141
+ /**
142
+ * Manages in-memory session cache with TTL expiration.
143
+ *
144
+ * Features:
145
+ * - Stores sessions in Better Auth native format
146
+ * - Automatic expiration based on JWT token expiration
147
+ * - Invalidation flag for sign-out scenarios
148
+ * - TTL calculation with clock skew buffer
149
+ *
150
+ * Example:
151
+ * ```typescript
152
+ * const cacheManager = new SessionCacheManager();
153
+ * cacheManager.setCachedSession({ session, user });
154
+ * const cached = cacheManager.getCachedSession();
155
+ * ```
156
+ */
157
+ var SessionCacheManager = class {
158
+ cache = null;
159
+ lastSessionData = null;
160
+ /**
161
+ * Get cached session if valid and not expired.
162
+ * Returns null if cache is invalid, expired, or doesn't exist.
163
+ */
164
+ getCachedSession() {
165
+ if (!this.cache || this.cache.invalidated) return null;
166
+ if (Date.now() > this.cache.expiresAt) {
167
+ this.clearSessionCache();
168
+ return null;
169
+ }
170
+ return this.cache.data;
171
+ }
172
+ /**
173
+ * Set cached session with optional TTL.
174
+ * If TTL not provided, calculates from JWT expiration.
175
+ * Skips caching if cache was invalidated (sign-out scenario).
176
+ */
177
+ setCachedSession(data, ttl) {
178
+ if (this.cache?.invalidated) return;
179
+ this.lastSessionData = this.cache?.data ?? null;
180
+ const calculatedTtl = ttl ?? this.calculateCacheTTL(data.session.token);
181
+ this.cache = {
182
+ data,
183
+ expiresAt: Date.now() + calculatedTtl,
184
+ invalidated: false
185
+ };
186
+ }
187
+ /**
188
+ * Invalidate cache (marks as invalid but doesn't clear).
189
+ * Useful for sign-out scenarios where in-flight requests should not cache.
190
+ */
191
+ invalidateSessionCache() {
192
+ if (this.cache) this.cache.invalidated = true;
193
+ }
194
+ /**
195
+ * Clear cache completely.
196
+ */
197
+ clearSessionCache() {
198
+ this.cache = null;
199
+ this.lastSessionData = null;
200
+ }
201
+ /**
202
+ * Check if token was refreshed by comparing tokens with previous session.
203
+ * Returns true if tokens differ (token was refreshed), false otherwise.
204
+ */
205
+ wasTokenRefreshed(data) {
206
+ if (!this.lastSessionData?.session?.token || !data?.session?.token) return false;
207
+ return this.lastSessionData.session.token !== data.session.token;
208
+ }
209
+ /**
210
+ * Calculate cache TTL from JWT expiration.
211
+ * Falls back to default TTL if JWT is invalid or missing.
212
+ */
213
+ calculateCacheTTL(jwt) {
214
+ if (!jwt) return SESSION_CACHE_TTL_MS;
215
+ const exp = getJwtExpiration(jwt);
216
+ if (!exp) return SESSION_CACHE_TTL_MS;
217
+ const now = Date.now();
218
+ const ttl = exp * 1e3 - now - CLOCK_SKEW_BUFFER_MS;
219
+ return Math.max(ttl, 1e3);
220
+ }
221
+ };
222
+
223
+ //#endregion
224
+ //#region src/utils/browser.ts
225
+ /**
226
+ * Checks if the code is running in a browser environment
227
+ * @returns true if in browser, false otherwise (e.g., Node.js)
228
+ */
229
+ const isBrowser = () => {
230
+ return globalThis.window !== void 0 && typeof document !== "undefined";
231
+ };
232
+
233
+ //#endregion
234
+ //#region src/core/better-auth-methods.ts
235
+ const CURRENT_TAB_CLIENT_ID = crypto.randomUUID();
236
+ const BETTER_AUTH_METHODS_IN_FLIGHT_REQUESTS = new InFlightRequestManager();
237
+ const BETTER_AUTH_METHODS_CACHE = new SessionCacheManager();
238
+ const BETTER_AUTH_ENDPOINTS = {
239
+ signUp: "/sign-up",
240
+ signIn: "/sign-in",
241
+ signOut: "/sign-out",
242
+ updateUser: "/update-user",
243
+ getSession: "/get-session",
244
+ token: "/token"
245
+ };
246
+ const BETTER_AUTH_METHODS_HOOKS = {
247
+ signUp: {
248
+ onRequest: () => {},
249
+ onSuccess: (responseData) => {
250
+ if (isSessionResponseData(responseData)) {
251
+ const sessionData = {
252
+ session: responseData.session,
253
+ user: responseData.user
254
+ };
255
+ BETTER_AUTH_METHODS_CACHE.setCachedSession(sessionData);
256
+ emitAuthEvent({
257
+ type: "SIGN_IN",
258
+ data: sessionData
259
+ });
260
+ }
261
+ }
262
+ },
263
+ signIn: {
264
+ onRequest: () => {},
265
+ onSuccess: (responseData) => {
266
+ if (isSessionResponseData(responseData)) {
267
+ const sessionData = {
268
+ session: responseData.session,
269
+ user: responseData.user
270
+ };
271
+ BETTER_AUTH_METHODS_CACHE.setCachedSession(sessionData);
272
+ emitAuthEvent({
273
+ type: "SIGN_IN",
274
+ data: sessionData
275
+ });
276
+ }
277
+ }
278
+ },
279
+ signOut: {
280
+ onRequest: () => {
281
+ BETTER_AUTH_METHODS_CACHE.invalidateSessionCache();
282
+ BETTER_AUTH_METHODS_IN_FLIGHT_REQUESTS.clearAll();
283
+ },
284
+ onSuccess: () => {
285
+ BETTER_AUTH_METHODS_CACHE.clearSessionCache();
286
+ emitAuthEvent({ type: "SIGN_OUT" });
287
+ }
288
+ },
289
+ updateUser: {
290
+ onRequest: () => {},
291
+ onSuccess: (responseData) => {
292
+ if (isSessionResponseData(responseData)) emitAuthEvent({
293
+ type: "USER_UPDATE",
294
+ data: {
295
+ session: responseData.session,
296
+ user: responseData.user
297
+ }
298
+ });
299
+ }
300
+ },
301
+ getSession: {
302
+ beforeRequest: () => {
303
+ const cachedData = BETTER_AUTH_METHODS_CACHE.getCachedSession();
304
+ if (!cachedData) return null;
305
+ return Response.json(cachedData, { status: 200 });
306
+ },
307
+ onRequest: (ctx) => {
308
+ if (!isBrowser()) return;
309
+ const neonAuthSessionVerifierParam = new URLSearchParams(globalThis.window.location.search).get(NEON_AUTH_SESSION_VERIFIER_PARAM_NAME);
310
+ if (neonAuthSessionVerifierParam) {
311
+ const url = typeof ctx.url === "string" ? new URL(ctx.url) : ctx.url;
312
+ url.searchParams.set(NEON_AUTH_SESSION_VERIFIER_PARAM_NAME, neonAuthSessionVerifierParam);
313
+ return {
314
+ ...ctx,
315
+ url
316
+ };
317
+ }
318
+ },
319
+ onSuccess: (responseData) => {
320
+ if (isSessionResponseData(responseData)) {
321
+ const sessionData = {
322
+ session: responseData.session,
323
+ user: responseData.user
324
+ };
325
+ const wasRefreshed = BETTER_AUTH_METHODS_CACHE.wasTokenRefreshed(sessionData);
326
+ BETTER_AUTH_METHODS_CACHE.setCachedSession(sessionData);
327
+ if (wasRefreshed) emitAuthEvent({
328
+ type: "TOKEN_REFRESH",
329
+ data: sessionData
330
+ });
331
+ if (isBrowser()) {
332
+ const url = new URL(globalThis.window.location.href);
333
+ if (url.searchParams.get(NEON_AUTH_SESSION_VERIFIER_PARAM_NAME)) {
334
+ url.searchParams.delete(NEON_AUTH_SESSION_VERIFIER_PARAM_NAME);
335
+ history.replaceState(history.state, "", url.href);
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ };
342
+ /**
343
+ * Unified event emission method that handles Better Auth broadcasts.
344
+ * Broadcasts use Better Auth native format - each adapter handles
345
+ * conversion to their specific format (e.g., Supabase Session).
346
+ *
347
+ * This ensures:
348
+ * - Single source of truth for all event emissions
349
+ * - Better Auth ecosystem compatibility via getGlobalBroadcastChannel()
350
+ * - Adapter-agnostic event format
351
+ * - Cross-tab synchronization via Better Auth's broadcast system
352
+ */
353
+ async function emitAuthEvent(event) {
354
+ const eventType = mapToEventType(event);
355
+ const sessionData = "data" in event ? event.data : null;
356
+ const trigger = mapToTrigger(event);
357
+ if (trigger) getGlobalBroadcastChannel().post({
358
+ event: "session",
359
+ data: { trigger },
360
+ clientId: CURRENT_TAB_CLIENT_ID
361
+ });
362
+ getGlobalBroadcastChannel().post({
363
+ event: "session",
364
+ data: {
365
+ trigger: eventType,
366
+ sessionData
367
+ },
368
+ clientId: CURRENT_TAB_CLIENT_ID
369
+ });
370
+ }
371
+ /** Maps internal event types to NeonAuthChangeEvent */
372
+ function mapToEventType(event) {
373
+ switch (event.type) {
374
+ case "SIGN_IN": return "SIGNED_IN";
375
+ case "SIGN_OUT": return "SIGNED_OUT";
376
+ case "TOKEN_REFRESH": return "TOKEN_REFRESHED";
377
+ case "USER_UPDATE": return "USER_UPDATED";
378
+ }
379
+ }
380
+ /** Maps internal event types to Better Auth broadcast triggers */
381
+ function mapToTrigger(event) {
382
+ switch (event.type) {
383
+ case "SIGN_OUT": return "signout";
384
+ case "TOKEN_REFRESH": return null;
385
+ case "USER_UPDATE": return "updateUser";
386
+ case "SIGN_IN": return null;
387
+ }
388
+ }
389
+ /**
390
+ * Type guard that validates response data has non-null session and user.
391
+ * Narrows the type to ensure session and user are not null.
392
+ */
393
+ function isSessionResponseData(responseData) {
394
+ return Boolean(responseData && typeof responseData === "object" && "session" in responseData && "user" in responseData && responseData.session !== null && responseData.user !== null);
395
+ }
396
+ function deriveBetterAuthMethodFromUrl(url) {
397
+ if (url.includes(BETTER_AUTH_ENDPOINTS.signIn)) return "signIn";
398
+ if (url.includes(BETTER_AUTH_ENDPOINTS.signUp)) return "signUp";
399
+ if (url.includes(BETTER_AUTH_ENDPOINTS.signOut)) return "signOut";
400
+ if (url.includes(BETTER_AUTH_ENDPOINTS.updateUser)) return "updateUser";
401
+ if (url.includes(BETTER_AUTH_ENDPOINTS.getSession) || url.includes(BETTER_AUTH_ENDPOINTS.token)) return "getSession";
402
+ }
403
+ function initBroadcastChannel() {
404
+ getGlobalBroadcastChannel().subscribe((message) => {
405
+ if (message.clientId === CURRENT_TAB_CLIENT_ID) return;
406
+ const trigger = message.data?.trigger;
407
+ if (trigger === "signout" || trigger === "updateUser" || trigger === "getSession") BETTER_AUTH_METHODS_CACHE.clearSessionCache();
408
+ });
409
+ }
410
+
411
+ //#endregion
412
+ //#region src/core/adapter-core.ts
413
+ const FORCE_FETCH_HEADER = "X-Force-Fetch";
414
+ const supportedBetterAuthClientPlugins = [
415
+ jwtClient(),
416
+ adminClient(),
417
+ organizationClient(),
418
+ emailOTPClient(),
419
+ anonymousClient(),
420
+ phoneNumberClient(),
421
+ magicLinkClient()
422
+ ];
423
+ var NeonAuthAdapterCore = class {
424
+ betterAuthOptions;
425
+ /**
426
+ * Better Auth adapter implementing the NeonAuthClient interface.
427
+ * See CLAUDE.md for architecture details and API mappings.
428
+ */
429
+ constructor(betterAuthClientOptions) {
430
+ const userOnSuccess = betterAuthClientOptions.fetchOptions?.onSuccess;
431
+ const userOnRequest = betterAuthClientOptions.fetchOptions?.onRequest;
432
+ this.betterAuthOptions = {
433
+ ...betterAuthClientOptions,
434
+ plugins: supportedBetterAuthClientPlugins,
435
+ fetchOptions: {
436
+ ...betterAuthClientOptions.fetchOptions,
437
+ throw: false,
438
+ onRequest: (request) => {
439
+ const url = request.url;
440
+ const method = deriveBetterAuthMethodFromUrl(url.toString());
441
+ if (method) BETTER_AUTH_METHODS_HOOKS[method].onRequest(request);
442
+ userOnRequest?.(request);
443
+ },
444
+ customFetchImpl: async (url, init) => {
445
+ if (init?.headers && FORCE_FETCH_HEADER in init.headers) {
446
+ const headers = { ...init.headers };
447
+ delete headers[FORCE_FETCH_HEADER];
448
+ const response$1 = await fetch(url, {
449
+ ...init,
450
+ headers
451
+ });
452
+ if (!response$1.ok) {
453
+ const body = await response$1.clone().json().catch(() => ({}));
454
+ const err = new Error(body.message || `HTTP ${response$1.status} ${response$1.statusText}`);
455
+ err.status = response$1.status;
456
+ err.statusText = response$1.statusText;
457
+ throw err;
458
+ }
459
+ return response$1;
460
+ }
461
+ const betterAuthMethod = deriveBetterAuthMethodFromUrl(url.toString());
462
+ if (betterAuthMethod) {
463
+ const response$1 = await BETTER_AUTH_METHODS_HOOKS[betterAuthMethod].beforeRequest?.(url, init);
464
+ if (response$1) return response$1;
465
+ }
466
+ const key = `${init?.method || "GET"}:${url}:${init?.body || ""}`;
467
+ const response = await BETTER_AUTH_METHODS_IN_FLIGHT_REQUESTS.deduplicate(key, () => fetch(url, init));
468
+ if (!response.ok) {
469
+ const errorBody = await response.clone().json().catch(() => ({}));
470
+ const err = new Error(errorBody.message || `HTTP ${response.status} ${response.statusText}`);
471
+ err.status = response.status;
472
+ err.statusText = response.statusText;
473
+ throw err;
474
+ }
475
+ return response.clone();
476
+ },
477
+ onSuccess: async (ctx) => {
478
+ const jwt = ctx.response.headers.get("set-auth-jwt");
479
+ if (jwt) if (ctx.data?.session) ctx.data.session.token = jwt;
480
+ else console.warn("[onSuccess] JWT found but no session data to inject into!");
481
+ const url = ctx.request.url.toString();
482
+ const responseData = ctx.data;
483
+ const method = deriveBetterAuthMethodFromUrl(url);
484
+ if (method) BETTER_AUTH_METHODS_HOOKS[method].onSuccess(responseData);
485
+ await userOnSuccess?.(ctx);
486
+ }
487
+ }
488
+ };
489
+ initBroadcastChannel();
490
+ }
491
+ };
492
+
493
+ //#endregion
494
+ export { DEFAULT_SESSION_EXPIRY_MS as a, CURRENT_TAB_CLIENT_ID as i, BETTER_AUTH_METHODS_CACHE as n, BETTER_AUTH_METHODS_HOOKS as r, NeonAuthAdapterCore as t };