@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.286.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"./auth-email-password/seeding": "./src/auth-email-password/seeding.ts",
|
|
94
94
|
"./auth-email-password/testing": "./src/auth-email-password/testing.ts",
|
|
95
95
|
"./auth-email-password/web": "./src/auth-email-password/web/index.ts",
|
|
96
|
+
"./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
|
|
96
97
|
"./delivery": "./src/delivery/index.ts",
|
|
97
98
|
"./delivery/web": "./src/delivery/web/index.ts",
|
|
98
99
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
@@ -129,12 +130,12 @@
|
|
|
129
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
131
|
},
|
|
131
132
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.286.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.286.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.286.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.286.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.286.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.286.0",
|
|
138
139
|
"@mollie/api-client": "^4.5.0",
|
|
139
140
|
"@node-rs/argon2": "^2.0.2",
|
|
140
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +164,7 @@
|
|
|
163
164
|
],
|
|
164
165
|
"devDependencies": {
|
|
165
166
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.286.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.286.0"
|
|
168
169
|
}
|
|
169
170
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.286.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "lockout-store and signup/invite-token-store now delegate to shared/lockout-counter and shared/single-use-token-store",
|
|
6
|
+
"detail": "No behavior change — same exported function/type names and Redis key prefixes as before the extraction."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.218.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,127 +1,78 @@
|
|
|
1
1
|
// Redis-backed token store for the tenant-invite magic-link flow.
|
|
2
2
|
//
|
|
3
|
-
// Subject is the invitation row ID (DB-row owner: tenant-feature).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Subject is the invitation row ID (DB-row owner: tenant-feature). The
|
|
4
|
+
// store mechanics (bidirectional token↔subject mapping, sha256-hashed
|
|
5
|
+
// keys, single-use burn) live in shared/single-use-token-store.ts — this
|
|
6
|
+
// file only wires the invite key prefixes onto it.
|
|
6
7
|
//
|
|
7
|
-
// Unlike signup-token-store we don't
|
|
8
|
-
// resend-idempotency lives at the invitation-row level (an
|
|
9
|
-
// inviting the same email twice reuses the existing row and mints a
|
|
10
|
-
// fresh token; invite-create looks up the *previous* token's hash via
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// needs the forward key to delete. Hence a second key,
|
|
15
|
-
// invite:by-id:<invitationId>, holding the hash of the live token.
|
|
16
|
-
// Cancel deletes both.
|
|
17
|
-
//
|
|
18
|
-
// Every key is derived from sha256(token), never the raw token —
|
|
19
|
-
// Redis key names, MONITOR output, replica traffic, and memory/backup
|
|
20
|
-
// dumps never carry the bearer secret in the clear (#2174). The
|
|
21
|
-
// by-id entry stores the *hash* of the live token, not the token
|
|
22
|
-
// itself, so it can only be used to invalidate — never to recover or
|
|
23
|
-
// resend the original token. A resend therefore always mints a fresh
|
|
24
|
-
// token and invalidates the previous one, rather than reusing the
|
|
25
|
-
// same link.
|
|
8
|
+
// Unlike signup-token-store we don't rely on the store's reverse lookup for
|
|
9
|
+
// reuse-detection: resend-idempotency lives at the invitation-row level (an
|
|
10
|
+
// admin inviting the same email twice reuses the existing row and mints a
|
|
11
|
+
// fresh token; invite-create looks up the *previous* token's hash via the
|
|
12
|
+
// by-id entry to invalidate it before storing the new one). The by-id entry
|
|
13
|
+
// is still useful for cancel: the admin knows row.id and needs the forward
|
|
14
|
+
// key to delete it.
|
|
26
15
|
//
|
|
27
16
|
// Bug pattern: TTL lives only in Redis. DB-row.expiresAt is UI display
|
|
28
17
|
// only. On an expired token, invite-accept doesn't find it → invalid-
|
|
29
|
-
// invite-token. The DB row stays status="pending" — a cleanup job
|
|
30
|
-
//
|
|
18
|
+
// invite-token. The DB row stays status="pending" — a cleanup job marks it
|
|
19
|
+
// "expired" (separate concern, tracked in U.3-cleanup).
|
|
31
20
|
//
|
|
32
|
-
// No collision with signup/reset/verify tokens: all invite keys carry
|
|
33
|
-
//
|
|
21
|
+
// No collision with signup/reset/verify tokens: all invite keys carry the
|
|
22
|
+
// `invite:`-prefix.
|
|
34
23
|
|
|
35
|
-
import { createHash } from "node:crypto";
|
|
36
24
|
import type Redis from "ioredis";
|
|
25
|
+
import { createSingleUseTokenStore } from "../shared";
|
|
37
26
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// and preauthTokenKeyOf (framework/api/auth-routes.ts): the token is
|
|
44
|
-
// high-entropy, so a single fast hash is enough — no brute-force surface
|
|
45
|
-
// that would justify a slow password-hash.
|
|
46
|
-
function hashToken(token: string): string {
|
|
47
|
-
return createHash("sha256").update(token).digest("hex");
|
|
48
|
-
}
|
|
27
|
+
const store = createSingleUseTokenStore({
|
|
28
|
+
tokenPrefix: "invite:by-token:",
|
|
29
|
+
subjectPrefix: "invite:by-id:",
|
|
30
|
+
burnPrefix: "invite:burn:",
|
|
31
|
+
});
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// Builds the forward key from an already-hashed value (e.g. read back from
|
|
54
|
-
// the by-id entry) — does NOT hash again. Keeping this separate from
|
|
55
|
-
// tokenKey() (which hashes a raw token) makes a double-hash mistake visible
|
|
56
|
-
// at the call site instead of silently no-op'ing a delete.
|
|
57
|
-
function forwardKeyForHash(tokenHash: string): string {
|
|
58
|
-
return `${TOKEN_KEY_PREFIX}${tokenHash}`;
|
|
59
|
-
}
|
|
60
|
-
function idKey(invitationId: string): string {
|
|
61
|
-
return `${ID_KEY_PREFIX}${invitationId}`;
|
|
62
|
-
}
|
|
63
|
-
function burnKey(token: string): string {
|
|
64
|
-
return `${BURN_KEY_PREFIX}${hashToken(token)}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
68
|
-
* Idempotent — re-write derselben Token-Invitation-Kombi ist OK
|
|
69
|
-
* (refresh TTL für Resend). The by-id value is the token's hash, not
|
|
70
|
-
* the token — see file header. */
|
|
33
|
+
/** Stores the pair bidirectionally and sets TTL on both keys.
|
|
34
|
+
* Idempotent — re-writing the same token-invitation pair is fine
|
|
35
|
+
* (refreshes the TTL for resend). */
|
|
71
36
|
export async function storeInviteToken(
|
|
72
37
|
redis: Redis,
|
|
73
38
|
args: { invitationId: string; token: string; ttlSeconds: number },
|
|
74
39
|
): Promise<void> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
40
|
+
return store.store(redis, {
|
|
41
|
+
subjectId: args.invitationId,
|
|
42
|
+
token: args.token,
|
|
43
|
+
ttlSeconds: args.ttlSeconds,
|
|
44
|
+
});
|
|
79
45
|
}
|
|
80
46
|
|
|
81
|
-
/** Lookup: invitationId
|
|
82
|
-
* (
|
|
83
|
-
export
|
|
84
|
-
return redis.get(tokenKey(token));
|
|
85
|
-
}
|
|
47
|
+
/** Lookup: invitationId for a token. Null if the token no longer exists
|
|
48
|
+
* (expired, already consumed, or invalid). */
|
|
49
|
+
export const getInvitationIdForToken = store.getSubjectForToken;
|
|
86
50
|
|
|
87
|
-
/** Deletes a still-live invite token for this invitation, if one exists
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* token per invitation"), and cancel-invitation (no replacement follows,
|
|
93
|
-
* so this is full cleanup). */
|
|
51
|
+
/** Deletes a still-live invite token for this invitation, if one exists.
|
|
52
|
+
* Two callers: invite-create on every resend (a fresh token + by-id entry
|
|
53
|
+
* follows right after, so this is "at most one live token per
|
|
54
|
+
* invitation"), and cancel-invitation (no replacement follows, so this is
|
|
55
|
+
* full cleanup). */
|
|
94
56
|
export async function invalidateExistingInviteToken(
|
|
95
57
|
redis: Redis,
|
|
96
58
|
invitationId: string,
|
|
97
59
|
): Promise<boolean> {
|
|
98
|
-
|
|
99
|
-
if (existingHash === null) return false;
|
|
100
|
-
await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(idKey(invitationId))]);
|
|
101
|
-
return true;
|
|
60
|
+
return store.invalidateExistingBySubject(redis, invitationId);
|
|
102
61
|
}
|
|
103
62
|
|
|
104
|
-
/** Single-
|
|
105
|
-
*
|
|
106
|
-
export
|
|
107
|
-
redis: Redis,
|
|
108
|
-
token: string,
|
|
109
|
-
): Promise<"burned" | "already-used"> {
|
|
110
|
-
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
111
|
-
return result === "OK" ? "burned" : "already-used";
|
|
112
|
-
}
|
|
63
|
+
/** Single-use burn. If two tabs click the accept link at the same time,
|
|
64
|
+
* the first one wins, the second gets "already-used". TTL = 1h. */
|
|
65
|
+
export const burnInviteToken = store.burn;
|
|
113
66
|
|
|
114
|
-
/** Cleanup
|
|
115
|
-
*
|
|
67
|
+
/** Cleanup after a successful accept OR cancel — deletes both lookup
|
|
68
|
+
* keys. The burn key stays for the remaining burn TTL as replay protection. */
|
|
116
69
|
export async function deleteInviteToken(
|
|
117
70
|
redis: Redis,
|
|
118
71
|
args: { invitationId: string; token: string },
|
|
119
72
|
): Promise<void> {
|
|
120
|
-
|
|
73
|
+
return store.deleteBoth(redis, { subjectId: args.invitationId, token: args.token });
|
|
121
74
|
}
|
|
122
75
|
|
|
123
|
-
/** Burn
|
|
124
|
-
*
|
|
125
|
-
export
|
|
126
|
-
await redis.del(burnKey(token));
|
|
127
|
-
}
|
|
76
|
+
/** Burn release for failed-accept paths (DB error etc.) so a legitimate
|
|
77
|
+
* retry isn't blocked by a stale burn marker. */
|
|
78
|
+
export const unburnInviteToken = store.unburn;
|
|
@@ -12,112 +12,23 @@
|
|
|
12
12
|
// active counter — an attacker could exploit the gap, though the IP-level
|
|
13
13
|
// rate-limiter (framework rate-limit) is the parallel defense for that
|
|
14
14
|
// case anyway.
|
|
15
|
+
//
|
|
16
|
+
// The counter mechanics (race-free INCR/NX, TTL rules, monotonic-counter
|
|
17
|
+
// semantics) live in shared/lockout-counter.ts — this file only wires the
|
|
18
|
+
// account-lockout key prefixes onto it. See that file for why a lock
|
|
19
|
+
// re-arms immediately after expiry, and why a successful login (or the
|
|
20
|
+
// account-unlock magic-link flow, #1266, see
|
|
21
|
+
// handlers/confirm-account-unlock.write.ts) is what resets the streak.
|
|
15
22
|
|
|
16
|
-
import type
|
|
23
|
+
import { createLockoutCounter, type LockoutCounterState } from "../shared";
|
|
17
24
|
|
|
18
|
-
export type LockoutState =
|
|
19
|
-
readonly failureCount: number;
|
|
20
|
-
// Epoch milliseconds when the account auto-unlocks. null while the
|
|
21
|
-
// counter is still below threshold.
|
|
22
|
-
readonly lockedUntil: number | null;
|
|
23
|
-
};
|
|
25
|
+
export type LockoutState = LockoutCounterState;
|
|
24
26
|
|
|
25
|
-
// Two keys per user so each can carry its own TTL:
|
|
26
|
-
// - count-key: 24h, carries the streak. Monotonic — once threshold is
|
|
27
|
-
// crossed it STAYS crossed until a successful login clears it.
|
|
28
|
-
// - until-key: exactly the lockout duration, auto-expires when the lock
|
|
29
|
-
// ends (Redis TTL replaces a "timer" that would otherwise need a job).
|
|
30
|
-
//
|
|
31
|
-
// Consequence of the monotonic counter: once a user has been locked, the
|
|
32
|
-
// NEXT wrong password after the lock expires re-locks immediately — the
|
|
33
|
-
// INCR still returns a value ≥ threshold, so the SET NX re-arms the lock.
|
|
34
|
-
// A successful login is one way to reset the streak; the other is the
|
|
35
|
-
// account-unlock magic-link flow (#1266, see
|
|
36
|
-
// handlers/confirm-account-unlock.write.ts), a deliberate escape hatch for
|
|
37
|
-
// a legitimate user who can't currently produce the right password (e.g.
|
|
38
|
-
// they forgot it too) but can prove mailbox ownership. Intentional:
|
|
39
|
-
// brute-force resistance favours strictness over UX for anonymous login
|
|
40
|
-
// attempts, while the unlock flow keeps the DoS from being permanent.
|
|
41
27
|
const COUNT_KEY_PREFIX = "kumiko:auth:lockout:count:";
|
|
42
28
|
const UNTIL_KEY_PREFIX = "kumiko:auth:lockout:until:";
|
|
43
29
|
|
|
44
|
-
|
|
45
|
-
return `${COUNT_KEY_PREFIX}${userId}`;
|
|
46
|
-
}
|
|
47
|
-
function untilKey(userId: string): string {
|
|
48
|
-
return `${UNTIL_KEY_PREFIX}${userId}`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function getLockoutState(redis: Redis, userId: string): Promise<LockoutState | null> {
|
|
52
|
-
const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
|
|
53
|
-
if (countRaw === null) return null;
|
|
54
|
-
const failureCount = Number(countRaw);
|
|
55
|
-
if (!Number.isFinite(failureCount)) return null;
|
|
56
|
-
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
57
|
-
return {
|
|
58
|
-
failureCount,
|
|
59
|
-
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Race-free: INCR is atomic at the Redis level, so N concurrent wrong-
|
|
64
|
-
// password attempts produce exactly N increments — no GET-SET window to
|
|
65
|
-
// lose an increment through. The NX on the until-key likewise guarantees
|
|
66
|
-
// only one attempt out of a concurrent batch sets the lock timestamp;
|
|
67
|
-
// subsequent concurrent attempts find the key already set and leave it
|
|
68
|
-
// alone, so the lock window stays anchored to the first-to-cross, not
|
|
69
|
-
// the last.
|
|
70
|
-
export async function recordFailedAttempt(
|
|
71
|
-
redis: Redis,
|
|
72
|
-
userId: string,
|
|
73
|
-
maxFailedAttempts: number,
|
|
74
|
-
lockoutDurationMinutes: number,
|
|
75
|
-
): Promise<LockoutState> {
|
|
76
|
-
const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
|
|
77
|
-
// TTL on the count-key: 24h covers "I fat-fingered yesterday". The
|
|
78
|
-
// lockout duration is on the until-key; the count-key outlives it so an
|
|
79
|
-
// expired lock leaves a counter ≥ threshold — that's what makes the next
|
|
80
|
-
// miss immediately re-lock (strict-semantic; see the type-comment above).
|
|
81
|
-
const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
|
|
82
|
-
|
|
83
|
-
const count = await redis.incr(countKey(userId));
|
|
84
|
-
if (count === 1) {
|
|
85
|
-
// First failure → set the TTL. INCR doesn't set one; a counter without
|
|
86
|
-
// TTL would leak forever for users that never return.
|
|
87
|
-
await redis.expire(countKey(userId), ttlSec);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let lockedUntil: number | null = null;
|
|
91
|
-
if (count >= maxFailedAttempts) {
|
|
92
|
-
const computedUntil = Date.now() + lockDurationMs;
|
|
93
|
-
// NX: only set if no lock is currently armed. A second concurrent attempt
|
|
94
|
-
// arriving after the first crossed the threshold must NOT reset the
|
|
95
|
-
// timer — the lock window should align with the attempt that crossed,
|
|
96
|
-
// not the one that happened a millisecond later.
|
|
97
|
-
const setOk = await redis.set(
|
|
98
|
-
untilKey(userId),
|
|
99
|
-
String(computedUntil),
|
|
100
|
-
"PX",
|
|
101
|
-
lockDurationMs,
|
|
102
|
-
"NX",
|
|
103
|
-
);
|
|
104
|
-
if (setOk === "OK") {
|
|
105
|
-
lockedUntil = computedUntil;
|
|
106
|
-
} else {
|
|
107
|
-
// Another concurrent attempt already locked — read the authoritative
|
|
108
|
-
// timestamp so the returned state matches what a follow-up
|
|
109
|
-
// getLockoutState would see.
|
|
110
|
-
const existing = await redis.get(untilKey(userId));
|
|
111
|
-
lockedUntil = existing !== null ? Number(existing) : null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return { failureCount: count, lockedUntil };
|
|
116
|
-
}
|
|
30
|
+
const counter = createLockoutCounter(COUNT_KEY_PREFIX, UNTIL_KEY_PREFIX);
|
|
117
31
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
export async function clearLockoutState(redis: Redis, userId: string): Promise<void> {
|
|
122
|
-
await redis.del(countKey(userId), untilKey(userId));
|
|
123
|
-
}
|
|
32
|
+
export const getLockoutState = counter.getState;
|
|
33
|
+
export const recordFailedAttempt = counter.recordFailedAttempt;
|
|
34
|
+
export const clearLockoutState = counter.clearState;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizeEmail, storeSignupToken } from "./signup-token-store";
|
|
3
|
+
|
|
4
|
+
// Only integration tests (signup-flow.integration.test.ts) exercised this
|
|
5
|
+
// module before — none assert on the raw Redis key, so a case-sensitivity
|
|
6
|
+
// regression in the by-email key wouldn't be caught: two signups from
|
|
7
|
+
// "User@Example.com" and "user@example.com" would silently get separate
|
|
8
|
+
// live-token entries instead of the second invalidating the first.
|
|
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
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
|
|
17
|
+
} as any;
|
|
18
|
+
return { redis, calls };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("storeSignupToken", () => {
|
|
22
|
+
test("builds the by-email key from the normalized (lowercased) email", async () => {
|
|
23
|
+
const { redis, calls } = fakeRedis();
|
|
24
|
+
await storeSignupToken(redis, { email: "User@Example.com", token: "tok-1", ttlSeconds: 60 });
|
|
25
|
+
const subjectKey = calls[1]?.args[0];
|
|
26
|
+
expect(subjectKey).toBe(`signup:by-email:${normalizeEmail("User@Example.com")}`);
|
|
27
|
+
expect(subjectKey).toBe("signup:by-email:user@example.com");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,132 +1,69 @@
|
|
|
1
1
|
// Redis-backed pre-activation token store for magic-link signup.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Subject is the (normalized) email — the user doesn't exist yet, so
|
|
4
|
+
// there's no userId claim for an HMAC-signed token to bind to. The store
|
|
5
|
+
// mechanics (bidirectional token↔subject mapping, sha256-hashed keys,
|
|
6
|
+
// single-use burn) live in shared/single-use-token-store.ts — this file
|
|
7
|
+
// only wires the signup key prefixes onto it and normalizes the email
|
|
8
|
+
// used as the subject id.
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
// signup tokens need a server-side lookup: the user doesn't exist yet,
|
|
10
|
-
// so there's no userId claim for the HMAC to bind to. We map token ↔
|
|
11
|
-
// email bidirectionally in Redis and delete the pair on confirm.
|
|
12
|
-
// Bidirectional because:
|
|
13
|
-
// - by-token: confirm-handler needs token → email
|
|
14
|
-
// - by-email: signup-request needs to know whether a token is still
|
|
15
|
-
// live for this email, so a resend can invalidate it instead of
|
|
16
|
-
// leaving two valid tokens for the same signup around
|
|
17
|
-
//
|
|
18
|
-
// Every key is derived from sha256(token), never the raw token —
|
|
19
|
-
// Redis key names, MONITOR output, replica traffic, and memory/backup
|
|
20
|
-
// dumps never carry the bearer secret in the clear (#2174). The
|
|
21
|
-
// by-email entry stores the *hash* of the live token, not the token
|
|
22
|
-
// itself, so it can only be used to invalidate (delete the matching
|
|
23
|
-
// forward entry) — never to recover or resend the original token. A
|
|
24
|
-
// resend therefore always mints a fresh token and invalidates the
|
|
25
|
-
// previous one, rather than reusing the same link.
|
|
26
|
-
//
|
|
27
|
-
// No collision with reset/verify tokens: all signup keys carry the
|
|
10
|
+
// No collision with reset/verify/invite tokens: all signup keys carry the
|
|
28
11
|
// `signup:`-prefix.
|
|
29
12
|
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
13
|
import type Redis from "ioredis";
|
|
14
|
+
import { createSingleUseTokenStore } from "../shared";
|
|
32
15
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Email-Normalisierung — single source für jede Lookup-Schicht (Store
|
|
38
|
-
* intern UND Caller die im Return-Body / Mail-Send eine konsistente
|
|
39
|
-
* Form brauchen). Vorher zwei Stellen mit `.toLowerCase()` — eine
|
|
40
|
-
* Quelle = kein Drift. */
|
|
16
|
+
/** Email normalization — single source for every lookup layer (used
|
|
17
|
+
* internally by the store AND by callers that need a consistent form
|
|
18
|
+
* in the return body / mail send). Previously two places called
|
|
19
|
+
* `.toLowerCase()` — one source means no drift. */
|
|
41
20
|
export function normalizeEmail(email: string): string {
|
|
42
21
|
return email.toLowerCase();
|
|
43
22
|
}
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return createHash("sha256").update(token).digest("hex");
|
|
51
|
-
}
|
|
24
|
+
const store = createSingleUseTokenStore({
|
|
25
|
+
tokenPrefix: "signup:by-token:",
|
|
26
|
+
subjectPrefix: "signup:by-email:",
|
|
27
|
+
burnPrefix: "signup:burn:",
|
|
28
|
+
});
|
|
52
29
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
// Builds the forward key from an already-hashed value (e.g. read back from
|
|
57
|
-
// the by-email entry) — does NOT hash again. Keeping this separate from
|
|
58
|
-
// tokenKey() (which hashes a raw token) makes a double-hash mistake visible
|
|
59
|
-
// at the call site instead of silently no-op'ing a delete.
|
|
60
|
-
function forwardKeyForHash(tokenHash: string): string {
|
|
61
|
-
return `${TOKEN_KEY_PREFIX}${tokenHash}`;
|
|
62
|
-
}
|
|
63
|
-
// @wrapper-known semantic-alias
|
|
64
|
-
function emailKey(email: string): string {
|
|
65
|
-
return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
|
|
66
|
-
}
|
|
67
|
-
function burnKey(token: string): string {
|
|
68
|
-
return `${BURN_KEY_PREFIX}${hashToken(token)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
72
|
-
* Idempotent — re-write derselben Token-Email-Kombi ist OK. The
|
|
73
|
-
* by-email value is the token's hash, not the token — see file header. */
|
|
30
|
+
/** Stores the pair bidirectionally and sets TTL on both keys.
|
|
31
|
+
* Idempotent — re-writing the same token-email pair is fine. */
|
|
74
32
|
export async function storeSignupToken(
|
|
75
33
|
redis: Redis,
|
|
76
34
|
args: { email: string; token: string; ttlSeconds: number },
|
|
77
35
|
): Promise<void> {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
36
|
+
return store.store(redis, {
|
|
37
|
+
subjectId: normalizeEmail(args.email),
|
|
38
|
+
token: args.token,
|
|
39
|
+
ttlSeconds: args.ttlSeconds,
|
|
40
|
+
});
|
|
82
41
|
}
|
|
83
42
|
|
|
84
|
-
/** Lookup:
|
|
85
|
-
* (
|
|
86
|
-
export
|
|
87
|
-
return redis.get(tokenKey(token));
|
|
88
|
-
}
|
|
43
|
+
/** Lookup: email for a token. Null if the token no longer exists
|
|
44
|
+
* (expired, already consumed, or invalid). */
|
|
45
|
+
export const getEmailForSignupToken = store.getSubjectForToken;
|
|
89
46
|
|
|
90
|
-
/** Deletes a still-live signup token for this email, if one exists
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* Returns whether a live token existed. Used by signup-request on every
|
|
94
|
-
* request; a fresh token + by-email entry follows right after, so this
|
|
95
|
-
* is "at most one live token per email." Deleting the by-email entry
|
|
96
|
-
* here too (not just the forward key) avoids leaving a dangling hash
|
|
97
|
-
* pointing at nothing if the request crashes before storeSignupToken. */
|
|
47
|
+
/** Deletes a still-live signup token for this email, if one exists. Used
|
|
48
|
+
* by signup-request on every request; a fresh token + by-email entry
|
|
49
|
+
* follows right after, so this is "at most one live token per email." */
|
|
98
50
|
export async function invalidateExistingSignupToken(redis: Redis, email: string): Promise<boolean> {
|
|
99
|
-
|
|
100
|
-
if (existingHash === null) return false;
|
|
101
|
-
await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(emailKey(email))]);
|
|
102
|
-
return true;
|
|
51
|
+
return store.invalidateExistingBySubject(redis, normalizeEmail(email));
|
|
103
52
|
}
|
|
104
53
|
|
|
105
|
-
/** Single-
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
* genug damit Replays in normalen Race-Windows abgefangen werden). */
|
|
109
|
-
export async function burnSignupToken(
|
|
110
|
-
redis: Redis,
|
|
111
|
-
token: string,
|
|
112
|
-
): Promise<"burned" | "already-used"> {
|
|
113
|
-
// SET NX EX — atomic check-and-set. Returnt "OK" wenn Key neu, null
|
|
114
|
-
// wenn schon da.
|
|
115
|
-
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
116
|
-
return result === "OK" ? "burned" : "already-used";
|
|
117
|
-
}
|
|
54
|
+
/** Single-use burn: if two tabs click the confirm link at the same time,
|
|
55
|
+
* the first one wins, the second gets "already-used". */
|
|
56
|
+
export const burnSignupToken = store.burn;
|
|
118
57
|
|
|
119
|
-
/** Cleanup
|
|
120
|
-
*
|
|
58
|
+
/** Cleanup after a successful confirm — deletes both lookup keys.
|
|
59
|
+
* The burn key stays (prevents replay within the burn TTL). */
|
|
121
60
|
export async function deleteSignupToken(
|
|
122
61
|
redis: Redis,
|
|
123
62
|
args: { email: string; token: string },
|
|
124
63
|
): Promise<void> {
|
|
125
|
-
|
|
64
|
+
return store.deleteBoth(redis, { subjectId: normalizeEmail(args.email), token: args.token });
|
|
126
65
|
}
|
|
127
66
|
|
|
128
|
-
/** Burn
|
|
129
|
-
*
|
|
130
|
-
export
|
|
131
|
-
await redis.del(burnKey(token));
|
|
132
|
-
}
|
|
67
|
+
/** Burn release for failed-confirm paths (DB error etc.) so a legitimate
|
|
68
|
+
* retry isn't blocked by a stale burn marker. */
|
|
69
|
+
export const unburnSignupToken = store.unburn;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.286.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "mfa-verify-attempts now delegates to shared/lockout-counter",
|
|
6
|
+
"detail": "No behavior change — same exported function/type names and Redis key prefixes as before the extraction."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.259.0",
|
|
4
10
|
"type": "improvement",
|