mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
feat(api-key): add option to disable inline expired key cleanup
This commit is contained in:
@@ -454,6 +454,10 @@ This function will delete all API keys that have an expired expiration date.
|
||||
We automatically delete expired API keys every time any apiKey plugin
|
||||
endpoints were called, however they are rate-limited to a 10 second cool down
|
||||
each call to prevent multiple calls to the database.
|
||||
|
||||
If you'd rather manage cleanup yourself, set `keyExpiration.autoCleanup` to
|
||||
`false` and call `auth.api.deleteAllExpiredApiKeys()` from a cron job or other
|
||||
external maintenance flow.
|
||||
</Callout>
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -86,7 +86,7 @@ The minimum length of the name.
|
||||
|
||||
Whether to enable metadata for an API key.
|
||||
|
||||
`keyExpiration` <span className="opacity-70">`{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; }`</span>
|
||||
`keyExpiration` <span className="opacity-70">`{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; autoCleanup?: boolean; }`</span>
|
||||
|
||||
Customize the key expiration.
|
||||
|
||||
@@ -113,6 +113,13 @@ Customize the key expiration.
|
||||
|
||||
The maximum expiresIn value allowed to be set from the client. in days.
|
||||
Default is `365`.
|
||||
|
||||
`autoCleanup` <span className="opacity-70">`boolean`</span>
|
||||
|
||||
Whether to automatically clean up expired API keys inline during API key requests.
|
||||
If `false`, inline cleanup is disabled and you can call `auth.api.deleteAllExpiredApiKeys()`
|
||||
from a cron job or another external maintenance task instead.
|
||||
Default is `true`.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ describe("api-key", async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const setCleanupCheckTime = (date = new Date("2030-01-01T00:00:00.000Z")) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(date);
|
||||
};
|
||||
|
||||
const advanceCleanupCheckTime = () => {
|
||||
vi.advanceTimersByTime(11_000);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// CREATE API KEY
|
||||
// =========================================================================
|
||||
@@ -4435,4 +4444,303 @@ describe("api-key", async () => {
|
||||
expect(updatedKey.referenceId).toBe(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyExpiration.autoCleanup", () => {
|
||||
it("should keep inline cleanup enabled by default", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const { auth, signInWithTestUser } = await getTestInstance({
|
||||
plugins: [apiKey()],
|
||||
});
|
||||
|
||||
const { user } = await signInWithTestUser();
|
||||
const ctx = await auth.$context;
|
||||
const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany");
|
||||
|
||||
await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteManySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "apikey",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable inline cleanup for create, get, list, update, and delete when autoCleanup is false", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const { auth, signInWithTestUser } = await getTestInstance({
|
||||
plugins: [
|
||||
apiKey({
|
||||
keyExpiration: {
|
||||
autoCleanup: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers, user } = await signInWithTestUser();
|
||||
const ctx = await auth.$context;
|
||||
const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany");
|
||||
|
||||
const createdKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.getApiKey({
|
||||
query: {
|
||||
id: createdKey.id,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.listApiKeys({
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.updateApiKey({
|
||||
body: {
|
||||
keyId: createdKey.id,
|
||||
name: "updated-inline-cleanup-disabled",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.deleteApiKey({
|
||||
body: {
|
||||
keyId: createdKey.id,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not run inline cleanup from the API key session hook when autoCleanup is false", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const { auth, signInWithTestUser } = await getTestInstance({
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableSessionForAPIKeys: true,
|
||||
keyExpiration: {
|
||||
autoCleanup: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { user } = await signInWithTestUser();
|
||||
const ctx = await auth.$context;
|
||||
const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany");
|
||||
|
||||
const createdKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
deleteManySpy.mockClear();
|
||||
advanceCleanupCheckTime();
|
||||
|
||||
const apiKeyHeaders = new Headers({
|
||||
"x-api-key": createdKey.key,
|
||||
});
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: apiKeyHeaders,
|
||||
});
|
||||
|
||||
expect(session?.session).toBeDefined();
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not queue expired key cleanup during verify when autoCleanup is false and deferUpdates is enabled", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const deferredPromises: Array<Promise<unknown>> = [];
|
||||
const { auth, signInWithTestUser } = await getTestInstance({
|
||||
advanced: {
|
||||
backgroundTasks: {
|
||||
handler: (promise: Promise<unknown>) => {
|
||||
deferredPromises.push(promise);
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({
|
||||
deferUpdates: true,
|
||||
keyExpiration: {
|
||||
autoCleanup: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { user } = await signInWithTestUser();
|
||||
const ctx = await auth.$context;
|
||||
const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany");
|
||||
|
||||
const createdKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
deleteManySpy.mockClear();
|
||||
deferredPromises.length = 0;
|
||||
|
||||
const result = await auth.api.verifyApiKey({
|
||||
body: {
|
||||
key: createdKey.key,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(deferredPromises.length).toBeGreaterThan(0);
|
||||
|
||||
await Promise.all(deferredPromises);
|
||||
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should still delete expired API keys through the explicit cleanup endpoint when autoCleanup is false", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const { auth, signInWithTestUser, db } = await getTestInstance({
|
||||
plugins: [
|
||||
apiKey({
|
||||
keyExpiration: {
|
||||
autoCleanup: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { user } = await signInWithTestUser();
|
||||
|
||||
const createdKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await db.update({
|
||||
model: "apikey",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: createdKey.id,
|
||||
},
|
||||
],
|
||||
update: {
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
const expiredKeyBeforeCleanup = await db.findOne({
|
||||
model: "apikey",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: createdKey.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(expiredKeyBeforeCleanup).not.toBeNull();
|
||||
|
||||
const result = await auth.api.deleteAllExpiredApiKeys();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const expiredKeyAfterCleanup = await db.findOne({
|
||||
model: "apikey",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: createdKey.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(expiredKeyAfterCleanup).toBeNull();
|
||||
});
|
||||
|
||||
it("should use the resolved key configuration to decide inline cleanup in multi-config routes", async () => {
|
||||
setCleanupCheckTime();
|
||||
|
||||
const { auth, signInWithTestUser } = await getTestInstance({
|
||||
plugins: [
|
||||
apiKey([
|
||||
{
|
||||
configId: "default",
|
||||
keyExpiration: {
|
||||
autoCleanup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
configId: "manual-cleanup",
|
||||
keyExpiration: {
|
||||
autoCleanup: false,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers, user } = await signInWithTestUser();
|
||||
const ctx = await auth.$context;
|
||||
const deleteManySpy = vi.spyOn(ctx.adapter, "deleteMany");
|
||||
|
||||
const defaultKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
configId: "default",
|
||||
},
|
||||
});
|
||||
const manualCleanupKey = await auth.api.createApiKey({
|
||||
body: {
|
||||
userId: user.id,
|
||||
configId: "manual-cleanup",
|
||||
},
|
||||
});
|
||||
|
||||
deleteManySpy.mockClear();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.getApiKey({
|
||||
query: {
|
||||
id: manualCleanupKey.id,
|
||||
configId: "manual-cleanup",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).not.toHaveBeenCalled();
|
||||
|
||||
advanceCleanupCheckTime();
|
||||
await auth.api.getApiKey({
|
||||
query: {
|
||||
id: defaultKey.id,
|
||||
configId: "default",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
expect(deleteManySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import { generateRandomString } from "better-auth/crypto";
|
||||
import { mergeSchema } from "better-auth/db";
|
||||
import { API_KEY_ERROR_CODES } from "./error-codes";
|
||||
import type { PredefinedApiKeyOptions } from "./routes";
|
||||
import { createApiKeyRoutes, deleteAllExpiredApiKeys } from "./routes";
|
||||
import {
|
||||
createApiKeyRoutes,
|
||||
deleteAllExpiredApiKeys,
|
||||
resolveConfiguration,
|
||||
} from "./routes";
|
||||
import { validateApiKey } from "./routes/verify-api-key";
|
||||
import { apiKeySchema } from "./schema";
|
||||
import type { ApiKeyConfigurationOptions, ApiKeyOptions } from "./types";
|
||||
@@ -94,6 +98,7 @@ export function apiKey(
|
||||
config?.keyExpiration?.disableCustomExpiresTime ?? false,
|
||||
maxExpiresIn: config?.keyExpiration?.maxExpiresIn ?? 365,
|
||||
minExpiresIn: config?.keyExpiration?.minExpiresIn ?? 1,
|
||||
autoCleanup: config?.keyExpiration?.autoCleanup ?? true,
|
||||
},
|
||||
startingCharactersConfig: {
|
||||
shouldStore: config?.startingCharactersConfig?.shouldStore ?? true,
|
||||
@@ -211,21 +216,29 @@ export function apiKey(
|
||||
schema,
|
||||
});
|
||||
|
||||
const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch(
|
||||
(err) => {
|
||||
ctx.context.logger.error(
|
||||
"Failed to delete expired API keys:",
|
||||
err,
|
||||
);
|
||||
},
|
||||
const resolvedConfig = resolveConfiguration(
|
||||
ctx.context,
|
||||
configurations,
|
||||
apiKey.configId,
|
||||
);
|
||||
if (config.deferUpdates) {
|
||||
ctx.context.runInBackground(cleanupTask);
|
||||
|
||||
if (resolvedConfig.keyExpiration.autoCleanup) {
|
||||
const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch(
|
||||
(err) => {
|
||||
ctx.context.logger.error(
|
||||
"Failed to delete expired API keys:",
|
||||
err,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (resolvedConfig.deferUpdates) {
|
||||
ctx.context.runInBackground(cleanupTask);
|
||||
}
|
||||
}
|
||||
|
||||
// Session mocking only works for user-owned API keys
|
||||
// Determine the reference type from the configuration
|
||||
const referencesType = config.references ?? "user";
|
||||
const referencesType = resolvedConfig.references ?? "user";
|
||||
if (referencesType !== "user") {
|
||||
const msg = API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY;
|
||||
throw APIError.from("UNAUTHORIZED", msg);
|
||||
|
||||
@@ -419,7 +419,9 @@ export function createApiKey({
|
||||
throw APIError.from("BAD_REQUEST", ERROR_CODES.NAME_REQUIRED);
|
||||
}
|
||||
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
if (opts.keyExpiration.autoCleanup) {
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
}
|
||||
|
||||
const key = await keyGenerator({
|
||||
length: opts.defaultKeyLength,
|
||||
|
||||
@@ -163,7 +163,9 @@ export function deleteApiKey({
|
||||
message: error?.message,
|
||||
});
|
||||
}
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
if (opts.keyExpiration.autoCleanup) {
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
}
|
||||
return ctx.json({
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -225,7 +225,9 @@ export function getApiKey({
|
||||
throw APIError.from("NOT_FOUND", ERROR_CODES.KEY_NOT_FOUND);
|
||||
}
|
||||
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
if (opts.keyExpiration.autoCleanup) {
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
}
|
||||
|
||||
// Migrate legacy double-stringified metadata if needed
|
||||
const metadata = await migrateDoubleStringifiedMetadata(
|
||||
|
||||
@@ -279,14 +279,11 @@ export function listApiKeys({
|
||||
const expectedReferencesType = organizationId ? "organization" : "user";
|
||||
|
||||
let allApiKeys: ApiKey[] = [];
|
||||
const resolvedConfig = configId
|
||||
? resolveConfiguration(ctx.context, configurations, configId)
|
||||
: null;
|
||||
|
||||
if (configId) {
|
||||
const resolvedConfig = resolveConfiguration(
|
||||
ctx.context,
|
||||
configurations,
|
||||
configId,
|
||||
);
|
||||
|
||||
if (resolvedConfig) {
|
||||
const { apiKeys } = await listApiKeysFromStorage(
|
||||
ctx,
|
||||
referenceId,
|
||||
@@ -366,7 +363,13 @@ export function listApiKeys({
|
||||
paginatedApiKeys = paginatedApiKeys.slice(0, limit);
|
||||
}
|
||||
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
const shouldAutoCleanup = resolvedConfig
|
||||
? resolvedConfig.keyExpiration.autoCleanup
|
||||
: configurations.every((config) => config.keyExpiration.autoCleanup);
|
||||
|
||||
if (shouldAutoCleanup) {
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
}
|
||||
|
||||
// Build response with parsed metadata (synchronous, no DB calls)
|
||||
const returningApiKeys = paginatedApiKeys.map((apiKey) => {
|
||||
|
||||
@@ -475,7 +475,9 @@ export function updateApiKey({
|
||||
});
|
||||
}
|
||||
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
if (opts.keyExpiration.autoCleanup) {
|
||||
deleteAllExpiredApiKeys(ctx.context);
|
||||
}
|
||||
|
||||
// Migrate legacy double-stringified metadata if needed
|
||||
const migratedMetadata = await migrateDoubleStringifiedMetadata(
|
||||
|
||||
@@ -298,15 +298,18 @@ export function verifyApiKey({
|
||||
? resolveConfiguration(ctx.context, configurations, apiKey.configId)
|
||||
: lookupOpts;
|
||||
|
||||
if (opts.deferUpdates) {
|
||||
ctx.context.runInBackground(
|
||||
deleteAllExpiredApiKeys(ctx.context).catch((err) => {
|
||||
if (opts.keyExpiration.autoCleanup) {
|
||||
const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch(
|
||||
(err) => {
|
||||
ctx.context.logger.error(
|
||||
"Failed to delete expired API keys:",
|
||||
err,
|
||||
);
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (opts.deferUpdates) {
|
||||
ctx.context.runInBackground(cleanupTask);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.context.logger.error("Failed to validate API key:", error);
|
||||
|
||||
@@ -162,6 +162,15 @@ export interface ApiKeyConfigurationOptions {
|
||||
* @default 365
|
||||
*/
|
||||
maxExpiresIn?: number;
|
||||
/**
|
||||
* Whether to automatically clean up expired API keys inline during API key requests.
|
||||
*
|
||||
* If `false`, expired key cleanup must be handled manually, for example by calling
|
||||
* `auth.api.deleteAllExpiredApiKeys()` from a cron job.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoCleanup?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user