Skip to content

Commit 8bd437f

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): count the per-number OTP send budget in the shared store (#4790) (#4806)
#2780's per-number OTP budget (60s cooldown + 5/hour) was shared across nodes ONLY when a host supplied better-auth's `secondaryStorage`. Nothing in the standard `serve` composition supplies one — and since #4788, AuthPlugin deliberately does not derive it from the kernel cache either — so the budget was counted per process: an N-node deployment granted one phone number N cooldowns and N hourly caps, in paid SMS, with no signal that the declared limit was not the enforced one (ADR-0049). Same defect class as #4772's rate-limit counters, and now the same cure rather than a second implementation of it. The lazy-resolution half of `createLazyCacheRateLimitStorage` is extracted as `createLazyCounterStore()`: resolve the `cache` service when a counter is CONSUMED (strictly after `kernel:ready`, so plugin start order decides nothing), memoise the handle, fall back to the bounded in-process store when there is genuinely no cache — and say which of the two happened, once. The OTP guard reaches it through the new `AuthManagerOptions.sharedCounterStore`, filled by AuthPlugin from the same `resolveCache` closure the rate-limit counters use. Deliberately NOT `secondaryStorage` (#4785): that also relocates the session of record into the cache and silently disables the ADR-0069 D4 session controls. A host-supplied `secondaryStorage` still wins for this budget, unchanged. The cooldown / rolling-hour semantics are untouched — only where the timestamps live changed. A fixed-window counter cannot express "N seconds since the last send", and converting the hourly cap to one would admit a 2× burst across the window boundary: trading one multiplication for another. Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffab803 commit 8bd437f

9 files changed

Lines changed: 655 additions & 94 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790)
6+
7+
#2780 的「每号码 OTP 发送预算」(60s 冷却 + 每小时 5 条)此前**只有宿主显式提供
8+
better-auth `secondaryStorage` 时才跨节点共享**`AuthManager.getOtpSendGuard()` 唯一的
9+
存储来源就是 `AuthManagerOptions.secondaryStorage`,而标准 `serve` 组合里没有任何一处
10+
提供它(#4788 之后 `AuthPlugin` 也明确不再从 cache 服务派生它)。于是预算落在**每个进程
11+
一份**:N 个节点的部署,一个号码实际能收到的是声明值的 N 倍,而且**没有任何信号**告诉你
12+
它没兑现(ADR-0049 声明 ≠ 强制)。这里的计价单位是**真金白银的短信**
13+
14+
这是 #4772 那条限流洞的同类,但是独立的一处:#4788 修的是 better-auth 自己的 `rateLimit`
15+
计数器(走 `rateLimit.customStorage`),OTP 预算是 ObjectStack 在 `AuthManager` 里自己实现
16+
的另一套计数,行为未被 #4788 改变。
17+
18+
**修法:复用 #4788 建好的那条路径,而不是再写一份。** `rate-limit-storage.ts` 中把「惰性
19+
解析 → 绑定即宣告 → 解析不到就降级到有界的进程内存储并响亮告警」抽成
20+
`createLazyCounterStore()``createLazyCacheRateLimitStorage()` 现在就是它的一层薄封装),
21+
OTP 预算经由新的 `AuthManagerOptions.sharedCounterStore` 接同一条路径:
22+
23+
- **存储在每次发送校验时才解析**,因此 `CacheServicePlugin` 晚于 `AuthPlugin` 注册也照样
24+
绑定得上(插件启动顺序不再决定任何事)—— 这正是 #4772 冻结结论造成的那个洞;
25+
- 配了 cache 的多节点部署,每号码预算**现在真的是一份**,换节点不会重新获得冷却额度;
26+
- 没有 cache 服务的部署**仍然限额**,只是降级为进程内计数,并在第一次真正计数时打一条
27+
点名代价的 warn(「an N-node deployment can send up to N× the configured number of PAID
28+
SMS to one number」)—— 降级不是关闭,两种情况在日志里可区分(绑定打 info,降级打 warn)。
29+
30+
**刻意不引入 `secondaryStorage` 来修它**#4785):那会把会话的记录之处搬进缓存,静默废掉
31+
ADR-0069 D4 的三个会话管控。宿主自己提供的 `secondaryStorage` 对这个预算仍然优先且行为不变。
32+
33+
冷却与滚动小时窗的语义**未做任何改动**:计数依旧是按号码的时间戳滚动窗口,只是换了它所在的
34+
存储。(固定窗口计数器无法表达「距上一次发送满 N 秒」,把它改成定窗会在窗口边界放行两倍突发
35+
——用一种倍增换另一种倍增。)
36+
37+
对使用者的影响:
38+
39+
- 新增 `AuthManagerOptions.sharedCounterStore``AuthPlugin` 自动填充,一般宿主无需感知;
40+
- 新增导出 `createLazyCounterStore()``counterStoreFromKv()`
41+
- `OtpSendGuard` 新增 `resolveStore` 选项,原有的 `storage`(字符串 KV)选项保持可用。

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,6 +1734,88 @@ describe('AuthManager', () => {
17341734
await manager.assertPhoneOtpSendAllowed(PHONE);
17351735
});
17361736

