Files
better-auth/packages/api-key/src/api-key.test.ts

4964 lines
127 KiB
TypeScript

import type { SecondaryStorage } from "@better-auth/core/db";
import type { APIError } from "@better-auth/core/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(
{
plugins: [
apiKey({
enableMetadata: true,
permissions: {
defaultPermissions: {
files: ["read"],
},
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers, user } = await signInWithTestUser();
afterEach(() => {
vi.useRealTimers();
});
// =========================================================================
// CREATE API KEY
// =========================================================================
it("should fail to create API keys from client without headers", async () => {
const apiKeyFail = await client.apiKey.create();
expect(apiKeyFail.data).toBeNull();
expect(apiKeyFail.error).toBeDefined();
expect(apiKeyFail.error?.status).toEqual(401);
expect(apiKeyFail.error?.statusText).toEqual("UNAUTHORIZED");
expect(apiKeyFail.error?.message).toEqual(
ERROR_CODES.UNAUTHORIZED_SESSION.message,
);
});
let firstApiKey: ApiKey;
it("should successfully create API keys from client with headers", async () => {
const apiKey = await client.apiKey.create({}, { headers: headers });
if (apiKey.data) {
firstApiKey = apiKey.data;
}
expect(apiKey.data).not.toBeNull();
expect(apiKey.data?.key).toBeDefined();
expect(apiKey.data?.referenceId).toEqual(user.id);
expect(apiKey.data?.name).toBeNull();
expect(apiKey.data?.prefix).toBeNull();
expect(apiKey.data?.refillInterval).toBeNull();
expect(apiKey.data?.refillAmount).toBeNull();
expect(apiKey.data?.lastRefillAt).toBeNull();
expect(apiKey.data?.enabled).toEqual(true);
expect(apiKey.data?.rateLimitTimeWindow).toEqual(86400000);
expect(apiKey.data?.rateLimitMax).toEqual(10);
expect(apiKey.data?.requestCount).toEqual(0);
expect(apiKey.data?.remaining).toBeNull();
expect(apiKey.data?.lastRequest).toBeNull();
expect(apiKey.data?.expiresAt).toBeNull();
expect(apiKey.data?.createdAt).toBeDefined();
expect(apiKey.data?.updatedAt).toBeDefined();
expect(apiKey.data?.metadata).toBeNull();
expect(apiKey.error).toBeNull();
});
interface Err {
body: {
code: string | undefined;
message: string | undefined;
};
status: string;
statusCode: string;
}
it("should fail to create API Keys from server without headers and userId", async () => {
const res: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({ body: {} });
res.data = apiKey;
} catch (error: any) {
res.error = error;
}
expect(res.data).toBeNull();
expect(res.error).toBeDefined();
expect(res.error?.statusCode).toEqual(401);
expect(res.error?.status).toEqual("UNAUTHORIZED");
expect(res.error?.body.message).toEqual(
ERROR_CODES.UNAUTHORIZED_SESSION.message,
);
});
it("should fail to create api keys from the client if user id is provided", async () => {
const { headers, user } = await signInWithTestUser();
const response = await client.apiKey.create({
userId: user.id,
});
expect(response.error?.status).toBe(401);
const newUser = await auth.api.signUpEmail({
body: {
email: "new-email@email.com",
password: "password",
name: "test-name",
},
});
const response2 = await client.apiKey.create(
{
userId: newUser.user.id,
},
{
headers,
},
);
expect(response2.error?.status).toBe(401);
});
it("should successfully create API keys from server with userId", async () => {
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.key).toBeDefined();
expect(apiKey.referenceId).toEqual(user.id);
expect(apiKey.name).toBeNull();
expect(apiKey.prefix).toBeNull();
expect(apiKey.refillInterval).toBeNull();
expect(apiKey.refillAmount).toBeNull();
expect(apiKey.lastRefillAt).toBeNull();
expect(apiKey.enabled).toEqual(true);
expect(apiKey.rateLimitTimeWindow).toEqual(86400000);
expect(apiKey.rateLimitMax).toEqual(10);
expect(apiKey.requestCount).toEqual(0);
expect(apiKey.remaining).toBeNull();
expect(apiKey.lastRequest).toBeNull();
expect(apiKey.rateLimitEnabled).toBe(true);
});
it("should have the real value from rateLimitEnabled", async () => {
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
rateLimitEnabled: false,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.rateLimitEnabled).toBe(false);
});
it("should have true if the rate limit is undefined", async () => {
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
rateLimitEnabled: undefined,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.rateLimitEnabled).toBe(true);
});
it("should require name in API keys if configured", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
requireName: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await signInWithTestUser();
let err: any;
try {
await auth.api.createApiKey({
body: {
userId: user.id,
},
});
} catch (error) {
err = error;
}
expect(err).toBeDefined();
expect(err.body.message).toBe(ERROR_CODES.NAME_REQUIRED.message);
});
it("should respect rateLimit configuration from plugin options", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
rateLimit: {
enabled: false,
timeWindow: 1000,
maxRequests: 10,
},
enableMetadata: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await signInWithTestUser();
const apiKeyResult = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(apiKeyResult).not.toBeNull();
expect(apiKeyResult.rateLimitEnabled).toBe(false);
expect(apiKeyResult.rateLimitTimeWindow).toBe(1000);
expect(apiKeyResult.rateLimitMax).toBe(10);
});
it("should create the API key with the given name", async () => {
const apiKey = await auth.api.createApiKey({
body: {
name: "test-api-key",
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.name).toEqual("test-api-key");
});
it("should create the API key with a name that's shorter than the allowed minimum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
name: "test-api-key-that-is-shorter-than-the-allowed-minimum",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_NAME_LENGTH.message,
);
});
it("should create the API key with a name that's longer than the allowed maximum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
name: "test-api-key-that-is-longer-than-the-allowed-maximum",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_NAME_LENGTH.message,
);
});
it("should create the API key with the given prefix", async () => {
const prefix = "test-api-key_";
const apiKey = await auth.api.createApiKey({
body: {
prefix: prefix,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.prefix).toEqual(prefix);
expect(apiKey.key.startsWith(prefix)).toEqual(true);
});
it("should create the API key with a prefix that's shorter than the allowed minimum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
prefix: "test-api-key-that-is-shorter-than-the-allowed-minimum",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_PREFIX_LENGTH.message,
);
});
it("should create the API key with a prefix that's longer than the allowed maximum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
prefix: "test-api-key-that-is-longer-than-the-allowed-maximum",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_PREFIX_LENGTH.message,
);
});
it("should create an API key with a custom expiresIn", async () => {
const expiresIn = 60 * 60 * 24 * 7; // 7 days
const expectedResult = Date.now() + expiresIn;
const apiKey = await auth.api.createApiKey({
body: {
expiresIn: expiresIn,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.expiresAt).toBeDefined();
expect(apiKey.expiresAt?.getTime()).toBeGreaterThanOrEqual(expectedResult);
});
it("should support disabling key hashing", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
disableKeyHashing: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const apiKey2 = await auth.api.createApiKey({
body: {},
headers,
});
const res = await (await auth.$context).adapter.findOne<ApiKey>({
model: "apikey",
where: [
{
field: "id",
value: apiKey2.id,
},
],
});
expect(res?.key).toEqual(apiKey2.key);
});
it("should be able to verify with key hashing disabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
disableKeyHashing: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const apiKey2 = await auth.api.createApiKey({
body: {},
headers,
});
const result = await auth.api.verifyApiKey({ body: { key: apiKey2.key } });
expect(result.valid).toEqual(true);
});
it("should fail to create a key with a custom expiresIn value when customExpiresTime is disabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableMetadata: true,
keyExpiration: {
disableCustomExpiresTime: true,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey2 = await auth.api.createApiKey({
body: {
expiresIn: 10000,
},
headers,
});
result.data = apiKey2;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.body.message).toEqual(
ERROR_CODES.KEY_DISABLED_EXPIRATION.message,
);
});
it("should create an API key with an expiresIn that's smaller than the allowed minimum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const expiresIn = 60 * 60 * 24 * 0.5; // half a day
const apiKey = await auth.api.createApiKey({
body: {
expiresIn: expiresIn,
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.EXPIRES_IN_IS_TOO_SMALL.message,
);
});
it("should fail to create an API key with an expiresIn that's larger than the allowed maximum", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const expiresIn = 60 * 60 * 24 * 365 * 10; // 10 year
const apiKey = await auth.api.createApiKey({
body: {
expiresIn: expiresIn,
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.EXPIRES_IN_IS_TOO_LARGE.message,
);
});
it("should fail to create API key with custom refillAndAmount from client auth", async () => {
const apiKey = await client.apiKey.create(
{
refillAmount: 10,
},
{ headers },
);
expect(apiKey.data).toBeNull();
expect(apiKey.error).toBeDefined();
expect(apiKey.error?.statusText).toEqual("BAD_REQUEST");
expect(apiKey.error?.message).toEqual(
ERROR_CODES.SERVER_ONLY_PROPERTY.message,
);
const apiKey2 = await client.apiKey.create(
{
refillInterval: 1001,
},
{ headers },
);
expect(apiKey2.data).toBeNull();
expect(apiKey2.error).toBeDefined();
expect(apiKey2.error?.statusText).toEqual("BAD_REQUEST");
expect(apiKey2.error?.message).toEqual(
ERROR_CODES.SERVER_ONLY_PROPERTY.message,
);
});
it("should fail to create API key when refill interval is provided, but no refill amount", async () => {
const res: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
refillInterval: 1000,
userId: user.id,
},
});
res.data = apiKey;
} catch (error: any) {
res.error = error;
}
expect(res.data).toBeNull();
expect(res.error).toBeDefined();
expect(res.error?.status).toEqual("BAD_REQUEST");
expect(res.error?.body.message).toEqual(
ERROR_CODES.REFILL_INTERVAL_AND_AMOUNT_REQUIRED.message,
);
});
it("should fail to create API key when refill amount is provided, but no refill interval", async () => {
const res: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
refillAmount: 10,
userId: user.id,
},
});
res.data = apiKey;
} catch (error: any) {
res.error = error;
}
expect(res.data).toBeNull();
expect(res.error).toBeDefined();
expect(res.error?.status).toEqual("BAD_REQUEST");
expect(res.error?.body.message).toEqual(
ERROR_CODES.REFILL_AMOUNT_AND_INTERVAL_REQUIRED.message,
);
});
it("should create the API key with the given refill interval & refill amount", async () => {
const refillInterval = 10000;
const refillAmount = 10;
const apiKey = await auth.api.createApiKey({
body: {
refillInterval: refillInterval,
refillAmount: refillAmount,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.refillInterval).toEqual(refillInterval);
expect(apiKey.refillAmount).toEqual(refillAmount);
});
it("should create API Key with custom remaining", async () => {
const remaining = 10;
const apiKey = await auth.api.createApiKey({
body: {
remaining: remaining,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toEqual(remaining);
});
it("should create API Key with remaining explicitly set to null", async () => {
const apiKey = await auth.api.createApiKey({
body: {
remaining: null,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toBeNull();
});
it("should create API Key with remaining explicitly set to null and refillAmount and refillInterval are also set", async () => {
const refillAmount = 10; // Arbitrary non-null value
const refillInterval = 1000;
const apiKey = await auth.api.createApiKey({
body: {
remaining: null,
refillAmount: refillAmount,
refillInterval: refillInterval,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toBeNull();
expect(apiKey.refillAmount).toBe(refillAmount);
expect(apiKey.refillInterval).toBe(refillInterval);
});
it("should create API Key with remaining explicitly set to 0 and refillAmount also set", async () => {
const remaining = 0;
const refillAmount = 10; // Arbitrary non-null value
const refillInterval = 1000;
const apiKey = await auth.api.createApiKey({
body: {
remaining: remaining,
refillAmount: refillAmount,
refillInterval: refillInterval,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toBe(remaining);
expect(apiKey.refillAmount).toBe(refillAmount);
expect(apiKey.refillInterval).toBe(refillInterval);
});
it("should create API Key with remaining undefined and default value of null is respected with refillAmount and refillInterval provided", async () => {
const refillAmount = 10; // Arbitrary non-null value
const refillInterval = 1000;
const apiKey = await auth.api.createApiKey({
body: {
refillAmount: refillAmount,
refillInterval: refillInterval,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toBeNull();
expect(apiKey.refillAmount).toBe(refillAmount);
expect(apiKey.refillInterval).toBe(refillInterval);
});
it("should create API key with invalid metadata", async () => {
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
metadata: "invalid",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_METADATA_TYPE.message,
);
});
it("should create API key with valid metadata", async () => {
const metadata = {
test: "test",
};
const apiKey = await auth.api.createApiKey({
body: {
metadata: metadata,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.metadata).toEqual(metadata);
const res = await auth.api.getApiKey({
query: {
id: apiKey.id,
},
headers,
});
expect(res).not.toBeNull();
if (res) {
expect(res.metadata).toEqual(metadata);
}
});
it("create API key's returned metadata should be an object", async () => {
const metadata = {
test: "test-123",
};
const apiKey = await auth.api.createApiKey({
body: {
metadata: metadata,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.metadata.test).toBeDefined();
expect(apiKey.metadata.test).toEqual(metadata.test);
});
it("create API key with with metadata when metadata is disabled (should fail)", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableMetadata: false,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const metadata = {
test: "test-123",
};
const result: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.createApiKey({
body: {
metadata: metadata,
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.METADATA_DISABLED.message,
);
});
it("should have the first 6 characters of the key as the start property", async () => {
const { data: apiKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(apiKey?.start).toBeDefined();
expect(apiKey?.start?.length).toEqual(6);
expect(apiKey?.start).toEqual(apiKey?.key?.substring(0, 6));
});
it("should have the start property as null if shouldStore is false", async () => {
const { client, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
startingCharactersConfig: {
shouldStore: false,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(apiKey2?.start).toBeNull();
});
it("should use the defined charactersLength if provided", async () => {
const customLength = 3;
const { client, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
startingCharactersConfig: {
shouldStore: true,
charactersLength: customLength,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(apiKey2?.start).toBeDefined();
expect(apiKey2?.start?.length).toEqual(customLength);
expect(apiKey2?.start).toEqual(apiKey2?.key?.substring(0, customLength));
});
it("should fail to create API key with custom rate-limit options from client auth", async () => {
const apiKey = await client.apiKey.create(
{
rateLimitMax: 15,
},
{ headers },
);
expect(apiKey.data).toBeNull();
expect(apiKey.error).toBeDefined();
expect(apiKey.error?.statusText).toEqual("BAD_REQUEST");
expect(apiKey.error?.message).toEqual(
ERROR_CODES.SERVER_ONLY_PROPERTY.message,
);
const apiKey2 = await client.apiKey.create(
{
rateLimitTimeWindow: 1001,
},
{ headers },
);
expect(apiKey2.data).toBeNull();
expect(apiKey2.error).toBeDefined();
expect(apiKey2.error?.statusText).toEqual("BAD_REQUEST");
expect(apiKey2.error?.message).toEqual(
ERROR_CODES.SERVER_ONLY_PROPERTY.message,
);
});
it("should successfully apply custom rate-limit options on the newly created API key", async () => {
const apiKey = await auth.api.createApiKey({
body: {
rateLimitMax: 15,
rateLimitTimeWindow: 1000,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey?.rateLimitMax).toEqual(15);
expect(apiKey?.rateLimitTimeWindow).toEqual(1000);
});
// =========================================================================
// VERIFY API KEY
// =========================================================================
it("verify API key without key and userId", async () => {
const apiKey = await auth.api.verifyApiKey({
body: {
key: firstApiKey.key,
},
});
expect(apiKey.key).not.toBe(null);
expect(apiKey.valid).toBe(true);
});
it("verify API key with invalid key (should fail)", async () => {
const apiKey = await auth.api.verifyApiKey({
body: {
key: "invalid",
},
});
expect(apiKey.valid).toBe(false);
expect(apiKey.error?.code).toBe("INVALID_API_KEY");
});
let rateLimitedApiKey: ApiKey;
const {
client: rateLimitClient,
auth: rateLimitAuth,
signInWithTestUser: rateLimitTestUser,
} = await getTestInstance(
{
plugins: [
apiKey({
rateLimit: {
enabled: true,
timeWindow: 1000,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: rateLimitUserHeaders } = await rateLimitTestUser();
it("should fail to verify API key 20 times in a row due to rate-limit", async () => {
const { data: apiKey2 } = await rateLimitClient.apiKey.create(
{},
{ headers: rateLimitUserHeaders },
);
if (!apiKey2) return;
rateLimitedApiKey = apiKey2;
for (let i = 0; i < 20; i++) {
const response = await rateLimitAuth.api.verifyApiKey({
body: {
key: apiKey2.key,
},
headers: rateLimitUserHeaders,
});
if (i >= 10) {
expect(response.error?.code).toBe("RATE_LIMITED");
} else {
expect(response.error).toBeNull();
}
}
});
it("should allow us to verify API key after rate-limit window has passed", async () => {
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000);
const response = await rateLimitAuth.api.verifyApiKey({
body: {
key: rateLimitedApiKey.key,
},
headers: rateLimitUserHeaders,
});
expect(response.error).toBeNull();
expect(response?.valid).toBe(true);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9504
*/
it("should return 429 when API key rate limit is exceeded via before hook", async () => {
const { client: rlClient, signInWithTestUser: rlSignIn } =
await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
enabled: true,
timeWindow: 60000,
maxRequests: 2,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await rlSignIn();
const { data: rlKey } = await rlClient.apiKey.create(
{},
{ headers: userHeaders },
);
if (!rlKey) throw new Error("apiKey.create returned null");
const headers = new Headers();
headers.set("x-api-key", rlKey.key);
for (let i = 0; i < 2; i++) {
const res = await rlClient.getSession({ fetchOptions: { headers } });
expect(res.error).toBeNull();
}
const res = await rlClient.getSession({ fetchOptions: { headers } });
expect(res.error?.status).toBe(429);
});
it("should check if verifying an API key's remaining count does go down", async () => {
const remaining = 10;
const { data: apiKey } = await client.apiKey.create(
{
remaining: remaining,
},
{ headers: headers },
);
if (!apiKey) return;
const afterVerificationOnce = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
headers,
});
expect(afterVerificationOnce?.valid).toEqual(true);
expect(afterVerificationOnce?.key?.remaining).toEqual(remaining - 1);
const afterVerificationTwice = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
headers,
});
expect(afterVerificationTwice?.valid).toEqual(true);
expect(afterVerificationTwice?.key?.remaining).toEqual(remaining - 2);
});
it("should fail if the API key has no remaining", async () => {
const apiKey = await auth.api.createApiKey({
body: {
remaining: 1,
userId: user.id,
},
});
if (!apiKey) return;
// run verify once to make the remaining count go down to 0
await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
headers,
});
const afterVerification = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
headers,
});
expect(afterVerification.error?.code).toBe("USAGE_EXCEEDED");
});
it("should fail if the API key is expired", async () => {
vi.useRealTimers();
const { headers } = await signInWithTestUser();
const apiKey2 = await client.apiKey.create(
{
expiresIn: 60 * 60 * 24,
},
{ headers: headers, throw: true },
);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 60 * 24 * 2);
const afterVerification = await auth.api.verifyApiKey({
body: {
key: apiKey2.key,
},
headers,
});
expect(afterVerification.error?.code).toEqual("KEY_EXPIRED");
vi.useRealTimers();
});
// =========================================================================
// UPDATE API KEY
// =========================================================================
interface Err {
body: {
code: string | undefined;
message: string | undefined;
};
status: string;
statusCode: string;
}
it("should fail to update API key name without headers or userId", async () => {
const res: { data: ApiKey | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
name: "test-api-key",
},
});
res.data = apiKey as ApiKey;
} catch (error: any) {
res.error = error;
}
expect(res.data).toBeNull();
expect(res.error).toBeDefined();
expect(res.error?.statusCode).toEqual(401);
expect(res.error?.status).toEqual("UNAUTHORIZED");
expect(res.error?.body.message).toEqual(
ERROR_CODES.UNAUTHORIZED_SESSION.message,
);
});
it("should update API key name with headers", async () => {
const newName = "Hello World";
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
name: newName,
},
headers,
});
expect(apiKey).toBeDefined();
expect(apiKey.name).not.toEqual(firstApiKey.name);
expect(apiKey.name).toEqual(newName);
});
it("should fail to update API key name with a length larger than the allowed maximum", async () => {
let error: APIError | null = null;
await auth.api
.updateApiKey({
body: {
keyId: firstApiKey.id,
name: "test-api-key-that-is-longer-than-the-allowed-maximum",
},
headers,
})
.catch((e) => {
if (isAPIError(e)) {
error = e;
expect(error?.status).toEqual("BAD_REQUEST");
expect(error?.body?.message).toEqual(
ERROR_CODES.INVALID_NAME_LENGTH.message,
);
}
});
expect(error).not.toBeNull();
});
it("should fail to update API key name with a length smaller than the allowed minimum", async () => {
let error: APIError | null = null;
await auth.api
.updateApiKey({
body: {
keyId: firstApiKey.id,
name: "",
},
headers,
})
.catch((e) => {
if (isAPIError(e)) {
error = e;
expect(error?.status).toEqual("BAD_REQUEST");
expect(error?.body?.message).toEqual(
ERROR_CODES.INVALID_NAME_LENGTH.message,
);
}
});
expect(error).not.toBeNull();
});
it("should fail to update API key with no values to update", async () => {
let error: APIError | null = null;
await auth.api
.updateApiKey({
body: {
keyId: firstApiKey.id,
},
headers,
})
.catch((e) => {
if (isAPIError(e)) {
error = e;
expect(error?.status).toEqual("BAD_REQUEST");
expect(error?.body?.message).toEqual(
ERROR_CODES.NO_VALUES_TO_UPDATE.message,
);
}
});
expect(error).not.toBeNull();
});
it("should update API key expiresIn value", async () => {
const expiresIn = 60 * 60 * 24 * 7; // 7 days
const expectedResult = Date.now() + expiresIn;
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
expiresIn: expiresIn,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.expiresAt).toBeDefined();
expect(apiKey.expiresAt?.getTime()).toBeGreaterThanOrEqual(expectedResult);
});
it("should fail to update expiresIn value if `disableCustomExpiresTime` is enabled", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
keyExpiration: {
disableCustomExpiresTime: true,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const { data: firstApiKey } = await client.apiKey.create({}, { headers });
if (!firstApiKey) return;
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
expiresIn: 1000 * 60 * 60 * 24 * 7, // 7 days
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.KEY_DISABLED_EXPIRATION.message,
);
});
it("should fail to update expiresIn value if it's smaller than the allowed minimum", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
keyExpiration: {
minExpiresIn: 1,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const { data: firstApiKey } = await client.apiKey.create({}, { headers });
if (!firstApiKey) return;
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
expiresIn: 1,
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.EXPIRES_IN_IS_TOO_SMALL.message,
);
});
it("should fail to update expiresIn value if it's larger than the allowed maximum", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
keyExpiration: {
maxExpiresIn: 1,
},
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers } = await signInWithTestUser();
const { data: firstApiKey } = await client.apiKey.create({}, { headers });
if (!firstApiKey) return;
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
expiresIn: 1000 * 60 * 60 * 24 * 365 * 10, // 10 years
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.EXPIRES_IN_IS_TOO_LARGE.message,
);
});
it("should update API key remaining count", async () => {
const remaining = 100;
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
remaining: remaining,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.remaining).toEqual(remaining);
});
it("should fail update the refillInterval value since it requires refillAmount as well", async () => {
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
refillInterval: 1000,
userId: user.id,
},
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.REFILL_INTERVAL_AND_AMOUNT_REQUIRED.message,
);
});
it("should fail update the refillAmount value since it requires refillInterval as well", async () => {
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
refillAmount: 10,
userId: user.id,
},
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.REFILL_AMOUNT_AND_INTERVAL_REQUIRED.message,
);
});
it("should update the refillInterval and refillAmount value", async () => {
const refillInterval = 10000;
const refillAmount = 100;
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
refillInterval: refillInterval,
refillAmount: refillAmount,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.refillInterval).toEqual(refillInterval);
expect(apiKey.refillAmount).toEqual(refillAmount);
});
it("should update API key enable value", async () => {
const newValue = false;
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
enabled: newValue,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.enabled).toEqual(newValue);
});
it("should fail to update metadata with invalid metadata type", async () => {
const result: { data: Partial<ApiKey> | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
metadata: "invalid",
userId: user.id,
},
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("BAD_REQUEST");
expect(result.error?.body.message).toEqual(
ERROR_CODES.INVALID_METADATA_TYPE.message,
);
});
it("should update metadata with valid metadata type", async () => {
const metadata = {
test: "test-123",
};
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
metadata: metadata,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.metadata).toEqual(metadata);
});
it("update API key's returned metadata should be an object", async () => {
const metadata = {
test: "test-12345",
};
const apiKey = await auth.api.updateApiKey({
body: {
keyId: firstApiKey.id,
metadata: metadata,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.metadata?.test).toBeDefined();
expect(apiKey.metadata?.test).toEqual(metadata.test);
});
// =========================================================================
// API KEY LASTREQUEST BUG FIX (#5309)
// =========================================================================
it("should not modify lastRequest when updating API key configuration", async () => {
const key = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(key.lastRequest).toBeNull();
const updated = await auth.api.updateApiKey({
body: {
keyId: key.id,
name: "updated-name",
userId: user.id,
},
});
expect(updated.lastRequest).toBeNull();
});
it("should not auto-decrement remaining when updating API key", async () => {
const key = await auth.api.createApiKey({
body: {
remaining: 100,
userId: user.id,
},
});
expect(key.remaining).toBe(100);
const updated = await auth.api.updateApiKey({
body: {
keyId: key.id,
metadata: { foo: "bar" },
userId: user.id,
},
});
expect(updated.remaining).toBe(100);
});
it("should allow explicit remaining updates via body parameter", async () => {
const key = await auth.api.createApiKey({
body: {
remaining: 100,
userId: user.id,
},
});
const updated = await auth.api.updateApiKey({
body: {
keyId: key.id,
remaining: 50,
userId: user.id,
},
});
expect(updated.remaining).toBe(50);
expect(updated.lastRequest).toBeNull();
});
it("verifyApiKey should still update lastRequest", async () => {
const key = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(key.lastRequest).toBeNull();
const verified = await auth.api.verifyApiKey({
body: { key: key.key },
});
expect(verified.valid).toBe(true);
const updated = await auth.api.getApiKey({
query: { id: key.id, configId: "default" },
headers,
});
expect(updated.lastRequest).not.toBeNull();
expect(updated.lastRequest).toBeInstanceOf(Date);
});
it("verifyApiKey should still decrement remaining", async () => {
const key = await auth.api.createApiKey({
body: {
remaining: 100,
userId: user.id,
},
});
await auth.api.verifyApiKey({
body: { key: key.key },
});
const updated = await auth.api.getApiKey({
query: { id: key.id, configId: "default" },
headers,
});
expect(updated.remaining).toBe(99);
});
// =========================================================================
// GET API KEY
// =========================================================================
it("should get an API key by id", async () => {
const apiKey = await client.apiKey.get({
query: {
id: firstApiKey.id,
},
fetchOptions: {
headers,
},
});
expect(apiKey.data).not.toBeNull();
expect(apiKey.data?.id).toBe(firstApiKey.id);
});
it("should fail to get an API key by ID that doesn't exist", async () => {
const result = await client.apiKey.get(
{
query: {
id: "invalid",
},
},
{ headers },
);
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual(404);
});
it("should successfully receive an object metadata from an API key", async () => {
const apiKey = await client.apiKey.get(
{
query: {
id: firstApiKey.id,
},
},
{
headers,
},
);
expect(apiKey).not.toBeNull();
expect(apiKey.data?.metadata).toBeDefined();
expect(apiKey.data?.metadata).toBeInstanceOf(Object);
});
// =========================================================================
// LIST API KEY
// =========================================================================
it("should fail to list API keys without headers", async () => {
const result: {
data: { apiKeys: Partial<ApiKey>[]; total: number } | null;
error: Err | null;
} = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.listApiKeys({});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("UNAUTHORIZED");
});
it("should list API keys with headers", async () => {
const apiKeys = await auth.api.listApiKeys({
headers,
});
expect(apiKeys).not.toBeNull();
expect(apiKeys.apiKeys.length).toBeGreaterThan(0);
expect(apiKeys.total).toBeGreaterThan(0);
});
it("should list API keys with metadata as an object", async () => {
const apiKeys = await auth.api.listApiKeys({
headers,
});
expect(apiKeys).not.toBeNull();
expect(apiKeys.apiKeys.length).toBeGreaterThan(0);
apiKeys.apiKeys.map((apiKey) => {
if (apiKey.metadata) {
expect(apiKey.metadata).toBeInstanceOf(Object);
}
});
});
// =========================================================================
// LIST API KEY PAGINATION
// =========================================================================
describe("list API keys pagination", () => {
it("should return paginated response with total count", async () => {
const result = await auth.api.listApiKeys({
headers,
});
expect(result).not.toBeNull();
expect(result.apiKeys).toBeDefined();
expect(Array.isArray(result.apiKeys)).toBe(true);
expect(typeof result.total).toBe("number");
expect(result.total).toBeGreaterThanOrEqual(result.apiKeys.length);
});
it("should limit the number of returned API keys", async () => {
// Create multiple API keys for testing
await auth.api.createApiKey({
body: { name: "pagination-key-1" },
headers,
});
await auth.api.createApiKey({
body: { name: "pagination-key-2" },
headers,
});
await auth.api.createApiKey({
body: { name: "pagination-key-3" },
headers,
});
const result = await auth.api.listApiKeys({
query: { limit: 2 },
headers,
});
expect(result.apiKeys.length).toBeLessThanOrEqual(2);
expect(result.limit).toBe(2);
expect(result.total).toBeGreaterThanOrEqual(3);
});
it("should skip API keys with offset", async () => {
const allResults = await auth.api.listApiKeys({
headers,
});
const offsetResults = await auth.api.listApiKeys({
query: { offset: 1 },
headers,
});
expect(offsetResults.offset).toBe(1);
expect(offsetResults.apiKeys.length).toBe(allResults.apiKeys.length - 1);
});
it("should support pagination with both limit and offset", async () => {
const page1 = await auth.api.listApiKeys({
query: { limit: 2, offset: 0 },
headers,
});
const page2 = await auth.api.listApiKeys({
query: { limit: 2, offset: 2 },
headers,
});
expect(page1.apiKeys.length).toBeLessThanOrEqual(2);
expect(page2.apiKeys.length).toBeLessThanOrEqual(2);
expect(page1.limit).toBe(2);
expect(page1.offset).toBe(0);
expect(page2.offset).toBe(2);
// Ensure no overlap between pages
const page1Ids = page1.apiKeys.map((k) => k.id);
const page2Ids = page2.apiKeys.map((k) => k.id);
const overlap = page1Ids.filter((id) => page2Ids.includes(id));
expect(overlap.length).toBe(0);
});
it("should sort API keys by createdAt ascending", async () => {
const result = await auth.api.listApiKeys({
query: { sortBy: "createdAt", sortDirection: "asc" },
headers,
});
expect(result.apiKeys.length).toBeGreaterThan(1);
for (let i = 1; i < result.apiKeys.length; i++) {
const prev = new Date(result.apiKeys[i - 1]!.createdAt).getTime();
const curr = new Date(result.apiKeys[i]!.createdAt).getTime();
expect(curr).toBeGreaterThanOrEqual(prev);
}
});
it("should sort API keys by createdAt descending", async () => {
const result = await auth.api.listApiKeys({
query: { sortBy: "createdAt", sortDirection: "desc" },
headers,
});
expect(result.apiKeys.length).toBeGreaterThan(1);
for (let i = 1; i < result.apiKeys.length; i++) {
const prev = new Date(result.apiKeys[i - 1]!.createdAt).getTime();
const curr = new Date(result.apiKeys[i]!.createdAt).getTime();
expect(curr).toBeLessThanOrEqual(prev);
}
});
it("should sort API keys by name", async () => {
// Create keys with specific names for sorting test
await auth.api.createApiKey({
body: { name: "aaa-sort-test" },
headers,
});
await auth.api.createApiKey({
body: { name: "zzz-sort-test" },
headers,
});
const ascResult = await auth.api.listApiKeys({
query: { sortBy: "name", sortDirection: "asc" },
headers,
});
const descResult = await auth.api.listApiKeys({
query: { sortBy: "name", sortDirection: "desc" },
headers,
});
// Filter to only named keys for comparison
const ascNamed = ascResult.apiKeys.filter((k) =>
k.name?.includes("-sort-test"),
);
const descNamed = descResult.apiKeys.filter((k) =>
k.name?.includes("-sort-test"),
);
expect(ascNamed[0]!.name).toBe("aaa-sort-test");
expect(descNamed[0]!.name).toBe("zzz-sort-test");
});
it("should combine sorting with pagination", async () => {
const result = await auth.api.listApiKeys({
query: {
limit: 3,
offset: 0,
sortBy: "createdAt",
sortDirection: "desc",
},
headers,
});
expect(result.apiKeys.length).toBeLessThanOrEqual(3);
expect(result.limit).toBe(3);
// Verify sorting is applied
for (let i = 1; i < result.apiKeys.length; i++) {
const prev = new Date(result.apiKeys[i - 1]!.createdAt).getTime();
const curr = new Date(result.apiKeys[i]!.createdAt).getTime();
expect(curr).toBeLessThanOrEqual(prev);
}
});
it("should return empty array when offset exceeds total", async () => {
const allResults = await auth.api.listApiKeys({
headers,
});
const result = await auth.api.listApiKeys({
query: { offset: allResults.total + 100 },
headers,
});
expect(result.apiKeys.length).toBe(0);
expect(result.total).toBe(allResults.total);
});
it("should handle string query parameters for limit and offset", async () => {
const result = await auth.api.listApiKeys({
query: { limit: "2", offset: "1" } as any,
headers,
});
expect(result.limit).toBe(2);
expect(result.offset).toBe(1);
expect(result.apiKeys.length).toBeLessThanOrEqual(2);
});
});
// =========================================================================
// Sessions from API keys
// =========================================================================
describe("enableSessionForAPIKeys", () => {
it("should get session from an API key", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const session = await auth.api.getSession({
headers: headers,
});
expect(session?.session).toBeDefined();
});
it("should run customAPIKeyValidator once on the session path", async () => {
const validator = vi.fn(() => true);
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
customAPIKeyValidator: validator,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await signInWithTestUser();
const apiKey2 = await auth.api.createApiKey({
body: { userId: user.id },
});
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
validator.mockClear();
await auth.api.getSession({ headers });
expect(validator).toHaveBeenCalledTimes(1);
});
it("should not grant a session for a key from a different config", async () => {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey([
{ configId: "primary", enableSessionForAPIKeys: true },
{ configId: "secondary", enableSessionForAPIKeys: true },
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await signInWithTestUser();
// Issued under "secondary" but presented via the shared default header,
// which matches the first config ("primary").
const secondaryKey = await auth.api.createApiKey({
body: { configId: "secondary", userId: user.id },
});
const headers = new Headers();
headers.set("x-api-key", secondaryKey.key);
await expect(auth.api.getSession({ headers })).rejects.toThrowError();
});
it("should not get session from an API key if enableSessionForAPIKeys is false", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: false,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const session = await auth.api.getSession({
headers: headers,
});
expect(session).toBeNull();
});
it("should get the Response object when asResponse is true", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const res = await auth.api.getSession({
headers: headers,
asResponse: true,
});
expect(res).toBeInstanceOf(Response);
});
});
// =========================================================================
// DELETE API KEY
// =========================================================================
it("should fail to delete an API key by ID without headers", async () => {
const result: { data: { success: boolean } | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.deleteApiKey({
body: {
keyId: firstApiKey.id,
},
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("UNAUTHORIZED");
});
it("should delete an API key by ID with headers", async () => {
const apiKey = await auth.api.deleteApiKey({
body: {
keyId: firstApiKey.id,
},
headers,
});
expect(apiKey).not.toBeNull();
expect(apiKey.success).toEqual(true);
});
it("should delete an API key by ID with headers using auth-client", async () => {
const newApiKey = await client.apiKey.create({}, { headers: headers });
if (!newApiKey.data) return;
const apiKey = await client.apiKey.delete(
{
keyId: newApiKey.data.id,
},
{ headers },
);
if (!apiKey.data?.success) {
console.log(apiKey.error);
}
expect(apiKey).not.toBeNull();
expect(apiKey.data?.success).toEqual(true);
});
it("should fail to delete an API key by ID that doesn't exist", async () => {
const result: { data: { success: boolean } | null; error: Err | null } = {
data: null,
error: null,
};
try {
const apiKey = await auth.api.deleteApiKey({
body: {
keyId: "invalid",
},
headers,
});
result.data = apiKey;
} catch (error: any) {
result.error = error;
}
expect(result.data).toBeNull();
expect(result.error).toBeDefined();
expect(result.error?.status).toEqual("NOT_FOUND");
expect(result.error?.body.message).toEqual(
ERROR_CODES.KEY_NOT_FOUND.message,
);
});
it("should create an API key with permissions", async () => {
const permissions = {
files: ["read", "write"],
users: ["read"],
};
const apiKey = await auth.api.createApiKey({
body: {
permissions,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.permissions).toEqual(permissions);
});
it("should have permissions as an object from getApiKey", async () => {
const permissions = {
files: ["read", "write"],
users: ["read"],
};
const apiKey = await auth.api.createApiKey({
body: {
permissions,
userId: user.id,
},
});
const apiKeyResults = await auth.api.getApiKey({
query: {
id: apiKey.id,
},
headers,
});
expect(apiKeyResults).not.toBeNull();
expect(apiKeyResults.permissions).toEqual(permissions);
});
it("should have permissions as an object from verifyApiKey", async () => {
const permissions = {
files: ["read", "write"],
users: ["read"],
};
const apiKey = await auth.api.createApiKey({
body: {
permissions,
userId: user.id,
},
});
const apiKeyResults = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
permissions: {
files: ["read"],
},
},
headers,
});
expect(apiKeyResults).not.toBeNull();
expect(apiKeyResults.key?.permissions).toEqual(permissions);
});
it("should create an API key with default permissions", async () => {
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.permissions).toEqual({
files: ["read"],
});
});
it("should have valid metadata from key verification results", async () => {
const metadata = {
test: "hello-world-123",
};
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
metadata: metadata,
},
headers,
});
expect(apiKey).not.toBeNull();
if (apiKey) {
const result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
headers,
});
expect(result.valid).toBe(true);
expect(result.error).toBeNull();
expect(result.key?.metadata).toEqual(metadata);
}
});
it("should verify an API key with matching permissions", async () => {
const permissions = {
files: ["read", "write"],
users: ["read"],
};
const apiKey = await auth.api.createApiKey({
body: {
permissions,
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
permissions: {
files: ["read"],
},
},
});
expect(result.valid).toBe(true);
expect(result.error).toBeNull();
expect(result.key?.permissions).toEqual(permissions);
});
it("should fail to verify an API key with non-matching permissions", async () => {
const permissions = {
files: ["read"],
users: ["read"],
};
const apiKey = await auth.api.createApiKey({
body: {
permissions,
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
permissions: {
files: ["write"],
},
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("KEY_NOT_FOUND");
});
it("should fail to verify when required permissions are specified but API key has no permissions", async () => {
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
permissions: {
files: ["write"],
},
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("KEY_NOT_FOUND");
});
it("should update an API key with permissions", async () => {
const permissions = {
files: ["read", "write"],
users: ["read"],
};
const createdApiKey = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(createdApiKey.permissions).not.toEqual(permissions);
const apiKey = await auth.api.updateApiKey({
body: {
keyId: createdApiKey.id,
permissions,
userId: user.id,
},
});
expect(apiKey).not.toBeNull();
expect(apiKey.permissions).toEqual(permissions);
});
it("should refill API key credits after refill interval (milliseconds)", async () => {
vi.useRealTimers();
const refillInterval = 3600000; // 1 hour in milliseconds
const refillAmount = 5;
const initialRemaining = 2;
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
remaining: initialRemaining,
refillInterval: refillInterval,
refillAmount: refillAmount,
},
});
let result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(initialRemaining - 1);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(0);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("USAGE_EXCEEDED");
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(refillInterval + 1000);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(refillAmount - 1);
vi.useRealTimers();
});
it("should not refill API key credits before refill interval expires", async () => {
vi.useRealTimers();
const refillInterval = 86400000; // 24 hours in milliseconds
const refillAmount = 10;
const initialRemaining = 1;
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
remaining: initialRemaining,
refillInterval: refillInterval,
refillAmount: refillAmount,
},
});
let result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(0);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(refillInterval / 2); // Only advance half the interval
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("USAGE_EXCEEDED");
await vi.advanceTimersByTimeAsync(refillInterval / 2 + 1000);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(refillAmount - 1);
vi.useRealTimers();
});
it("should handle multiple refill cycles correctly", async () => {
vi.useRealTimers();
const refillInterval = 3600000; // 1 hour in milliseconds
const refillAmount = 3;
const apiKey = await auth.api.createApiKey({
body: {
userId: user.id,
remaining: 1,
refillInterval: refillInterval,
refillAmount: refillAmount,
},
});
let result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(0);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(refillInterval + 1000);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(refillAmount - 1);
for (let i = 0; i < refillAmount - 1; i++) {
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
}
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("USAGE_EXCEEDED");
await vi.advanceTimersByTimeAsync(refillInterval + 1000);
result = await auth.api.verifyApiKey({
body: {
key: apiKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(refillAmount - 1);
vi.useRealTimers();
});
describe("secondary storage", async () => {
const store = new Map<string, string>();
const expirationMap = new Map<string, number>();
const secondaryStorage: SecondaryStorage = {
set(key, value, ttl) {
store.set(key, value as string);
if (ttl) expirationMap.set(key, ttl);
},
get(key) {
return store.get(key) || null;
},
delete(key) {
store.delete(key);
expirationMap.delete(key);
},
};
const { client, auth, signInWithTestUser } = await getTestInstance(
{
secondaryStorage,
plugins: [
apiKey({
storage: "secondary-storage",
enableMetadata: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
beforeEach(() => {
store.clear();
expirationMap.clear();
});
it("should create API key in secondary storage", async () => {
const { headers, user } = await signInWithTestUser();
const { data: apiKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(apiKey).not.toBeNull();
expect(apiKey?.key).toBeDefined();
// Check that API key is stored in secondary storage by ID
expect(store.has(`api-key:by-id:${apiKey?.id}`)).toBe(true);
// Check that user's API key list is updated
expect(store.has(`api-key:by-ref:${user.id}`)).toBe(true);
// Verify the stored data can be retrieved
const storedData = store.get(`api-key:by-id:${apiKey?.id}`);
expect(storedData).toBeDefined();
const parsed = JSON.parse(storedData!);
expect(parsed.id).toBe(apiKey?.id);
expect(parsed.referenceId).toBe(user.id);
});
it("should get API key from secondary storage", async () => {
const { headers, user } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
const { data: retrievedKey } = await client.apiKey.get(
{ query: { id: createdKey!.id } },
{ headers: headers },
);
expect(retrievedKey).not.toBeNull();
expect(retrievedKey?.id).toBe(createdKey?.id);
expect(retrievedKey?.referenceId).toBe(user.id);
});
it("should list API keys from secondary storage", async () => {
const { headers } = await signInWithTestUser();
// Create multiple API keys
const { data: key1 } = await client.apiKey.create(
{},
{ headers: headers },
);
const { data: key2 } = await client.apiKey.create(
{},
{ headers: headers },
);
const { data: keys } = await client.apiKey.list({
fetchOptions: { headers: headers },
});
expect(keys).not.toBeNull();
expect(keys?.apiKeys?.length).toBeGreaterThanOrEqual(2);
expect(keys?.apiKeys?.some((k) => k.id === key1?.id)).toBe(true);
expect(keys?.apiKeys?.some((k) => k.id === key2?.id)).toBe(true);
});
it("should fetch keys from secondary storage in parallel, not sequentially", async () => {
const { headers } = await signInWithTestUser();
const keyCount = 5;
for (let i = 0; i < keyCount; i++) {
await client.apiKey.create({}, { headers });
}
let concurrentGets = 0;
let maxConcurrentGets = 0;
const originalGet = secondaryStorage.get;
vi.spyOn(secondaryStorage, "get").mockImplementation(async (key) => {
if (!key.startsWith("api-key:by-id:")) return originalGet(key);
concurrentGets++;
maxConcurrentGets = Math.max(maxConcurrentGets, concurrentGets);
await new Promise((r) => setTimeout(r, 20));
const result = await originalGet(key);
concurrentGets--;
return result;
});
const { data: keys } = await client.apiKey.list({
fetchOptions: { headers },
});
expect(keys).not.toBeNull();
expect(keys!.apiKeys!.length).toBe(keyCount);
expect(maxConcurrentGets).toBe(keyCount);
});
it("should update API key in secondary storage", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{ name: "Original Name" },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
const { data: updatedKey } = await client.apiKey.update(
{
keyId: createdKey!.id,
name: "Updated Name",
},
{ headers: headers },
);
expect(updatedKey).not.toBeNull();
expect(updatedKey?.name).toBe("Updated Name");
expect(updatedKey?.id).toBe(createdKey?.id);
});
it("should delete API key from secondary storage", async () => {
const { headers, user } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
const { data: deleteResult } = await client.apiKey.delete(
{ keyId: createdKey!.id },
{ headers: headers },
);
expect(deleteResult?.success).toBe(true);
// Verify it's deleted from secondary storage
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(false);
// Verify it's removed from user's list
const userListData = store.get(`api-key:by-ref:${user.id}`);
if (userListData) {
const userIds = JSON.parse(userListData);
expect(userIds.includes(createdKey!.id)).toBe(false);
}
});
it("should verify API key from secondary storage", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
const result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(true);
expect(result.key).not.toBeNull();
expect(result.key?.id).toBe(createdKey?.id);
});
it("should set TTL when API key has expiration", async () => {
const { headers } = await signInWithTestUser();
const expiresIn = 60 * 60 * 24; // 1 day in seconds
const { data: createdKey } = await client.apiKey.create(
{ expiresIn },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(createdKey?.expiresAt).not.toBeNull();
// Check that TTL was set in expiration map (check by ID key)
const storedKey = `api-key:by-id:${createdKey!.id}`;
const ttl = expirationMap.get(storedKey);
expect(ttl).toBeDefined();
expect(ttl).toBeGreaterThan(0);
// TTL should be approximately expiresIn seconds (within 5 seconds tolerance)
expect(Math.abs(ttl! - expiresIn)).toBeLessThan(5);
});
it("should handle metadata in secondary storage", async () => {
const { headers } = await signInWithTestUser();
const metadata = { plan: "premium", environment: "production" };
const { data: createdKey } = await client.apiKey.create(
{ metadata },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(createdKey?.metadata).toEqual(metadata);
const { data: retrievedKey } = await client.apiKey.get(
{ query: { id: createdKey!.id } },
{ headers: headers },
);
expect(retrievedKey?.metadata).toEqual(metadata);
});
it("should handle rate limiting with secondary storage", async () => {
const { user } = await signInWithTestUser();
const createdKey = await auth.api.createApiKey({
body: {
rateLimitEnabled: true,
rateLimitMax: 2,
rateLimitTimeWindow: 1000 * 60, // 1 minute
userId: user.id,
},
});
expect(createdKey).not.toBeNull();
// First request should succeed
let result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(true);
// Second request should succeed
result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(true);
// Third request should fail due to rate limit
result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("RATE_LIMITED");
});
it("should handle remaining count with secondary storage", async () => {
const { user } = await signInWithTestUser();
const remaining = 5;
const createdKey = await auth.api.createApiKey({
body: {
remaining,
userId: user.id,
},
});
expect(createdKey).not.toBeNull();
expect(createdKey?.remaining).toBe(remaining);
let result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(remaining - 1);
result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(remaining - 2);
});
it("should handle expired keys with TTL in secondary storage", async () => {
vi.useFakeTimers();
const { headers } = await signInWithTestUser();
// Use 1 day in seconds (minimum allowed) + 1 second for testing expiration
const expiresIn = 60 * 60 * 24 + 1; // 86401 seconds = 1 day + 1 second
const { data: createdKey } = await client.apiKey.create(
{ expiresIn },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(createdKey?.expiresAt).not.toBeNull();
// Advance time past expiration
await vi.advanceTimersByTimeAsync((expiresIn + 1) * 1000);
const result = await auth.api.verifyApiKey({
body: {
key: createdKey!.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("KEY_EXPIRED");
vi.useRealTimers();
});
it("should maintain user's API key list in secondary storage", async () => {
const { headers } = await signInWithTestUser();
// Create multiple API keys for the same user
const { data: key1 } = await client.apiKey.create(
{},
{ headers: headers },
);
const { data: key2 } = await client.apiKey.create(
{},
{ headers: headers },
);
const { data: key3 } = await client.apiKey.create(
{},
{ headers: headers },
);
// List should return all keys
const { data: keys } = await client.apiKey.list({
fetchOptions: { headers: headers },
});
expect(keys?.apiKeys?.length).toBeGreaterThanOrEqual(3);
expect(keys?.apiKeys?.some((k) => k.id === key1?.id)).toBe(true);
expect(keys?.apiKeys?.some((k) => k.id === key2?.id)).toBe(true);
expect(keys?.apiKeys?.some((k) => k.id === key3?.id)).toBe(true);
// Delete one key
await client.apiKey.delete({ keyId: key2!.id }, { headers: headers });
// List should now have one less key
const { data: keysAfterDelete } = await client.apiKey.list({
fetchOptions: { headers: headers },
});
expect(keysAfterDelete?.apiKeys?.length).toBe(keys!.apiKeys!.length - 1);
expect(keysAfterDelete?.apiKeys?.some((k) => k.id === key2?.id)).toBe(
false,
);
});
});
describe("secondary-storage-with-fallback", async () => {
const store = new Map<string, string>();
const expirationMap = new Map<string, number>();
const fallbackStorage: SecondaryStorage = {
set(key, value, ttl) {
store.set(key, value as string);
if (ttl) expirationMap.set(key, ttl);
},
get(key) {
return store.get(key) || null;
},
delete(key) {
store.delete(key);
expirationMap.delete(key);
},
};
const { client, auth, signInWithTestUser } = await getTestInstance(
{
secondaryStorage: fallbackStorage,
plugins: [
apiKey({
storage: "secondary-storage",
fallbackToDatabase: true,
enableMetadata: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
beforeEach(() => {
store.clear();
expirationMap.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should read from secondary storage first", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(true);
const { data: retrievedKey } = await client.apiKey.get(
{ query: { id: createdKey!.id } },
{ headers: headers },
);
expect(retrievedKey).not.toBeNull();
expect(retrievedKey?.id).toBe(createdKey?.id);
});
it("verifyApiKey should persist quota updates to the database when fallbackToDatabase is true", async () => {
const { headers, user } = await signInWithTestUser();
const createdKey = await auth.api.createApiKey({
body: {
remaining: 1,
userId: user.id,
},
});
const first = await auth.api.verifyApiKey({
body: {
key: createdKey.key,
},
});
expect(first.valid).toBe(true);
expect(first.key?.remaining).toBe(0);
// Ensure the canonical DB row was updated (not just the cache).
const dbAfterFirst = await auth.api.getApiKey({
query: { id: createdKey.id },
headers,
});
expect(dbAfterFirst.remaining).toBe(0);
// Simulate cache eviction/deletion and ensure we don't repopulate stale allowances.
store.clear();
const second = await auth.api.verifyApiKey({
body: {
key: createdKey.key,
},
});
expect(second.valid).toBe(false);
expect(second.error?.code).toBe("USAGE_EXCEEDED");
});
it("should fallback to database when not found in storage and auto-populate storage", async () => {
const { headers, user } = await signInWithTestUser();
// Create key directly in database adapter (bypassing storage)
const context = await auth.$context;
const hashedKey = "test_hashed_key_123";
const dbKey = await context.adapter.create<Omit<ApiKey, "id">, ApiKey>({
model: "apikey",
data: {
configId: "default",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Key",
prefix: "test",
start: "test_",
key: hashedKey,
enabled: true,
expiresAt: null,
referenceId: user.id,
lastRefillAt: null,
lastRequest: null,
metadata: null,
rateLimitMax: null,
rateLimitTimeWindow: null,
remaining: null,
refillAmount: null,
refillInterval: null,
rateLimitEnabled: false,
requestCount: 0,
permissions: null,
},
});
expect(dbKey).not.toBeNull();
// Ensure key is NOT in storage initially
expect(store.has(`api-key:by-id:${dbKey!.id}`)).toBe(false);
expect(store.has(`api-key:${hashedKey}`)).toBe(false);
// Retrieve it via API (should fallback to DB and auto-populate storage)
const { data: retrievedKey } = await client.apiKey.get(
{ query: { id: dbKey!.id } },
{ headers: headers },
);
expect(retrievedKey).not.toBeNull();
expect(retrievedKey?.id).toBe(dbKey?.id);
// Verify it's now in storage (auto-populated)
expect(store.has(`api-key:by-id:${dbKey!.id}`)).toBe(true);
expect(store.has(`api-key:${hashedKey}`)).toBe(true);
});
it("should populate storage when listing keys falls back to database", async () => {
const { headers, user } = await signInWithTestUser();
// Create keys directly in database adapter (bypassing storage)
const context = await auth.$context;
const hashedKey1 = "test_hashed_key_1";
const hashedKey2 = "test_hashed_key_2";
const dbKey1 = await context.adapter.create<Omit<ApiKey, "id">, ApiKey>({
model: "apikey",
data: {
configId: "default",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Key 1",
prefix: "test",
start: "test_",
key: hashedKey1,
enabled: true,
expiresAt: null,
referenceId: user.id,
lastRefillAt: null,
lastRequest: null,
metadata: null,
rateLimitMax: null,
rateLimitTimeWindow: null,
remaining: null,
refillAmount: null,
refillInterval: null,
rateLimitEnabled: false,
requestCount: 0,
permissions: null,
},
});
const dbKey2 = await context.adapter.create<Omit<ApiKey, "id">, ApiKey>({
model: "apikey",
data: {
configId: "default",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Key 2",
prefix: "test",
start: "test_",
key: hashedKey2,
enabled: true,
expiresAt: null,
referenceId: user.id,
lastRefillAt: null,
lastRequest: null,
metadata: null,
rateLimitMax: null,
rateLimitTimeWindow: null,
remaining: null,
refillAmount: null,
refillInterval: null,
rateLimitEnabled: false,
requestCount: 0,
permissions: null,
},
});
expect(dbKey1).not.toBeNull();
expect(dbKey2).not.toBeNull();
// Ensure keys are NOT in storage initially
expect(store.has(`api-key:by-id:${dbKey1!.id}`)).toBe(false);
expect(store.has(`api-key:by-id:${dbKey2!.id}`)).toBe(false);
expect(store.has(`api-key:by-ref:${user.id}`)).toBe(false);
// List keys via API (should fallback to DB and auto-populate storage)
const { data: keys } = await client.apiKey.list({}, { headers: headers });
expect(keys).not.toBeNull();
expect(keys?.apiKeys?.length).toBeGreaterThanOrEqual(2);
expect(keys?.apiKeys?.some((k) => k.id === dbKey1!.id)).toBe(true);
expect(keys?.apiKeys?.some((k) => k.id === dbKey2!.id)).toBe(true);
// Verify keys are now in storage (auto-populated)
expect(store.has(`api-key:by-id:${dbKey1!.id}`)).toBe(true);
expect(store.has(`api-key:by-id:${dbKey2!.id}`)).toBe(true);
expect(store.has(`api-key:${hashedKey1}`)).toBe(true);
expect(store.has(`api-key:${hashedKey2}`)).toBe(true);
// Verify user's key list is populated
expect(store.has(`api-key:by-ref:${user.id}`)).toBe(true);
});
it("should populate storage in parallel when listing falls back to database", async () => {
const { headers, user } = await signInWithTestUser();
const context = await auth.$context;
const keyCount = 5;
for (let i = 0; i < keyCount; i++) {
await context.adapter.create<Omit<ApiKey, "id">, ApiKey>({
model: "apikey",
data: {
configId: "default",
createdAt: new Date(),
updatedAt: new Date(),
name: `Parallel Key ${i}`,
prefix: "test",
start: "test_",
key: `hashed_parallel_${i}`,
enabled: true,
expiresAt: null,
referenceId: user.id,
lastRefillAt: null,
lastRequest: null,
metadata: null,
rateLimitMax: null,
rateLimitTimeWindow: null,
remaining: null,
refillAmount: null,
refillInterval: null,
rateLimitEnabled: false,
requestCount: 0,
permissions: null,
},
});
}
let concurrentSets = 0;
let maxConcurrentSets = 0;
const originalSet = fallbackStorage.set;
vi.spyOn(fallbackStorage, "set").mockImplementation(
async (key, value) => {
if (!key.startsWith("api-key:by-id:")) {
originalSet(key, value);
return;
}
concurrentSets++;
maxConcurrentSets = Math.max(maxConcurrentSets, concurrentSets);
await new Promise((r) => setTimeout(r, 20));
originalSet(key, value);
concurrentSets--;
},
);
const { data: keys } = await client.apiKey.list({}, { headers });
expect(keys).not.toBeNull();
expect(keys!.apiKeys!.length).toBeGreaterThanOrEqual(keyCount);
expect(maxConcurrentSets).toBeGreaterThanOrEqual(keyCount);
});
it("should not touch the ref list per key while populating", async () => {
const { headers, user } = await signInWithTestUser();
const context = await auth.$context;
const keyCount = 5;
for (let i = 0; i < keyCount; i++) {
await context.adapter.create<Omit<ApiKey, "id">, ApiKey>({
model: "apikey",
data: {
configId: "default",
createdAt: new Date(),
updatedAt: new Date(),
name: `Ref List Key ${i}`,
prefix: "test",
start: "test_",
key: `hashed_ref_list_${i}`,
enabled: true,
expiresAt: null,
referenceId: user.id,
lastRefillAt: null,
lastRequest: null,
metadata: null,
rateLimitMax: null,
rateLimitTimeWindow: null,
remaining: null,
refillAmount: null,
refillInterval: null,
rateLimitEnabled: false,
requestCount: 0,
permissions: null,
},
});
}
const refKey = `api-key:by-ref:${user.id}`;
const getSpy = vi.spyOn(fallbackStorage, "get");
const setSpy = vi.spyOn(fallbackStorage, "set");
const { data: keys } = await client.apiKey.list({}, { headers });
expect(keys).not.toBeNull();
expect(keys!.apiKeys!.length).toBeGreaterThanOrEqual(keyCount);
expect(
getSpy.mock.calls.filter(([k]) => k === refKey).length,
).toBeLessThanOrEqual(1);
expect(setSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(1);
});
it("should invalidate (not mutate) the ref list on create", async () => {
const { headers, user } = await signInWithTestUser();
const refKey = `api-key:by-ref:${user.id}`;
await client.apiKey.list({}, { headers });
const getSpy = vi.spyOn(fallbackStorage, "get");
const setSpy = vi.spyOn(fallbackStorage, "set");
const deleteSpy = vi.spyOn(fallbackStorage, "delete");
await client.apiKey.create({}, { headers });
expect(getSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(0);
expect(setSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(0);
expect(deleteSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(1);
});
it("should invalidate (not mutate) the ref list on delete", async () => {
const { headers, user } = await signInWithTestUser();
const { data: created } = await client.apiKey.create({}, { headers });
await client.apiKey.list({}, { headers });
const refKey = `api-key:by-ref:${user.id}`;
const getSpy = vi.spyOn(fallbackStorage, "get");
const setSpy = vi.spyOn(fallbackStorage, "set");
const deleteSpy = vi.spyOn(fallbackStorage, "delete");
await client.apiKey.delete({ keyId: created!.id }, { headers });
expect(getSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(0);
expect(setSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(0);
expect(deleteSpy.mock.calls.filter(([k]) => k === refKey).length).toBe(1);
});
it("should not lose ids when two creates race on the ref list", async () => {
const { headers } = await signInWithTestUser();
// Widen the RMW window so both writers would observe the same
// empty ref list before either wrote back. The fix avoids get entirely.
const originalGet = fallbackStorage.get.bind(fallbackStorage);
vi.spyOn(fallbackStorage, "get").mockImplementation(
async (key: string) => {
const value = await originalGet(key);
if (key.startsWith("api-key:by-ref:")) {
await new Promise((r) => setTimeout(r, 30));
}
return value;
},
);
const [a, b] = await Promise.all([
client.apiKey.create({ name: "race-a" }, { headers }),
client.apiKey.create({ name: "race-b" }, { headers }),
]);
const { data: keys } = await client.apiKey.list({}, { headers });
const ids = keys!.apiKeys!.map((k) => k.id);
expect(ids).toContain(a.data!.id);
expect(ids).toContain(b.data!.id);
});
it("should write to secondary storage only", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{ name: "Test Key" },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(true);
});
it("should create in both database and secondary storage when fallbackToDatabase is true", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{ name: "Fallback Test Key" },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
// Should be in secondary storage
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(true);
// Should also be in database (verify by direct DB query)
const dbKey = await auth.api.getApiKey({
query: { id: createdKey!.id },
headers,
});
expect(dbKey).not.toBeNull();
expect(dbKey?.id).toBe(createdKey?.id);
expect(dbKey?.name).toBe("Fallback Test Key");
});
it("should update both database and secondary storage when fallbackToDatabase is true", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{ name: "Original Name" },
{ headers: headers },
);
expect(createdKey).not.toBeNull();
// Update the key
const { data: updatedKey } = await client.apiKey.update(
{
keyId: createdKey!.id,
name: "Updated Name",
},
{ headers: headers },
);
expect(updatedKey).not.toBeNull();
expect(updatedKey?.name).toBe("Updated Name");
// Verify secondary storage is updated
const cachedData = store.get(`api-key:by-id:${createdKey!.id}`);
expect(cachedData).toBeDefined();
const parsed = JSON.parse(cachedData!);
expect(parsed.name).toBe("Updated Name");
// Verify database is updated
const dbKey = await auth.api.getApiKey({
query: { id: createdKey!.id },
headers,
});
expect(dbKey?.name).toBe("Updated Name");
});
it("should delete from both database and secondary storage when fallbackToDatabase is true", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(true);
// Verify it exists in DB
const dbKeyBefore = await auth.api.getApiKey({
query: { id: createdKey!.id },
headers,
});
expect(dbKeyBefore).not.toBeNull();
// Delete the key
const { data: deleteResult } = await client.apiKey.delete(
{ keyId: createdKey!.id },
{ headers: headers },
);
expect(deleteResult?.success).toBe(true);
// Should be deleted from secondary storage
expect(store.has(`api-key:by-id:${createdKey!.id}`)).toBe(false);
// Should be deleted from database
let error: any = null;
try {
await auth.api.getApiKey({
query: { id: createdKey!.id },
headers,
});
} catch (e) {
error = e;
}
expect(error).not.toBeNull();
expect(error.status).toBe("NOT_FOUND");
});
});
describe("deferUpdates option", () => {
it("should defer updates when deferUpdates is enabled with global backgroundTasks", async () => {
const deferredPromises: Array<Promise<unknown>> = [];
const { auth, signInWithTestUser } = await getTestInstance({
advanced: {
backgroundTasks: {
handler: (p: Promise<unknown>) => {
deferredPromises.push(p);
},
},
},
plugins: [
apiKey({
deferUpdates: true,
}),
],
});
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: { userId: user.id },
headers,
});
const result = await auth.api.verifyApiKey({
body: { key: key.key },
});
expect(result.valid).toBe(true);
expect(deferredPromises.length).toBeGreaterThan(0);
await Promise.all(deferredPromises);
const updatedKey = await auth.api.getApiKey({
query: { id: key.id },
headers,
});
expect(updatedKey.lastRequest).not.toBeNull();
});
it("should still validate rate limits correctly with deferred updates", async () => {
const deferredPromises: Array<Promise<unknown>> = [];
const { auth, signInWithTestUser } = await getTestInstance({
advanced: {
backgroundTasks: {
handler: (p: Promise<unknown>) => {
deferredPromises.push(p);
},
},
},
plugins: [
apiKey({
deferUpdates: true,
rateLimit: {
enabled: true,
maxRequests: 2,
timeWindow: 60000,
},
}),
],
});
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: { userId: user.id },
headers,
});
const result1 = await auth.api.verifyApiKey({ body: { key: key.key } });
expect(result1.valid).toBe(true);
await Promise.all(deferredPromises);
deferredPromises.length = 0;
const result2 = await auth.api.verifyApiKey({ body: { key: key.key } });
expect(result2.valid).toBe(true);
await Promise.all(deferredPromises);
deferredPromises.length = 0;
const result3 = await auth.api.verifyApiKey({ body: { key: key.key } });
expect(result3.valid).toBe(false);
expect(result3.error?.code).toBe("RATE_LIMITED");
expect(result3.error).toHaveProperty("details");
expect((result3.error as any)?.details).toHaveProperty("tryAgainIn");
});
it("should defer remaining count updates", async () => {
const deferredPromises: Array<Promise<unknown>> = [];
const { auth, signInWithTestUser } = await getTestInstance({
advanced: {
backgroundTasks: {
handler: (p: Promise<unknown>) => {
deferredPromises.push(p);
},
},
},
plugins: [
apiKey({
deferUpdates: true,
}),
],
});
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: { userId: user.id, remaining: 10 },
});
const result = await auth.api.verifyApiKey({
body: { key: key.key },
});
expect(result.valid).toBe(true);
expect(result.key?.remaining).toBe(9);
expect(deferredPromises.length).toBeGreaterThan(0);
await Promise.all(deferredPromises);
const updatedKey = await auth.api.getApiKey({
query: { id: key.id },
headers,
});
expect(updatedKey.remaining).toBe(9);
});
it("should not defer updates when backgroundTasks handler is not configured", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
apiKey({
deferUpdates: true,
}),
],
});
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: { userId: user.id },
headers,
});
const result = await auth.api.verifyApiKey({
body: { key: key.key },
});
expect(result.valid).toBe(true);
// Without advanced.backgroundTasks handler, updates should happen synchronously
const updatedKey = await auth.api.getApiKey({
query: { id: key.id },
headers,
});
expect(updatedKey.lastRequest).not.toBeNull();
});
});
describe("custom storage methods", async () => {
const customStore = new Map<string, string>();
let customGetCalled = false;
let customSetCalled = false;
let customDeleteCalled = false;
const { client, signInWithTestUser } = await getTestInstance(
{
// Don't provide global secondaryStorage
plugins: [
apiKey({
storage: "secondary-storage",
customStorage: {
set(key, value, ttl) {
customSetCalled = true;
customStore.set(key, value);
},
get(key) {
customGetCalled = true;
return customStore.get(key) || null;
},
delete(key) {
customDeleteCalled = true;
customStore.delete(key);
},
},
enableMetadata: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
beforeEach(() => {
customStore.clear();
customGetCalled = false;
customSetCalled = false;
customDeleteCalled = false;
});
it("should use custom storage methods instead of global secondaryStorage", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
expect(customSetCalled).toBe(true);
expect(customStore.has(`api-key:by-id:${createdKey!.id}`)).toBe(true);
});
it("should use custom get method", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
customGetCalled = false;
const { data: retrievedKey } = await client.apiKey.get(
{ query: { id: createdKey!.id } },
{ headers: headers },
);
expect(retrievedKey).not.toBeNull();
expect(customGetCalled).toBe(true);
});
it("should use custom delete method", async () => {
const { headers } = await signInWithTestUser();
const { data: createdKey } = await client.apiKey.create(
{},
{ headers: headers },
);
expect(createdKey).not.toBeNull();
customDeleteCalled = false;
const { data: deleteResult } = await client.apiKey.delete(
{ keyId: createdKey!.id },
{ headers: headers },
);
expect(deleteResult?.success).toBe(true);
expect(customDeleteCalled).toBe(true);
expect(customStore.has(`api-key:by-id:${createdKey!.id}`)).toBe(false);
});
});
// =========================================================================
// LEGACY DOUBLE-STRINGIFIED METADATA MIGRATION
// =========================================================================
describe("legacy double-stringified metadata migration", async () => {
const { auth, signInWithTestUser, db } = await getTestInstance(
{
plugins: [
apiKey({
enableMetadata: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
it("should migrate double-stringified metadata on getApiKey", async () => {
const { headers } = await signInWithTestUser();
// Create a key first
const createdKey = await auth.api.createApiKey({
body: {
metadata: { organizationId: "test-org" },
},
headers,
});
// Pass a single-stringified value - the adapter's transform.input will stringify it again,
// resulting in double-stringified data in the database (simulating legacy bug)
const legacyMetadata = JSON.stringify({ organizationId: "legacy-org" });
await db.update({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
update: { metadata: legacyMetadata },
});
// Verify it's double-stringified in DB (adapter added extra layer of stringification)
const rawKey = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
})) as { metadata?: string } | null;
expect(typeof rawKey?.metadata).toBe("string");
// Read via API - should return properly parsed object
const result = await auth.api.getApiKey({
query: { id: createdKey.id },
headers,
});
expect(result).not.toBeNull();
expect(result.metadata).toEqual({ organizationId: "legacy-org" });
expect(typeof result.metadata).toBe("object");
// Verify the database was migrated (no longer double-stringified)
// After migration, the adapter's transform.output returns the parsed object
const migratedKey = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
})) as { metadata?: Record<string, any> } | null;
// After migration, metadata should be a properly formatted object
expect(migratedKey?.metadata).toEqual({ organizationId: "legacy-org" });
});
it("should migrate double-stringified metadata on listApiKeys", async () => {
const { headers } = await signInWithTestUser();
// Create first key with double-stringified metadata
const createdKey1 = await auth.api.createApiKey({
body: {
name: "key-1",
metadata: { plan: "pro" },
},
headers,
});
// Create second key with double-stringified metadata
const createdKey2 = await auth.api.createApiKey({
body: {
name: "key-2",
metadata: { plan: "enterprise" },
},
headers,
});
// Pass single-stringified values - the adapter will double-stringify them
const legacyMetadata1 = JSON.stringify({ plan: "legacy-1" });
const legacyMetadata2 = JSON.stringify({ plan: "legacy-2" });
await db.update({
model: "apikey",
where: [{ field: "id", value: createdKey1.id }],
update: { metadata: legacyMetadata1 },
});
await db.update({
model: "apikey",
where: [{ field: "id", value: createdKey2.id }],
update: { metadata: legacyMetadata2 },
});
// List via API - both keys should have properly parsed metadata objects
const results = await auth.api.listApiKeys({ headers });
const foundKey1 = results.apiKeys.find(
(k: any) => k.id === createdKey1.id,
);
const foundKey2 = results.apiKeys.find(
(k: any) => k.id === createdKey2.id,
);
expect(foundKey1).toBeDefined();
expect(foundKey1?.metadata).toEqual({ plan: "legacy-1" });
expect(typeof foundKey1?.metadata).toBe("object");
expect(foundKey2).toBeDefined();
expect(foundKey2?.metadata).toEqual({ plan: "legacy-2" });
expect(typeof foundKey2?.metadata).toBe("object");
// Verify the database was migrated for both keys
const migratedKey1 = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey1.id }],
})) as { metadata?: Record<string, any> } | null;
expect(migratedKey1?.metadata).toEqual({ plan: "legacy-1" });
const migratedKey2 = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey2.id }],
})) as { metadata?: Record<string, any> } | null;
expect(migratedKey2?.metadata).toEqual({ plan: "legacy-2" });
});
it("should migrate double-stringified metadata on updateApiKey", async () => {
const { headers } = await signInWithTestUser();
// Create a key first
const createdKey = await auth.api.createApiKey({
body: {
name: "test-key",
metadata: { tier: "free" },
},
headers,
});
// Pass a single-stringified value - the adapter will double-stringify it
const legacyMetadata = JSON.stringify({ tier: "legacy-tier" });
await db.update({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
update: { metadata: legacyMetadata },
});
// Update via API (changing a different field, not metadata)
const result = await auth.api.updateApiKey({
body: {
keyId: createdKey.id,
name: "updated-name",
},
headers,
});
expect(result).not.toBeNull();
expect(result.name).toBe("updated-name");
// Metadata should be migrated and returned as object
expect(result.metadata).toEqual({ tier: "legacy-tier" });
expect(typeof result.metadata).toBe("object");
// Verify the database was migrated
const migratedKey = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
})) as { metadata?: Record<string, any> } | null;
expect(migratedKey?.metadata).toEqual({ tier: "legacy-tier" });
});
it("should migrate double-stringified metadata on verifyApiKey", async () => {
const { headers } = await signInWithTestUser();
// Create a key first
const createdKey = await auth.api.createApiKey({
body: {
metadata: { scope: "read" },
},
headers,
});
// Pass a single-stringified value - the adapter will double-stringify it
const legacyMetadata = JSON.stringify({ scope: "legacy-scope" });
await db.update({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
update: { metadata: legacyMetadata },
});
// Verify via API - should return properly parsed object
const result = await auth.api.verifyApiKey({
body: { key: createdKey.key },
});
expect(result.valid).toBe(true);
expect(result.key).not.toBeNull();
expect(result.key?.metadata).toEqual({ scope: "legacy-scope" });
expect(typeof result.key?.metadata).toBe("object");
// Verify the database was migrated
const migratedKey = (await db.findOne({
model: "apikey",
where: [{ field: "id", value: createdKey.id }],
})) as { metadata?: Record<string, any> } | null;
expect(migratedKey?.metadata).toEqual({ scope: "legacy-scope" });
});
it("should handle already properly formatted metadata (no migration needed)", async () => {
const { headers } = await signInWithTestUser();
const metadata = { alreadyCorrect: true, value: 123 };
// Create a key with proper metadata
const createdKey = await auth.api.createApiKey({
body: { metadata },
headers,
});
// Read via API - should return the same object
const result = await auth.api.getApiKey({
query: { id: createdKey.id },
headers,
});
expect(result.metadata).toEqual(metadata);
expect(typeof result.metadata).toBe("object");
});
it("should handle null metadata gracefully", async () => {
const { headers } = await signInWithTestUser();
// Create a key without metadata
const createdKey = await auth.api.createApiKey({
body: {},
headers,
});
// Read via API - should return null
const result = await auth.api.getApiKey({
query: { id: createdKey.id },
headers,
});
expect(result.metadata).toBeNull();
});
});
// =========================================================================
// MULTIPLE CONFIGURATIONS
// =========================================================================
describe("multiple configurations", async () => {
const { auth, signInWithTestUser, client } = await getTestInstance(
{
plugins: [
apiKey([
{
configId: "public-api",
defaultPrefix: "pub_",
rateLimit: {
enabled: true,
maxRequests: 100,
timeWindow: 60000,
},
},
{
configId: "internal-api",
defaultPrefix: "int_",
rateLimit: {
enabled: true,
maxRequests: 1000,
timeWindow: 60000,
},
},
{
configId: "default",
defaultPrefix: "def_",
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
it("should create API key with specific configId", async () => {
const { user } = await signInWithTestUser();
const publicKey = await auth.api.createApiKey({
body: {
configId: "public-api",
userId: user.id,
},
});
expect(publicKey).not.toBeNull();
expect(publicKey.configId).toBe("public-api");
expect(publicKey.prefix).toBe("pub_");
expect(publicKey.rateLimitMax).toBe(100);
const internalKey = await auth.api.createApiKey({
body: {
configId: "internal-api",
userId: user.id,
},
});
expect(internalKey).not.toBeNull();
expect(internalKey.configId).toBe("internal-api");
expect(internalKey.prefix).toBe("int_");
expect(internalKey.rateLimitMax).toBe(1000);
});
it("should use default config when no configId is provided", async () => {
const { user } = await signInWithTestUser();
const defaultKey = await auth.api.createApiKey({
body: {
userId: user.id,
},
});
expect(defaultKey).not.toBeNull();
expect(defaultKey.configId).toBe("default");
expect(defaultKey.prefix).toBe("def_");
});
it("should list keys filtered by configId", async () => {
const { headers, user } = await signInWithTestUser();
// Create keys with different configs
await auth.api.createApiKey({
body: { configId: "public-api", userId: user.id },
});
await auth.api.createApiKey({
body: { configId: "internal-api", userId: user.id },
});
await auth.api.createApiKey({
body: { configId: "default", userId: user.id },
});
// List all keys
const allKeys = await auth.api.listApiKeys({ headers });
expect(allKeys.apiKeys.length).toBeGreaterThanOrEqual(3);
// List only public-api keys
const publicKeys = await client.apiKey.list(
{ query: { configId: "public-api" } },
{ headers },
);
expect(
publicKeys.data?.apiKeys.every((k) => k.configId === "public-api"),
).toBe(true);
// List only internal-api keys
const internalKeys = await client.apiKey.list(
{ query: { configId: "internal-api" } },
{ headers },
);
expect(
internalKeys.data?.apiKeys.every((k) => k.configId === "internal-api"),
).toBe(true);
});
it("should verify key and apply correct config rate limits", async () => {
const { user } = await signInWithTestUser();
const publicKey = await auth.api.createApiKey({
body: {
configId: "public-api",
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: {
configId: "public-api",
key: publicKey.key,
},
});
expect(result.valid).toBe(true);
expect(result.key?.configId).toBe("public-api");
expect(result.key?.rateLimitMax).toBe(100);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9779
*/
describe("verify scoping by configId", () => {
it("should verify a non-default key when configId is omitted", async () => {
const { user } = await signInWithTestUser();
const publicKey = await auth.api.createApiKey({
body: {
configId: "public-api",
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: { key: publicKey.key },
});
expect(result.valid).toBe(true);
expect(result.key?.configId).toBe("public-api");
});
it("should still verify a default key when configId is omitted", async () => {
const { user } = await signInWithTestUser();
const defaultKey = await auth.api.createApiKey({
body: { userId: user.id },
});
const result = await auth.api.verifyApiKey({
body: { key: defaultKey.key },
});
expect(result.valid).toBe(true);
expect(result.key?.configId).toBe("default");
});
// Preserves the #9393 check: scoping to the wrong config is rejected.
it("should reject a key when scoped to a different configId", async () => {
const { user } = await signInWithTestUser();
const publicKey = await auth.api.createApiKey({
body: {
configId: "public-api",
userId: user.id,
},
});
const result = await auth.api.verifyApiKey({
body: {
configId: "internal-api",
key: publicKey.key,
},
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("INVALID_API_KEY");
});
// Default config disables rate limiting; the key's config enforces it,
// so the second unscoped verify must be rate limited.
it("should apply the key's own config when configId is omitted", async () => {
const { auth: scopedAuth, signInWithTestUser: scopedSignIn } =
await getTestInstance(
{
plugins: [
apiKey([
{
configId: "limited",
defaultPrefix: "lim_",
rateLimit: {
enabled: true,
maxRequests: 1,
timeWindow: 60000,
},
},
{
configId: "default",
defaultPrefix: "def_",
rateLimit: { enabled: false },
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await scopedSignIn();
const limitedKey = await scopedAuth.api.createApiKey({
body: { configId: "limited", userId: user.id },
});
const first = await scopedAuth.api.verifyApiKey({
body: { key: limitedKey.key },
});
expect(first.valid).toBe(true);
const second = await scopedAuth.api.verifyApiKey({
body: { key: limitedKey.key },
});
expect(second.valid).toBe(false);
expect(second.error?.code).toBe("RATE_LIMITED");
});
it("should run the key's own customAPIKeyValidator when configId is omitted", async () => {
const { auth: scopedAuth, signInWithTestUser: scopedSignIn } =
await getTestInstance(
{
plugins: [
apiKey([
{
configId: "guarded",
defaultPrefix: "grd_",
customAPIKeyValidator: () => false,
},
{ configId: "default", defaultPrefix: "def_" },
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await scopedSignIn();
const guardedKey = await scopedAuth.api.createApiKey({
body: { configId: "guarded", userId: user.id },
});
const result = await scopedAuth.api.verifyApiKey({
body: { key: guardedKey.key },
});
expect(result.valid).toBe(false);
expect(result.error?.code).toBe("KEY_NOT_FOUND");
});
it("should not apply the default config validator to a non-default key", async () => {
const { auth: scopedAuth, signInWithTestUser: scopedSignIn } =
await getTestInstance(
{
plugins: [
apiKey([
{
configId: "default",
defaultPrefix: "def_",
customAPIKeyValidator: () => false,
},
{ configId: "open", defaultPrefix: "opn_" },
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { user } = await scopedSignIn();
const openKey = await scopedAuth.api.createApiKey({
body: { configId: "open", userId: user.id },
});
const result = await scopedAuth.api.verifyApiKey({
body: { key: openKey.key },
});
expect(result.valid).toBe(true);
expect(result.key?.configId).toBe("open");
});
});
it("should get key and resolve correct config", async () => {
const { headers, user } = await signInWithTestUser();
const internalKey = await auth.api.createApiKey({
body: {
configId: "internal-api",
userId: user.id,
},
});
const retrievedKey = await auth.api.getApiKey({
query: { id: internalKey.id, configId: "internal-api" },
headers,
});
expect(retrievedKey.configId).toBe("internal-api");
expect(retrievedKey.prefix).toBe("int_");
});
it("should update key while preserving configId", async () => {
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: {
configId: "public-api",
name: "original-name",
userId: user.id,
},
});
const updatedKey = await auth.api.updateApiKey({
body: {
keyId: key.id,
name: "updated-name",
configId: "public-api",
},
headers,
});
expect(updatedKey.configId).toBe("public-api");
expect(updatedKey.name).toBe("updated-name");
});
it("should delete key from specific config", async () => {
const { headers, user } = await signInWithTestUser();
const key = await auth.api.createApiKey({
body: {
configId: "internal-api",
userId: user.id,
},
});
const deleteResult = await auth.api.deleteApiKey({
body: { keyId: key.id, configId: "internal-api" },
headers,
});
expect(deleteResult.success).toBe(true);
// Verify key is deleted
try {
await auth.api.getApiKey({
query: { id: key.id, configId: "internal-api" },
headers,
});
expect.fail("Should have thrown an error");
} catch (error: any) {
expect(isAPIError(error)).toBe(true);
}
});
it("should throw error when configId array has non-unique configIds", async () => {
expect(() =>
apiKey([{ configId: "duplicate" }, { configId: "duplicate" }]),
).toThrow("configId must be unique");
});
it("should throw error when configId is missing in array config", async () => {
expect(() =>
apiKey([
{ configId: "valid" },
{}, // Missing configId
]),
).toThrow("configId is required");
});
});
// =========================================================================
// ORGANIZATION-OWNED API KEYS
// =========================================================================
describe("organization-owned API keys", async () => {
const { organization } = await import(
"../../better-auth/src/plugins/organization"
);
const { organizationClient } = await import(
"../../better-auth/src/plugins/organization/client"
);
const { auth, signInWithTestUser, client } = await getTestInstance(
{
plugins: [
organization({
async sendInvitationEmail() {},
}),
apiKey([
{
configId: "user-keys",
defaultPrefix: "usr_",
references: "user",
},
{
configId: "org-keys",
defaultPrefix: "org_",
references: "organization",
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient(), organizationClient()],
},
},
);
it("should create organization-owned API key", async () => {
const { headers } = await signInWithTestUser();
// Create an organization
const org = await auth.api.createOrganization({
body: { name: "Test Org", slug: "test-org" },
headers,
});
// Create org-owned API key
const orgKey = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
},
headers,
});
expect(orgKey).not.toBeNull();
expect(orgKey.configId).toBe("org-keys");
expect(orgKey.referenceId).toBe(org.id);
expect(orgKey.prefix).toBe("org_");
});
it("should create user-owned API key", async () => {
const { user } = await signInWithTestUser();
const userKey = await auth.api.createApiKey({
body: {
configId: "user-keys",
userId: user.id,
},
});
expect(userKey).not.toBeNull();
expect(userKey.configId).toBe("user-keys");
expect(userKey.referenceId).toBe(user.id);
expect(userKey.prefix).toBe("usr_");
});
it("should fail to create org key without organizationId", async () => {
const { user } = await signInWithTestUser();
try {
await auth.api.createApiKey({
body: {
configId: "org-keys",
userId: user.id, // Wrong - should be organizationId
},
});
expect.fail("Should have thrown an error");
} catch (error: any) {
expect(isAPIError(error)).toBe(true);
expect(error.body?.code).toBe(
ERROR_CODES.ORGANIZATION_ID_REQUIRED.code,
);
}
});
it("should verify organization-owned API key", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Verify Org", slug: "verify-org" },
headers,
});
const orgKey = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
},
headers,
});
const result = await auth.api.verifyApiKey({
body: { key: orgKey.key, configId: "org-keys" },
});
expect(result.valid).toBe(true);
expect(result.key?.configId).toBe("org-keys");
expect(result.key?.referenceId).toBe(org.id);
});
it("should list only user-owned keys when no organizationId provided", async () => {
const { headers, user } = await signInWithTestUser();
// Create an organization
const org = await auth.api.createOrganization({
body: { name: "List Org", slug: "list-org" },
headers,
});
// Create user-owned key
await auth.api.createApiKey({
body: { configId: "user-keys", userId: user.id },
});
// Create org-owned key
await auth.api.createApiKey({
body: { configId: "org-keys", organizationId: org.id },
headers,
});
// List from client without organizationId should only return user-owned keys
const result = await client.apiKey.list({}, { headers });
expect(result.data?.apiKeys).toBeDefined();
// All returned keys should be user-owned (from user-keys config)
result.data?.apiKeys.forEach((key) => {
expect(key.configId).toBe("user-keys");
expect(key.referenceId).toBe(user.id);
});
});
it("should list organization-owned keys when organizationId is provided", async () => {
const { headers, user } = await signInWithTestUser();
// Create an organization
const org = await auth.api.createOrganization({
body: { name: "List Org Keys", slug: "list-org-keys" },
headers,
});
// Create user-owned key
const userKey = await auth.api.createApiKey({
body: { configId: "user-keys", userId: user.id },
});
// Create org-owned keys
const _orgKey1 = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
name: "org-key-1",
},
headers,
});
const _orgKey2 = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
name: "org-key-2",
},
headers,
});
// List with organizationId should only return org-owned keys
// Note: Must use server-side API for organization-owned keys (client requests are blocked for security)
const result = await auth.api.listApiKeys({
query: { organizationId: org.id },
headers,
});
expect(result.apiKeys).toBeDefined();
expect(result.apiKeys.length).toBe(2);
// All returned keys should be org-owned
result.apiKeys.forEach((key) => {
expect(key.configId).toBe("org-keys");
expect(key.referenceId).toBe(org.id);
});
// Verify user key is not in the list
const userKeyInList = result.apiKeys.find((k) => k.id === userKey.id);
expect(userKeyInList).toBeUndefined();
});
it("should filter organization keys by configId", async () => {
const { auth: authMultiOrg, signInWithTestUser: signIn } =
await getTestInstance(
{
plugins: [
organization({
async sendInvitationEmail() {},
}),
apiKey([
{
configId: "org-public",
defaultPrefix: "pub_",
references: "organization",
},
{
configId: "org-internal",
defaultPrefix: "int_",
references: "organization",
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient(), organizationClient()],
},
},
);
const { headers } = await signIn();
const org = await authMultiOrg.api.createOrganization({
body: { name: "Filter Org", slug: "filter-org" },
headers,
});
// Create keys with different configs
await authMultiOrg.api.createApiKey({
body: { configId: "org-public", organizationId: org.id },
headers,
});
await authMultiOrg.api.createApiKey({
body: { configId: "org-internal", organizationId: org.id },
headers,
});
// List only org-public keys
// Note: Must use server-side API for organization-owned keys (client requests are blocked for security)
const result = await authMultiOrg.api.listApiKeys({
query: { organizationId: org.id, configId: "org-public" },
headers,
});
expect(result.apiKeys).toBeDefined();
expect(result.apiKeys.length).toBe(1);
expect(result.apiKeys[0]?.configId).toBe("org-public");
});
it("should allow organization owners to manage API keys", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Owner Access Org", slug: "owner-access-org" },
headers,
});
// Organization owners have full access to API keys
const result = await client.apiKey.list(
{ query: { organizationId: org.id } },
{ headers },
);
// Owner should be able to list org API keys
expect(result.error).toBeFalsy();
expect(result.data?.apiKeys).toBeDefined();
});
it("should deny non-members from accessing organization API keys", async () => {
// Create owner user and their organization
const { headers: ownerHeaders } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Non Member Org", slug: "non-member-org" },
headers: ownerHeaders,
});
// Create a different user who is not a member
const nonMemberEmail = `non-member-${Date.now()}@test.com`;
await auth.api.signUpEmail({
body: {
email: nonMemberEmail,
password: "password123",
name: "Non Member User",
},
});
const nonMemberSession = await auth.api.signInEmail({
body: { email: nonMemberEmail, password: "password123" },
});
const nonMemberHeaders = {
cookie: `better-auth.session_token=${nonMemberSession.token}`,
};
// Non-member should not be able to list org API keys
const result = await client.apiKey.list(
{ query: { organizationId: org.id } },
{ headers: nonMemberHeaders },
);
// Should fail - either 403 (FORBIDDEN) for non-members or 401 (UNAUTHORIZED) for session issues
expect(result.error).toBeDefined();
// Non-members get FORBIDDEN, invalid session gets UNAUTHORIZED
expect([401, 403]).toContain(result.error?.status);
});
it("should not allow session mocking for org-owned keys", async () => {
const { auth: authWithSessionMocking, signInWithTestUser: signIn } =
await getTestInstance(
{
plugins: [
organization({
async sendInvitationEmail() {},
}),
apiKey([
{
configId: "org-keys",
defaultPrefix: "org_",
references: "organization",
enableSessionForAPIKeys: true, // Enable session mocking
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient(), organizationClient()],
},
},
);
const { headers } = await signIn();
const org = await authWithSessionMocking.api.createOrganization({
body: { name: "Session Org", slug: "session-org" },
headers,
});
const orgKey = await authWithSessionMocking.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
},
headers,
});
// Try to use org key for session mocking - should fail
try {
await authWithSessionMocking.api.getSession({
headers: {
"x-api-key": orgKey.key,
},
});
expect.fail("Should have thrown an error");
} catch (error: any) {
expect(isAPIError(error)).toBe(true);
expect(error.body?.code).toBe(
ERROR_CODES.INVALID_REFERENCE_ID_FROM_API_KEY.code,
);
}
});
it("should allow session mocking for user-owned keys only", async () => {
const { auth: authWithSessionMocking, signInWithTestUser: signIn } =
await getTestInstance(
{
plugins: [
organization({
async sendInvitationEmail() {},
}),
apiKey([
{
configId: "user-keys",
defaultPrefix: "usr_",
references: "user",
enableSessionForAPIKeys: true, // Enable session mocking
},
]),
],
},
{
clientOptions: {
plugins: [apiKeyClient(), organizationClient()],
},
},
);
const { user } = await signIn();
const userKey = await authWithSessionMocking.api.createApiKey({
body: {
configId: "user-keys",
userId: user.id,
},
});
// Use user key for session mocking - should work
const session = await authWithSessionMocking.api.getSession({
headers: {
"x-api-key": userKey.key,
},
});
expect(session).not.toBeNull();
expect(session?.user.id).toBe(user.id);
});
it("should handle mixed user and org keys in same instance", async () => {
const { headers, user } = await signInWithTestUser();
// Create organization
const org = await auth.api.createOrganization({
body: { name: "Mixed Org", slug: "mixed-org" },
headers,
});
// Create both types of keys
const userKey = await auth.api.createApiKey({
body: { configId: "user-keys", userId: user.id },
});
const orgKey = await auth.api.createApiKey({
body: { configId: "org-keys", organizationId: org.id },
headers,
});
// Verify both keys work
const userResult = await auth.api.verifyApiKey({
body: { key: userKey.key, configId: "user-keys" },
});
expect(userResult.valid).toBe(true);
expect(userResult.key?.referenceId).toBe(user.id);
const orgResult = await auth.api.verifyApiKey({
body: { key: orgKey.key, configId: "org-keys" },
});
expect(orgResult.valid).toBe(true);
expect(orgResult.key?.referenceId).toBe(org.id);
});
it("should get org-owned key by id from server", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Get Org", slug: "get-org" },
headers,
});
const orgKey = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
name: "my-org-key",
},
headers,
});
// Get key by ID (server-side)
const retrievedKey = await auth.api.getApiKey({
query: { id: orgKey.id, configId: "org-keys" },
headers,
});
expect(retrievedKey).not.toBeNull();
expect(retrievedKey.id).toBe(orgKey.id);
expect(retrievedKey.configId).toBe("org-keys");
expect(retrievedKey.referenceId).toBe(org.id);
expect(retrievedKey.name).toBe("my-org-key");
});
it("should delete org-owned key", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Delete Org", slug: "delete-org" },
headers,
});
const orgKey = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
},
headers,
});
// Delete the key
const deleteResult = await auth.api.deleteApiKey({
body: { keyId: orgKey.id, configId: "org-keys" },
headers,
});
expect(deleteResult.success).toBe(true);
// Verify key is deleted
const verifyResult = await auth.api.verifyApiKey({
body: { key: orgKey.key, configId: "org-keys" },
});
expect(verifyResult.valid).toBe(false);
});
it("should update org-owned key", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: { name: "Update Org", slug: "update-org" },
headers,
});
const orgKey = await auth.api.createApiKey({
body: {
configId: "org-keys",
organizationId: org.id,
name: "original-name",
},
headers,
});
// Update the key
const updatedKey = await auth.api.updateApiKey({
body: {
keyId: orgKey.id,
name: "updated-name",
enabled: false,
configId: "org-keys",
},
headers,
});
expect(updatedKey.name).toBe("updated-name");
expect(updatedKey.enabled).toBe(false);
expect(updatedKey.configId).toBe("org-keys");
expect(updatedKey.referenceId).toBe(org.id);
});
});
});
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();
it("returns the key that createApiKey just wrote", async () => {
const created = await auth.api.createApiKey({ body: {}, headers });
expect(created.id).toBeDefined();
const result = await auth.api.listApiKeys({ headers });
expect(result.total).toBeGreaterThan(0);
expect(result.apiKeys.find((k) => k.id === created.id)).toBeDefined();
});
});