mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(api-key)!: distinguish verification failures from invalid keys
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user