Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions server/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import type { SecondaryStorage } from "better-auth";
import type { DiscordProfile } from "better-auth/social-providers";
import { createAuthSecondaryStorage } from "#server/utils/auth-rate-limit-storage";
import { invalidateCurrentUserCache } from "#server/utils/discord/cache";
import { runtimeConfig } from "#server/utils/runtimeConfig";
import { defineServerAuth } from "@onmax/nuxt-better-auth/config";
import { createAuthMiddleware } from "better-auth/api";
import { isDevelopment } from "std-env";

const authRateLimitStorage = useStorage("wolfstar:auth-ratelimiter");

// Adapts the Nitro/unstorage mount above to better-auth's SecondaryStorage
// shape so its rate-limit counters survive across serverless invocations.
const authSecondaryStorage: SecondaryStorage = {
get: (key) => authRateLimitStorage.getItem(key),
set: (key, value) => authRateLimitStorage.setItem(key, value),
delete: (key) => authRateLimitStorage.removeItem(key),
};
// Adapts the Nitro/unstorage mount to better-auth's SecondaryStorage shape so
// its rate-limit counters survive across serverless invocations.
const authSecondaryStorage = createAuthSecondaryStorage(useStorage("wolfstar:auth-ratelimiter"));

export default defineServerAuth(() => ({
socialProviders: {
Expand Down
68 changes: 68 additions & 0 deletions server/utils/auth-rate-limit-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { SecondaryStorage } from "better-auth";
import { Collection } from "@discordjs/collection";
import { AsyncQueue } from "@sapphire/async-queue";

interface AuthRateLimitCounter {
count: number;
expiresAt: number;
}

/** The subset of unstorage's `Storage` interface this adapter relies on. */
export interface AuthRateLimitStorageDriver {
getItem: <T>(key: string) => Promise<T | null>;
setItem: <T>(key: string, value: T, opts?: { ttl?: number }) => Promise<void>;
removeItem: (key: string) => Promise<void>;
}

/**
* Adapts a Nitro/unstorage mount to better-auth's `SecondaryStorage` shape,
* including an atomic-ish `increment` so better-auth's rate limiter can use
* its single-step `consume` path instead of the non-atomic get-then-set
* fallback.
*/
export function createAuthSecondaryStorage(storage: AuthRateLimitStorageDriver): SecondaryStorage {
// In-process keyed mutex serializing increment() read-modify-write cycles
// per storage key, mirroring server/utils/wrappedEventHandler.ts. The
// production driver (Cloudflare KV over HTTP) exposes no atomic increment
// primitive, so this only bounds races to a single server instance;
// cross-instance races remain possible and are an accepted limitation of
// the storage backend.
const incrementKeyLocks = new Collection<string, AsyncQueue>();

async function withIncrementLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const queue = incrementKeyLocks.ensure(key, () => new AsyncQueue());
await queue.wait();
try {
return await fn();
} finally {
queue.shift();
if (queue.remaining === 0) {
incrementKeyLocks.delete(key);
}
}
}

return {
get: (key) => storage.getItem(key),
set: (key, value) => storage.setItem(key, value),
delete: (key) => storage.removeItem(key),
// Fixed-window counter: `ttl` (seconds) is only applied when the window
// is (re)created, and every write re-derives the remaining time until
// that same expiry so later increments never push the window further out.
increment: (key, ttl) =>
withIncrementLock(key, async () => {
const now = Date.now();
const existing = await storage.getItem<AuthRateLimitCounter>(key);
const isFresh = !existing || existing.expiresAt <= now;
const expiresAt = isFresh ? now + ttl * 1000 : existing.expiresAt;
const count = isFresh ? 1 : existing.count + 1;
const remainingTtl = Math.max(1, Math.ceil((expiresAt - now) / 1000));
await storage.setItem<AuthRateLimitCounter>(
key,
{ count, expiresAt },
{ ttl: remainingTtl },
);
return count;
}),
};
}
100 changes: 100 additions & 0 deletions test/unit/server/utils/auth-rate-limit-storage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createAuthSecondaryStorage } from "#server/utils/auth-rate-limit-storage";
import { beforeEach, describe, expect, it } from "vitest";

/**
* `increment()` backs better-auth's atomic `consume` path (see
* server/auth.config.ts). Without it, better-auth falls back to a
* non-atomic get-then-set check that logs:
* "Rate limiting is best-effort: the configured storage has no atomic
* consume...".
*
* Invariants:
* 1. Counts increment within a fixed window and reset once it expires.
* 2. The window's expiry is fixed at creation; later increments never push
* it further out.
* 3. Concurrent increments for the same key are serialized by an in-process
* keyed mutex, so none are lost to a stale read.
*/

function createFakeStorage() {
const state = new Map<string, { value: unknown; ttl?: number }>();
return {
state,
storage: {
getItem: async (key: string) => state.get(key)?.value ?? null,
setItem: async (key: string, value: unknown, opts?: { ttl?: number }) => {
state.set(key, { value, ttl: opts?.ttl });
},
removeItem: async (key: string) => {
state.delete(key);
},
},
};
}

describe("createAuthSecondaryStorage", () => {
let fake: ReturnType<typeof createFakeStorage>;
let secondaryStorage: ReturnType<typeof createAuthSecondaryStorage>;

beforeEach(() => {
fake = createFakeStorage();
secondaryStorage = createAuthSecondaryStorage(fake.storage);
});

it("starts a fresh window at count 1", async () => {
const count = await secondaryStorage.increment?.("key", 60);
expect(count).toBe(1);
});

it("increments the count within the same window", async () => {
await secondaryStorage.increment?.("key", 60);
await secondaryStorage.increment?.("key", 60);
const count = await secondaryStorage.increment?.("key", 60);
expect(count).toBe(3);
});

it("does not extend the window's expiry on later increments", async () => {
await secondaryStorage.increment?.("key", 60);
const firstEntry = fake.state.get("key")?.value as { expiresAt: number } | undefined;

await secondaryStorage.increment?.("key", 60);
const secondEntry = fake.state.get("key")?.value as { expiresAt: number } | undefined;

expect(secondEntry?.expiresAt).toBe(firstEntry?.expiresAt);
});

it("resets the count once the window has expired", async () => {
await secondaryStorage.increment?.("key", 60);
// Force the stored window into the past so the next increment sees it as expired
const entry = fake.state.get("key");
const value = entry?.value as { count: number; expiresAt: number };
fake.state.set("key", { value: { ...value, expiresAt: Date.now() - 1 } });

const count = await secondaryStorage.increment?.("key", 60);
expect(count).toBe(1);
});

it("serializes concurrent increments for the same key", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, () => secondaryStorage.increment?.("key", 60)),
);

expect(new Set(results).size).toBe(10);
expect(Math.max(...results.map((r) => r ?? 0))).toBe(10);
});

it("keeps counters for different keys independent", async () => {
const a = await secondaryStorage.increment?.("key-a", 60);
const b = await secondaryStorage.increment?.("key-b", 60);
expect(a).toBe(1);
expect(b).toBe(1);
});

it("still supports get/set/delete for non-consume storage reads", async () => {
await secondaryStorage.set("plain-key", "plain-value");
expect(await secondaryStorage.get("plain-key")).toBe("plain-value");

await secondaryStorage.delete("plain-key");
expect(await secondaryStorage.get("plain-key")).toBeNull();
});
});