Skip to content

Commit beecab4

Browse files
committed
feat(auth): shared cross-node rate-limit + session store via cache service (ADR-0069 D2)
Multi-node deployments rate-limited per-process — better-auth's default rateLimit store is in-memory, so each node counted independently and an attacker could rotate nodes to bypass the limit. - new cacheSecondaryStorage(cache): ICacheService → better-auth SecondaryStorage (get maps undefined→null; set forwards TTL; delete). - AuthManager: accept secondaryStorage; when present, wire it into the betterAuth config AND flip rateLimit.storage to 'secondary-storage' so counters go to the shared store (works with or without an explicit rateLimit config block). - AuthPlugin: bind the kernel `cache` service as secondaryStorage when registered (shared across nodes iff the cache is — Redis adapter in a cluster, memory single-node). Logs a warning when no cache service is present so a multi-node deploy isn't silently per-process (ADR-0049 honesty). Atomicity note (in code + changeset): ICacheService has no atomic increment, so the get→set counter path can slightly over-count under high concurrency — acceptable for a rate limiter and strictly better than independent per-node counters; a future cache adapter exposing INCR can add an increment method for exact counting. Tests: adapter round-trip / miss→null / TTL / delete; auth-manager flips rateLimit.storage to secondary-storage (with and without an explicit rateLimit block). plugin-auth 6 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT
1 parent 07f055c commit beecab4

6 files changed

Lines changed: 197 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): shared cross-node rate-limit + session store via the cache service (ADR-0069 D2)
6+
7+
Multi-node deployments previously rate-limited **per process** — better-auth's
8+
default `rateLimit` store is in-memory, so each node counted independently and
9+
an attacker could rotate nodes to bypass the limit. `AuthPlugin` now wires the
10+
kernel `cache` service as better-auth's `secondaryStorage` and flips
11+
`rateLimit.storage` to `'secondary-storage'`, so rate-limit counters (and the
12+
session cache) are enforced against **one shared store across every node**
13+
shared iff the cache service is (Redis adapter in a cluster; memory single-node,
14+
where behavior is unchanged). When no cache service is registered the plugin
15+
logs a warning that a multi-node deployment needs a shared cache (ADR-0069
16+
honesty — no silent per-process limiting presented as global).
17+
18+
New `cacheSecondaryStorage(cache)` adapter (`ICacheService` → better-auth
19+
`SecondaryStorage`). Note: the cache has no atomic increment, so under high
20+
concurrency the get→set counter path can slightly over-count — acceptable for a
21+
rate limiter and strictly better than independent per-node counters; a future
22+
cache adapter exposing atomic INCR can add an `increment` method for exact
23+
counting.

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,36 @@ describe('AuthManager', () => {
18061806
warn.mockRestore();
18071807
expect(captured).not.toHaveProperty('rateLimit');
18081808
});
1809+
1810+
// ── ADR-0069 D2 — shared secondaryStorage → cross-node rate limiting ──
1811+
it('wires secondaryStorage and flips rateLimit.storage to "secondary-storage"', async () => {
1812+
let captured: any;
1813+
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
1814+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1815+
const ss = { get: vi.fn(), set: vi.fn(), delete: vi.fn() };
1816+
const m = new AuthManager({
1817+
secret: SECRET, baseUrl: 'http://localhost:3000',
1818+
rateLimit: { enabled: true, window: 60, max: 10 } as any,
1819+
secondaryStorage: ss as any,
1820+
});
1821+
await m.getAuthInstance();
1822+
warn.mockRestore();
1823+
expect(captured.secondaryStorage).toBe(ss);
1824+
expect(captured.rateLimit.storage).toBe('secondary-storage');
1825+
expect(captured.rateLimit).toMatchObject({ enabled: true, max: 10, window: 60 });
1826+
});
1827+
1828+
it('flips rateLimit.storage even when no explicit rateLimit config is given (secondaryStorage alone)', async () => {
1829+
let captured: any;
1830+
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
1831+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1832+
const ss = { get: vi.fn(), set: vi.fn(), delete: vi.fn() };
1833+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', secondaryStorage: ss as any });
1834+
await m.getAuthInstance();
1835+
warn.mockRestore();
1836+
expect(captured.secondaryStorage).toBe(ss);
1837+
expect(captured.rateLimit.storage).toBe('secondary-storage');
1838+
});
18091839
});
18101840

