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
43 changes: 43 additions & 0 deletions .changeset/auth-lazy-cache-rate-limit-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/plugin-auth": patch
---

fix(plugin-auth): 限流计数器改为惰性解析 kernel cache —— 修掉误报的告警,也修掉「共享限流从未生效」的功能洞 (#4772)

`pnpm dev`(showcase)每次冷启都会打一条:

```
WARN [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
```

而 `CacheServicePlugin` 就在 **21ms 后**注册好了,它本来就在已加载插件列表里。这条告警把运维引向「你需要 Redis」,接完 Redis 还是同一条告警 —— 因为缺的不是 Redis。

**这不只是日志误报。** `AuthPlugin.init()` 里那次 `getServiceAsync('cache')`
探测的结论会被**冻结整个进程生命周期**:better-auth 实例是懒创建的,但它读的是 init
时定下的 config。所以标准组合下 auth 这一侧永远拿着「没有 cache」这个结论,限流计数器
**从未**用上共享存储 —— 多节点部署的限额从来没有被全局强制过,每个节点各算各的,轮换
节点即可绕过。ADR-0069 D2 声明的能力与运行时不一致。

**修法:把「取 cache 服务」放回真正用到它的那一刻。** 新增
`createLazyCacheRateLimitStorage()`,实现 better-auth 的 `rateLimit.customStorage`:
计数器被消费时才解析 `cache` 服务(这一刻必然在 `kernel:ready` 之后,因此与插件启动
顺序无关),解析到就一直用它。告警保留,但只在**计数器真的要用共享存储、而此刻确实
一个 cache 服务都没有**时才打一次 —— 那时它才是真信号,「加一个共享缓存」也才是对的
建议。真没有 cache 的部署仍然限流,只是退化成进程内计数(降级,不是关闭)。

**刻意走 `rateLimit.customStorage` 而不是 `secondaryStorage`。** 后者会连带把**会话
的记录之处**搬进缓存:better-auth 的 `createSession` 不再写 `sys_session` 行,
`findSession` 直接从缓存快照作答、根本不查库;而 ADR-0069 D4 的空闲超时 / 绝对时长
上限 / 并发上限**全部靠写那一行来撤销会话**。所以自动把 cache 绑成 `secondaryStorage`
会静默废掉 D4 的三个管控。本次因此不再从 cache 服务自动派生 `secondaryStorage`:
它回归「宿主显式提供才生效」,`cacheSecondaryStorage()` 改为从包根导出,供知情的宿主
自行选用。会话到底该存哪,是一个需要维护者裁定的架构问题,记录在 #4785。

对使用者的影响:

- 配了 cache 插件的部署不再出现那条 warn,改为一条 info(计数器已绑定到 cache 服务);
- 多节点 + Redis cache 的部署,限流计数**现在真的**是全局的;
- 新增 `AuthManagerOptions.rateLimitStorage`(counters-only,不迁移会话);宿主自己
提供的 `secondaryStorage` 行为不变,仍然优先并继续走
`rateLimit.storage: 'secondary-storage'`。
2 changes: 1 addition & 1 deletion docs/adr/0069-enterprise-authentication-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit
| Phase | Status | Notes |
|---|---|---|
| **P1** (D1/D2/D3 + D7 fields) | ✅ **implemented** | Password complexity/history/expiry (`assertPasswordComplexity`/`assertPasswordNotReused`/`stampPasswordChangedAt`), HIBP (`haveIBeenPwned` plugin), account lockout (`assertAccountNotLocked`/`recordSignInOutcome` + `unlock_user` action), enforced MFA + grace (`computeAuthGate` → `MFA_REQUIRED`, per-org `require_mfa`), rate-limit tuning (`customRules`). All settings in `auth.manifest.ts`, bound via `bindAuthSettings`. Login-audit fields `last_login_at`/`last_login_ip` stamped on sign-in (`stampLastLogin`). |
| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`), the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`), and the **shared multi-node rate-limit + session store** (better-auth `secondaryStorage` bound to the kernel cache service via `cacheSecondaryStorage`; shared iff the cache is — Redis adapter in a cluster) are landed. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override) — tracked in #2571. |
| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`), the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`), and the **shared multi-node rate-limit counters** (better-auth `rateLimit.customStorage` fed by the kernel cache service through `createLazyCacheRateLimitStorage`; shared iff the cache is — Redis adapter in a cluster) are landed. **Correction (#4772):** this row previously claimed a shared **session** store via `secondaryStorage` as landed. It was not: the binding was taken in `AuthPlugin.init()`, which runs *before* `CacheServicePlugin` registers `cache`, so it never fired in the standard composition — and the counters it was supposed to share never reached the cache either. The counters now ride `rateLimit.customStorage`, resolved at counting time. The **session** half is deliberately NOT auto-wired: better-auth answers `findSession` from a `secondaryStorage` snapshot without reading the database, while D4 above revokes by writing the `sys_session` row, so a cache-backed session store silently disables idle-timeout / absolute-max / concurrent-cap enforcement. `cacheSecondaryStorage` remains exported for a host that opts into that trade knowingly. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override) — tracked in #2571; the session-store question — tracked in #4785. |
| **P2/P3** (D6) | 🟡 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. |
| **P3** (SAML, broader social) | 🟡 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. |

Expand Down
76 changes: 76 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2782,6 +2782,82 @@ describe('AuthManager', () => {
expect(captured.secondaryStorage).toBe(ss);
expect(captured.rateLimit.storage).toBe('secondary-storage');
});

// ── #4772 — counters-only store, no session relocation ────────────────
it('passes rateLimitStorage through as rateLimit.customStorage, without a secondaryStorage', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rls = { consume: vi.fn(async () => ({ allowed: true, retryAfter: null })) };
const m = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
rateLimitStorage: rls as any,
});
await m.getAuthInstance();
warn.mockRestore();
expect(captured.rateLimit.customStorage).toBe(rls);
// The session store is untouched: ADR-0069 D4 revokes by writing the
// `sys_session` row, which better-auth stops reading once it has a
// secondaryStorage snapshot to answer from.
expect(captured).not.toHaveProperty('secondaryStorage');
});

