fix(api-key)!: distinguish verification failures from invalid keys

This commit is contained in:
Taesu
2026-10-10 13:27:33 +09:00
parent 9e0b1780b0
commit 4cf1c5b4d0
7 changed files with 642 additions and 194 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/api-key": minor
---
`verifyApiKey` now throws an `APIError` when verification fails to complete instead of reporting an invalid key. Key validation rejections still return `valid: false`.
@@ -197,6 +197,12 @@ Otherwise if it throws, it will throw an `APIError`.
#### Result
| Outcome | Behavior |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| The key is accepted | Returns `valid: true`, the key details, and `error: null`. |
| The key is rejected | Returns `valid: false`, an error describing the rejection, and `key: null`. |
| Verification fails to complete | Throws an `APIError`, for example when a database connection fails. Handle this separately from a rejected key. |
```ts
type Result = {
valid: boolean;
+18 -18
View File
@@ -1,11 +1,11 @@
import type { SecondaryStorage } from "@better-auth/core/db";
import type { APIError } from "@better-auth/core/error";
import { isAPIError } from "@better-auth/core/utils/is-api-error";
import { getTestInstance } from "better-auth/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiKey, API_KEY_ERROR_CODES as ERROR_CODES } from ".";
import { apiKeyClient } from "./client";
import type { ApiKey } from "./types";
import { isAPIError } from "./utils";
describe("api-key", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
@@ -5288,24 +5288,24 @@ describe("concurrent verification enforces atomic counters", async () => {
});
});
describe("listApiKeys with integer user.id (postgres + serial)", async () => {
const testUserEmail = `api-key-serial-${crypto.randomUUID()}@test.com`;
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [apiKey()],
advanced: {
database: { generateId: "serial" },
},
},
{
testWith: "postgres",
testUser: { email: testUserEmail },
clientOptions: { plugins: [apiKeyClient()] },
},
);
const { headers } = await signInWithTestUser();
describe("listApiKeys with integer user.id (postgres + serial)", () => {
it("returns the key that createApiKey just wrote", async () => {
const testUserEmail = `api-key-serial-${crypto.randomUUID()}@test.com`;
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [apiKey()],
advanced: {
database: { generateId: "serial" },
},
},
{
testWith: "postgres",
testUser: { email: testUserEmail },
clientOptions: { plugins: [apiKeyClient()] },
},
);
const { headers } = await signInWithTestUser();
const created = await auth.api.createApiKey({ body: {}, headers });
expect(created.id).toBeDefined();
+105 -82
View File
@@ -1,6 +1,7 @@
import type { BetterAuthPlugin, HookEndpointContext } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { getIP } from "@better-auth/core/utils/ip";
import { isAPIError } from "@better-auth/core/utils/is-api-error";
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import { BetterAuthError } from "better-auth";
@@ -171,102 +172,121 @@ export function apiKey(
{
matcher: (ctx) => !!findApiKeyAndConfig(ctx),
handler: createAuthMiddleware(async (ctx) => {
const result = findApiKeyAndConfig(ctx)!;
const { key, config } = result;
try {
const result = findApiKeyAndConfig(ctx)!;
const { key, config } = result;
if (typeof key !== "string") {
throw APIError.from(
"BAD_REQUEST",
API_KEY_ERROR_CODES.INVALID_API_KEY_GETTER_RETURN_TYPE,
);
}
if (typeof key !== "string") {
throw APIError.from(
"BAD_REQUEST",
API_KEY_ERROR_CODES.INVALID_API_KEY_GETTER_RETURN_TYPE,
);
}
if (key.length < config.defaultKeyLength) {
throw APIError.from(
"FORBIDDEN",
API_KEY_ERROR_CODES.INVALID_API_KEY,
);
}
if (config.customAPIKeyValidator) {
const isValid = await config.customAPIKeyValidator({
ctx,
key,
});
if (!isValid) {
if (key.length < config.defaultKeyLength) {
throw APIError.from(
"FORBIDDEN",
API_KEY_ERROR_CODES.INVALID_API_KEY,
);
}
}
const { apiKey } = await validateApiKey({
key,
ctx,
lookupOpts: config,
configurations,
schema,
expectedConfigId: config.configId,
});
if (config.customAPIKeyValidator) {
const isValid = await config.customAPIKeyValidator({
ctx,
key,
});
if (!isValid) {
throw APIError.from(
"FORBIDDEN",
API_KEY_ERROR_CODES.INVALID_API_KEY,
);
}
}
const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch(
(err) => {
ctx.context.logger.error(
"Failed to delete expired API keys:",
err,
);
},
);
if (config.deferUpdates) {
ctx.context.runInBackground(cleanupTask);
}
const { apiKey } = await validateApiKey({
key,
ctx,
lookupOpts: config,
configurations,
schema,
expectedConfigId: config.configId,
});
// Session mocking only works for user-owned API keys
// Determine the reference type from the configuration
const referencesType = config.references ?? "user";
if (referencesType !== "user") {
const msg = API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY;
throw APIError.from("UNAUTHORIZED", msg);
}
const cleanupTask = deleteAllExpiredApiKeys(ctx.context).catch(
(err) => {
ctx.context.logger.error(
"Failed to delete expired API keys:",
err,
);
},
);
if (config.deferUpdates) {
ctx.context.runInBackground(cleanupTask);
}
const user = await ctx.context.internalAdapter.findUserById(
apiKey.referenceId,
);
if (!user) {
const msg = API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY;
throw APIError.from("UNAUTHORIZED", msg);
}
// Session mocking only works for user-owned API keys
// Determine the reference type from the configuration
const referencesType = config.references ?? "user";
if (referencesType !== "user") {
const msg =
API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY;
throw APIError.from("UNAUTHORIZED", msg);
}
const session = {
user,
session: {
id: apiKey.id,
token: key,
userId: apiKey.referenceId,
userAgent: ctx.request?.headers.get("user-agent") ?? null,
ipAddress: ctx.request
? getIP(ctx.request, ctx.context.options)
: null,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt:
apiKey.expiresAt ||
getDate(
ctx.context.options.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days
"ms",
),
},
};
const user = await ctx.context.internalAdapter.findUserById(
apiKey.referenceId,
);
if (!user) {
const msg =
API_KEY_ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY;
throw APIError.from("UNAUTHORIZED", msg);
}
ctx.context.session = session;
if (ctx.path === "/get-session") {
return session;
} else {
return {
context: ctx,
const session = {
user,
session: {
id: apiKey.id,
token: key,
userId: apiKey.referenceId,
userAgent: ctx.request?.headers.get("user-agent") ?? null,
ipAddress: ctx.request
? getIP(ctx.request, ctx.context.options)
: null,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt:
apiKey.expiresAt ||
getDate(
ctx.context.options.session?.expiresIn ||
60 * 60 * 24 * 7, // 7 days
"ms",
),
},
};
ctx.context.session = session;
if (ctx.path === "/get-session") {
return session;
} else {
return {
context: ctx,
};
}
} catch (error) {
if (isAPIError(error)) {
throw error;
}
ctx.context.logger.error("Failed to validate API key:", error);
const apiError = APIError.fromStatus("INTERNAL_SERVER_ERROR", {
message: "Internal error during API key verification.",
});
Object.defineProperty(apiError, "cause", {
value: error,
writable: true,
configurable: true,
});
throw apiError;
}
}),
},
@@ -299,6 +319,9 @@ export function apiKey(
* **server:**
* `auth.api.verifyApiKey`
*
* Returns validation rejections as results. Storage and callback
* execution failures throw an APIError.
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-verify)
*/
verifyApiKey: routes.verifyApiKey,
@@ -0,0 +1,448 @@
import type { SecondaryStorage } from "@better-auth/core/db";
import { APIError } from "@better-auth/core/error";
import type { Auth } from "better-auth";
import { getTestInstance } from "better-auth/test";
import { beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest";
import { apiKey } from "..";
import { apiKeyClient } from "../client";
describe("API key verification failures", () => {
let auth: Auth<{ plugins: [ReturnType<typeof apiKey>] }>;
let userId: string;
beforeAll(async () => {
const instance = await getTestInstance(
{ plugins: [apiKey()] },
{ clientOptions: { plugins: [apiKeyClient()] } },
);
auth = instance.auth;
userId = (await instance.signInWithTestUser()).user.id;
});
it.each([
"findOne",
"update",
"incrementOne",
] as const)("distinguishes database %s failures from invalid keys", async (operation) => {
const created = await auth.api.createApiKey({
body: { userId, remaining: 3 },
});
const context = await auth.$context;
const failure = new Error(
"Connection terminated due to connection timeout",
);
const logError = vi
.spyOn(context.logger, "error")
.mockImplementation(() => {});
const databaseOperation = vi
.spyOn(context.adapter, operation)
.mockRejectedValueOnce(failure);
const verification = auth.api.verifyApiKey({
body: { key: created.key },
});
await expect(verification).rejects.toBeInstanceOf(APIError);
await expect(verification).rejects.toHaveProperty("cause", failure);
await expect(verification).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
statusCode: 500,
body: {
message: "Internal error during API key verification.",
},
});
expect(logError).toHaveBeenCalledWith(
"Failed to validate API key:",
failure,
);
expect(databaseOperation).toHaveBeenCalledTimes(1);
databaseOperation.mockRestore();
const recovered = await auth.api.verifyApiKey({
body: { key: created.key },
});
expect(recovered.valid).toBe(true);
});
it("keeps missing keys distinguishable from verification failures", async () => {
const context = await auth.$context;
const logError = vi
.spyOn(context.logger, "error")
.mockImplementation(() => {});
const result = await auth.api.verifyApiKey({
body: { key: "missing-api-key" },
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("INVALID_API_KEY");
expect(result.key).toBeNull();
expect(logError).not.toHaveBeenCalled();
if (!result.valid) {
expectTypeOf(result.key).toEqualTypeOf<null>();
expectTypeOf(result.error.message).toEqualTypeOf<string>();
}
});
it("preserves explicit API authentication rejection results", async () => {
const failure = APIError.from("UNAUTHORIZED", {
code: "KEY_DISABLED",
message: "Explicit verification error",
});
const context = await auth.$context;
vi.spyOn(context.adapter, "findOne").mockRejectedValueOnce(failure);
const result = await auth.api.verifyApiKey({
body: { key: "test-api-key" },
});
expect(result).toEqual({ valid: false, error: failure.body, key: null });
});
it.each([
"BAD_REQUEST",
"FORBIDDEN",
"TOO_MANY_REQUESTS",
] as const)("preserves existing %s rejection results", async (status) => {
const failure = APIError.from(status, {
code: "REQUEST_REJECTED",
message: "Explicit client error",
});
const context = await auth.$context;
vi.spyOn(context.adapter, "findOne").mockRejectedValueOnce(failure);
const result = await auth.api.verifyApiKey({
body: { key: "test-api-key" },
});
expect(result).toEqual({ valid: false, error: failure.body, key: null });
});
it.each([
"INTERNAL_SERVER_ERROR",
"SERVICE_UNAVAILABLE",
"GATEWAY_TIMEOUT",
] as const)("propagates explicit %s API errors unchanged", async (status) => {
const failure = APIError.from(status, {
code: "STORAGE_UNAVAILABLE",
message: "Explicit storage error",
});
const context = await auth.$context;
vi.spyOn(context.adapter, "findOne").mockRejectedValueOnce(failure);
await expect(
auth.api.verifyApiKey({ body: { key: "test-api-key" } }),
).rejects.toBe(failure);
});
it("serializes unexpected errors as HTTP 500 without storage details", async () => {
const context = await auth.$context;
const failure = new Error(
"Database connection timed out: private storage details",
);
vi.spyOn(context.adapter, "findOne").mockRejectedValueOnce(failure);
const response = await auth.api.verifyApiKey({
body: { key: "test-api-key" },
asResponse: true,
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
message: "Internal error during API key verification.",
});
});
it("preserves HTTP 503 and retry headers from an explicit service error", async () => {
const context = await auth.$context;
const failure = new APIError(
"SERVICE_UNAVAILABLE",
{
code: "STORAGE_UNAVAILABLE",
message: "Storage temporarily unavailable",
},
{ "retry-after": "5" },
);
vi.spyOn(context.adapter, "findOne").mockRejectedValueOnce(failure);
const response = await auth.api.verifyApiKey({
body: { key: "test-api-key" },
asResponse: true,
});
expect(response.status).toBe(503);
expect(response.headers.get("retry-after")).toBe("5");
expect(await response.json()).toEqual(failure.body);
});
it.each([
"get",
"set",
] as const)("distinguishes secondary storage %s failures from invalid keys", async (operation) => {
const store = new Map<string, string>();
const storage = {
get: vi.fn(async (key: string) => store.get(key) ?? null),
getAndDelete(key) {
const value = store.get(key) ?? null;
store.delete(key);
return value;
},
increment(key) {
const count = Number(store.get(key) ?? 0) + 1;
store.set(key, String(count));
return count;
},
set: vi.fn(async (key: string, value: string) => {
store.set(key, value);
}),
delete: async (key) => {
store.delete(key);
},
} satisfies SecondaryStorage;
const { auth: storageAuth, signInWithTestUser: signInStorageUser } =
await getTestInstance({
plugins: [
apiKey({
storage: "secondary-storage",
customStorage: storage,
}),
],
});
const { user: storageUser } = await signInStorageUser();
const created = await storageAuth.api.createApiKey({
body: { userId: storageUser.id },
});
const failure = new Error("Secondary storage connection timed out");
storage.get.mockClear();
storage.set.mockClear();
storage[operation].mockRejectedValueOnce(failure);
await expect(
storageAuth.api.verifyApiKey({ body: { key: created.key } }),
).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
statusCode: 500,
cause: failure,
});
expect(storage.get).toHaveBeenCalled();
expect(storage[operation]).toHaveBeenCalled();
});
it("reports database write failures even when the secondary storage lookup succeeds", async () => {
const store = new Map<string, string>();
const { auth: cachedAuth, signInWithTestUser: signInCachedUser } =
await getTestInstance({
plugins: [
apiKey({
storage: "secondary-storage",
fallbackToDatabase: true,
customStorage: {
get: async (key) => store.get(key) ?? null,
getAndDelete(key) {
const value = store.get(key) ?? null;
store.delete(key);
return value;
},
increment(key) {
const count = Number(store.get(key) ?? 0) + 1;
store.set(key, String(count));
return count;
},
set: async (key, value) => {
store.set(key, value);
},
delete: async (key) => {
store.delete(key);
},
},
}),
],
});
const { user: cachedUser } = await signInCachedUser();
const created = await cachedAuth.api.createApiKey({
body: { userId: cachedUser.id },
});
const context = await cachedAuth.$context;
const databaseRead = vi.spyOn(context.adapter, "findOne");
vi.spyOn(context.adapter, "update").mockRejectedValueOnce(
new Error("Database write timed out"),
);
await expect(
cachedAuth.api.verifyApiKey({ body: { key: created.key } }),
).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
statusCode: 500,
});
expect(databaseRead).not.toHaveBeenCalled();
});
it("preserves disabled key rejections", async () => {
const created = await auth.api.createApiKey({ body: { userId } });
const context = await auth.$context;
await context.adapter.update({
model: "apikey",
where: [{ field: "id", value: created.id }],
update: { enabled: false },
});
const result = await auth.api.verifyApiKey({ body: { key: created.key } });
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("KEY_DISABLED");
});
it("preserves rate limit rejections", async () => {
const created = await auth.api.createApiKey({
body: { userId, rateLimitMax: 1, rateLimitTimeWindow: 60_000 },
});
const accepted = await auth.api.verifyApiKey({
body: { key: created.key },
});
const rejected = await auth.api.verifyApiKey({
body: { key: created.key },
});
expect(accepted.valid).toBe(true);
if (accepted.valid) {
expectTypeOf(accepted.error).toEqualTypeOf<null>();
expectTypeOf(accepted.key).not.toBeNullable();
}
expect(rejected.valid).toBe(false);
expect(rejected.error?.code).toBe("RATE_LIMITED");
expect(rejected.error).toMatchObject({
details: { tryAgainIn: expect.any(Number) },
});
});
it.each([
undefined,
"default",
])("keeps custom validator rejection separate from execution failure for config %s", async (configId) => {
const customAPIKeyValidator = vi.fn(async () => true);
const { auth: customAuth, signInWithTestUser: signInCustomUser } =
await getTestInstance({ plugins: [apiKey({ customAPIKeyValidator })] });
const { user: customUser } = await signInCustomUser();
const created = await customAuth.api.createApiKey({
body: { userId: customUser.id },
});
customAPIKeyValidator.mockResolvedValueOnce(false);
const rejected = await customAuth.api.verifyApiKey({
body: { key: created.key, configId },
});
expect(rejected.valid).toBe(false);
expect(rejected.error?.code).toBe("KEY_NOT_FOUND");
expect(rejected.key).toBeNull();
const failure = APIError.from("SERVICE_UNAVAILABLE", {
code: "VALIDATION_UNAVAILABLE",
message: "Custom validation service failed",
});
customAPIKeyValidator.mockRejectedValueOnce(failure);
await expect(
customAuth.api.verifyApiKey({ body: { key: created.key, configId } }),
).rejects.toBe(failure);
const unexpected = new Error("Custom validation service unavailable");
customAPIKeyValidator.mockRejectedValueOnce(unexpected);
await expect(
customAuth.api.verifyApiKey({ body: { key: created.key, configId } }),
).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
cause: unexpected,
});
});
});
describe("API key session validation", () => {
let auth: Auth<{ plugins: [ReturnType<typeof apiKey>] }>;
let client: Awaited<ReturnType<typeof getTestInstance>>["client"];
let userId: string;
beforeAll(async () => {
const instance = await getTestInstance(
{ plugins: [apiKey({ enableSessionForAPIKeys: true })] },
{ clientOptions: { plugins: [apiKeyClient()] } },
);
auth = instance.auth;
client = instance.client;
userId = (await instance.signInWithTestUser()).user.id;
});
it("converts a missing key decision to HTTP 401", async () => {
const result = await client.getSession({
fetchOptions: { headers: { "x-api-key": "x".repeat(64) } },
});
expect(result.error?.status).toBe(401);
expect(result.error?.code).toBe("INVALID_API_KEY");
});
it("converts a rate limit decision to HTTP 429 with retry details", async () => {
const created = await auth.api.createApiKey({
body: { userId, rateLimitMax: 1, rateLimitTimeWindow: 60_000 },
});
const fetchOptions = { headers: { "x-api-key": created.key } };
const accepted = await client.getSession({ fetchOptions });
const rejected = await client.getSession({ fetchOptions });
expect(accepted.data?.user.id).toBe(userId);
expect(rejected.error?.status).toBe(429);
expect(rejected.error?.code).toBe("RATE_LIMITED");
expect(rejected.error).toMatchObject({
details: { tryAgainIn: expect.any(Number) },
});
});
it("normalizes unexpected storage failures for server and HTTP calls", async () => {
const created = await auth.api.createApiKey({ body: { userId } });
const context = await auth.$context;
vi.spyOn(context.adapter, "findOne").mockRejectedValue(
new Error("Database unavailable"),
);
const headers = new Headers({ "x-api-key": created.key });
await expect(auth.api.getSession({ headers })).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
statusCode: 500,
});
const result = await client.getSession({ fetchOptions: { headers } });
expect(result.error?.status).toBe(500);
expect(result.error?.message).not.toContain("Database unavailable");
});
it("preserves custom rejection and normalizes callback failures", async () => {
const customAPIKeyValidator = vi.fn(async () => true);
const {
auth: customAuth,
client: customClient,
signInWithTestUser: signInCustomUser,
} = await getTestInstance(
{
plugins: [
apiKey({ enableSessionForAPIKeys: true, customAPIKeyValidator }),
],
},
{ clientOptions: { plugins: [apiKeyClient()] } },
);
const { user: customUser } = await signInCustomUser();
const created = await customAuth.api.createApiKey({
body: { userId: customUser.id },
});
const headers = new Headers({ "x-api-key": created.key });
customAPIKeyValidator.mockResolvedValueOnce(false);
const rejected = await customClient.getSession({
fetchOptions: { headers },
});
expect(rejected.error?.status).toBe(403);
expect(rejected.error?.code).toBe("INVALID_API_KEY");
const failure = new Error("Custom validation service unavailable");
customAPIKeyValidator.mockRejectedValueOnce(failure);
await expect(customAuth.api.getSession({ headers })).rejects.toMatchObject({
status: "INTERNAL_SERVER_ERROR",
cause: failure,
});
});
});
+60 -83
View File
@@ -1,6 +1,7 @@
import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import { APIError } from "@better-auth/core/error";
import { isAPIError } from "@better-auth/core/utils/is-api-error";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { role } from "better-auth/plugins/access";
import * as z from "zod";
@@ -15,7 +16,6 @@ import {
import { evaluateRateLimit } from "../rate-limit";
import type { apiKeySchema } from "../schema";
import type { ApiKey } from "../types";
import { isAPIError } from "../utils";
import type { PredefinedApiKeyOptions } from ".";
import { configIdMatches, resolveConfiguration } from ".";
@@ -123,7 +123,7 @@ export async function validateApiKey({
if (!apiKeyPermissions) {
throw APIError.from("UNAUTHORIZED", ERROR_CODES.KEY_NOT_FOUND);
}
const r = role(apiKeyPermissions as any);
const r = role(apiKeyPermissions);
const result = r.authorize(permissions);
if (!result.success) {
throw APIError.from("UNAUTHORIZED", ERROR_CODES.KEY_NOT_FOUND);
@@ -441,10 +441,7 @@ async function claimUsageInSecondaryStorage({
const updated = await performUpdate();
if (!updated) {
throw APIError.from(
"INTERNAL_SERVER_ERROR",
ERROR_CODES.FAILED_TO_UPDATE_API_KEY,
);
throw APIError.from("UNAUTHORIZED", ERROR_CODES.INVALID_API_KEY);
}
return updated;
}
@@ -518,34 +515,27 @@ export function verifyApiKey({
},
async (ctx) => {
const { configId, key } = ctx.body;
// Use provided configId or fall back to default config
const lookupOpts = resolveConfiguration(
ctx.context,
configurations,
configId,
);
// Scoped: lookup config is the key's config, so run the validator now.
// Unscoped runs it inside validateApiKey once the key's config is known.
if (configId !== undefined && lookupOpts.customAPIKeyValidator) {
const isValid = await lookupOpts.customAPIKeyValidator({ ctx, key });
if (!isValid) {
return ctx.json({
valid: false,
error: {
message: ERROR_CODES.INVALID_API_KEY,
code: "KEY_NOT_FOUND" as const,
},
key: null,
});
}
}
let apiKey: ApiKey | null = null;
let opts: PredefinedApiKeyOptions;
try {
if (configId !== undefined && lookupOpts.customAPIKeyValidator) {
const valid = await lookupOpts.customAPIKeyValidator({ ctx, key });
if (!valid) {
return ctx.json({
valid: false as const,
error: {
message: ERROR_CODES.INVALID_API_KEY.message,
code: "KEY_NOT_FOUND",
},
key: null,
});
}
}
const result = await validateApiKey({
key,
permissions: ctx.body.permissions,
@@ -554,78 +544,65 @@ export function verifyApiKey({
configurations,
schema,
expectedConfigId: configId,
// Scoped calls already ran the validator above with the right config.
runCustomValidator: configId === undefined,
});
apiKey = result.apiKey;
opts = result.opts;
const { apiKey, opts } = result;
if (opts.deferUpdates) {
ctx.context.runInBackground(
deleteAllExpiredApiKeys(ctx.context).catch((err) => {
deleteAllExpiredApiKeys(ctx.context).catch((error) => {
ctx.context.logger.error(
"Failed to delete expired API keys:",
err,
error,
);
}),
);
}
} catch (error) {
ctx.context.logger.error("Failed to validate API key:", error);
if (isAPIError(error)) {
return ctx.json({
valid: false,
error: {
...error.body,
message: error.body?.message,
code: error.body?.code as string,
},
key: null,
});
}
return ctx.json({
valid: false,
error: {
message: ERROR_CODES.INVALID_API_KEY,
code: "INVALID_API_KEY" as const,
},
key: null,
});
}
const { key: _, ...returningApiKey } = apiKey ?? {
key: 1,
permissions: undefined,
};
// Migrate legacy double-stringified metadata if needed
let migratedMetadata: Record<string, any> | null = null;
if (apiKey) {
migratedMetadata = await migrateDoubleStringifiedMetadata(
const { key: _, ...returningApiKey } = apiKey;
const metadata = await migrateDoubleStringifiedMetadata(
ctx,
apiKey,
opts,
);
const permissions = returningApiKey.permissions
? safeJSONParse<Record<string, string[]>>(returningApiKey.permissions)
: null;
return ctx.json({
valid: true as const,
error: null,
key: {
...returningApiKey,
permissions,
metadata,
} as Omit<ApiKey, "key">,
});
} catch (error) {
if (!isAPIError(error)) {
ctx.context.logger.error("Failed to validate API key:", error);
const apiError = APIError.fromStatus("INTERNAL_SERVER_ERROR", {
message: "Internal error during API key verification.",
});
Object.defineProperty(apiError, "cause", {
value: error,
writable: true,
configurable: true,
});
throw apiError;
}
if (error.statusCode >= 500) {
throw error;
}
return ctx.json({
valid: false as const,
error: {
...error.body,
message: error.message,
code: error.body?.code as string,
},
key: null,
});
}
returningApiKey.permissions = returningApiKey.permissions
? safeJSONParse<{
[key: string]: string[];
}>(returningApiKey.permissions)
: null;
return ctx.json({
valid: true,
error: null,
key:
apiKey === null
? null
: ({
...returningApiKey,
metadata: migratedMetadata,
} as Omit<ApiKey, "key">),
});
},
);
}
-11
View File
@@ -1,14 +1,3 @@
import { APIError } from "@better-auth/core/error";
import { APIError as BaseAPIError } from "better-auth/api";
export const getDate = (span: number, unit: "sec" | "ms" = "ms") => {
return new Date(Date.now() + (unit === "sec" ? span * 1000 : span));
};
export function isAPIError(error: unknown): error is APIError {
return (
error instanceof BaseAPIError ||
error instanceof APIError ||
(error as any)?.name === "APIError"
);
}