better-auth
Version:
The most comprehensive authentication framework for TypeScript.
860 lines (859 loc) • 30.7 kB
JavaScript
import { getSessionDefaultFields, parseSessionOutput, parseUserOutput } from "./schema.mjs";
import { getDate } from "../utils/date.mjs";
import { getStorageOption, processIdentifier } from "./verification-token-storage.mjs";
import { getWithHooks } from "./with-hooks.mjs";
import { getCurrentAdapter, getCurrentAuthContext, runWithTransaction } from "@better-auth/core/context";
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();
let warnedNonAtomicConsume = false;
const sessionExpiration = options.session?.expiresIn || 3600 * 24 * 7;
const { createWithHooks, updateWithHooks, updateManyWithHooks, deleteWithHooks, deleteManyWithHooks, consumeOneWithHooks } = getWithHooks(adapter, ctx);
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 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);
return {
user: createdUser,
account: await createWithHooks({
...account,
userId: createdUser.id,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}, "account", void 0)
};
});
},
createUser: async (user) => {
return await createWithHooks({
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
...user,
email: user.email?.toLowerCase()
}, "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) => {
if (!secondaryStorage || options.session?.storeSessionInDatabase) await deleteManyWithHooks([{
field: "userId",
value: userId
}], "session", void 0);
await deleteManyWithHooks([{
field: "userId",
value: userId
}], "account", void 0);
await deleteWithHooks([{
field: "id",
value: userId
}], "user", void 0);
},
createSession: async (userId, dontRememberMe, override, overrideAll) => {
const headers = await (async () => {
const ctx = await getCurrentAuthContext().catch(() => null);
return ctx?.headers || ctx?.request?.headers;
})();
const storeInDb = options.session?.storeSessionInDatabase;
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 : {}
};
return await createWithHooks(data, "session", secondaryStorage ? {
fn: async (sessionData) => {
/**
* store the session token for the user
* so we can retrieve it later for listing sessions
*/
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;
},
executeMainFn: storeInDb
} : void 0);
},
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) return [];
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 || ctx.options.session?.preserveSessionInDatabase) 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 (the `id` column, not the `accountId` column).
*/
deleteAccount: async (id) => {
await deleteWithHooks([{
field: "id",
value: id
}], "account", void 0);
},
deleteUserSessions: async (userId) => {
if (secondaryStorage) {
const activeSession = await secondaryStorage.get(`active-sessions-${userId}`);
const sessions = activeSession ? safeJSONParse(activeSession) : [];
if (!sessions) return;
for (const session of sessions) await secondaryStorage.delete(session.token);
await secondaryStorage.delete(`active-sessions-${userId}`);
if (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase) return;
}
await deleteManyWithHooks([{
field: "userId",
value: userId
}], "session", void 0);
},
deleteSessions: async (sessionTokens) => {
if (secondaryStorage) {
for (const sessionToken of sessionTokens) if (await secondaryStorage.get(sessionToken)) await secondaryStorage.delete(sessionToken);
if (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase) return;
}
await deleteManyWithHooks([{
field: "token",
value: sessionTokens,
operator: "in"
}], "session", void 0);
},
findOAuthUser: async (email, accountId, providerId) => {
const account = await (await getCurrentAdapter(adapter)).findOne({
model: "account",
where: [{
value: accountId,
field: "accountId"
}, {
value: providerId,
field: "providerId"
}],
join: { user: true }
});
if (account) if (account.user) return {
user: account.user,
linkedAccount: account,
accounts: [account]
};
else {
const user = await (await getCurrentAdapter(adapter)).findOne({
model: "user",
where: [{
value: email.toLowerCase(),
field: "email"
}]
});
if (user) return {
user,
linkedAccount: account,
accounts: [account]
};
return null;
}
else {
const user = await (await getCurrentAdapter(adapter)).findOne({
model: "user",
where: [{
value: email.toLowerCase(),
field: "email"
}]
});
if (user) return {
user,
linkedAccount: null,
accounts: await (await getCurrentAdapter(adapter)).findMany({
model: "account",
where: [{
value: user.id,
field: "userId"
}]
}) || []
};
else return null;
}
},
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 refreshUserSessions(user);
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 refreshUserSessions(user);
return user;
},
updatePassword: async (userId, password) => {
await updateManyWithHooks({ password }, [{
field: "userId",
value: userId
}, {
field: "providerId",
value: "credential"
}], "account", void 0);
},
findAccounts: async (userId) => {
return await (await getCurrentAdapter(adapter)).findMany({
model: "account",
where: [{
field: "userId",
value: userId
}]
});
},
findAccountByProviderId: async (accountId, providerId) => {
return await (await getCurrentAdapter(adapter)).findOne({
model: "account",
where: [{
field: "accountId",
value: accountId
}, {
field: "providerId",
value: providerId
}]
});
},
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`) is atomic
* only when the configured storage implements `getAndDelete`; otherwise
* it falls back to an in-process lock around `get` then `delete` and
* warns once, since that fallback cannot coordinate across processes.
*
* FIXME(consume-atomic): make `SecondaryStorage.getAndDelete` required
* in the next breaking release, or require database-backed verification
* storage for security-sensitive consume paths, so the non-atomic
* fallback can be removed entirely.
*/
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) => {
if (secondaryStorage.getAndDelete) return hydrateCachedVerification(await secondaryStorage.getAndDelete(key));
if (!warnedNonAtomicConsume) {
warnedNonAtomicConsume = true;
logger.warn("Secondary storage does not implement `getAndDelete`, so single-use verification values cannot be consumed atomically across processes. Implement `getAndDelete` or use database-backed verification storage to guarantee single use.");
}
return withVerificationConsumeLock(key, async () => {
const raw = await secondaryStorage.get(key);
const parsed = hydrateCachedVerification(raw);
if (!parsed) return null;
await secondaryStorage.delete(key);
return parsed;
});
};
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. The
* secondary-storage-only path has no primary key to enforce uniqueness, so
* it is best-effort under concurrency.
*
* 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) {
const cacheKey = `verification:${storedIdentifier}`;
if (await secondaryStorage.get(cacheKey)) return false;
await secondaryStorage.set(cacheKey, JSON.stringify({
id: reservationId,
identifier: storedIdentifier,
value: data.value,
expiresAt: data.expiresAt
}), getTTLSeconds(data.expiresAt));
return true;
}
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 };