18111841
// ADR-0069 D1: password complexity validator (custom; better-auth only does

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,17 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
348348
* `/reset-password`). Multi-node deployments need a shared `storage`.
349349
*/
350350
rateLimit?: BetterAuthOptions['rateLimit'];
351+
352+
/**
353+
* ADR-0069 D2 — shared KV store for cross-node state. When set, better-auth
354+
* uses it for **rate-limit counters** (the manager also flips
355+
* `rateLimit.storage` to `'secondary-storage'`) and session caching, so both
356+
* are enforced against ONE store across every node — closing the multi-node
357+
* rate-limit-bypass hole (each node otherwise counts independently). Wired by
358+
* `AuthPlugin` from the kernel `cache` service (memory single-node, Redis in
359+
* a cluster). Absent → better-auth keeps its per-process in-memory store.
360+
*/
361+
secondaryStorage?: BetterAuthOptions['secondaryStorage'];
351362
}
352363

353364
/**
@@ -643,8 +654,20 @@ export class AuthManager {
643654

644655
// ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
645656
// so better-auth keeps its own defaults otherwise. The settings bind
646-
// supplies stricter `customRules` for the auth endpoints.
647-
...(this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {}),
657+
// supplies stricter `customRules` for the auth endpoints. When a shared
658+
// secondaryStorage is wired, flip the rate-limit store to it so counters
659+
// are enforced across nodes (default 'memory' is per-process).
660+
...(this.config.rateLimit || this.config.secondaryStorage
661+
? {
662+
rateLimit: {
663+
...(this.config.rateLimit ?? {}),
664+
...(this.config.secondaryStorage ? { storage: 'secondary-storage' as const } : {}),
665+
},
666+
}
667+
: {}),
668+
669+
// ADR-0069 D2 — shared KV for cross-node rate-limit + session state.
670+
...(this.config.secondaryStorage ? { secondaryStorage: this.config.secondaryStorage } : {}),
648671

649672
// better-auth plugins — registered based on AuthPluginConfig flags
650673
plugins,

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,28 @@ export class AuthPlugin implements Plugin {
157157
...this.options,
158158
dataEngine,
159159
};
160+
161+
// ADR-0069 D2 — wire the kernel `cache` service as better-auth's shared
162+
// secondaryStorage (rate-limit counters + session cache). Shared across
163+
// nodes iff the cache service is (Redis adapter in a cluster; memory
164+
// single-node). An explicit `secondaryStorage` on the options wins. Skipped
165+
// when no cache service is registered — with a warning, because a multi-node
166+
// deployment then silently rate-limits per-process (ADR-0069 D2 honesty).
167+
if (!authConfig.secondaryStorage) {
168+
const cache = ctx.getService<any>('cache');
169+
if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {
170+
const { cacheSecondaryStorage } = await import('./secondary-storage.js');
171+
authConfig.secondaryStorage = cacheSecondaryStorage(cache);
172+
ctx.logger.info(
173+
'[auth] rate-limit + session store bound to the kernel cache service — shared across nodes iff the cache is (ADR-0069 D2)',
174+
);
175+
} else {
176+
ctx.logger.warn(
177+
'[auth] no cache service registered — rate-limit counters use a per-process in-memory store; a multi-node deployment needs a shared cache (Redis) to enforce limits globally (ADR-0069 D2)',
178+
);
179+
}
180+
}
181+
160182
this.applyEnvSocialProviderFallbacks(authConfig);
161183

162184
// Open extension point for packages that contribute auth providers
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { cacheSecondaryStorage } from './secondary-storage.js';
5+
6+
/** Minimal in-memory ICacheService stand-in. */
7+
function makeCache() {
8+
const store = new Map<string, unknown>();
9+
return {
10+
store,
11+
get: vi.fn(async (k: string) => (store.has(k) ? store.get(k) : undefined)),
12+
set: vi.fn(async (k: string, v: unknown, _ttl?: number) => { store.set(k, v); }),
13+
delete: vi.fn(async (k: string) => store.delete(k)),
14+
has: vi.fn(async (k: string) => store.has(k)),
15+
clear: vi.fn(async () => store.clear()),
16+
stats: vi.fn(async () => ({ hits: 0, misses: 0, keys: store.size })),
17+
};
18+
}
19+
20+
describe('cacheSecondaryStorage (ADR-0069 D2 — shared rate-limit/session store)', () => {
21+
it('round-trips a value through the cache service', async () => {
22+
const cache = makeCache();
23+
const ss = cacheSecondaryStorage(cache as any);
24+
await ss.set('k1', '{"count":1}', 60);
25+
expect(cache.set).toHaveBeenCalledWith('k1', '{"count":1}', 60);
26+
expect(await ss.get('k1')).toBe('{"count":1}');
27+
});
28+
29+
it('maps a cache MISS (undefined) to null (better-auth contract)', async () => {
30+
const cache = makeCache();
31+
const ss = cacheSecondaryStorage(cache as any);
32+
expect(await ss.get('absent')).toBeNull();
33+
});
34+
35+
it('forwards the TTL (seconds) to the cache set', async () => {
36+
const cache = makeCache();
37+
const ss = cacheSecondaryStorage(cache as any);
38+
await ss.set('k', 'v', 10);
39+
expect(cache.set).toHaveBeenCalledWith('k', 'v', 10);
40+
});
41+
42+
it('deletes via the cache service', async () => {
43+
const cache = makeCache();
44+
const ss = cacheSecondaryStorage(cache as any);
45+
await ss.set('k', 'v');
46+
await ss.delete('k');
47+
expect(cache.delete).toHaveBeenCalledWith('k');
48+
expect(await ss.get('k')).toBeNull();
49+
});
50+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { BetterAuthOptions } from 'better-auth';
4+
import type { ICacheService } from '@objectstack/spec/contracts';
5+
6+
type SecondaryStorage = NonNullable<BetterAuthOptions['secondaryStorage']>;
7+
8+
/**
9+
* ADR-0069 D2 — adapt the kernel `cache` service into a better-auth
10+
* `secondaryStorage`. When wired, better-auth uses it for **rate-limit
11+
* counters** (`rateLimit.storage: 'secondary-storage'`) and session caching —
12+
* so both become **shared across nodes iff the cache service is shared**.
13+
*
14+
* In a single-node deployment the cache is memory-backed and this behaves like
15+
* the default per-process store. In a multi-node deployment the operator
16+
* configures the cache service with the Redis adapter (already supported by
17+
* `@objectstack/service-cache`), and rate limiting is then enforced against a
18+
* single shared counter — closing the "each node counts independently, so an
19+
* attacker rotates nodes to bypass the limit" hole (ADR-0069 D2).
20+
*
21+
* better-auth's `secondaryStorage` contract is string-valued: `get` returns the
22+
* stored string (or null), `set` takes a string value + optional TTL (seconds),
23+
* `delete` removes it. We map straight onto `ICacheService`, translating
24+
* `undefined` (miss) → `null`.
25+
*
26+
* NOTE on atomicity: better-auth's secondary-storage rate-limit path uses
27+
* get→compute→set (not an atomic increment) unless the storage exposes
28+
* `increment`. `ICacheService` has no atomic increment, so under high
29+
* concurrency two nodes can read the same counter and both admit a request — a
30+
* small over-count, acceptable for a rate limiter and still strictly better
31+
* than the per-node independent counters it replaces. A future cache adapter
32+
* exposing atomic INCR can add an `increment` method here for exact counting.
33+
*/
34+
export function cacheSecondaryStorage(cache: ICacheService): SecondaryStorage {
35+
return {
36+
get: async (key: string): Promise<string | null> => {
37+
const v = await cache.get<string>(key);
38+
return v === undefined ? null : v;
39+
},
40+
set: async (key: string, value: string, ttl?: number): Promise<void> => {
41+
await cache.set(key, value, ttl);
42+
},
43+
delete: async (key: string): Promise<void> => {
44+
await cache.delete(key);
45+
},
46+
};
47+
}

0 commit comments

Comments
 (0)