diff --git a/.changeset/api-key-verification-errors.md b/.changeset/api-key-verification-errors.md new file mode 100644 index 0000000000..ecd533df95 --- /dev/null +++ b/.changeset/api-key-verification-errors.md @@ -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`. diff --git a/docs/content/docs/plugins/api-key/index.mdx b/docs/content/docs/plugins/api-key/index.mdx index 856467195e..09451bcf9e 100644 --- a/docs/content/docs/plugins/api-key/index.mdx +++ b/docs/content/docs/plugins/api-key/index.mdx @@ -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; diff --git a/packages/api-key/src/api-key.test.ts b/packages/api-key/src/api-key.test.ts index 5d1de77d50..0538ac184a 100644 --- a/packages/api-key/src/api-key.test.ts +++ b/packages/api-key/src/api-key.test.ts @@ -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(); diff --git a/packages/api-key/src/index.ts b/packages/api-key/src/index.ts index 493e645841..1b1af876c0 100644 --- a/packages/api-key/src/index.ts +++ b/packages/api-key/src/index.ts @@ -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, diff --git a/packages/api-key/src/routes/verify-api-key.test.ts b/packages/api-key/src/routes/verify-api-key.test.ts new file mode 100644 index 0000000000..aed9f05219 --- /dev/null +++ b/packages/api-key/src/routes/verify-api-key.test.ts @@ -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] }>; + 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(); + expectTypeOf(result.error.message).toEqualTypeOf(); + } + }); + + 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(); + 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(); + 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(); + 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] }>; + let client: Awaited>["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, + }); + }); +}); diff --git a/packages/api-key/src/routes/verify-api-key.ts b/packages/api-key/src/routes/verify-api-key.ts index 0047329050..195c74b334 100644 --- a/packages/api-key/src/routes/verify-api-key.ts +++ b/packages/api-key/src/routes/verify-api-key.ts @@ -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 | null = null; - if (apiKey) { - migratedMetadata = await migrateDoubleStringifiedMetadata( + const { key: _, ...returningApiKey } = apiKey; + const metadata = await migrateDoubleStringifiedMetadata( ctx, apiKey, opts, ); + const permissions = returningApiKey.permissions + ? safeJSONParse>(returningApiKey.permissions) + : null; + + return ctx.json({ + valid: true as const, + error: null, + key: { + ...returningApiKey, + permissions, + metadata, + } as Omit, + }); + } 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), - }); }, ); } diff --git a/packages/api-key/src/utils.ts b/packages/api-key/src/utils.ts index e3bbdf65d1..5367b0611e 100644 --- a/packages/api-key/src/utils.ts +++ b/packages/api-key/src/utils.ts @@ -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" - ); -}