@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Generic Redis-backed pre-activation token store: bidirectional
|
|
2
|
+
// token↔subject mapping plus single-use burn/unburn semantics. Extracted
|
|
3
|
+
// from auth-email-password/signup-token-store.ts and
|
|
4
|
+
// auth-email-password/invite-token-store.ts (infra#446) — both were the
|
|
5
|
+
// same Redis layout, differing only in their key prefixes and which field
|
|
6
|
+
// (email vs. invitationId) plays the "subject" role.
|
|
7
|
+
//
|
|
8
|
+
// Public subpath export (./shared/single-use-token-store in package.json):
|
|
9
|
+
// offlot-app (a separate repo, external consumer) carries its own copy of
|
|
10
|
+
// this exact logic under `src/features/waitlist/signup-token-store.ts`,
|
|
11
|
+
// with a comment noting it "must stay byte-compatible with
|
|
12
|
+
// auth-email-password/signup-token-store" because signup-confirm resolves
|
|
13
|
+
// tokens via the same Redis key layout. offlot-app#418 (separate issue,
|
|
14
|
+
// after this ships) will replace that copy with
|
|
15
|
+
// `createSingleUseTokenStore({ tokenPrefix: "signup:by-token:", subjectPrefix:
|
|
16
|
+
// "signup:by-email:", burnPrefix: "signup:burn:" })` — i.e. the exact same
|
|
17
|
+
// prefix strings the framework's own signup store below uses, so both stay
|
|
18
|
+
// byte-compatible by construction instead of by hand-copied logic.
|
|
19
|
+
//
|
|
20
|
+
// Token material: opaque random 256-bit (e.g. crypto.randomBytes,
|
|
21
|
+
// base64url-encoded). Not designed for human typing — the subject clicks a
|
|
22
|
+
// mail link, nobody types the token.
|
|
23
|
+
//
|
|
24
|
+
// Why a server-side lookup at all (not a stateless HMAC-signed token, like
|
|
25
|
+
// password-reset/email-verification)? Some subjects (e.g. a not-yet-created
|
|
26
|
+
// signup) have no stable identity claim yet for an HMAC to bind to. We map
|
|
27
|
+
// token ↔ subject bidirectionally in Redis and delete the pair on confirm.
|
|
28
|
+
// Bidirectional because:
|
|
29
|
+
// - forward (by-token): confirm/accept needs token → subject
|
|
30
|
+
// - reverse (by-subject): the create/request flow needs to know whether a
|
|
31
|
+
// token is still live for this subject, so a resend can invalidate it
|
|
32
|
+
// instead of leaving two valid tokens around
|
|
33
|
+
//
|
|
34
|
+
// Every key is derived from sha256(token), never the raw token — Redis key
|
|
35
|
+
// names, MONITOR output, replica traffic, and memory/backup dumps never
|
|
36
|
+
// carry the bearer secret in the clear (#2174). The by-subject entry stores
|
|
37
|
+
// the *hash* of the live token, not the token itself, so it can only be
|
|
38
|
+
// used to invalidate (delete the matching forward entry) — never to
|
|
39
|
+
// recover or resend the original token. A resend therefore always mints a
|
|
40
|
+
// fresh token and invalidates the previous one, rather than reusing the
|
|
41
|
+
// same link.
|
|
42
|
+
//
|
|
43
|
+
// Single-use burn: `SET burn:<hash> "1" EX 3600 NX` — first caller to
|
|
44
|
+
// confirm/accept wins ("OK"), a concurrent second tab racing the same link
|
|
45
|
+
// gets "already-used". TTL is 1h (short enough that the burn-key doesn't
|
|
46
|
+
// permanently tax Redis, long enough to catch replays inside any realistic
|
|
47
|
+
// race window).
|
|
48
|
+
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import type Redis from "ioredis";
|
|
51
|
+
|
|
52
|
+
function hashToken(token: string): string {
|
|
53
|
+
return createHash("sha256").update(token).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSingleUseTokenStore(prefixes: {
|
|
57
|
+
readonly tokenPrefix: string;
|
|
58
|
+
readonly subjectPrefix: string;
|
|
59
|
+
readonly burnPrefix: string;
|
|
60
|
+
}) {
|
|
61
|
+
function tokenKey(token: string): string {
|
|
62
|
+
return `${prefixes.tokenPrefix}${hashToken(token)}`;
|
|
63
|
+
}
|
|
64
|
+
// Builds the forward key from an already-hashed value (e.g. read back
|
|
65
|
+
// from the by-subject entry) — does NOT hash again. Kept separate from
|
|
66
|
+
// tokenKey() (which hashes a raw token) so a double-hash mistake is
|
|
67
|
+
// visible at the call site instead of silently no-op'ing a delete.
|
|
68
|
+
function forwardKeyForHash(tokenHash: string): string {
|
|
69
|
+
return `${prefixes.tokenPrefix}${tokenHash}`;
|
|
70
|
+
}
|
|
71
|
+
function subjectKey(subjectId: string): string {
|
|
72
|
+
return `${prefixes.subjectPrefix}${subjectId}`;
|
|
73
|
+
}
|
|
74
|
+
function burnKey(token: string): string {
|
|
75
|
+
return `${prefixes.burnPrefix}${hashToken(token)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
|
|
79
|
+
// re-writing the same token/subject pair is fine. The by-subject value is
|
|
80
|
+
// the token's hash, not the token — see file header.
|
|
81
|
+
async function store(
|
|
82
|
+
redis: Redis,
|
|
83
|
+
args: { subjectId: string; token: string; ttlSeconds: number },
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
await Promise.all([
|
|
86
|
+
redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
|
|
87
|
+
redis.set(subjectKey(args.subjectId), hashToken(args.token), "EX", args.ttlSeconds),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Lookup: subject for a token. Null when the token doesn't (or no longer)
|
|
92
|
+
// exist (expired, already consumed, or invalid).
|
|
93
|
+
async function getSubjectForToken(redis: Redis, token: string): Promise<string | null> {
|
|
94
|
+
return redis.get(tokenKey(token));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Deletes a still-live token for this subject, if one exists — both the
|
|
98
|
+
// forward entry (built from the hash already stored in the by-subject
|
|
99
|
+
// entry, never recovers the raw token) and the by-subject entry itself.
|
|
100
|
+
// Returns whether a live token existed. Deleting the by-subject entry
|
|
101
|
+
// here too (not just the forward key) avoids leaving a dangling hash
|
|
102
|
+
// pointing at nothing if the caller crashes before the following store().
|
|
103
|
+
async function invalidateExistingBySubject(redis: Redis, subjectId: string): Promise<boolean> {
|
|
104
|
+
const existingHash = await redis.get(subjectKey(subjectId));
|
|
105
|
+
if (existingHash === null) return false;
|
|
106
|
+
await Promise.all([
|
|
107
|
+
redis.del(forwardKeyForHash(existingHash)),
|
|
108
|
+
redis.del(subjectKey(subjectId)),
|
|
109
|
+
]);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// SET NX EX — atomic check-and-set. Returns "OK" when the key is new,
|
|
114
|
+
// null when it's already there.
|
|
115
|
+
async function burn(redis: Redis, token: string): Promise<"burned" | "already-used"> {
|
|
116
|
+
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
117
|
+
return result === "OK" ? "burned" : "already-used";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cleanup after a successful confirm/accept — both lookup keys. The
|
|
121
|
+
// burn-key stays (prevents a replay for the rest of the burn TTL).
|
|
122
|
+
async function deleteBoth(
|
|
123
|
+
redis: Redis,
|
|
124
|
+
args: { subjectId: string; token: string },
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await Promise.all([redis.del(tokenKey(args.token)), redis.del(subjectKey(args.subjectId))]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Burn-release for a failed confirm/accept path (e.g. a DB error) so a
|
|
130
|
+
// legitimate retry isn't blocked by a stale burn marker.
|
|
131
|
+
async function unburn(redis: Redis, token: string): Promise<void> {
|
|
132
|
+
await redis.del(burnKey(token));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { store, getSubjectForToken, invalidateExistingBySubject, burn, deleteBoth, unburn };
|
|
136
|
+
}
|