feat: Secondary storage (#127)

This commit is contained in:
Bereket Engida
2024-10-09 18:19:40 +03:00
committed by GitHub
parent 529e298448
commit 3817742ce0
12 changed files with 264 additions and 32 deletions

View File

@@ -260,6 +260,23 @@ export const contents: Content[] = [
</svg>
),
},
{
title: "Secondary Storage",
href: "/docs/concepts/secondary-storage",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 32 32"
>
<path
fill="currentColor"
d="M31.99 19.12c-.01.307-.417.646-1.245 1.078c-1.708.891-10.552 4.531-12.438 5.51c-1.885.984-2.927.974-4.417.26c-1.49-.708-10.901-4.516-12.599-5.323c-.844-.406-1.276-.745-1.292-1.068v3.234c0 .323.448.661 1.292 1.068c1.698.813 11.115 4.615 12.599 5.323c1.49.714 2.531.724 4.417-.26c1.885-.979 10.729-4.62 12.438-5.51c.87-.448 1.255-.802 1.255-1.12v-3.188q-.001-.006-.01-.005zm0-5.271c-.016.302-.417.641-1.245 1.078c-1.708.885-10.552 4.526-12.438 5.505c-1.885.984-2.927.974-4.417.266c-1.49-.714-10.901-4.516-12.599-5.328c-.844-.401-1.276-.745-1.292-1.068v3.234c0 .323.448.667 1.292 1.068c1.698.813 11.109 4.615 12.599 5.328c1.49.708 2.531.719 4.417-.26c1.885-.984 10.729-4.62 12.438-5.51c.87-.453 1.255-.807 1.255-1.125v-3.188zm0-5.474c.016-.323-.406-.609-1.266-.922c-1.661-.609-10.458-4.109-12.141-4.729c-1.682-.615-2.37-.589-4.349.12c-1.979.714-11.339 4.385-13.005 5.036c-.833.328-1.24.63-1.224.953v3.234c0 .323.443.661 1.292 1.068c1.693.813 11.109 4.615 12.599 5.328c1.484.708 2.531.719 4.417-.266c1.88-.979 10.729-4.62 12.438-5.505c.865-.453 1.25-.807 1.25-1.125V8.374zm-20.532 3.063l7.417-1.135l-2.24 3.281zm16.401-2.959L23 10.401l-4.385-1.734l4.854-1.917zM14.984 5.302l-.719-1.323l2.24.875l2.109-.688l-.573 1.365l2.151.807l-2.771.286l-.625 1.495l-1-1.667l-3.203-.286zm-5.526 1.87c2.193 0 3.964.688 3.964 1.531c0 .849-1.776 1.536-3.964 1.536s-3.964-.688-3.964-1.536c0-.844 1.776-1.531 3.964-1.531"
></path>
</svg>
),
},
{
title: "Users & Accounts",
href: "/docs/concepts/users-accounts",

View File

@@ -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.
</Callout>
## 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<string | null>
set: (
key: string,
value: string,
ttl?: number,
) => Promise<void>;
delete: (key: string) => Promise<void>;
}
```
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.

View File

@@ -0,0 +1,5 @@
---
title: Secondary Storage
description: Using secondary storage with BetterAuth
---

8
docs/lib/auth.ts Normal file
View File

@@ -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;

View File

@@ -79,3 +79,36 @@ describe("rate-limiter", async () => {
}
});
});
describe("custom rate limiting storage", async () => {
let store = new Map<string, string>();
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();
}
}
});
});

View File

@@ -75,8 +75,16 @@ function createDBStorage(ctx: AuthContext, tableName?: string) {
const memory = new Map<string, RateLimit>();
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") {

View File

@@ -212,3 +212,53 @@ describe("session", async () => {
expect(revokeRes.data?.status).toBe(true);
});
});
describe("session storage", async () => {
let store = new Map<string, string>();
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),
},
});
});
});

View File

@@ -32,6 +32,7 @@ export const getSession = <Option extends BetterAuthOptions>() =>
const session =
await ctx.context.internalAdapter.findSession(sessionCookieToken);
if (!session || session.session.expiresAt < new Date()) {
deleteSessionCookie(ctx);
if (session) {

View File

@@ -14,6 +14,7 @@ export const createInternalAdapter = (
},
) => {
const options = ctx.options;
const secondaryStorage = options.secondaryStorage;
const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days
const tables = getAuthTables(options);
const { createWithHooks, updateWithHooks } = getWithHooks(adapter, ctx);
@@ -80,21 +81,42 @@ export const createInternalAdapter = (
userAgent: headers?.get("user-agent") || "",
};
const session = await createWithHooks(data, "session");
if (secondaryStorage && session) {
secondaryStorage.set(
session.id,
JSON.stringify(session),
sessionExpiration,
);
}
return session;
},
findSession: async (sessionId: string) => {
const session = await adapter.findOne<Session>({
model: tables.session.tableName,
where: [
{
value: sessionId,
field: "id",
},
],
});
let session: Session | null = null;
if (secondaryStorage) {
const sessionStringified = await secondaryStorage.get(sessionId);
if (sessionStringified) {
const s = JSON.parse(sessionStringified);
session = {
...s,
expiresAt: new Date(s.expiresAt),
};
}
} else {
session = await adapter.findOne<Session>({
model: tables.session.tableName,
where: [
{
value: sessionId,
field: "id",
},
],
});
}
if (!session) {
return null;
}
const user = await adapter.findOne<User>({
model: tables.user.tableName,
where: [
@@ -121,7 +143,10 @@ export const createInternalAdapter = (
return updatedSession;
},
deleteSession: async (id: string) => {
const session = await adapter.delete<Session>({
if (secondaryStorage) {
await secondaryStorage.delete(id);
}
await adapter.delete<Session>({
model: tables.session.tableName,
where: [
{
@@ -132,7 +157,21 @@ export const createInternalAdapter = (
});
},
deleteSessions: async (userId: string) => {
return await adapter.delete({
if (secondaryStorage) {
const sessions = await adapter.findMany<Session>({
model: tables.session.tableName,
where: [
{
field: "userId",
value: userId,
},
],
});
for (const session of sessions) {
await secondaryStorage.delete(session.id);
}
}
await adapter.delete({
model: tables.session.tableName,
where: [
{

View File

@@ -9,8 +9,8 @@ import type {
BetterAuthOptions,
BetterAuthPlugin,
OAuthProvider,
SecondaryStorage,
} from "./types";
import { defu } from "defu";
import { getBaseURL } from "./utils/base-url";
import { DEFAULT_SECRET } from "./utils/constants";
@@ -81,13 +81,17 @@ export const init = async (opts: BetterAuthOptions) => {
options.rateLimit?.enabled ?? process.env.NODE_ENV !== "development",
window: options.rateLimit?.window || 60,
max: options.rateLimit?.max || 100,
storage: options.rateLimit?.storage || "memory",
storage:
options.rateLimit?.storage || options.secondaryStorage
? ("secondary-storage" as const)
: ("memory" as const),
},
authCookies: cookies,
logger: createLogger({
disabled: options.logger?.disabled || false,
}),
db,
secondaryStorage: options.secondaryStorage,
password: {
hash: options.emailAndPassword?.password?.hash || hashPassword,
verify: options.emailAndPassword?.password?.verify || verifyPassword,
@@ -118,7 +122,7 @@ export type AuthContext = {
enabled: boolean;
window: number;
max: number;
storage: "memory" | "database";
storage: "memory" | "database" | "secondary-storage";
} & BetterAuthOptions["rateLimit"];
adapter: Adapter;
internalAdapter: ReturnType<typeof createInternalAdapter>;
@@ -128,6 +132,7 @@ export type AuthContext = {
updateAge: number;
expiresIn: number;
};
secondaryStorage: SecondaryStorage | undefined;
password: {
hash: (password: string) => Promise<string>;
verify: (hash: string, password: string) => Promise<boolean>;

View File

@@ -1,5 +1,3 @@
import type { Session } from "../db/schema";
import type { FieldAttribute } from "../db";
import type { BetterAuthOptions } from "./options";
/**
@@ -55,12 +53,12 @@ export interface Adapter {
options?: Record<string, any>;
}
export interface SessionAdapter {
create: (data: {
userId: string;
expiresAt: Date;
}) => Promise<Session>;
findOne: (data: { userId: string }) => Promise<Session | null>;
update: (data: Session) => Promise<Session>;
delete: (data: { sessionId: string }) => Promise<void>;
export interface SecondaryStorage {
get: (key: string) => Promise<string | null> | string | null;
set: (
key: string,
value: string,
ttl?: number,
) => Promise<void | null | string> | void;
delete: (key: string) => Promise<void | null | string> | void;
}

View File

@@ -3,12 +3,12 @@ import type { Account, Session, User, Verification } from "../db/schema";
import type { BetterAuthPlugin } from "./plugins";
import type { OAuthProviderList } from "../social-providers/types";
import type { SocialProviders } from "../social-providers";
import type { RateLimit } from "./models";
import type { Adapter } from "./adapter";
import type { Adapter, SecondaryStorage } from "./adapter";
import type { BetterSqlite3Database, MysqlPool } from "./database";
import type { KyselyDatabaseType } from "../adapters/kysely-adapter/types";
import type { FieldAttribute } from "../db";
import type { EligibleCookies } from "../internal-plugins";
import type { RateLimit } from "./models";
export interface BetterAuthOptions {
/**
@@ -83,6 +83,12 @@ export interface BetterAuthOptions {
*/
type: KyselyDatabaseType;
};
/**
* Secondary storage configuration
*
* This is used to store session and rate limit data.
*/
secondaryStorage?: SecondaryStorage;
/**
* Email and password authentication
*/
@@ -257,9 +263,13 @@ export interface BetterAuthOptions {
/**
* Storage configuration
*
* By default, rate limiting is stored in memory. If you passed a
* secondary storage, rate limiting will be stored in the secondary
* storage.
*
* @default "memory"
*/
storage?: "memory" | "database";
storage?: "memory" | "database" | "secondary-storage";
/**
* If database is used as storage, the name of the table to
* use for rate limiting.