@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.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 +13 -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/signed-token.ts +4 -86
- 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/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.287.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,9 @@
|
|
|
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",
|
|
97
|
+
"./shared/signed-token": "./src/shared/signed-token.ts",
|
|
98
|
+
"./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
|
|
96
99
|
"./delivery": "./src/delivery/index.ts",
|
|
97
100
|
"./delivery/web": "./src/delivery/web/index.ts",
|
|
98
101
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
@@ -129,12 +132,12 @@
|
|
|
129
132
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
133
|
},
|
|
131
134
|
"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.
|
|
135
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.287.0",
|
|
136
|
+
"@cosmicdrift/kumiko-framework": "0.287.0",
|
|
137
|
+
"@cosmicdrift/kumiko-headless": "0.287.0",
|
|
138
|
+
"@cosmicdrift/kumiko-renderer": "0.287.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer-web": "0.287.0",
|
|
140
|
+
"@cosmicdrift/kumiko-types": "0.287.0",
|
|
138
141
|
"@mollie/api-client": "^4.5.0",
|
|
139
142
|
"@node-rs/argon2": "^2.0.2",
|
|
140
143
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +166,8 @@
|
|
|
163
166
|
],
|
|
164
167
|
"devDependencies": {
|
|
165
168
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
169
|
+
"@cosmicdrift/kumiko-locale-de": "0.287.0",
|
|
170
|
+
"@cosmicdrift/kumiko-locale-es": "0.287.0",
|
|
171
|
+
"jsqr": "^1.4.0"
|
|
168
172
|
}
|
|
169
173
|
}
|
|
@@ -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;
|
|
@@ -1,86 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
// The `purpose` is mixed INTO the HMAC input so a token minted for one
|
|
7
|
-
// purpose (e.g. password-reset) can't be replayed against an endpoint that
|
|
8
|
-
// expects another (e.g. verify-email), even if the caller knows the
|
|
9
|
-
// userId and a valid expiry. Purpose is NOT carried in the token body —
|
|
10
|
-
// verify() takes the purpose as argument and recomputes.
|
|
11
|
-
//
|
|
12
|
-
// Timing-safe comparison on verify so a valid-length forgery can't leak
|
|
13
|
-
// signal through a short-circuit.
|
|
14
|
-
|
|
15
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
|
-
import { Temporal } from "temporal-polyfill";
|
|
17
|
-
|
|
18
|
-
export type VerifyResult =
|
|
19
|
-
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
20
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
21
|
-
|
|
22
|
-
function sign(input: string, secret: string): string {
|
|
23
|
-
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function payload(purpose: string, userId: string, expiresAtMs: number): string {
|
|
27
|
-
return `${purpose}:${userId}.${expiresAtMs}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function signToken(
|
|
31
|
-
userId: string,
|
|
32
|
-
purpose: string,
|
|
33
|
-
ttlMinutes: number,
|
|
34
|
-
secret: string,
|
|
35
|
-
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
|
-
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
-
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
|
-
const expiresAtMs = expiresAt.epochMilliseconds;
|
|
39
|
-
const signature = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
40
|
-
return {
|
|
41
|
-
token: `${userId}.${expiresAtMs}.${signature}`,
|
|
42
|
-
expiresAt,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function verifyToken(
|
|
47
|
-
token: string,
|
|
48
|
-
purpose: string,
|
|
49
|
-
secret: string,
|
|
50
|
-
now: Temporal.Instant = Temporal.Now.instant(),
|
|
51
|
-
): VerifyResult {
|
|
52
|
-
const parts = token.split(".");
|
|
53
|
-
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
|
54
|
-
const [userId, expiresAtRaw, providedSig] = parts;
|
|
55
|
-
if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
|
|
56
|
-
|
|
57
|
-
const expiresAtMs = Number(expiresAtRaw);
|
|
58
|
-
if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
|
|
59
|
-
return { ok: false, reason: "malformed" };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const expected = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
63
|
-
const expectedBuf = Buffer.from(expected, "base64url");
|
|
64
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
65
|
-
// Length mismatch fails BEFORE timingSafeEqual, which throws on different
|
|
66
|
-
// lengths — but that throw itself leaks via timing. Explicit length check
|
|
67
|
-
// keeps the path uniform.
|
|
68
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
69
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
70
|
-
|
|
71
|
-
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
72
|
-
return { ok: false, reason: "expired" };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
|
|
76
|
-
// have to re-parse the token themselves.
|
|
77
|
-
return { ok: true, userId, expiresAtMs };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Canonical purposes baked into the framework. Features that introduce new
|
|
81
|
-
// flows extend this set (or just pass an inline string).
|
|
82
|
-
export const TokenPurpose = {
|
|
83
|
-
passwordReset: "reset",
|
|
84
|
-
emailVerification: "verify",
|
|
85
|
-
accountUnlock: "unlock",
|
|
86
|
-
} as const;
|
|
1
|
+
// Moved to shared/signed-token.ts — the mechanism was never specific to
|
|
2
|
+
// email/password auth (user-data-rights and any row-bound grant use it too).
|
|
3
|
+
// Re-exported so this feature's barrel and external importers keep working.
|
|
4
|
+
export * from "../shared/signed-token";
|
|
@@ -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
|
+
});
|