it('keeps the operator-tuned rateLimit rules alongside the custom storage', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rls = { consume: vi.fn(async () => ({ allowed: true, retryAfter: null })) };
const m = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
rateLimit: { enabled: true, window: 60, max: 10 } as any,
rateLimitStorage: rls as any,
});
await m.getAuthInstance();
warn.mockRestore();
expect(captured.rateLimit).toMatchObject({ enabled: true, window: 60, max: 10 });
expect(captured.rateLimit.customStorage).toBe(rls);
});

it('a host-supplied secondaryStorage wins — no customStorage is added', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const ss2 = { get: vi.fn(), set: vi.fn(), delete: vi.fn() };
const rls = { consume: vi.fn(async () => ({ allowed: true, retryAfter: null })) };
const m = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
secondaryStorage: ss2 as any,
rateLimitStorage: rls as any,
});
await m.getAuthInstance();
warn.mockRestore();
expect(captured.rateLimit.storage).toBe('secondary-storage');
expect(captured.rateLimit.customStorage).toBeUndefined();
});

// A settings patch replaces `rateLimit` wholesale (bindAuthSettings builds
// a fresh object); the counter store must survive that, or tuning the
// limits in Setup would silently un-share them again.
it('survives an applyConfigPatch that replaces rateLimit wholesale', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rls = { consume: vi.fn(async () => ({ allowed: true, retryAfter: null })) };
const m = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
rateLimitStorage: rls as any,
});
await m.getAuthInstance();
m.applyConfigPatch({ rateLimit: { enabled: true, window: 30, max: 5 } as any });
await m.getAuthInstance();
warn.mockRestore();
expect(captured.rateLimit).toMatchObject({ enabled: true, window: 30, max: 5 });
expect(captured.rateLimit.customStorage).toBe(rls);
});
});

