diff --git a/docs/content/docs/plugins/api-key.mdx b/docs/content/docs/plugins/api-key.mdx index a5a0c28da3..009de9216f 100644 --- a/docs/content/docs/plugins/api-key.mdx +++ b/docs/content/docs/plugins/api-key.mdx @@ -111,6 +111,7 @@ You can view the list of API Key plugin options [here](/docs/plugins/api-key#api }); ``` + All API keys are assigned to a user. If you're creating an API key on the server, without access to headers, you must pass the `userId` property. This is the ID of the user that the API key is associated with. @@ -136,9 +137,10 @@ All properties are optional. However if you pass a `refillAmount`, you must also ```ts const example = { - projects: ["read", "read-write"] -} + projects: ["read", "read-write"], +}; ``` + - `userId`?: The ID of the user associated with the API key. When creating an API Key, you must pass the headers of the user who will own the key. However if you do not have the headers, you can pass this field, which will allow you to bypass the need for headers. #### Result @@ -164,8 +166,8 @@ const { valid, error, key } = await auth.api.verifyApiKey({ body: { key: "your_api_key_here", permissions: { - projects: ["read", "read-write"] - } + projects: ["read", "read-write"], + }, }, }); ``` @@ -252,22 +254,20 @@ type Result = Omit; #### Properties - Client -- `keyId`: The API key Id to update on. -- `name`?: Update the key name. - Server Only -- `userId`?: Update the user id who owns this key. -- `name`?: Update the key name. -- `enabled`?: Update whether the API key is enabled or not. -- `remaining`?: Update the remaining count. -- `refillAmount`?: Update the amount to refill the `remaining` count every interval. -- `refillInterval`?: Update the interval to refill the `remaining` count. -- `metadata`?: Update the metadata of the API key. -- `expiresIn`?: Update the expiration time of the API key. In seconds. -- `rateLimitEnabled`?: Update whether the rate-limiter is enabled or not. -- `rateLimitTimeWindow`?: Update the time window for the rate-limiter. -- `rateLimitMax`?: Update the maximum number of requests they can make during the rate-limit-time-window. +Client- `keyId`: The API key Id to update on. - +`name`?: Update the key name. + +Server Only- `userId`?: Update the user id who owns +this key. - `name`?: Update the key name. - `enabled`?: Update whether the API +key is enabled or not. - `remaining`?: Update the remaining count. - +`refillAmount`?: Update the amount to refill the `remaining` count every +interval. - `refillInterval`?: Update the interval to refill the `remaining` +count. - `metadata`?: Update the metadata of the API key. - `expiresIn`?: Update +the expiration time of the API key. In seconds. - `rateLimitEnabled`?: Update +whether the rate-limiter is enabled or not. - `rateLimitTimeWindow`?: Update the +time window for the rate-limiter. - `rateLimitMax`?: Update the maximum number +of requests they can make during the rate-limit-time-window. #### Result @@ -341,18 +341,21 @@ If fails, throws `APIError`. Otherwise, you'll receive: ```ts -type Result = ApiKey[] +type Result = ApiKey[]; ``` + --- ### Delete all expired API keys This function will delete all API keys that have an expired expiration date. - -```ts -await auth.api.deleteAllExpiredApiKeys(); -``` + +```ts await auth.api.deleteAllExpiredApiKeys(); ``` We automatically delete expired API keys every time any apiKey plugin @@ -372,10 +375,10 @@ The default header key is `x-api-key`, but this can be changed by setting the `a export const auth = betterAuth({ plugins: [ apiKey({ - apiKeyHeaders: ['x-api-key', 'xyz-api-key'], // or you can pass just a string, eg: "x-api-key" - }) - ] -}) + apiKeyHeaders: ["x-api-key", "xyz-api-key"], // or you can pass just a string, eg: "x-api-key" + }), + ], +}); ``` Or optionally, you can pass an `apiKeyGetter` function to the plugin options, which will be called with the `GenericEndpointContext`, and from there, you should return the API key, or `null` if the request is invalid. @@ -385,13 +388,13 @@ export const auth = betterAuth({ plugins: [ apiKey({ apiKeyGetter: (ctx) => { - const has = ctx.request.headers.has('x-api-key') - if(!has) return null - return ctx.request.headers.get('x-api-key') - } - }) - ] -}) + const has = ctx.request.headers.has("x-api-key"); + if (!has) return null; + return ctx.request.headers.get("x-api-key"); + }, + }), + ], +}); ``` ## Rate Limiting @@ -412,9 +415,9 @@ export const auth = betterAuth({ timeWindow: 1000 * 60 * 60 * 24, // 1 day maxRequests: 10, // 10 requests per day }, - }) - ] -}) + }), + ], +}); ``` For each API key, you can customize the rate-limit options on create. @@ -448,22 +451,24 @@ The expiration time is the expiration date of the API key. ### How does it work? #### Remaining: + Whenever an API key is used, the `remaining` count is updated. If the `remaining` count is `null`, then there is no cap to key usage. Otherwise, the `remaining` count is decremented by 1. If the `remaining` count is 0, then the API key is disabled & removed. #### refillInterval & refillAmount: + Whenever an API key is created, the `refillInterval` and `refillAmount` are set to `null`. This means that the API key will not be refilled automatically. However, if `refillInterval` & `refillAmount` are set, then the API key will be refilled accordingly. #### Expiration: + Whenever an API key is created, the `expiresAt` is set to `null`. This means that the API key will never expire. However, if the `expiresIn` is set, then the API key will expire after the `expiresIn` time. - ## Custom Key generation & verification You can customize the key generation and verification process straight from the plugin options. @@ -474,19 +479,25 @@ Here's an example: export const auth = betterAuth({ plugins: [ apiKey({ - customKeyGenerator: (options: { length: number, prefix: string | undefined }) => { - const apiKey = mySuperSecretApiKeyGenerator(options.length, options.prefix); + customKeyGenerator: (options: { + length: number; + prefix: string | undefined; + }) => { + const apiKey = mySuperSecretApiKeyGenerator( + options.length, + options.prefix + ); return apiKey; }, - customAPIKeyValidator: ({ctx, key}) => { - if(key.endsWith("_super_secret_api_key")) { - return true; - } else { - return false; - } + customAPIKeyValidator: ({ ctx, key }) => { + if (key.endsWith("_super_secret_api_key")) { + return true; + } else { + return false; + } }, - }) - ] + }), + ], }); ``` @@ -516,17 +527,19 @@ as all failed keys can be invalidated without having to query your database. We allow you to store metadata alongside your API keys. This is useful for storing information about the key, such as a subscription plan for example. To store metadata, make sure you haven't disabled the metadata feature in the plugin options. + ```ts export const auth = betterAuth({ plugins: [ apiKey({ enableMetadata: true, - }) - ] -}) + }), + ], +}); ``` Then, you can store metadata in the `metadata` field of the API key object. + ```ts const apiKey = await auth.api.createApiKey({ body: { @@ -538,6 +551,7 @@ const apiKey = await auth.api.createApiKey({ ``` You can then retrieve the metadata from the API key object. + ```ts const apiKey = await auth.api.getApiKey({ body: { @@ -554,12 +568,10 @@ console.log(apiKey.metadata.plan); // "premium" The header name to check for API key. Default is `x-api-key`. - `customAPIKeyGetter` `(ctx: GenericEndpointContext) => string | null` A custom function to get the API key from the context. - `customAPIKeyValidator` `(options: { ctx: GenericEndpointContext; key: string; }) => boolean` A custom function to validate the API key. @@ -576,15 +588,16 @@ Customize the starting characters configuration. `shouldStore` `boolean` - Wether to store the starting characters in the database. - If false, we will set `start` to `null`. + Wether to store the starting characters in the database. + If false, we will set `start` to `null`. Default is `true`. - + `charactersLength` `number` - The length of the starting characters to store in the database. - This includes the prefix length. + The length of the starting characters to store in the database. + This includes the prefix length. Default is `6`. + @@ -632,7 +645,7 @@ Customize the key expiration. `disableCustomExpiresTime` `boolean` - Wether to disable the expires time passed from the client. + Wether to disable the expires time passed from the client. If `true`, the expires time will be based on the default values. Default is `false`. @@ -645,6 +658,7 @@ Customize the key expiration. The maximum expiresIn value allowed to be set from the client. in days. Default is `365`. + @@ -660,24 +674,24 @@ Customize the rate-limiting. `timeWindow` `number` - The duration in milliseconds where each request is counted. - Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. + The duration in milliseconds where each request is counted. + Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. `maxRequests` `number` - Maximum amount of requests allowed within a window. + Maximum amount of requests allowed within a window. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. + - `schema` `InferOptionSchema>` Custom schema for the API key plugin. `disableSessionForAPIKeys` `boolean` -An API Key can represent a valid session, so we automatically mock a session for the user if we find a valid API key in the request headers. +An API Key can represent a valid session, so we automatically mock a session for the user if we find a valid API key in the request headers. `permissions` `{ defaultPermissions?: Statements | ((userId: string, ctx: GenericEndpointContext) => Statements | Promise) }` @@ -690,9 +704,17 @@ Read more about permissions [here](/docs/plugins/api-key#permissions). `defaultPermissions` `Statements | ((userId: string, ctx: GenericEndpointContext) => Statements | Promise)` The default permissions for the API key. + +`disableKeyHashing` `boolean` + +Disable hashing of the API key. + +⚠️ Security Warning: It's strongly recommended to not disable hashing. +Storing API keys in plaintext makes them vulnerable to database breaches, potentially exposing all your users' API keys. + --- ## Schema @@ -843,12 +865,12 @@ export const auth = betterAuth({ permissions: { defaultPermissions: { files: ["read"], - users: ["read"] - } - } - }) - ] -}) + users: ["read"], + }, + }, + }), + ], +}); ``` You can also provide a function that returns permissions dynamically: @@ -862,13 +884,13 @@ export const auth = betterAuth({ // Fetch user role or other data to determine permissions return { files: ["read"], - users: ["read"] + users: ["read"], }; - } - } - }) - ] -}) + }, + }, + }), + ], +}); ``` ### Creating API Keys with Permissions @@ -881,9 +903,9 @@ const apiKey = await auth.api.createApiKey({ name: "My API Key", permissions: { files: ["read", "write"], - users: ["read"] + users: ["read"], }, - userId: "userId" + userId: "userId", }, }); ``` @@ -897,9 +919,9 @@ const result = await auth.api.verifyApiKey({ body: { key: "your_api_key_here", permissions: { - files: ["read"] - } - } + files: ["read"], + }, + }, }); if (result.valid) { @@ -919,8 +941,8 @@ const apiKey = await auth.api.updateApiKey({ keyId: existingApiKeyId, permissions: { files: ["read", "write", "delete"], - users: ["read", "write"] - } + users: ["read", "write"], + }, }, headers: user_headers, }); @@ -939,7 +961,7 @@ type Permissions = { const permissions = { files: ["read", "write", "delete"], users: ["read"], - projects: ["read", "write"] + projects: ["read", "write"], }; ``` diff --git a/packages/better-auth/src/plugins/api-key/api-key.test.ts b/packages/better-auth/src/plugins/api-key/api-key.test.ts index 21fc56a5a9..7db5def6f7 100644 --- a/packages/better-auth/src/plugins/api-key/api-key.test.ts +++ b/packages/better-auth/src/plugins/api-key/api-key.test.ts @@ -312,6 +312,65 @@ describe("api-key", async () => { 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({ + 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 { client, auth, signInWithTestUser } = await getTestInstance( { diff --git a/packages/better-auth/src/plugins/api-key/index.ts b/packages/better-auth/src/plugins/api-key/index.ts index 452c4bd8d8..43150a250c 100644 --- a/packages/better-auth/src/plugins/api-key/index.ts +++ b/packages/better-auth/src/plugins/api-key/index.ts @@ -1,5 +1,3 @@ -import { base64Url } from "@better-auth/utils/base64"; -import { createHash } from "@better-auth/utils/hash"; import { APIError, createAuthMiddleware } from "../../api"; import type { BetterAuthPlugin } from "../../types/plugins"; import { mergeSchema } from "../../db"; @@ -9,6 +7,18 @@ import { getDate } from "../../utils/date"; import type { ApiKey, ApiKeyOptions } from "./types"; import { createApiKeyRoutes } from "./routes"; import type { User } from "../../types"; +import { base64Url } from "@better-auth/utils/base64"; +import { createHash } from "@better-auth/utils/hash"; + +export const defaultKeyHasher = async (key: string) => { + const hash = await createHash("SHA-256").digest( + new TextEncoder().encode(key), + ); + const hashed = base64Url.encode(new Uint8Array(hash), { + padding: false, + }); + return hashed; +}; export const ERROR_CODES = { INVALID_METADATA_TYPE: "metadata must be an object or undefined", @@ -54,6 +64,7 @@ export const apiKey = (options?: ApiKeyOptions) => { maximumNameLength: options?.maximumNameLength ?? 32, minimumNameLength: options?.minimumNameLength ?? 1, enableMetadata: options?.enableMetadata ?? false, + disableKeyHashing: options?.disableKeyHashing ?? false, rateLimit: { enabled: options?.rateLimit?.enabled === undefined @@ -150,12 +161,9 @@ export const apiKey = (options?: ApiKeyOptions) => { }); } - const hash = await createHash("SHA-256").digest( - new TextEncoder().encode(key), - ); - const hashed = base64Url.encode(new Uint8Array(hash), { - padding: false, - }); + const hashed = opts.disableKeyHashing + ? key + : await defaultKeyHasher(key); const apiKey = await ctx.context.adapter.findOne({ model: API_KEY_TABLE_NAME, diff --git a/packages/better-auth/src/plugins/api-key/routes/create-api-key.ts b/packages/better-auth/src/plugins/api-key/routes/create-api-key.ts index 925b241df1..ad554ac104 100644 --- a/packages/better-auth/src/plugins/api-key/routes/create-api-key.ts +++ b/packages/better-auth/src/plugins/api-key/routes/create-api-key.ts @@ -5,10 +5,9 @@ import { getDate } from "../../../utils/date"; import { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import type { AuthContext } from "../../../types"; -import { createHash } from "@better-auth/utils/hash"; -import { base64Url } from "@better-auth/utils/base64"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; +import { defaultKeyHasher } from "../"; export function createApiKey({ keyGenerator, @@ -357,10 +356,7 @@ export function createApiKey({ prefix: prefix || opts.defaultPrefix, }); - const hash = await createHash("SHA-256").digest(key); - const hashed = base64Url.encode(hash, { - padding: false, - }); + const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); let start: string | null = null; diff --git a/packages/better-auth/src/plugins/api-key/routes/index.ts b/packages/better-auth/src/plugins/api-key/routes/index.ts index 3defa4dabd..809aa3e687 100644 --- a/packages/better-auth/src/plugins/api-key/routes/index.ts +++ b/packages/better-auth/src/plugins/api-key/routes/index.ts @@ -21,6 +21,7 @@ export type PredefinedApiKeyOptions = ApiKeyOptions & | "maximumPrefixLength" | "minimumPrefixLength" | "maximumNameLength" + | "disableKeyHashing" | "minimumNameLength" | "enableMetadata" | "disableSessionForAPIKeys" diff --git a/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts b/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts index 31bfa602e6..78af9c722d 100644 --- a/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts +++ b/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts @@ -3,13 +3,12 @@ import { createAuthEndpoint } from "../../../api"; import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; import type { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; -import { base64Url } from "@better-auth/utils/base64"; -import { createHash } from "@better-auth/utils/hash"; import { isRateLimited } from "../rate-limit"; import type { AuthContext } from "../../../types"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import { role } from "../../access"; +import { defaultKeyHasher } from "../"; export function verifyApiKey({ opts, @@ -68,12 +67,7 @@ export function verifyApiKey({ }); } - const hash = await createHash("SHA-256").digest( - new TextEncoder().encode(key), - ); - const hashed = base64Url.encode(new Uint8Array(hash), { - padding: false, - }); + const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); const apiKey = await ctx.context.adapter.findOne({ model: API_KEY_TABLE_NAME, diff --git a/packages/better-auth/src/plugins/api-key/types.ts b/packages/better-auth/src/plugins/api-key/types.ts index 3da8a40d5b..e83520208f 100644 --- a/packages/better-auth/src/plugins/api-key/types.ts +++ b/packages/better-auth/src/plugins/api-key/types.ts @@ -7,6 +7,15 @@ export interface ApiKeyOptions { * @default "x-api-key" */ apiKeyHeaders?: string | string[]; + /** + * Disable hashing of the API key. + * + * ⚠️ Security Warning: It's strongly recommended to not disable hashing. + * Storing API keys in plaintext makes them vulnerable to database breaches, potentially exposing all your users' API keys. + * + * @default false + */ + disableKeyHashing?: boolean; /** * The function to get the API key from the context */