@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.286.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
|
@@ -13,78 +13,20 @@
|
|
|
13
13
|
// This is INDEPENDENT of the AuthRoutesConfig.mfaVerifyRateLimit on the
|
|
14
14
|
// framework route (IP-scoped abuse protection for the endpoint itself) —
|
|
15
15
|
// both are needed, neither substitutes for the other.
|
|
16
|
+
//
|
|
17
|
+
// The counter mechanics (race-free INCR/NX, TTL rules, monotonic-counter
|
|
18
|
+
// semantics) live in shared/lockout-counter.ts — this file only wires the
|
|
19
|
+
// mfa-verify key prefixes onto it.
|
|
16
20
|
|
|
17
|
-
import type
|
|
21
|
+
import { createLockoutCounter, type LockoutCounterState } from "../shared";
|
|
18
22
|
|
|
19
|
-
export type MfaVerifyLockoutState =
|
|
20
|
-
readonly failureCount: number;
|
|
21
|
-
readonly lockedUntil: number | null;
|
|
22
|
-
};
|
|
23
|
+
export type MfaVerifyLockoutState = LockoutCounterState;
|
|
23
24
|
|
|
24
25
|
const COUNT_KEY_PREFIX = "kumiko:auth:mfa-verify:count:";
|
|
25
26
|
const UNTIL_KEY_PREFIX = "kumiko:auth:mfa-verify:until:";
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
return `${COUNT_KEY_PREFIX}${userId}`;
|
|
29
|
-
}
|
|
30
|
-
function untilKey(userId: string): string {
|
|
31
|
-
return `${UNTIL_KEY_PREFIX}${userId}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function getMfaVerifyLockoutState(
|
|
35
|
-
redis: Redis,
|
|
36
|
-
userId: string,
|
|
37
|
-
): Promise<MfaVerifyLockoutState | null> {
|
|
38
|
-
const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
|
|
39
|
-
if (countRaw === null) return null;
|
|
40
|
-
const failureCount = Number(countRaw);
|
|
41
|
-
if (!Number.isFinite(failureCount)) return null;
|
|
42
|
-
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
43
|
-
return {
|
|
44
|
-
failureCount,
|
|
45
|
-
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Race-free: INCR is atomic, NX on the until-key means only the attempt
|
|
50
|
-
// that first crosses the threshold anchors the lock window — see
|
|
51
|
-
// lockout-store.ts's recordFailedAttempt for the identical reasoning.
|
|
52
|
-
export async function recordFailedMfaVerifyAttempt(
|
|
53
|
-
redis: Redis,
|
|
54
|
-
userId: string,
|
|
55
|
-
maxAttempts: number,
|
|
56
|
-
lockoutMinutes: number,
|
|
57
|
-
): Promise<MfaVerifyLockoutState> {
|
|
58
|
-
const lockDurationMs = lockoutMinutes * 60 * 1000;
|
|
59
|
-
const ttlSec = Math.max(lockoutMinutes * 60, 24 * 3600);
|
|
60
|
-
|
|
61
|
-
const count = await redis.incr(countKey(userId));
|
|
62
|
-
if (count === 1) {
|
|
63
|
-
await redis.expire(countKey(userId), ttlSec);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let lockedUntil: number | null = null;
|
|
67
|
-
if (count >= maxAttempts) {
|
|
68
|
-
const computedUntil = Date.now() + lockDurationMs;
|
|
69
|
-
const setOk = await redis.set(
|
|
70
|
-
untilKey(userId),
|
|
71
|
-
String(computedUntil),
|
|
72
|
-
"PX",
|
|
73
|
-
lockDurationMs,
|
|
74
|
-
"NX",
|
|
75
|
-
);
|
|
76
|
-
if (setOk === "OK") {
|
|
77
|
-
lockedUntil = computedUntil;
|
|
78
|
-
} else {
|
|
79
|
-
const existing = await redis.get(untilKey(userId));
|
|
80
|
-
lockedUntil = existing !== null ? Number(existing) : null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return { failureCount: count, lockedUntil };
|
|
85
|
-
}
|
|
28
|
+
const counter = createLockoutCounter(COUNT_KEY_PREFIX, UNTIL_KEY_PREFIX);
|
|
86
29
|
|
|
87
|
-
|
|
88
|
-
export
|
|
89
|
-
|
|
90
|
-
}
|
|
30
|
+
export const getMfaVerifyLockoutState = counter.getState;
|
|
31
|
+
export const recordFailedMfaVerifyAttempt = counter.recordFailedAttempt;
|
|
32
|
+
export const clearMfaVerifyAttempts = counter.clearState;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Unit tests for createBillingInfoQueryConfig — stub ctx/deps instead of a
|
|
2
|
+
// real DB, see subscription-stripe/__tests__/runtime.test.ts for the same
|
|
3
|
+
// stub pattern. Covers the three app-relevant branches: the
|
|
4
|
+
// no-prices-early-return (no billing provider configured), that the
|
|
5
|
+
// tier resolution is passed through unchanged, and the
|
|
6
|
+
// subscription shape validation.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { createBillingInfoQueryConfig } from "../billing-info-query";
|
|
11
|
+
|
|
12
|
+
type TestTier = "free" | "starter" | "pro";
|
|
13
|
+
|
|
14
|
+
function stubCtx(opts: {
|
|
15
|
+
fetchOne: (...args: unknown[]) => Promise<unknown>;
|
|
16
|
+
billingLive?: boolean;
|
|
17
|
+
apiKeySet?: boolean;
|
|
18
|
+
tenantId?: string;
|
|
19
|
+
}): HandlerContext {
|
|
20
|
+
return {
|
|
21
|
+
db: { fetchOne: opts.fetchOne },
|
|
22
|
+
user: { tenantId: opts.tenantId ?? "tenant-1" },
|
|
23
|
+
config: async () => opts.billingLive ?? true,
|
|
24
|
+
secrets: { has: async () => opts.apiKeySet ?? true },
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal HandlerContext stub, see runtime.test.ts precedent
|
|
26
|
+
} as any as HandlerContext; // @cast-boundary test-stub — partial ctx
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildConfig(overrides: { resolveTier?: () => Promise<TestTier> } = {}) {
|
|
30
|
+
return createBillingInfoQueryConfig<TestTier>({
|
|
31
|
+
roles: ["Admin"],
|
|
32
|
+
resolveTier: overrides.resolveTier ?? (async () => "free"),
|
|
33
|
+
getBillingPrices: () => ({ starter: "price_starter", pro: "price_pro" }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("createBillingInfoQueryConfig", () => {
|
|
38
|
+
test("no billing prices configured → enabled:false, no DB query", async () => {
|
|
39
|
+
const fetchOne = mock(async () => ({ status: "active", tier: "pro", providerName: "stripe" }));
|
|
40
|
+
const config = createBillingInfoQueryConfig<TestTier>({
|
|
41
|
+
roles: ["Admin"],
|
|
42
|
+
resolveTier: async () => "free",
|
|
43
|
+
getBillingPrices: () => null,
|
|
44
|
+
});
|
|
45
|
+
const ctx = stubCtx({ fetchOne });
|
|
46
|
+
|
|
47
|
+
const result = await config.handler({} as never, ctx);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({ enabled: false, tier: "free", subscription: null, prices: {} });
|
|
50
|
+
expect(fetchOne).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("resolveTier's result is passed through unchanged", async () => {
|
|
54
|
+
const fetchOne = mock(async () => null);
|
|
55
|
+
const config = buildConfig({ resolveTier: async () => "pro" });
|
|
56
|
+
const ctx = stubCtx({ fetchOne });
|
|
57
|
+
|
|
58
|
+
const result = await config.handler({} as never, ctx);
|
|
59
|
+
|
|
60
|
+
expect(result.tier).toBe("pro");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("subscription row with all-string status/tier/providerName is returned", async () => {
|
|
64
|
+
const fetchOne = mock(async () => ({ status: "active", tier: "pro", providerName: "stripe" }));
|
|
65
|
+
const config = buildConfig();
|
|
66
|
+
const ctx = stubCtx({ fetchOne, billingLive: true, apiKeySet: true });
|
|
67
|
+
|
|
68
|
+
const result = await config.handler({} as never, ctx);
|
|
69
|
+
|
|
70
|
+
expect(result.enabled).toBe(true);
|
|
71
|
+
expect(result.subscription).toEqual({ status: "active", tier: "pro", providerName: "stripe" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("subscription row with a non-string field → null (fails the shape check)", async () => {
|
|
75
|
+
const fetchOne = mock(async () => ({ status: "active", tier: 42, providerName: "stripe" }));
|
|
76
|
+
const config = buildConfig();
|
|
77
|
+
const ctx = stubCtx({ fetchOne });
|
|
78
|
+
|
|
79
|
+
const result = await config.handler({} as never, ctx);
|
|
80
|
+
|
|
81
|
+
expect(result.subscription).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("no subscription row → null", async () => {
|
|
85
|
+
const fetchOne = mock(async () => null);
|
|
86
|
+
const config = buildConfig();
|
|
87
|
+
const ctx = stubCtx({ fetchOne });
|
|
88
|
+
|
|
89
|
+
const result = await config.handler({} as never, ctx);
|
|
90
|
+
|
|
91
|
+
expect(result.subscription).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("access.roles carries the caller's role list through unchanged", () => {
|
|
95
|
+
const config = createBillingInfoQueryConfig<TestTier>({
|
|
96
|
+
roles: ["Admin", "TenantAdmin"],
|
|
97
|
+
resolveTier: async () => "free",
|
|
98
|
+
getBillingPrices: () => null,
|
|
99
|
+
});
|
|
100
|
+
expect(config.access).toEqual({ roles: ["Admin", "TenantAdmin"] });
|
|
101
|
+
expect(config.name).toBe("billing-info");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Generic billing-info query-handler-config factory. Extracted from the
|
|
2
|
+
// near-identical billing-info.query.ts app copies in show-pony and
|
|
3
|
+
// publicstatus (infra#446) — the only per-app variables were the
|
|
4
|
+
// TTier union/resolver, the allowed roles, and how Stripe prices are read
|
|
5
|
+
// off the app's extraContext.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately does NOT call defineQueryHandler itself: apps use different
|
|
8
|
+
// wrappers around it (publicstatus's `@app/define`, which adds
|
|
9
|
+
// `agent: { expose: false }`; show-pony's plain framework
|
|
10
|
+
// `defineQueryHandler`). Calling defineQueryHandler here would bypass
|
|
11
|
+
// whatever an app's own wrapper does. Instead this factory returns just the
|
|
12
|
+
// handler-config object — name/schema/access/handler — and each app spreads
|
|
13
|
+
// it into its own defineQueryHandler call, adding app-specific fields
|
|
14
|
+
// (like `agent`) on top.
|
|
15
|
+
|
|
16
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { QnTypes, qn, SYSTEM_TENANT_ID, toKebab } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
// kumiko-lint-ignore cross-feature-import SUBSCRIPTION_STRIPE_FEATURE is a plain string const in constants.ts (no imports); the barrel import (../subscription-stripe) would create a module cycle with billing-foundation via feature.ts → verify-webhook.ts, because billing-foundation/index.ts now re-exports billing-info-query.ts.
|
|
21
|
+
import { SUBSCRIPTION_STRIPE_FEATURE } from "../subscription-stripe/constants";
|
|
22
|
+
import { subscriptionAggregateId } from "./aggregate-id";
|
|
23
|
+
import { subscriptionsProjectionTable } from "./projection";
|
|
24
|
+
|
|
25
|
+
// subscription-stripe addresses its credentials + the live flag as config
|
|
26
|
+
// keys (api-key via backing:"secrets" in the secrets store, billingLive
|
|
27
|
+
// plain). We only READ them here to surface billing readiness on the
|
|
28
|
+
// customer screen — the write-side UI is the feature's auto-derived
|
|
29
|
+
// sysadmin settings screen.
|
|
30
|
+
const stripeFeature = toKebab(SUBSCRIPTION_STRIPE_FEATURE);
|
|
31
|
+
const STRIPE_API_KEY_CONFIG_QN = qn(stripeFeature, QnTypes.config, "api-key");
|
|
32
|
+
const STRIPE_BILLING_LIVE_CONFIG_QN = qn(stripeFeature, QnTypes.config, toKebab("billingLive"));
|
|
33
|
+
|
|
34
|
+
export type BillingInfo<TTier extends string> = {
|
|
35
|
+
readonly enabled: boolean;
|
|
36
|
+
readonly tier: TTier;
|
|
37
|
+
readonly subscription: {
|
|
38
|
+
readonly status: string;
|
|
39
|
+
readonly tier: string;
|
|
40
|
+
readonly providerName: string;
|
|
41
|
+
} | null;
|
|
42
|
+
readonly prices: Readonly<Partial<Record<string, string>>>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BillingInfoQueryDeps<TTier extends string> = {
|
|
46
|
+
readonly roles: readonly string[];
|
|
47
|
+
readonly resolveTier: (db: TenantDb, tenantId: TenantId) => Promise<TTier>;
|
|
48
|
+
// ctx is the same HandlerContext the returned handler receives — app
|
|
49
|
+
// implementations read app-specific extraContext fields off it (e.g.
|
|
50
|
+
// publicstatus/show-pony's getBillingPrices(ctx) reads `billingPrices`),
|
|
51
|
+
// so they type their own ctx param as `unknown` and cast internally.
|
|
52
|
+
readonly getBillingPrices: (
|
|
53
|
+
ctx: HandlerContext,
|
|
54
|
+
) => Readonly<Partial<Record<string, string>>> | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function createBillingInfoQueryConfig<TTier extends string>(
|
|
58
|
+
deps: BillingInfoQueryDeps<TTier>,
|
|
59
|
+
) {
|
|
60
|
+
return {
|
|
61
|
+
name: "billing-info",
|
|
62
|
+
schema: z.object({}),
|
|
63
|
+
access: { roles: deps.roles },
|
|
64
|
+
async handler(_event: unknown, ctx: HandlerContext): Promise<BillingInfo<TTier>> {
|
|
65
|
+
const tier = await deps.resolveTier(ctx.db, ctx.user.tenantId);
|
|
66
|
+
const prices = deps.getBillingPrices(ctx);
|
|
67
|
+
if (!prices) return { enabled: false, tier, subscription: null, prices: {} };
|
|
68
|
+
|
|
69
|
+
const billingLive = ctx.config
|
|
70
|
+
? (await ctx.config(STRIPE_BILLING_LIVE_CONFIG_QN)) === true
|
|
71
|
+
: false;
|
|
72
|
+
const apiKeySet = ctx.secrets
|
|
73
|
+
? await ctx.secrets.has(SYSTEM_TENANT_ID, STRIPE_API_KEY_CONFIG_QN)
|
|
74
|
+
: false;
|
|
75
|
+
|
|
76
|
+
const sub = await ctx.db.fetchOne<{
|
|
77
|
+
status?: unknown;
|
|
78
|
+
tier?: unknown;
|
|
79
|
+
providerName?: unknown;
|
|
80
|
+
}>(subscriptionsProjectionTable, { id: subscriptionAggregateId(ctx.user.tenantId) });
|
|
81
|
+
return {
|
|
82
|
+
enabled: billingLive && apiKeySet,
|
|
83
|
+
tier,
|
|
84
|
+
subscription:
|
|
85
|
+
sub &&
|
|
86
|
+
typeof sub.status === "string" &&
|
|
87
|
+
typeof sub.tier === "string" &&
|
|
88
|
+
typeof sub.providerName === "string"
|
|
89
|
+
? { status: sub.status, tier: sub.tier, providerName: sub.providerName }
|
|
90
|
+
: null,
|
|
91
|
+
prices,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.286.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Add createBillingInfoQueryConfig factory for the billing-info query handler",
|
|
6
|
+
"detail": "Extracted from the near-identical app copies in show-pony and publicstatus. Returns a handler-config object rather than calling defineQueryHandler itself, so apps can pass it through their own wrapper."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Public API of the subscription-foundation bundled-feature.
|
|
2
2
|
|
|
3
3
|
export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
|
|
4
|
+
export {
|
|
5
|
+
type BillingInfo,
|
|
6
|
+
type BillingInfoQueryDeps,
|
|
7
|
+
createBillingInfoQueryConfig,
|
|
8
|
+
} from "./billing-info-query";
|
|
4
9
|
export {
|
|
5
10
|
BILLING_FOUNDATION_FEATURE,
|
|
6
11
|
type BillingEventKind,
|
package/src/shared/index.ts
CHANGED
|
@@ -16,11 +16,13 @@ export { entitiesOf } from "./entities-of";
|
|
|
16
16
|
export { isWithinGracePeriod } from "./grace-period";
|
|
17
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
|
+
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter";
|
|
19
20
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
20
21
|
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
|
|
21
22
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
22
23
|
export { sessionField } from "./session-field";
|
|
23
24
|
export { sessionLocaleField } from "./session-locale-field";
|
|
24
25
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
26
|
+
export { createSingleUseTokenStore } from "./single-use-token-store";
|
|
25
27
|
export type { SystemQueryFn } from "./system-query";
|
|
26
28
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLockoutCounter } from "./lockout-counter";
|
|
3
|
+
|
|
4
|
+
// Production Redis has active lockout/mfa-verify entries under these exact
|
|
5
|
+
// keys — only asserting the generated Redis key strings (not just behavior
|
|
6
|
+
// through a mocked client) catches a prefix typo that would silently make
|
|
7
|
+
// existing entries unreachable. The two prefix pairs below are byte-copies
|
|
8
|
+
// of the ones lockout-store.ts and mfa-verify-attempts.ts wire onto this
|
|
9
|
+
// factory; only integration tests exercised this logic before (which never
|
|
10
|
+
// assert on the raw key string), so this is new coverage.
|
|
11
|
+
// countKey/untilKey are not exported by the factory (only the bound
|
|
12
|
+
// operations are) — verify the key shape indirectly through a fake Redis
|
|
13
|
+
// client that records the keys it's called with.
|
|
14
|
+
function fakeRedis() {
|
|
15
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
16
|
+
const redis = {
|
|
17
|
+
mget: async (...args: unknown[]) => {
|
|
18
|
+
calls.push({ method: "mget", args });
|
|
19
|
+
return [null, null];
|
|
20
|
+
},
|
|
21
|
+
incr: async (...args: unknown[]) => {
|
|
22
|
+
calls.push({ method: "incr", args });
|
|
23
|
+
return 1;
|
|
24
|
+
},
|
|
25
|
+
expire: async (...args: unknown[]) => {
|
|
26
|
+
calls.push({ method: "expire", args });
|
|
27
|
+
return 1;
|
|
28
|
+
},
|
|
29
|
+
set: async (...args: unknown[]) => {
|
|
30
|
+
calls.push({ method: "set", args });
|
|
31
|
+
return "OK";
|
|
32
|
+
},
|
|
33
|
+
get: async (...args: unknown[]) => {
|
|
34
|
+
calls.push({ method: "get", args });
|
|
35
|
+
return null;
|
|
36
|
+
},
|
|
37
|
+
del: async (...args: unknown[]) => {
|
|
38
|
+
calls.push({ method: "del", args });
|
|
39
|
+
return 1;
|
|
40
|
+
},
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
|
|
42
|
+
} as any;
|
|
43
|
+
return { redis, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("account-lockout Redis key strings", () => {
|
|
47
|
+
const counter = createLockoutCounter("kumiko:auth:lockout:count:", "kumiko:auth:lockout:until:");
|
|
48
|
+
|
|
49
|
+
test("getState reads the byte-identical count/until keys", async () => {
|
|
50
|
+
const { redis, calls } = fakeRedis();
|
|
51
|
+
await counter.getState(redis, "u1");
|
|
52
|
+
expect(calls[0]).toEqual({
|
|
53
|
+
method: "mget",
|
|
54
|
+
args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("clearState deletes the byte-identical count/until keys", async () => {
|
|
59
|
+
const { redis, calls } = fakeRedis();
|
|
60
|
+
await counter.clearState(redis, "u1");
|
|
61
|
+
expect(calls[0]).toEqual({
|
|
62
|
+
method: "del",
|
|
63
|
+
args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("mfa-verify Redis key strings", () => {
|
|
69
|
+
const counter = createLockoutCounter(
|
|
70
|
+
"kumiko:auth:mfa-verify:count:",
|
|
71
|
+
"kumiko:auth:mfa-verify:until:",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
test("getState reads the byte-identical count/until keys", async () => {
|
|
75
|
+
const { redis, calls } = fakeRedis();
|
|
76
|
+
await counter.getState(redis, "u1");
|
|
77
|
+
expect(calls[0]).toEqual({
|
|
78
|
+
method: "mget",
|
|
79
|
+
args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("clearState deletes the byte-identical count/until keys", async () => {
|
|
84
|
+
const { redis, calls } = fakeRedis();
|
|
85
|
+
await counter.clearState(redis, "u1");
|
|
86
|
+
expect(calls[0]).toEqual({
|
|
87
|
+
method: "del",
|
|
88
|
+
args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generic Redis-backed failed-attempt counter with auto-expiring lockout.
|
|
2
|
+
// Extracted from auth-email-password/lockout-store.ts and
|
|
3
|
+
// auth-mfa/mfa-verify-attempts.ts (infra#446) — both were byte-identical
|
|
4
|
+
// INCR/NX logic, differing only in their Redis key prefixes.
|
|
5
|
+
|
|
6
|
+
import type Redis from "ioredis";
|
|
7
|
+
|
|
8
|
+
export type LockoutCounterState = {
|
|
9
|
+
readonly failureCount: number;
|
|
10
|
+
// Epoch milliseconds when the account/attempt auto-unlocks. null while the
|
|
11
|
+
// counter is still below threshold.
|
|
12
|
+
readonly lockedUntil: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Two keys per subject so each can carry its own TTL:
|
|
16
|
+
// - count-key: 24h, carries the streak. Monotonic — once threshold is
|
|
17
|
+
// crossed it STAYS crossed until the caller explicitly clears it.
|
|
18
|
+
// - until-key: exactly the lockout duration, auto-expires when the lock
|
|
19
|
+
// ends (Redis TTL replaces a "timer" that would otherwise need a job).
|
|
20
|
+
//
|
|
21
|
+
// Consequence of the monotonic counter: once a subject has been locked, the
|
|
22
|
+
// NEXT failure after the lock expires re-locks immediately — the INCR still
|
|
23
|
+
// returns a value ≥ threshold, so the SET NX re-arms the lock. Clearing the
|
|
24
|
+
// streak is the caller's responsibility (e.g. a successful login, or a
|
|
25
|
+
// dedicated unlock flow) — intentional: brute-force resistance favours
|
|
26
|
+
// strictness over UX for anonymous attempts.
|
|
27
|
+
export function createLockoutCounter(countKeyPrefix: string, untilKeyPrefix: string) {
|
|
28
|
+
function countKey(subjectId: string): string {
|
|
29
|
+
return `${countKeyPrefix}${subjectId}`;
|
|
30
|
+
}
|
|
31
|
+
function untilKey(subjectId: string): string {
|
|
32
|
+
return `${untilKeyPrefix}${subjectId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getState(redis: Redis, subjectId: string): Promise<LockoutCounterState | null> {
|
|
36
|
+
const [countRaw, untilRaw] = await redis.mget(countKey(subjectId), untilKey(subjectId));
|
|
37
|
+
if (countRaw === null) return null;
|
|
38
|
+
const failureCount = Number(countRaw);
|
|
39
|
+
if (!Number.isFinite(failureCount)) return null;
|
|
40
|
+
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
41
|
+
return {
|
|
42
|
+
failureCount,
|
|
43
|
+
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Race-free: INCR is atomic at the Redis level, so N concurrent failed
|
|
48
|
+
// attempts produce exactly N increments — no GET-SET window to lose an
|
|
49
|
+
// increment through. The NX on the until-key likewise guarantees only one
|
|
50
|
+
// attempt out of a concurrent batch sets the lock timestamp; subsequent
|
|
51
|
+
// concurrent attempts find the key already set and leave it alone, so the
|
|
52
|
+
// lock window stays anchored to the first-to-cross, not the last.
|
|
53
|
+
async function recordFailedAttempt(
|
|
54
|
+
redis: Redis,
|
|
55
|
+
subjectId: string,
|
|
56
|
+
maxFailedAttempts: number,
|
|
57
|
+
lockoutDurationMinutes: number,
|
|
58
|
+
): Promise<LockoutCounterState> {
|
|
59
|
+
const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
|
|
60
|
+
// TTL on the count-key: 24h covers "I fat-fingered yesterday". The
|
|
61
|
+
// lockout duration is on the until-key; the count-key outlives it so an
|
|
62
|
+
// expired lock leaves a counter ≥ threshold — that's what makes the next
|
|
63
|
+
// miss immediately re-lock (strict-semantic; see the type-comment above).
|
|
64
|
+
const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
|
|
65
|
+
|
|
66
|
+
const count = await redis.incr(countKey(subjectId));
|
|
67
|
+
if (count === 1) {
|
|
68
|
+
// First failure → set the TTL. INCR doesn't set one; a counter without
|
|
69
|
+
// TTL would leak forever for subjects that never return.
|
|
70
|
+
await redis.expire(countKey(subjectId), ttlSec);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let lockedUntil: number | null = null;
|
|
74
|
+
if (count >= maxFailedAttempts) {
|
|
75
|
+
const computedUntil = Date.now() + lockDurationMs;
|
|
76
|
+
// NX: only set if no lock is currently armed. A second concurrent attempt
|
|
77
|
+
// arriving after the first crossed the threshold must NOT reset the
|
|
78
|
+
// timer — the lock window should align with the attempt that crossed,
|
|
79
|
+
// not the one that happened a millisecond later.
|
|
80
|
+
const setOk = await redis.set(
|
|
81
|
+
untilKey(subjectId),
|
|
82
|
+
String(computedUntil),
|
|
83
|
+
"PX",
|
|
84
|
+
lockDurationMs,
|
|
85
|
+
"NX",
|
|
86
|
+
);
|
|
87
|
+
if (setOk === "OK") {
|
|
88
|
+
lockedUntil = computedUntil;
|
|
89
|
+
} else {
|
|
90
|
+
// Another concurrent attempt already locked — read the authoritative
|
|
91
|
+
// timestamp so the returned state matches what a follow-up
|
|
92
|
+
// getState would see.
|
|
93
|
+
const existing = await redis.get(untilKey(subjectId));
|
|
94
|
+
lockedUntil = existing !== null ? Number(existing) : null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { failureCount: count, lockedUntil };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Idempotent — deleting missing keys is a no-op, so a replayed clear just
|
|
102
|
+
// re-clears harmlessly.
|
|
103
|
+
async function clearState(redis: Redis, subjectId: string): Promise<void> {
|
|
104
|
+
await redis.del(countKey(subjectId), untilKey(subjectId));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { getState, recordFailedAttempt, clearState };
|
|
108
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSingleUseTokenStore } from "./single-use-token-store";
|
|
3
|
+
|
|
4
|
+
// Production Redis has active signup/invite tokens under these exact keys —
|
|
5
|
+
// asserting the generated Redis key strings (not just behavior through a
|
|
6
|
+
// mocked client) catches a prefix typo that would silently make existing
|
|
7
|
+
// tokens unreachable. Only integration tests exercised this logic before
|
|
8
|
+
// (which never assert on the raw key string), so this is new coverage.
|
|
9
|
+
function fakeRedis() {
|
|
10
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
11
|
+
const redis = {
|
|
12
|
+
set: async (...args: unknown[]) => {
|
|
13
|
+
calls.push({ method: "set", args });
|
|
14
|
+
return "OK";
|
|
15
|
+
},
|
|
16
|
+
get: async (...args: unknown[]) => {
|
|
17
|
+
calls.push({ method: "get", args });
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
del: async (...args: unknown[]) => {
|
|
21
|
+
calls.push({ method: "del", args });
|
|
22
|
+
return 1;
|
|
23
|
+
},
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
|
|
25
|
+
} as any;
|
|
26
|
+
return { redis, calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOKEN = "tok-1";
|
|
30
|
+
|
|
31
|
+
describe("signup Redis key strings", () => {
|
|
32
|
+
const store = createSingleUseTokenStore({
|
|
33
|
+
tokenPrefix: "signup:by-token:",
|
|
34
|
+
subjectPrefix: "signup:by-email:",
|
|
35
|
+
burnPrefix: "signup:burn:",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("store writes forward key under signup:by-token: and reverse key under signup:by-email:", async () => {
|
|
39
|
+
const { redis, calls } = fakeRedis();
|
|
40
|
+
await store.store(redis, { subjectId: "user@example.com", token: TOKEN, ttlSeconds: 60 });
|
|
41
|
+
const forwardKey = calls[0]?.args[0];
|
|
42
|
+
const subjectKey = calls[1]?.args[0];
|
|
43
|
+
expect(String(forwardKey)).toStartWith("signup:by-token:");
|
|
44
|
+
expect(subjectKey).toBe("signup:by-email:user@example.com");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("burn writes under signup:burn:", async () => {
|
|
48
|
+
const { redis, calls } = fakeRedis();
|
|
49
|
+
await store.burn(redis, TOKEN);
|
|
50
|
+
expect(String(calls[0]?.args[0])).toStartWith("signup:burn:");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("invite Redis key strings", () => {
|
|
55
|
+
const store = createSingleUseTokenStore({
|
|
56
|
+
tokenPrefix: "invite:by-token:",
|
|
57
|
+
subjectPrefix: "invite:by-id:",
|
|
58
|
+
burnPrefix: "invite:burn:",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("store writes forward key under invite:by-token: and reverse key under invite:by-id:", async () => {
|
|
62
|
+
const { redis, calls } = fakeRedis();
|
|
63
|
+
await store.store(redis, { subjectId: "inv-1", token: TOKEN, ttlSeconds: 60 });
|
|
64
|
+
const forwardKey = calls[0]?.args[0];
|
|
65
|
+
const subjectKey = calls[1]?.args[0];
|
|
66
|
+
expect(String(forwardKey)).toStartWith("invite:by-token:");
|
|
67
|
+
expect(subjectKey).toBe("invite:by-id:inv-1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("burn writes under invite:burn:", async () => {
|
|
71
|
+
const { redis, calls } = fakeRedis();
|
|
72
|
+
await store.burn(redis, TOKEN);
|
|
73
|
+
expect(String(calls[0]?.args[0])).toStartWith("invite:burn:");
|
|
74
|
+
});
|
|
75
|
+
});
|