mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 16:36:34 -05:00
feat: Secondary storage (#127)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
5
docs/content/docs/concepts/secondary-storage.mdx
Normal file
5
docs/content/docs/concepts/secondary-storage.mdx
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Secondary Storage
|
||||
description: Using secondary storage with BetterAuth
|
||||
---
|
||||
|
||||
8
docs/lib/auth.ts
Normal file
8
docs/lib/auth.ts
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user