UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

955 lines (954 loc) • 34.6 kB
import { getSessionDefaultFields, parseSessionOutput, parseUserOutput } from "./schema.mjs"; import { getDate } from "../utils/date.mjs"; import { assertValidUserInfo, assertValidUserInfoSource } from "../utils/validate-user-info.mjs"; import { getStorageOption, processIdentifier } from "./verification-token-storage.mjs"; import { getWithHooks } from "./with-hooks.mjs"; import { getCurrentAdapter, getCurrentAuthEndpointContext, queueAfterTransactionHook, runWithTransaction, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context"; import { createLocalAccountIssuer } from "@better-auth/core/db"; import { APIError, BetterAuthError } from "@better-auth/core/error"; import { generateId } from "@better-auth/core/utils/id"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { getIP } from "@better-auth/core/utils/ip"; import { base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; //#region src/db/internal-adapter.ts function getTTLSeconds(expiresAt, now = Date.now()) { const expiresMs = typeof expiresAt === "number" ? expiresAt : expiresAt.getTime(); return Math.max(Math.floor((expiresMs - now) / 1e3), 0); } const createInternalAdapter = (adapter, ctx) => { const logger = ctx.logger; const options = ctx.options; const secondaryStorage = options.secondaryStorage; const verificationConsumeLocks = /* @__PURE__ */ new Map(); const sessionExpiration = options.session?.expiresIn || 3600 * 24 * 7; const { createWithHooks, updateWithHooks, updateManyWithHooks, deleteWithHooks, deleteManyWithHooks, consumeOneWithHooks } = getWithHooks(adapter, ctx); /** * Ends the live session rows matched by `where` without physically deleting * them. * * Used for `secondaryStorage` + `preserveSessionInDatabase`, where the row is * kept for audit. The session-delete hooks still run (so OAuth token * revocation and back-channel logout fire on session end), and the preserved * row's `expiresAt` is set to now so every liveness check that keys off the * session row (introspection, `/userinfo`) treats it as ended. * * Matching is restricted to still-live rows. The preserved row outlives the * session, so without this a later delete call (a repeated `deleteSession`, * or `deleteUserSessions` sweeping a user's accumulated preserved rows) would * re-match it and re-fire the hooks, re-dispatching back-channel logout. */ const endPreservedSessions = (where) => { const liveSessions = [...where, { field: "expiresAt", value: /* @__PURE__ */ new Date(), operator: "gt" }]; return deleteManyWithHooks(liveSessions, "session", { fn: async () => { await (await getCurrentAdapter(adapter)).updateMany({ model: "session", where: liveSessions, update: { expiresAt: /* @__PURE__ */ new Date() } }); }, executeMainFn: false }); }; async function refreshUserSessions(user) { if (!secondaryStorage) return; const listRaw = await secondaryStorage.get(`active-sessions-${user.id}`); if (!listRaw) return; const now = Date.now(); const validSessions = (safeJSONParse(listRaw) || []).filter((s) => s.expiresAt > now); await Promise.all(validSessions.map(async ({ token }) => { const cached = await secondaryStorage.get(token); if (!cached) return; const parsed = safeJSONParse(cached); if (!parsed) return; const sessionTTL = getTTLSeconds(parsed.session.expiresAt, now); await secondaryStorage.set(token, JSON.stringify({ session: parsed.session, user }), Math.floor(sessionTTL)); })); } async function getActiveSessionReferences(userId) { if (!secondaryStorage) return []; const activeSessions = await secondaryStorage.get(`active-sessions-${userId}`); return activeSessions ? safeJSONParse(activeSessions) || [] : []; } async function deleteCachedUserSessions(userId, sessionReferences) { if (!secondaryStorage) return; const deletedTokens = new Set(sessionReferences.map((session) => session.token)); for (const { token } of sessionReferences) await secondaryStorage.delete(token); const activeSessionsKey = `active-sessions-${userId}`; const currentSessionReferences = await getActiveSessionReferences(userId); const now = Date.now(); const remainingSessionReferences = currentSessionReferences.filter((session) => session.expiresAt > now && !deletedTokens.has(session.token)); remainingSessionReferences.sort((a, b) => a.expiresAt - b.expiresAt); const furthestExpiration = remainingSessionReferences.at(-1)?.expiresAt; if (furthestExpiration) { await secondaryStorage.set(activeSessionsKey, JSON.stringify(remainingSessionReferences), getTTLSeconds(furthestExpiration, now)); return; } await secondaryStorage.delete(activeSessionsKey); } async function queueCachedUserSessionDeletion(userId, sessionReferences) { if (!secondaryStorage) return; const references = sessionReferences ?? await getActiveSessionReferences(userId); await queueAfterTransactionHook(() => deleteCachedUserSessions(userId, references), { onError(error) { logger.error("Failed to delete committed user sessions from secondary storage", error); } }); } async function withVerificationConsumeLock(key, fn) { const previous = verificationConsumeLocks.get(key) ?? Promise.resolve(); let release; const current = new Promise((resolve) => { release = resolve; }); const next = previous.catch(() => {}).then(() => current); verificationConsumeLocks.set(key, next); await previous.catch(() => {}); try { return await fn(); } finally { release(); if (verificationConsumeLocks.get(key) === next) verificationConsumeLocks.delete(key); } } return { createOAuthUser: async (user, account) => { return runWithTransaction(adapter, async () => { const createdUser = await createWithHooks({ createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...user, email: user.email?.toLowerCase() }, "user", void 0); if (!createdUser) throw new APIError("BAD_REQUEST", { message: "Failed to create user" }); return { user: createdUser, account: await createWithHooks({ ...account, userId: createdUser.id, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }, "account", void 0) }; }); }, createUser: async (user, source) => { const data = { createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...user, email: user.email?.toLowerCase() }; if (options.user?.validateUserInfo) { const validationSource = { ...source, action: "create-user" }; assertValidUserInfoSource(validationSource); let endpointContext; try { endpointContext = getCurrentAuthEndpointContext(); } catch (error) { logger.error("Unable to run validateUserInfo: missing endpoint context", error); throw new APIError("FORBIDDEN", { code: "validation_context_missing", message: "User validation requires an endpoint context" }); } await assertValidUserInfo(endpointContext, { user: data, source: validationSource }); } return await createWithHooks(data, "user", void 0); }, createAccount: async (account) => { return await createWithHooks({ createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...account }, "account", void 0); }, listSessions: async (userId, options) => { if (secondaryStorage) { const currentList = await secondaryStorage.get(`active-sessions-${userId}`); if (!currentList) return []; const list = safeJSONParse(currentList) || []; const now = Date.now(); const seenTokens = /* @__PURE__ */ new Set(); const sessions = []; for (const { token, expiresAt } of list) { if (expiresAt <= now || seenTokens.has(token)) continue; seenTokens.add(token); const data = await secondaryStorage.get(token); if (!data) continue; try { const parsed = typeof data === "string" ? JSON.parse(data) : data; if (!parsed?.session) continue; sessions.push(parseSessionOutput(ctx.options, { ...parsed.session, expiresAt: new Date(parsed.session.expiresAt) })); } catch { continue; } } return sessions; } return await (await getCurrentAdapter(adapter)).findMany({ model: "session", where: [{ field: "userId", value: userId }, ...options?.onlyActiveSessions ? [{ field: "expiresAt", value: /* @__PURE__ */ new Date(), operator: "gt" }] : []] }); }, listUsers: async (limit, offset, sortBy, where) => { return await (await getCurrentAdapter(adapter)).findMany({ model: "user", limit, offset, sortBy, where }); }, countTotalUsers: async (where) => { const total = await (await getCurrentAdapter(adapter)).count({ model: "user", where }); if (typeof total === "string") return parseInt(total); return total; }, deleteUser: async (userId) => { const sessionReferences = await getActiveSessionReferences(userId); if (!secondaryStorage || options.session?.storeSessionInDatabase) await deleteManyWithHooks([{ field: "userId", value: userId }], "session", void 0); await deleteManyWithHooks([{ field: "userId", value: userId }], "account", void 0); if (await deleteWithHooks([{ field: "id", value: userId }], "user", void 0) !== null) await queueCachedUserSessionDeletion(userId, sessionReferences); }, createSession: async (userId, dontRememberMe, override, overrideAll, storageOptions) => { const headers = await (async () => { const ctx = tryGetCurrentAuthEndpointContext(); return ctx?.headers || ctx?.request?.headers; })(); const storeInDb = options.session?.storeSessionInDatabase; const databaseSessionFallbackEnabled = storeInDb === true && options.session?.preserveSessionInDatabase !== true; const { id: _, ...rest } = override || {}; let sessionId; if (secondaryStorage && !storeInDb) { const generatedId = ctx.generateId({ model: "session" }); sessionId = generatedId !== false ? generatedId : generateId(); } const defaultAdditionalFields = getSessionDefaultFields(options); const data = { ...sessionId ? { id: sessionId } : {}, ipAddress: headers ? getIP(headers, options) || "" : "", userAgent: headers?.get("user-agent") || "", ...rest, /** * If the user doesn't want to be remembered * set the session to expire in 1 day. * The cookie will be set to expire at the end of the session */ expiresAt: dontRememberMe ? getDate(3600 * 24, "sec") : getDate(sessionExpiration, "sec"), userId, token: generateId(32), createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...defaultAdditionalFields, ...overrideAll ? rest : {} }; const mirrorSessionToSecondaryStorage = async (sessionData) => { if (!secondaryStorage) return sessionData; const currentList = await secondaryStorage.get(`active-sessions-${userId}`); let list = []; const now = Date.now(); if (currentList) { list = safeJSONParse(currentList) || []; list = list.filter((session) => session.expiresAt > now && session.token !== data.token); } const sorted = [...list, { token: data.token, expiresAt: data.expiresAt.getTime() }].sort((a, b) => a.expiresAt - b.expiresAt); const furthestSessionTTL = getTTLSeconds(sorted.at(-1)?.expiresAt ?? data.expiresAt.getTime(), now); if (furthestSessionTTL > 0) await secondaryStorage.set(`active-sessions-${userId}`, JSON.stringify(sorted), furthestSessionTTL); const user = await (await getCurrentAdapter(adapter)).findOne({ model: "user", where: [{ field: "id", value: userId }] }); const sessionTTL = getTTLSeconds(data.expiresAt, now); if (sessionTTL > 0) await secondaryStorage.set(data.token, JSON.stringify({ session: sessionData, user }), sessionTTL); return sessionData; }; const res = await createWithHooks(data, "session", secondaryStorage ? { fn: async (sessionData) => { return storageOptions?.deferSecondaryStorageWrites ? sessionData : mirrorSessionToSecondaryStorage(sessionData); }, executeMainFn: storeInDb } : void 0); if (secondaryStorage && storageOptions?.deferSecondaryStorageWrites && res) await queueAfterTransactionHook(async () => { await mirrorSessionToSecondaryStorage(res); }, databaseSessionFallbackEnabled ? { onError(error) { logger.error("Failed to mirror committed session to secondary storage", error); } } : void 0); return res; }, findSession: async (token) => { if (secondaryStorage) { const sessionStringified = await secondaryStorage.get(token); if (!sessionStringified && (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase)) return null; if (sessionStringified) { const s = safeJSONParse(sessionStringified); if (!s) return null; return { session: parseSessionOutput(ctx.options, { ...s.session, expiresAt: new Date(s.session.expiresAt), createdAt: new Date(s.session.createdAt), updatedAt: new Date(s.session.updatedAt) }), user: parseUserOutput(ctx.options, { ...s.user, createdAt: new Date(s.user.createdAt), updatedAt: new Date(s.user.updatedAt) }) }; } } const result = await (await getCurrentAdapter(adapter)).findOne({ model: "session", where: [{ value: token, field: "token" }], join: { user: true } }); if (!result) return null; const { user, ...session } = result; if (!user) return null; return { session: parseSessionOutput(ctx.options, session), user: parseUserOutput(ctx.options, user) }; }, findSessions: async (sessionTokens, options) => { if (secondaryStorage) { const sessions = []; for (const sessionToken of sessionTokens) { const sessionStringified = await secondaryStorage.get(sessionToken); if (sessionStringified) try { const s = typeof sessionStringified === "string" ? JSON.parse(sessionStringified) : sessionStringified; if (!s) continue; const expiresAt = new Date(s.session.expiresAt); if (options?.onlyActiveSessions && expiresAt <= /* @__PURE__ */ new Date()) continue; const session = { session: { ...s.session, expiresAt: new Date(s.session.expiresAt) }, user: { ...s.user, createdAt: new Date(s.user.createdAt), updatedAt: new Date(s.user.updatedAt) } }; sessions.push(session); } catch { continue; } } return sessions; } const sessions = await (await getCurrentAdapter(adapter)).findMany({ model: "session", where: [{ field: "token", value: sessionTokens, operator: "in" }, ...options?.onlyActiveSessions ? [{ field: "expiresAt", value: /* @__PURE__ */ new Date(), operator: "gt" }] : []], join: { user: true } }); if (!sessions.length) return []; if (sessions.some((session) => !session.user)) return []; return sessions.map((_session) => { const { user, ...session } = _session; return { session, user }; }); }, updateSession: async (sessionToken, session) => { return await updateWithHooks(session, [{ field: "token", value: sessionToken }], "session", secondaryStorage ? { async fn(data) { const currentSession = await secondaryStorage.get(sessionToken); if (!currentSession) return null; const parsedSession = safeJSONParse(currentSession); if (!parsedSession) return null; const mergedSession = { ...parsedSession.session, ...data, expiresAt: new Date(data.expiresAt ?? parsedSession.session.expiresAt), createdAt: new Date(parsedSession.session.createdAt), updatedAt: new Date(data.updatedAt ?? parsedSession.session.updatedAt) }; const updatedSession = parseSessionOutput(ctx.options, mergedSession); const now = Date.now(); const expiresMs = new Date(updatedSession.expiresAt).getTime(); const sessionTTL = getTTLSeconds(expiresMs, now); if (sessionTTL > 0) { await secondaryStorage.set(sessionToken, JSON.stringify({ session: updatedSession, user: parsedSession.user }), sessionTTL); const listKey = `active-sessions-${updatedSession.userId}`; const listRaw = await secondaryStorage.get(listKey); const sorted = (listRaw ? safeJSONParse(listRaw) || [] : []).filter((s) => s.token !== sessionToken && s.expiresAt > now).concat([{ token: sessionToken, expiresAt: expiresMs }]).sort((a, b) => a.expiresAt - b.expiresAt); const furthestSessionExp = sorted.at(-1)?.expiresAt; if (furthestSessionExp && furthestSessionExp > now) await secondaryStorage.set(listKey, JSON.stringify(sorted), getTTLSeconds(furthestSessionExp, now)); else await secondaryStorage.delete(listKey); } return updatedSession; }, executeMainFn: options.session?.storeSessionInDatabase } : void 0); }, deleteSession: async (token) => { if (secondaryStorage) { const data = await secondaryStorage.get(token); if (data) { const { session } = safeJSONParse(data) ?? {}; if (!session) { logger.error("Session not found in secondary storage"); return; } const userId = session.userId; const currentList = await secondaryStorage.get(`active-sessions-${userId}`); if (currentList) { const list = safeJSONParse(currentList) || []; const now = Date.now(); const filtered = list.filter((session) => session.expiresAt > now && session.token !== token); const furthestSessionExp = filtered.sort((a, b) => a.expiresAt - b.expiresAt).at(-1)?.expiresAt; if (filtered.length > 0 && furthestSessionExp && furthestSessionExp > Date.now()) await secondaryStorage.set(`active-sessions-${userId}`, JSON.stringify(filtered), getTTLSeconds(furthestSessionExp, now)); else await secondaryStorage.delete(`active-sessions-${userId}`); } else logger.error("Active sessions list not found in secondary storage"); } await secondaryStorage.delete(token); if (!options.session?.storeSessionInDatabase) return; if (ctx.options.session?.preserveSessionInDatabase) { await endPreservedSessions([{ field: "token", value: token }]); return; } } await deleteWithHooks([{ field: "token", value: token }], "session", void 0); }, deleteAccounts: async (userId) => { await deleteManyWithHooks([{ field: "userId", value: userId }], "account", void 0); }, /** * Delete an account by its primary key. * * @param id - The account row's primary key, not its accountId. */ deleteAccount: async (id) => { await deleteWithHooks([{ field: "id", value: id }], "account", void 0); }, deleteUserSessions: async (userId) => { const sessionReferences = await getActiveSessionReferences(userId); if (secondaryStorage) { if (!options.session?.storeSessionInDatabase) { await queueCachedUserSessionDeletion(userId, sessionReferences); return; } if (ctx.options.session?.preserveSessionInDatabase) { if (await endPreservedSessions([{ field: "userId", value: userId }]) !== null) await queueCachedUserSessionDeletion(userId, sessionReferences); return; } } if (await deleteManyWithHooks([{ field: "userId", value: userId }], "session", void 0) !== null) await queueCachedUserSessionDeletion(userId, sessionReferences); }, deleteSessions: async (sessionTokens) => { if (secondaryStorage) { await Promise.all(sessionTokens.map((token) => secondaryStorage.delete(token))); if (!options.session?.storeSessionInDatabase) return; if (ctx.options.session?.preserveSessionInDatabase) { await endPreservedSessions([{ field: "token", value: sessionTokens, operator: "in" }]); return; } } await deleteManyWithHooks([{ field: "token", value: sessionTokens, operator: "in" }], "session", void 0); }, findAccountOwnerByKey: async ({ issuer, accountId }) => { const accountWithUser = await (await getCurrentAdapter(adapter)).findOne({ model: "account", where: [{ field: "issuer", value: issuer }, { field: "accountId", value: accountId }], join: { user: true } }); if (!accountWithUser) return null; const { user, ...account } = accountWithUser; return user ? { kind: "owned", user, account } : { kind: "orphaned", account }; }, findUserByEmail: async (email, options) => { const result = await (await getCurrentAdapter(adapter)).findOne({ model: "user", where: [{ value: email.toLowerCase(), field: "email" }], join: { ...options?.includeAccounts ? { account: true } : {} } }); if (!result) return null; const { account: accounts, ...user } = result; return { user, accounts: accounts ?? [] }; }, findUserById: async (userId) => { if (!userId) return null; return await (await getCurrentAdapter(adapter)).findOne({ model: "user", where: [{ field: "id", value: userId }] }); }, linkAccount: async (account) => { return await createWithHooks({ createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...account }, "account", void 0); }, updateUser: async (userId, data) => { const user = await updateWithHooks({ ...data, ...data.email ? { email: data.email.toLowerCase() } : {} }, [{ field: "id", value: userId }], "user", void 0); await queueAfterTransactionHook(() => refreshUserSessions(user), { onError(error) { logger.error("Failed to refresh committed user sessions in secondary storage", error); } }); return user; }, updateUserByEmail: async (email, data) => { const user = await updateWithHooks({ ...data, ...data.email ? { email: data.email.toLowerCase() } : {} }, [{ field: "email", value: email.toLowerCase() }], "user", void 0); await queueAfterTransactionHook(() => refreshUserSessions(user), { onError(error) { logger.error("Failed to refresh committed user sessions in secondary storage", error); } }); return user; }, updatePassword: async (userId, password) => { await updateManyWithHooks({ password }, [ { field: "userId", value: userId }, { field: "providerId", value: "credential" }, { field: "issuer", value: createLocalAccountIssuer("credential") }, { field: "accountId", value: userId } ], "account", void 0); }, findAccounts: async (userId) => { return await (await getCurrentAdapter(adapter)).findMany({ model: "account", where: [{ field: "userId", value: userId }] }); }, findCredentialAccount: async (userId) => { return (await getCurrentAdapter(adapter)).findOne({ model: "account", where: [ { field: "userId", value: userId }, { field: "providerId", value: "credential" }, { field: "issuer", value: createLocalAccountIssuer("credential") }, { field: "accountId", value: userId } ] }); }, findAccountByKey: async ({ issuer, accountId }) => { return await (await getCurrentAdapter(adapter)).findOne({ model: "account", where: [{ field: "issuer", value: issuer }, { field: "accountId", value: accountId }] }); }, findAccountByUserId: async (userId) => { return await (await getCurrentAdapter(adapter)).findMany({ model: "account", where: [{ field: "userId", value: userId }] }); }, updateAccount: async (id, data) => { return await updateWithHooks(data, [{ field: "id", value: id }], "account", void 0); }, createVerificationValue: async (data) => { const storageOption = getStorageOption(data.identifier, options.verification?.storeIdentifier); const storedIdentifier = await processIdentifier(data.identifier, storageOption); return await createWithHooks({ createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), ...data, identifier: storedIdentifier }, "verification", secondaryStorage ? { async fn(verificationData) { const ttl = getTTLSeconds(verificationData.expiresAt); if (ttl > 0) await secondaryStorage.set(`verification:${storedIdentifier}`, JSON.stringify(verificationData), ttl); return verificationData; }, executeMainFn: options.verification?.storeInDatabase } : void 0); }, findVerificationValue: async (identifier) => { const storageOption = getStorageOption(identifier, options.verification?.storeIdentifier); const storedIdentifier = await processIdentifier(identifier, storageOption); if (secondaryStorage) { const cached = await secondaryStorage.get(`verification:${storedIdentifier}`); if (cached) { const parsed = safeJSONParse(cached); if (parsed) return parsed; } if (storageOption && storageOption !== "plain") { const plainCached = await secondaryStorage.get(`verification:${identifier}`); if (plainCached) { const parsed = safeJSONParse(plainCached); if (parsed) return parsed; } } if (!options.verification?.storeInDatabase) return null; } const currentAdapter = await getCurrentAdapter(adapter); async function findByIdentifier(id) { return currentAdapter.findMany({ model: "verification", where: [{ field: "identifier", value: id }], sortBy: { field: "createdAt", direction: "desc" }, limit: 1 }); } let verification = await findByIdentifier(storedIdentifier); if (!verification.length && storageOption && storageOption !== "plain") verification = await findByIdentifier(identifier); if (!options.verification?.disableCleanup) await deleteManyWithHooks([{ field: "expiresAt", value: /* @__PURE__ */ new Date(), operator: "lt" }], "verification", void 0); return verification[0] || null; }, deleteVerificationByIdentifier: async (identifier) => { const storedIdentifier = await processIdentifier(identifier, getStorageOption(identifier, options.verification?.storeIdentifier)); if (secondaryStorage) await secondaryStorage.delete(`verification:${storedIdentifier}`); if (!secondaryStorage || options.verification?.storeInDatabase) await deleteWithHooks([{ field: "identifier", value: storedIdentifier }], "verification", void 0); }, /** * Atomically consume a single-use verification row by `identifier` and * return it. The first concurrent caller receives the latest row for the * identifier; every other caller racing against it receives `null`. * * Race-safe replacement for the `findVerificationValue` then * `deleteVerificationByIdentifier` pair. Callers MUST gate any state * change (issue session, mint token, change password) on a non-null * return value, because consuming one row invalidates the whole * identifier and stale rows cannot be replayed. * * Rows past their `expiresAt` are treated as already invalid: the row * is still deleted (so it cannot be replayed later) but `null` is * returned. Callers do not need their own `expiresAt` gate. * * The secondary-storage-only path (`storeInDatabase: false`) consumes * through `getAndDelete`, which is required on `SecondaryStorage` so * single-use values are not read and deleted as separate operations. */ consumeVerificationValue: async (identifier) => { const storageOption = getStorageOption(identifier, options.verification?.storeIdentifier); const storedIdentifier = await processIdentifier(identifier, storageOption); const identifiersToTry = storageOption && storageOption !== "plain" ? [storedIdentifier, identifier] : [storedIdentifier]; const hydrateCachedVerification = (raw) => { if (!raw) return null; const candidate = typeof raw === "string" ? safeJSONParse(raw) : typeof raw === "object" ? raw : null; if (!candidate) return null; const expiresAt = new Date(candidate.expiresAt); if (!Number.isFinite(expiresAt.getTime())) return null; return { ...candidate, expiresAt }; }; let consumed = null; if (secondaryStorage && !options.verification?.storeInDatabase) { const consumeCacheKey = async (key) => { return hydrateCachedVerification(await secondaryStorage.getAndDelete(key)); }; for (const stored of identifiersToTry) { const cached = await consumeCacheKey(`verification:${stored}`); if (!cached) continue; await Promise.all(identifiersToTry.filter((candidate) => candidate !== stored).map((candidate) => secondaryStorage.delete(`verification:${candidate}`))); consumed = cached; break; } } else { const consumeByIdentifier = async (id) => withVerificationConsumeLock(`verification:${id}`, () => runWithTransaction(adapter, async () => { const txAdapter = await getCurrentAdapter(adapter); const where = [{ field: "identifier", value: id }]; const latest = (await txAdapter.findMany({ model: "verification", where, sortBy: { field: "createdAt", direction: "desc" }, limit: 1 }))[0] ?? null; if (!latest) return null; return consumeOneWithHooks("verification", [{ field: "id", value: latest.id }], async () => { const row = await txAdapter.consumeOne({ model: "verification", where: [{ field: "id", value: latest.id }] }); if (!row) return null; await txAdapter.deleteMany({ model: "verification", where }); return row; }, latest); })); for (const stored of identifiersToTry) { consumed = await consumeByIdentifier(stored); if (consumed) break; } if (consumed && secondaryStorage) await Promise.all(identifiersToTry.map((stored) => secondaryStorage.delete(`verification:${stored}`))); } if (!consumed || consumed.expiresAt < /* @__PURE__ */ new Date()) return null; return consumed; }, /** * First-writer-wins create keyed by a deterministic primary key derived * from `identifier`. Returns `true` when this caller created the row and * `false` when a row for the same identifier already existed. * * The dual of `consumeVerificationValue`: where consume races to delete a * marker exactly once, reserve races to create a marker exactly once. Use * it for replay tombstones (a SAML assertion id, a JWT `jti`) where the * first caller wins and every later caller must observe that the marker is * already taken. * * The `verification.identifier` column is non-unique, so uniqueness comes * from a deterministic primary key (`SHA-256` of `reserve:<identifier>`). * The database path is atomic: the primary key turns the INSERT into the * first-writer-wins gate, and a duplicate is detected portably by * re-reading the row rather than matching adapter-specific errors. * Secondary-storage-only verification cannot enforce the deterministic * primary-key gate, so this operation fails closed unless verification is * backed by the database. * * The atomic guarantee requires the configured adapter to reject a * duplicate primary key on insert, which every real database enforces. The * in-memory adapter does not enforce primary-key uniqueness, so reservation * is best-effort there (it is intended for development and tests). */ reserveVerificationValue: async (data) => { const reservationId = base64Url.encode(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), { padding: false }); const storageOption = getStorageOption(data.identifier, options.verification?.storeIdentifier); const storedIdentifier = await processIdentifier(data.identifier, storageOption); if (secondaryStorage && !options.verification?.storeInDatabase) throw new BetterAuthError("reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values."); try { await adapter.create({ model: "verification", data: { id: reservationId, identifier: storedIdentifier, value: data.value, expiresAt: data.expiresAt, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }, forceAllowId: true }); } catch (error) { if (await adapter.findOne({ model: "verification", where: [{ field: "id", value: reservationId }] })) return false; throw error; } if (secondaryStorage) { const ttl = getTTLSeconds(data.expiresAt); if (ttl > 0) await secondaryStorage.set(`verification:${storedIdentifier}`, JSON.stringify({ id: reservationId, identifier: storedIdentifier, value: data.value, expiresAt: data.expiresAt }), ttl); } return true; }, updateVerificationByIdentifier: async (identifier, data) => { const storedIdentifier = await processIdentifier(identifier, getStorageOption(identifier, options.verification?.storeIdentifier)); if (secondaryStorage) { const cached = await secondaryStorage.get(`verification:${storedIdentifier}`); if (cached) { const parsed = safeJSONParse(cached); if (parsed) { const updated = { ...parsed, ...data }; const expiresAt = updated.expiresAt ?? parsed.expiresAt; const ttl = getTTLSeconds(expiresAt instanceof Date ? expiresAt : new Date(expiresAt)); if (ttl > 0) await secondaryStorage.set(`verification:${storedIdentifier}`, JSON.stringify(updated), ttl); if (!options.verification?.storeInDatabase) return updated; } } } if (!secondaryStorage || options.verification?.storeInDatabase) return await updateWithHooks(data, [{ field: "identifier", value: storedIdentifier }], "verification", void 0); return data; }, refreshUserSessions }; }; //#endregion export { createInternalAdapter };