Files
better-auth/packages/api-key/src/adapter.ts
T

756 lines
18 KiB
TypeScript

import type { GenericEndpointContext } from "@better-auth/core";
import type { SecondaryStorage } from "@better-auth/core/db";
import { safeJSONParse } from "@better-auth/core/utils/json";
import type { PredefinedApiKeyOptions } from "./routes";
import type { ApiKey } from "./types";
/**
* Parses double-stringified metadata synchronously without updating the database.
* Use this for reading metadata, then call migrateLegacyMetadataInBackground for DB updates.
*
* @returns The properly parsed metadata object, or the original if already an object
*/
export function parseDoubleStringifiedMetadata(
metadata: ApiKey["metadata"],
): Record<string, any> | null {
// If metadata is null/undefined, return null
if (metadata == null) {
return null;
}
// If metadata is already an object, no migration needed
if (typeof metadata === "object") {
return metadata;
}
// Metadata is a string - this is legacy double-stringified data
// Parse it to get the actual object
return safeJSONParse<Record<string, any>>(metadata);
}
/**
* Checks if metadata needs migration (is a string instead of object)
*/
function needsMetadataMigration(metadata: ApiKey["metadata"]): boolean {
return metadata != null && typeof metadata === "string";
}
/**
* Batch migrates double-stringified metadata for multiple API keys.
* Runs all updates in parallel to avoid N sequential database calls.
*/
export async function batchMigrateLegacyMetadata(
ctx: GenericEndpointContext,
apiKeys: ApiKey[],
opts: PredefinedApiKeyOptions,
): Promise<void> {
// Only migrate for database storage
if (opts.storage !== "database" && !opts.fallbackToDatabase) {
return;
}
// Filter keys that need migration
const keysToMigrate = apiKeys.filter((key) =>
needsMetadataMigration(key.metadata),
);
if (keysToMigrate.length === 0) {
return;
}
// Run migrations in parallel (not sequentially)
const migrationPromises = keysToMigrate.map(async (apiKey) => {
const parsed = parseDoubleStringifiedMetadata(apiKey.metadata);
try {
await ctx.context.adapter.update({
model: "apikey",
where: [{ field: "id", value: apiKey.id }],
update: { metadata: parsed },
});
} catch (error) {
ctx.context.logger.warn(
`Failed to migrate double-stringified metadata for API key ${apiKey.id}:`,
error,
);
}
});
await Promise.all(migrationPromises);
}
/**
* Migrates double-stringified metadata to properly parsed object.
*
* This handles legacy data where metadata was incorrectly double-stringified.
* If metadata is a string (should be object after adapter's transform.output),
* it parses it and optionally updates the database.
*
* @returns The properly parsed metadata object
*/
export async function migrateDoubleStringifiedMetadata(
ctx: GenericEndpointContext,
apiKey: ApiKey,
opts: PredefinedApiKeyOptions,
): Promise<Record<string, any> | null> {
const parsed = parseDoubleStringifiedMetadata(apiKey.metadata);
// Update the database to fix the legacy data (only for database storage)
if (
needsMetadataMigration(apiKey.metadata) &&
(opts.storage === "database" || opts.fallbackToDatabase)
) {
try {
await ctx.context.adapter.update({
model: "apikey",
where: [{ field: "id", value: apiKey.id }],
update: { metadata: parsed },
});
} catch (error) {
// Log but don't fail the request if migration update fails
ctx.context.logger.warn(
`Failed to migrate double-stringified metadata for API key ${apiKey.id}:`,
error,
);
}
}
return parsed;
}
/**
* Generate storage key for API key by hashed key
*/
function getStorageKeyByHashedKey(hashedKey: string): string {
return `api-key:${hashedKey}`;
}
/**
* Generate storage key for API key by ID
*/
function getStorageKeyById(id: string): string {
return `api-key:by-id:${id}`;
}
/**
* Generate storage key for reference's API key list (user or org)
*/
function getStorageKeyByReferenceId(referenceId: string): string {
return `api-key:by-ref:${referenceId}`;
}
/**
* Serialize API key for storage
*/
function serializeApiKey(apiKey: ApiKey): string {
return JSON.stringify({
...apiKey,
createdAt: apiKey.createdAt.toISOString(),
updatedAt: apiKey.updatedAt.toISOString(),
expiresAt: apiKey.expiresAt?.toISOString() ?? null,
lastRefillAt: apiKey.lastRefillAt?.toISOString() ?? null,
lastRequest: apiKey.lastRequest?.toISOString() ?? null,
});
}
/**
* Deserialize API key from storage
*/
function deserializeApiKey(data: unknown): ApiKey | null {
if (!data || typeof data !== "string") {
return null;
}
try {
const parsed = JSON.parse(data);
return {
...parsed,
createdAt: new Date(parsed.createdAt),
updatedAt: new Date(parsed.updatedAt),
expiresAt: parsed.expiresAt ? new Date(parsed.expiresAt) : null,
lastRefillAt: parsed.lastRefillAt ? new Date(parsed.lastRefillAt) : null,
lastRequest: parsed.lastRequest ? new Date(parsed.lastRequest) : null,
} as ApiKey;
} catch {
return null;
}
}
/**
* Get the storage instance to use (custom methods take precedence)
*/
function getStorageInstance(
ctx: GenericEndpointContext,
opts: PredefinedApiKeyOptions,
): SecondaryStorage | null {
if (opts.customStorage) {
return opts.customStorage as SecondaryStorage;
}
return ctx.context.secondaryStorage || null;
}
/**
* Calculate TTL in seconds for an API key
*/
function calculateTTL(apiKey: ApiKey): number | undefined {
if (apiKey.expiresAt) {
const now = Date.now();
const expiresAt = new Date(apiKey.expiresAt).getTime();
const ttlSeconds = Math.floor((expiresAt - now) / 1000);
// Only set TTL if expiration is in the future
if (ttlSeconds > 0) {
return ttlSeconds;
}
}
return undefined;
}
/**
* Get API key from secondary storage by hashed key
*/
async function getApiKeyFromStorage(
ctx: GenericEndpointContext,
hashedKey: string,
storage: SecondaryStorage,
): Promise<ApiKey | null> {
const key = getStorageKeyByHashedKey(hashedKey);
const data = await storage.get(key);
return deserializeApiKey(data);
}
/**
* Get API key from secondary storage by ID
*/
async function getApiKeyByIdFromStorage(
ctx: GenericEndpointContext,
id: string,
storage: SecondaryStorage,
): Promise<ApiKey | null> {
const key = getStorageKeyById(id);
const data = await storage.get(key);
return deserializeApiKey(data);
}
/**
* Store API key in secondary storage
*/
async function setApiKeyInStorage(
ctx: GenericEndpointContext,
apiKey: ApiKey,
storage: SecondaryStorage,
ttl?: number | undefined,
): Promise<void> {
const serialized = serializeApiKey(apiKey);
const hashedKey = apiKey.key;
const id = apiKey.id;
// Store by hashed key (primary lookup)
await storage.set(getStorageKeyByHashedKey(hashedKey), serialized, ttl);
// Store by ID (for ID-based lookups)
await storage.set(getStorageKeyById(id), serialized, ttl);
// Update reference's API key list (user or org)
const refKey = getStorageKeyByReferenceId(apiKey.referenceId);
const refListData = await storage.get(refKey);
let keyIds: string[] = [];
if (refListData && typeof refListData === "string") {
try {
keyIds = JSON.parse(refListData);
} catch {
keyIds = [];
}
} else if (Array.isArray(refListData)) {
keyIds = refListData;
}
if (!keyIds.includes(id)) {
keyIds.push(id);
await storage.set(refKey, JSON.stringify(keyIds));
}
}
/**
* Delete API key from secondary storage
*/
async function deleteApiKeyFromStorage(
ctx: GenericEndpointContext,
apiKey: ApiKey,
storage: SecondaryStorage,
): Promise<void> {
const hashedKey = apiKey.key;
const id = apiKey.id;
const referenceId = apiKey.referenceId;
// Delete by hashed key
await storage.delete(getStorageKeyByHashedKey(hashedKey));
// Delete by ID
await storage.delete(getStorageKeyById(id));
// Update reference's API key list
const refKey = getStorageKeyByReferenceId(referenceId);
const refListData = await storage.get(refKey);
let keyIds: string[] = [];
if (refListData && typeof refListData === "string") {
try {
keyIds = JSON.parse(refListData);
} catch {
keyIds = [];
}
} else if (Array.isArray(refListData)) {
keyIds = refListData;
}
const filteredIds = keyIds.filter((keyId) => keyId !== id);
if (filteredIds.length === 0) {
await storage.delete(refKey);
} else {
await storage.set(refKey, JSON.stringify(filteredIds));
}
}
/**
* Unified getter for API keys with support for all storage modes
*/
export async function getApiKey(
ctx: GenericEndpointContext,
hashedKey: string,
opts: PredefinedApiKeyOptions,
): Promise<ApiKey | null> {
const storage = getStorageInstance(ctx, opts);
// Database mode only
if (opts.storage === "database") {
return await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "key",
value: hashedKey,
},
],
});
}
// Secondary storage mode with fallback
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
if (storage) {
const cached = await getApiKeyFromStorage(ctx, hashedKey, storage);
if (cached) {
return cached;
}
}
const dbKey = await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "key",
value: hashedKey,
},
],
});
if (dbKey && storage) {
// Populate secondary storage for future reads
const ttl = calculateTTL(dbKey);
await setApiKeyInStorage(ctx, dbKey, storage, ttl);
}
return dbKey;
}
// Secondary storage mode only
if (opts.storage === "secondary-storage") {
if (!storage) {
return null;
}
return await getApiKeyFromStorage(ctx, hashedKey, storage);
}
// Default fallback
return await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "key",
value: hashedKey,
},
],
});
}
/**
* Unified getter for API keys by ID
*/
export async function getApiKeyById(
ctx: GenericEndpointContext,
id: string,
opts: PredefinedApiKeyOptions,
): Promise<ApiKey | null> {
const storage = getStorageInstance(ctx, opts);
// Database mode only
if (opts.storage === "database") {
return await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "id",
value: id,
},
],
});
}
// Secondary storage mode with fallback
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
if (storage) {
const cached = await getApiKeyByIdFromStorage(ctx, id, storage);
if (cached) {
return cached;
}
}
const dbKey = await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "id",
value: id,
},
],
});
if (dbKey && storage) {
// Populate secondary storage for future reads
const ttl = calculateTTL(dbKey);
await setApiKeyInStorage(ctx, dbKey, storage, ttl);
}
return dbKey;
}
// Secondary storage mode only
if (opts.storage === "secondary-storage") {
if (!storage) {
return null;
}
return await getApiKeyByIdFromStorage(ctx, id, storage);
}
// Default fallback
return await ctx.context.adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "id",
value: id,
},
],
});
}
/**
* Unified setter for API keys with support for all storage modes
*/
export async function setApiKey(
ctx: GenericEndpointContext,
apiKey: ApiKey,
opts: PredefinedApiKeyOptions,
): Promise<void> {
const storage = getStorageInstance(ctx, opts);
const ttl = calculateTTL(apiKey);
// Database mode only - handled by adapter in route handlers
if (opts.storage === "database") {
return;
}
// Secondary storage mode (with or without fallback)
if (opts.storage === "secondary-storage") {
if (!storage) {
throw new Error(
"Secondary storage is required when storage mode is 'secondary-storage'",
);
}
await setApiKeyInStorage(ctx, apiKey, storage, ttl);
return;
}
}
/**
* Unified deleter for API keys with support for all storage modes
*/
export async function deleteApiKey(
ctx: GenericEndpointContext,
apiKey: ApiKey,
opts: PredefinedApiKeyOptions,
): Promise<void> {
const storage = getStorageInstance(ctx, opts);
// Database mode only - handled by adapter in route handlers
if (opts.storage === "database") {
return;
}
// Secondary storage mode (with or without fallback)
if (opts.storage === "secondary-storage") {
if (!storage) {
throw new Error(
"Secondary storage is required when storage mode is 'secondary-storage'",
);
}
await deleteApiKeyFromStorage(ctx, apiKey, storage);
return;
}
}
export interface ListApiKeysOptions {
limit?: number;
offset?: number;
sortBy?: string;
sortDirection?: "asc" | "desc";
}
export interface ListApiKeysResult {
apiKeys: ApiKey[];
total: number;
}
/**
* Apply sorting and pagination to an array of API keys in memory
* Used for secondary storage mode where we can't rely on database operations
*/
function applySortingAndPagination(
apiKeys: ApiKey[],
sortBy?: string,
sortDirection?: "asc" | "desc",
limit?: number,
offset?: number,
): ApiKey[] {
let result = [...apiKeys];
// Apply sorting if sortBy is specified
if (sortBy) {
const direction = sortDirection || "asc";
result.sort((a, b) => {
const aValue = a[sortBy as keyof ApiKey];
const bValue = b[sortBy as keyof ApiKey];
// Handle null/undefined values
if (aValue == null && bValue == null) return 0;
if (aValue == null) return direction === "asc" ? -1 : 1;
if (bValue == null) return direction === "asc" ? 1 : -1;
// Compare values
if (aValue < bValue) return direction === "asc" ? -1 : 1;
if (aValue > bValue) return direction === "asc" ? 1 : -1;
return 0;
});
}
// Apply pagination
if (offset !== undefined) {
result = result.slice(offset);
}
if (limit !== undefined) {
result = result.slice(0, limit);
}
return result;
}
/**
* List API keys for a reference (user or org) with support for all storage modes
*/
export async function listApiKeys(
ctx: GenericEndpointContext,
referenceId: string,
opts: PredefinedApiKeyOptions,
paginationOpts?: ListApiKeysOptions,
): Promise<ListApiKeysResult> {
const storage = getStorageInstance(ctx, opts);
const { limit, offset, sortBy, sortDirection } = paginationOpts || {};
// Database mode only
if (opts.storage === "database") {
const [apiKeys, total] = await Promise.all([
ctx.context.adapter.findMany<ApiKey>({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
limit,
offset,
sortBy: sortBy
? { field: sortBy, direction: sortDirection || "asc" }
: undefined,
}),
ctx.context.adapter.count({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
}),
]);
return { apiKeys, total };
}
// Secondary storage mode with fallback
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
const refKey = getStorageKeyByReferenceId(referenceId);
if (storage) {
const refListData = await storage.get(refKey);
let keyIds: string[] = [];
if (refListData && typeof refListData === "string") {
try {
keyIds = JSON.parse(refListData);
} catch {
keyIds = [];
}
} else if (Array.isArray(refListData)) {
keyIds = refListData;
}
if (keyIds.length > 0) {
const apiKeys: ApiKey[] = [];
for (const id of keyIds) {
const apiKey = await getApiKeyByIdFromStorage(ctx, id, storage);
if (apiKey) {
apiKeys.push(apiKey);
}
}
// Apply sorting and pagination in memory for secondary storage
const sortedKeys = applySortingAndPagination(
apiKeys,
sortBy,
sortDirection,
limit,
offset,
);
return { apiKeys: sortedKeys, total: apiKeys.length };
}
}
// Fallback to database
const [dbKeys, total] = await Promise.all([
ctx.context.adapter.findMany<ApiKey>({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
limit,
offset,
sortBy: sortBy
? { field: sortBy, direction: sortDirection || "asc" }
: undefined,
}),
ctx.context.adapter.count({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
}),
]);
// Populate secondary storage with fetched keys
if (storage && dbKeys.length > 0) {
const keyIds: string[] = [];
for (const apiKey of dbKeys) {
// Store each key in secondary storage
const ttl = calculateTTL(apiKey);
await setApiKeyInStorage(ctx, apiKey, storage, ttl);
keyIds.push(apiKey.id);
}
// Update reference's key list in secondary storage
await storage.set(refKey, JSON.stringify(keyIds));
}
return { apiKeys: dbKeys, total };
}
// Secondary storage mode only
if (opts.storage === "secondary-storage") {
if (!storage) {
return { apiKeys: [], total: 0 };
}
const refKey = getStorageKeyByReferenceId(referenceId);
const refListData = await storage.get(refKey);
let keyIds: string[] = [];
if (refListData && typeof refListData === "string") {
try {
keyIds = JSON.parse(refListData);
} catch {
return { apiKeys: [], total: 0 };
}
} else if (Array.isArray(refListData)) {
keyIds = refListData;
} else {
return { apiKeys: [], total: 0 };
}
const apiKeys: ApiKey[] = [];
for (const id of keyIds) {
const apiKey = await getApiKeyByIdFromStorage(ctx, id, storage);
if (apiKey) {
apiKeys.push(apiKey);
}
}
// Apply sorting and pagination in memory for secondary storage
const sortedKeys = applySortingAndPagination(
apiKeys,
sortBy,
sortDirection,
limit,
offset,
);
return { apiKeys: sortedKeys, total: apiKeys.length };
}
// Default fallback
const [apiKeys, total] = await Promise.all([
ctx.context.adapter.findMany<ApiKey>({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
limit,
offset,
sortBy: sortBy
? { field: sortBy, direction: sortDirection || "asc" }
: undefined,
}),
ctx.context.adapter.count({
model: "apikey",
where: [
{
field: "referenceId",
value: referenceId,
},
],
}),
]);
return { apiKeys, total };
}