mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-18 10:44:43 -05:00
4964 lines
127 KiB
TypeScript
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();
|
|
});
|
|
});
|