// ADR-0069 D1: password complexity validator (custom; better-auth only does
Expand Down
47 changes: 39 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,11 +594,32 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
* uses it for **rate-limit counters** (the manager also flips
* `rateLimit.storage` to `'secondary-storage'`) and session caching, so both
* are enforced against ONE store across every node — closing the multi-node
* rate-limit-bypass hole (each node otherwise counts independently). Wired by
* `AuthPlugin` from the kernel `cache` service (memory single-node, Redis in
* a cluster). Absent → better-auth keeps its per-process in-memory store.
* rate-limit-bypass hole (each node otherwise counts independently).
*
* HOST-SUPPLIED ONLY (#4772). `AuthPlugin` no longer derives this from the
* kernel `cache` service: better-auth also makes `secondaryStorage` the
* session store (`createSession` skips the `sys_session` row, `findSession`
* answers from the snapshot without reading the database), which silently
* disables the ADR-0069 D4 session controls that revoke by writing that row.
* The counters ride {@link rateLimitStorage} instead. Absent → better-auth
* keeps its own session + rate-limit defaults.
*/
secondaryStorage?: BetterAuthOptions['secondaryStorage'];

/**
* ADR-0069 D2 (#4772) — better-auth `rateLimit.customStorage`: the store the
* per-IP counters are consumed from, and NOTHING else (no session
* relocation, unlike {@link secondaryStorage}). `AuthPlugin` supplies a
* lazily-cache-resolving implementation (`rate-limit-storage.ts`) so the
* counters reach the kernel `cache` service regardless of plugin start
* order. Ignored when `secondaryStorage` is set — better-auth's own
* `storage: 'secondary-storage'` already routes counters there.
*
* Kept as its own option rather than inside {@link rateLimit} so a settings
* patch that replaces `rateLimit` wholesale (`bindAuthSettings`) can never
* drop the store.
*/
rateLimitStorage?: NonNullable<BetterAuthOptions['rateLimit']>['customStorage'];
}

/**
Expand Down Expand Up @@ -919,14 +940,24 @@ export class AuthManager {

// ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
// so better-auth keeps its own defaults otherwise. The settings bind
// supplies stricter `customRules` for the auth endpoints. When a shared
// secondaryStorage is wired, flip the rate-limit store to it so counters
// are enforced across nodes (default 'memory' is per-process).
...(this.config.rateLimit || this.config.secondaryStorage
// supplies stricter `customRules` for the auth endpoints.
//
// Where the counters live, in precedence order:
// 1. a host-supplied `secondaryStorage` → better-auth's own
// `storage: 'secondary-storage'` (unchanged behaviour);
// 2. otherwise `rateLimitStorage` → `customStorage`, the counters-only
// seam AuthPlugin fills with the lazily-resolved kernel cache
// (#4772). better-auth ignores `storage` when `customStorage` is set.
// Neither → better-auth's per-process 'memory' default.
...(this.config.rateLimit || this.config.secondaryStorage || this.config.rateLimitStorage
? {
rateLimit: {
...(this.config.rateLimit ?? {}),
...(this.config.secondaryStorage ? { storage: 'secondary-storage' as const } : {}),
...(this.config.secondaryStorage
? { storage: 'secondary-storage' as const }
: this.config.rateLimitStorage
? { customStorage: this.config.rateLimitStorage }
: {}),
},
}
: {}),
Expand Down
111 changes: 111 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1209,4 +1209,115 @@ describe('AuthPlugin', () => {
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2')).toBeUndefined();
});
});

// ── #4772 — the kernel `cache` service is resolved at COUNTING time ─────────
//
// AuthPlugin.init() runs before CacheServicePlugin registers `cache` (21ms
// earlier in a showcase cold start). The eager probe that used to live in
// init() therefore concluded "no cache" in deployments that had one, printed
// a warning telling the operator to provision Redis, and — the part that was
// not just noise — froze that conclusion, so the rate-limit counters never
// reached the shared store even after it came up.
describe('rate-limit counter store (ADR-0069 D2 / #4772)', () => {
const makeCache = () => {
const store = new Map<string, unknown>();
return {
store,
get: vi.fn(async (k: string) => (store.has(k) ? store.get(k) : undefined)),
set: vi.fn(async (k: string, v: unknown, _ttl?: number) => { store.set(k, v); }),
delete: vi.fn(async (k: string) => store.delete(k)),
has: vi.fn(async (k: string) => store.has(k)),
clear: vi.fn(async () => store.clear()),
stats: vi.fn(async () => ({ hits: 0, misses: 0, keys: store.size })),
};
};

/** Boot the plugin against a context whose `cache` resolution is under test control. */
const bootWith = async (resolveCache: () => Promise<unknown>) => {
const ctx = {
...mockContext,
getServiceAsync: vi.fn(async (name: string) => {
if (name === 'cache') return await resolveCache();
return undefined;
}),
} as unknown as PluginContext;
const plugin = new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
});
await plugin.init(ctx);
const manager = (ctx.registerService as any).mock.calls.find(
([name]: [string]) => name === 'auth',
)?.[1] as AuthManager;
return { ctx, manager, storage: (manager as any).config.rateLimitStorage };
};