1737+
// ── #4790 — the budget is only global if its STORE is ─────────────────
1738+
describe('where the per-number budget is counted (#4790)', () => {
1739+
const makeCache = () => {
1740+
const store = new Map<string, unknown>();
1741+
return {
1742+
store,
1743+
get: vi.fn(async (k: string) => (store.has(k) ? store.get(k) : undefined)),
1744+
set: vi.fn(async (k: string, v: unknown, _ttl?: number) => { store.set(k, v); }),
1745+
};
1746+
};
1747+
const bootNode = async (sharedCounterStore: any) => {
1748+
const { manager } = await bootOtp({ sharedCounterStore });
1749+
manager.setSmsService(fakeSms().service);
1750+
return manager;
1751+
};
1752+
1753+
it('spends ONE budget across nodes when a shared counter store is wired', async () => {
1754+
const { createLazyCounterStore } = await import('./rate-limit-storage.js');
1755+
const cache = makeCache();
1756+
// Two nodes: separate managers, separate resolvers, one cache.
1757+
const nodeStore = () =>
1758+
createLazyCounterStore({ resolveCache: async () => cache as any, subject: 'otp-budget' });
1759+
const nodeA = await bootNode(nodeStore());
1760+
const nodeB = await bootNode(nodeStore());
1761+
1762+
await nodeA.assertPhoneOtpSendAllowed(PHONE);
1763+
// Rotating nodes no longer buys a fresh cooldown.
1764+
await expect(nodeB.assertPhoneOtpSendAllowed(PHONE))
1765+
.rejects.toThrow(/Too many verification codes/);
1766+
expect(cache.store.size).toBe(1);
1767+
expect([...cache.store.keys()][0]).toContain(PHONE);
1768+
});
1769+
1770+
it('resolves the store per check, so a cache registered after boot still binds', async () => {
1771+
const { createLazyCounterStore } = await import('./rate-limit-storage.js');
1772+
const cache = makeCache();
1773+
let registered = false;
1774+
const manager = await bootNode(
1775+
createLazyCounterStore({
1776+
resolveCache: async () => (registered ? (cache as any) : undefined),
1777+
subject: 'otp-budget',
1778+
}),
1779+
);
1780+
registered = true; // CacheServicePlugin comes up after plugin-auth.
1781+
await manager.assertPhoneOtpSendAllowed(PHONE);
1782+
expect(cache.store.size).toBe(1);
1783+
});
1784+
1785+
it('a host-supplied secondaryStorage keeps owning the budget', async () => {
1786+
const kv = new Map<string, string>();
1787+
const secondaryStorage = {
1788+
get: async (k: string) => kv.get(k) ?? null,
1789+
set: async (k: string, v: string) => { kv.set(k, v); },
1790+
delete: async (k: string) => { kv.delete(k); },
1791+
};
1792+
const cache = makeCache();
1793+
const { manager } = await bootOtp({
1794+
secondaryStorage,
1795+
sharedCounterStore: async () => cache as any,
1796+
});
1797+
manager.setSmsService(fakeSms().service);
1798+
await manager.assertPhoneOtpSendAllowed(PHONE);
1799+
expect(kv.size).toBe(1);
1800+
expect(cache.store.size).toBe(0);
1801+
});
1802+
1803+
it('without any shared store the budget is per-manager — degraded, still enforced', async () => {
1804+
const { manager: nodeA } = await bootOtp();
1805+
const { manager: nodeB } = await bootOtp();
1806+
nodeA.setSmsService(fakeSms().service);
1807+
nodeB.setSmsService(fakeSms().service);
1808+
1809+
await nodeA.assertPhoneOtpSendAllowed(PHONE);
1810+
// Enforced on its own node…
1811+
await expect(nodeA.assertPhoneOtpSendAllowed(PHONE))
1812+
.rejects.toThrow(/Too many verification codes/);
1813+
// …and not on the other one: exactly the N× multiplication #4790 is
1814+
// about, which is why AuthPlugin warns loudly when it has to do this.
1815+
await nodeB.assertPhoneOtpSendAllowed(PHONE);
1816+
});
1817+
});
1818+
17371819
it('features.phoneNumberOtp requires plugin + deliverable SMS', async () => {
17381820
const { manager } = await bootOtp();
17391821
expect((manager.getPublicConfig() as any).features.phoneNumberOtp).toBe(false);

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { isPlaceholderEmail } from './placeholder-email.js';
2626
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
2727
import type { TenancyService } from './tenancy-service.js';
2828
import { OtpSendGuard } from './otp-send-guard.js';
29+
import type { CounterStore } from './rate-limit-storage.js';
2930
import {
3031
PHONE_SMS_TOPICS,
3132
builtinPhoneSmsBody,
@@ -620,6 +621,26 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
620621
* drop the store.
621622
*/
622623
rateLimitStorage?: NonNullable<BetterAuthOptions['rateLimit']>['customStorage'];
624+
625+
/**
626+
* ADR-0069 D2 (#4790) — the store ObjectStack's OWN cross-node counters
627+
* count in, resolved LAZILY (called per count, not at construction). Today
628+
* that is the #2780 per-number OTP send budget; {@link rateLimitStorage}
629+
* covers better-auth's own per-IP counters, which never pass through here.
630+
*
631+
* `AuthPlugin` supplies `createLazyCounterStore(...)` over the kernel `cache`
632+
* service (`rate-limit-storage.ts`) — the same resolution the rate-limit
633+
* counters use, so plugin start order decides nothing and a deployment
634+
* WITHOUT a cache still counts, per process, and is told so. Absent → the
635+
* budget is per-process silently, which is the pre-#4790 behaviour and is
636+
* why this option exists.
637+
*
638+
* NOT `secondaryStorage`: handing better-auth one of those also relocates the
639+
* session of record into the cache and silently disables the ADR-0069 D4
640+
* session controls (#4785). A host that supplies `secondaryStorage`
641+
* deliberately still wins for this budget — see `getOtpSendGuard`.
642+
*/
643+
sharedCounterStore?: () => Promise<CounterStore>;
623644
}
624645

625646
/**
@@ -2511,12 +2532,22 @@ export class AuthManager {
25112532
private getOtpSendGuard(): OtpSendGuard {
25122533
if (!this._otpSendGuard) {
25132534
const otpCfg = this.config.phoneOtp ?? {};
2535+
// WHERE the budget is counted decides whether it is a budget at all
2536+
// (#4790): counted per process, a declared "5 per hour" is 5×N across N
2537+
// nodes. Precedence, most deliberate first:
2538+
// 1. a host-supplied `secondaryStorage` — an explicit cross-node KV;
2539+
// 2. `sharedCounterStore` — AuthPlugin's lazily-resolved kernel `cache`,
2540+
// which also announces the degraded (no cache) case loudly;
2541+
// 3. neither → the guard's own bounded per-process store.
2542+
const storeOption = this.config.secondaryStorage
2543+
? { storage: this.config.secondaryStorage }
2544+
: this.config.sharedCounterStore
2545+
? { resolveStore: this.config.sharedCounterStore }
2546+
: {};
25142547
this._otpSendGuard = new OtpSendGuard({
25152548
...(otpCfg.cooldownSeconds != null ? { cooldownSeconds: otpCfg.cooldownSeconds } : {}),
25162549
...(otpCfg.maxPerHour != null ? { maxPerHour: otpCfg.maxPerHour } : {}),
2517-
// Share better-auth's cross-node KV when wired (ADR-0069 D2) so the
2518-
// per-number budget is enforced against ONE store across nodes.
2519-
...(this.config.secondaryStorage ? { storage: this.config.secondaryStorage } : {}),
2550+
...storeOption,
25202551
});
25212552
}
25222553
return this._otpSendGuard;

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,5 +1319,69 @@ describe('AuthPlugin', () => {
13191319
const { manager } = await bootWith(async () => cache);
13201320
expect((manager as any).config.secondaryStorage).toBeUndefined();
13211321
});
1322+
1323+
// ── #4790 — the SECOND counter with the same hole: ObjectStack's own
1324+
// per-number OTP send budget (#2780). It shared state only through a
1325+
// host-supplied `secondaryStorage`, which the standard `serve` composition
1326+
// never supplies, so the declared per-number cap was cap×N across N nodes —
1327+
// in paid SMS. Same cure, same resolution path, deliberately NOT
1328+
// `secondaryStorage` (#4785).
1329+
describe('per-number OTP send budget (#2780 / #4790)', () => {
1330+
const PHONE = '+8613800000000';
1331+
const deliverableSms = { async send() { return { id: 'x', status: 'sent' }; }, isConfigured: () => true };
1332+
1333+
it('counts the budget in a cache registered AFTER auth init', async () => {
1334+
const cache = makeCache();
1335+
let registered = false;
1336+
const { ctx, manager } = await bootWith(async () => (registered ? cache : undefined));
1337+
expect((manager as any).config.sharedCounterStore).toBeTypeOf('function');
1338+
1339+
// CacheServicePlugin comes up 21ms later.
1340+
registered = true;
1341+
manager.setSmsService(deliverableSms as any);
1342+
1343+
await manager.assertPhoneOtpSendAllowed(PHONE);
1344+
// The send landed in the SHARED store — this is the assertion the
1345+
// pre-#4790 wiring could not satisfy in any standard composition.
1346+
expect([...cache.store.keys()]).toEqual([`phone-otp-sends:${PHONE}`]);
1347+
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
1348+
.rejects.toThrow(/Too many verification codes/);
1349+
1350+
// Bound → an info line and NO warning: the operator must be able to
1351+
// tell "shared" from "degraded" without reading the code.
1352+
const info = (ctx.logger.info as any).mock.calls.map((c: any[]) => String(c[0]));
1353+
expect(info.some((m: string) => m.includes('per-number OTP send budget (#2780) bound to the kernel cache service'))).toBe(true);
1354+
expect((ctx.logger.warn as any).mock.calls
1355+
.map((c: any[]) => String(c[0]))
1356+
.filter((m: string) => m.includes('per-number OTP send budget'))).toEqual([]);
1357+
});
1358+
1359+
it('warns loudly at counting time when there is no cache — degraded, never disabled', async () => {
1360+
const { ctx, manager } = await bootWith(async () => undefined);
1361+
manager.setSmsService(deliverableSms as any);
1362+
const otpWarnings = () =>
1363+
(ctx.logger.warn as any).mock.calls
1364+
.map((c: any[]) => String(c[0]))
1365+
.filter((m: string) => m.includes('per-number OTP send budget'));
1366+
1367+
// Nothing is said at boot — a deployment that never sends an OTP is not
1368+
// warned about a store it never needs.
1369+
expect(otpWarnings()).toEqual([]);
1370+
1371+
await manager.assertPhoneOtpSendAllowed(PHONE);
1372+
// Still enforced, in-process.
1373+
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
1374+
.rejects.toThrow(/Too many verification codes/);
1375+
1376+
expect(otpWarnings()).toHaveLength(1);
1377+
expect(otpWarnings()[0]).toContain('PAID SMS');
1378+
expect(otpWarnings()[0]).toContain('no `cache` service registered at all');
1379+
// Degraded → a warning and NO "bound" info line; the mirror image of
1380+
// the cache-present case above.
1381+
expect((ctx.logger.info as any).mock.calls
1382+
.map((c: any[]) => String(c[0]))
1383+
.filter((m: string) => m.includes('per-number OTP send budget'))).toEqual([]);
1384+
});
1385+
});
13221386
});
13231387
});

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js
4141
import { runSetInitialPassword } from './set-initial-password.js';
4242
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
4343
import { runResendVerificationEmail } from './send-verification-email.js';
44+
import type { CounterStore } from './rate-limit-storage.js';
4445
import {
4546
authIdentityObjects,
4647
authPluginManifestHeader,
@@ -356,24 +357,51 @@ export class AuthPlugin implements Plugin {
356357
// Whether the session of record should live in the cache at all is #4785.
357358
// A host that supplies `secondaryStorage` itself still wins and keeps
358359
// better-auth's own `storage: 'secondary-storage'` counters.
359-
if (!authConfig.secondaryStorage) {
360-
const { createLazyCacheRateLimitStorage } = await import('./rate-limit-storage.js');
361-
authConfig.rateLimitStorage = createLazyCacheRateLimitStorage({
362-
// The `cache` service is registered ASYNC — `getService` throws for it,
363-
// so resolve via `getServiceAsync` and treat any failure (not
364-
// registered, or not yet ready) as "no shared cache, ask again later".
365-
resolveCache: async () => {
366-
let cache: any;
367-
try {
368-
cache = await (ctx as { getServiceAsync?: (n: string) => Promise<unknown> })
369-
.getServiceAsync?.('cache');
370-
} catch {
371-
return undefined;
372-
}
373-
if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') return cache;
374-
return undefined;
375-
},
360+
//
361+
// The SAME resolution carries ObjectStack's own per-number OTP send budget
362+
// (#2780) below — that counter had the identical hole (#4790): it counted
363+
// in `secondaryStorage` when a host supplied one and per-process otherwise,
364+
// which under the standard `serve` composition (nobody supplies one) meant
365+
// every node granted the same phone number its own cooldown + hourly cap.
366+
// The currency there is paid SMS, so an N-node deployment was billed for up
367+
// to N× the declared budget.
368+
//
369+
// The `cache` service is registered ASYNC — `getService` throws for it, so
370+
// resolve via `getServiceAsync` and treat any failure (not registered, or
371+
// not yet ready) as "no shared cache, ask again later".
372+
const resolveCache = async (): Promise<CounterStore | undefined> => {
373+
let cache: any;
374+
try {
375+
cache = await (ctx as { getServiceAsync?: (n: string) => Promise<unknown> })
376+
.getServiceAsync?.('cache');
377+
} catch {
378+
return undefined;
379+
}
380+
if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') return cache;
381+
return undefined;
382+
};
383+
384+
{
385+
const { createLazyCacheRateLimitStorage, createLazyCounterStore } = await import(
386+
'./rate-limit-storage.js'
387+
);
388+
if (!authConfig.secondaryStorage) {
389+
authConfig.rateLimitStorage = createLazyCacheRateLimitStorage({
390+
resolveCache,
391+
logger: ctx.logger,
392+
});
393+
}
394+
// #4790 — ObjectStack's own counters. Wired even when the host supplied a
395+
// `secondaryStorage` (AuthManager prefers that store for the budget and
396+
// then never calls this resolver), so the two decisions stay independent.
397+
authConfig.sharedCounterStore = createLazyCounterStore({
398+
resolveCache,
376399
logger: ctx.logger,
400+
subject: 'per-number OTP send budget (#2780)',
401+
degradedImpact:
402+
'The budget is still enforced, but PER NODE: every node grants the same phone number its own ' +
403+
'cooldown and hourly cap, so an N-node deployment can send up to N× the configured number of ' +
404+
'PAID SMS to one number (#4790)',
377405
});
378406
}
379407

0 commit comments

Comments
 (0)