From 3817742ce0eb666a402bbd64b599bf85e4f5b7b0 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:19:40 +0300 Subject: [PATCH] feat: Secondary storage (#127) --- docs/components/sidebar-content.tsx | 17 +++++ docs/content/docs/concepts/database.mdx | 64 ++++++++++++++++++- .../docs/concepts/secondary-storage.mdx | 5 ++ docs/lib/auth.ts | 8 +++ .../better-auth/src/api/rate-limiter.test.ts | 33 ++++++++++ packages/better-auth/src/api/rate-limiter.ts | 12 +++- .../src/api/routes/session.test.ts | 50 +++++++++++++++ .../better-auth/src/api/routes/session.ts | 1 + .../better-auth/src/db/internal-adapter.ts | 61 ++++++++++++++---- packages/better-auth/src/init.ts | 11 +++- packages/better-auth/src/types/adapter.ts | 18 +++--- packages/better-auth/src/types/options.ts | 16 ++++- 12 files changed, 264 insertions(+), 32 deletions(-) create mode 100644 docs/content/docs/concepts/secondary-storage.mdx create mode 100644 docs/lib/auth.ts diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index 84b8fd412d..460a8a95ca 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -260,6 +260,23 @@ export const contents: Content[] = [ ), }, + { + title: "Secondary Storage", + href: "/docs/concepts/secondary-storage", + icon: () => ( + + + + ), + }, { title: "Users & Accounts", href: "/docs/concepts/users-accounts", diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index 1f8463656f..b18a4eeffb 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -142,11 +142,12 @@ export const auth = betterAuth({ }) ``` +## CLI -## Running Migrations +Better Auth comes with a CLI tool to manage database migrations and generate schema. -Better Auth comes with a CLI tool to manage database migrations. Use the `migrate` command to create or update tables as needed. +### Running Migrations The cli checks your database and prompts you to add missing tables or update existing ones with new columns. @@ -154,7 +155,7 @@ The cli checks your database and prompts you to add missing tables or update exi npx better-auth migrate ``` -## Generting Schema +### Generting Schema Better Auth also provides a `generate` command to generate the schema required by Better Auth. The `generate` command creates the schema required by Better Auth. If you're using a database adapter like Prisma or Drizzle, this command will generate the right schema for your ORM. If you're using the built-in Kysely adapter, it will generate an SQL file you can run directly on your database. @@ -168,6 +169,63 @@ See the [CLI](/docs/concepts/cli) documentation for more information on the CLI. If you prefer adding tables manually, you can do that as well. The core schema required by Better Auth is described below and you can find additional schema required by plugins in the plugin documentation. + +## Secondary Storage + +Secondary storage in BetterAuth allows you to use key-value stores for managing session data, rate limiting counters, etc. + +### Implementation + +To use secondary storage, implement the `SecondaryStorage` interface: + +```typescript +interface SecondaryStorage { + get: (key: string) => Promise + set: ( + key: string, + value: string, + ttl?: number, + ) => Promise; + delete: (key: string) => Promise; +} +``` + +Then, provide your implementation to the `betterAuth` function: + +```typescript +betterAuth({ + // ... other options + secondaryStorage: { + // Your implementation here + } +}) +``` + +**Example: Redis Implementation** + +Here's a basic example using Redis: + +```typescript +import { createClient } from "redis"; +import { betterAuth } from "better-auth"; + +const redis = createClient(); + +export const auth = betterAuth({ + // ... other options + secondaryStorage: { + get: async (key) => await redis.get(key), + set: async (key, value, ttl) => { + if (ttl) await redis.set(key, value, { EX: ttl }); + else await redis.set(key, value); + }, + delete: async (key) => await redis.del(key), + } +}); +``` + +This implementation allows BetterAuth to use Redis for storing session data and rate limiting counters. + ## Core Schema Better Auth requires the following tables to be present in the database. The types are in `typescript` format. You can use corresponding types in your database. diff --git a/docs/content/docs/concepts/secondary-storage.mdx b/docs/content/docs/concepts/secondary-storage.mdx new file mode 100644 index 0000000000..66bc6dc82f --- /dev/null +++ b/docs/content/docs/concepts/secondary-storage.mdx @@ -0,0 +1,5 @@ +--- +title: Secondary Storage +description: Using secondary storage with BetterAuth +--- + diff --git a/docs/lib/auth.ts b/docs/lib/auth.ts new file mode 100644 index 0000000000..74c391f038 --- /dev/null +++ b/docs/lib/auth.ts @@ -0,0 +1,8 @@ +import { betterAuth } from "better-auth"; +import Database from "better-sqlite3"; + +export const auth = betterAuth({ + database: new Database("database.db"), +}); + +export type Session = typeof auth.$Infer.Session; diff --git a/packages/better-auth/src/api/rate-limiter.test.ts b/packages/better-auth/src/api/rate-limiter.test.ts index 1eed820bda..fa99889660 100644 --- a/packages/better-auth/src/api/rate-limiter.test.ts +++ b/packages/better-auth/src/api/rate-limiter.test.ts @@ -79,3 +79,36 @@ describe("rate-limiter", async () => { } }); }); + +describe("custom rate limiting storage", async () => { + let store = new Map(); + const { client, testUser } = await getTestInstance({ + secondaryStorage: { + set(key, value, ttl) { + store.set(key, value); + }, + get(key) { + return store.get(key) || null; + }, + delete(key) { + store.delete(key); + }, + }, + }); + + it("should use custom storage", async () => { + await client.session(); + expect(store.size).toBe(2); + for (let i = 0; i < 10; i++) { + const response = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + if (i >= 7) { + expect(response.error?.status).toBe(429); + } else { + expect(response.error).toBeNull(); + } + } + }); +}); diff --git a/packages/better-auth/src/api/rate-limiter.ts b/packages/better-auth/src/api/rate-limiter.ts index b9fa861a71..01bd4187fd 100644 --- a/packages/better-auth/src/api/rate-limiter.ts +++ b/packages/better-auth/src/api/rate-limiter.ts @@ -75,8 +75,16 @@ function createDBStorage(ctx: AuthContext, tableName?: string) { const memory = new Map(); export function getRateLimitStorage(ctx: AuthContext) { - if (ctx.rateLimit.customStorage) { - return ctx.rateLimit.customStorage; + if (ctx.rateLimit.storage === "secondary-storage") { + return { + get: async (key: string) => { + const stringified = await ctx.options.secondaryStorage?.get(key); + return stringified ? (JSON.parse(stringified) as RateLimit) : undefined; + }, + set: async (key: string, value: RateLimit) => { + await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value)); + }, + }; } const storage = ctx.rateLimit.storage; if (storage === "memory") { diff --git a/packages/better-auth/src/api/routes/session.test.ts b/packages/better-auth/src/api/routes/session.test.ts index 627770ad1c..9b20ffd875 100644 --- a/packages/better-auth/src/api/routes/session.test.ts +++ b/packages/better-auth/src/api/routes/session.test.ts @@ -212,3 +212,53 @@ describe("session", async () => { expect(revokeRes.data?.status).toBe(true); }); }); + +describe("session storage", async () => { + let store = new Map(); + const { client, signInWithTestUser } = await getTestInstance({ + secondaryStorage: { + set(key, value, ttl) { + store.set(key, value); + }, + get(key) { + return store.get(key) || null; + }, + delete(key) { + store.delete(key); + }, + }, + rateLimit: { + enabled: false, + }, + }); + + it("should store session in secondary storage", async () => { + //since the instance creates a session on init, we expect the store to have 1 item + expect(store.size).toBe(1); + const { headers } = await signInWithTestUser(); + expect(store.size).toBe(2); + const session = await client.session({ + fetchOptions: { + headers, + }, + }); + expect(session.data).toMatchObject({ + session: { + id: expect.any(String), + userId: expect.any(String), + expiresAt: expect.any(String), + ipAddress: expect.any(String), + userAgent: expect.any(String), + }, + user: { + id: expect.any(String), + name: "test", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }); + }); +}); diff --git a/packages/better-auth/src/api/routes/session.ts b/packages/better-auth/src/api/routes/session.ts index 0b62c3e9bf..2df355782c 100644 --- a/packages/better-auth/src/api/routes/session.ts +++ b/packages/better-auth/src/api/routes/session.ts @@ -32,6 +32,7 @@ export const getSession =