it('does not warn during init when the cache has not registered yet', async () => {
// Exactly the showcase cold start: `cache` is not in the registry when
// plugin-auth initializes.
const { ctx } = await bootWith(async () => undefined);
const warned = (ctx.logger.warn as any).mock.calls.map((c: any[]) => String(c[0]));
expect(warned.some((m: string) => m.includes('no cache service registered'))).toBe(false);
expect(warned.some((m: string) => m.includes('rate-limit'))).toBe(false);
});

it('counts in the cache registered AFTER init — the counters really are shared', async () => {
const cache = makeCache();
let registered = false;
const { ctx, storage } = await bootWith(async () => (registered ? cache : undefined));
expect(storage).toBeDefined();

// CacheServicePlugin comes up 21ms later.
registered = true;

const decision = await storage.consume('ip:203.0.113.7', { window: 60, max: 3 });
expect(decision).toEqual({ allowed: true, retryAfter: null });
expect(cache.store.size).toBe(1);
const info = (ctx.logger.info as any).mock.calls.map((c: any[]) => String(c[0]));
expect(info.some((m: string) => m.includes('rate-limit counters bound to the kernel cache service'))).toBe(true);
});

it('warns at counting time — and only then — when there is genuinely no cache service', async () => {
const { ctx, storage } = await bootWith(async () => undefined);
const rateLimitWarnings = () =>
(ctx.logger.warn as any).mock.calls
.map((c: any[]) => String(c[0]))
.filter((m: string) => m.includes('rate-limit counters'));
expect(rateLimitWarnings()).toEqual([]);

await storage.consume('ip:198.51.100.9', { window: 60, max: 3 });
expect(rateLimitWarnings()).toHaveLength(1);

const warned = (ctx.logger.warn as any).mock.calls.map((c: any[]) => String(c[0]));
expect(warned.some((m: string) => m.includes('no `cache` service registered at all'))).toBe(true);
});

it('leaves the counters to better-auth when the host supplies its own secondaryStorage', async () => {
const ctx = {
...mockContext,
getServiceAsync: vi.fn(async () => undefined),
} as unknown as PluginContext;
const plugin = new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
secondaryStorage: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },
} as any);
await plugin.init(ctx);
const manager = (ctx.registerService as any).mock.calls.find(
([name]: [string]) => name === 'auth',
)?.[1] as AuthManager;
expect((manager as any).config.rateLimitStorage).toBeUndefined();
expect((manager as any).config.secondaryStorage).toBeDefined();
});

it('never binds the kernel cache as better-auth secondaryStorage (sessions stay in sys_session)', async () => {
// ADR-0069 D4's session controls revoke by writing the `sys_session` row;
// better-auth answers `findSession` from a secondaryStorage snapshot
// without reading the database, so a cache-backed session store would
// silently disable them. The cache reaches the COUNTERS only.
const cache = makeCache();
const { manager } = await bootWith(async () => cache);
expect((manager as any).config.secondaryStorage).toBeUndefined();
});
});
});
Loading
Loading