@cosmicdrift/kumiko-bundled-features 0.204.1 → 0.205.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 +7 -7
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +26 -6
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +17 -4
- package/src/auth-email-password/handlers/invite-create.write.ts +10 -9
- package/src/auth-email-password/handlers/signup-request.write.ts +13 -11
- package/src/auth-email-password/invite-token-store.ts +62 -27
- package/src/auth-email-password/signup-token-store.ts +58 -31
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.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>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.205.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.205.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.205.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.205.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.205.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.205.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -250,22 +250,42 @@ describe("invite-create", () => {
|
|
|
250
250
|
expect(rows[0]?.["tenantId"]).toBe(TENANT_A_ID);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
-
test("Resend:
|
|
253
|
+
test("Resend: second invite for the same email reuses the row but mints a new token and invalidates the previous one (#2174)", async () => {
|
|
254
254
|
const firstToken = await inviteEmail(BOB_EMAIL, "Admin");
|
|
255
255
|
const secondToken = await inviteEmail(BOB_EMAIL, "Editor");
|
|
256
256
|
|
|
257
|
-
expect(secondToken).toBe(firstToken);
|
|
257
|
+
expect(secondToken).not.toBe(firstToken);
|
|
258
258
|
|
|
259
|
-
//
|
|
259
|
+
// Same row, role updated
|
|
260
260
|
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
|
|
261
261
|
expect(rows).toHaveLength(1);
|
|
262
262
|
expect(rows[0]?.["role"]).toBe("Editor");
|
|
263
263
|
|
|
264
264
|
// inviteEmail() only reads emailTransport.sent.at(-1) — a silent second-
|
|
265
|
-
// dispatch failure would still leave sent.length===1
|
|
266
|
-
//
|
|
267
|
-
//
|
|
265
|
+
// dispatch failure would still leave sent.length===1, making the
|
|
266
|
+
// not-equal check above pass for the wrong reason. Assert an actual
|
|
267
|
+
// second dispatch happened.
|
|
268
268
|
expect(emailTransport.sent).toHaveLength(2);
|
|
269
|
+
|
|
270
|
+
// The first mail's link must actually stop working (not just look
|
|
271
|
+
// different) — this is what catches a double-hash bug in invalidation.
|
|
272
|
+
const firstRes = await authedRaw(
|
|
273
|
+
"POST",
|
|
274
|
+
"/api/auth/invite-accept",
|
|
275
|
+
{ token: firstToken },
|
|
276
|
+
bobSession(),
|
|
277
|
+
);
|
|
278
|
+
expect(firstRes.status).toBe(422);
|
|
279
|
+
const firstBody = (await firstRes.json()) as { error?: { details?: { reason?: string } } };
|
|
280
|
+
expect(firstBody.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
|
|
281
|
+
|
|
282
|
+
const secondRes = await authedRaw(
|
|
283
|
+
"POST",
|
|
284
|
+
"/api/auth/invite-accept",
|
|
285
|
+
{ token: secondToken },
|
|
286
|
+
bobSession(),
|
|
287
|
+
);
|
|
288
|
+
expect(secondRes.status).toBe(200);
|
|
269
289
|
});
|
|
270
290
|
});
|
|
271
291
|
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// Pinst:
|
|
7
7
|
// 1. POST signup-request mit valid email → 200, Activation-Mail via
|
|
8
8
|
// delivery (channel-email in-memory transport) mit Token-URL.
|
|
9
|
-
// 2. Resend
|
|
10
|
-
//
|
|
9
|
+
// 2. Resend: second request for the same email → a NEW token, and
|
|
10
|
+
// the previous token is invalidated (#2174 — Redis never stores
|
|
11
|
+
// the raw token as a value, so it can't be recovered/reused).
|
|
11
12
|
// 3. POST signup-confirm mit captured Token + Password → 200, Cookies
|
|
12
13
|
// gesetzt (kumiko_auth + kumiko_csrf), Body mit user + tenantKey,
|
|
13
14
|
// DB hat user (emailVerified=true) + tenant + Admin-membership.
|
|
@@ -206,14 +207,26 @@ describe("POST /api/auth/signup-request", () => {
|
|
|
206
207
|
expect(sent.html).toContain(`${APP_ACTIVATION_URL}?token=`);
|
|
207
208
|
});
|
|
208
209
|
|
|
209
|
-
test("Resend:
|
|
210
|
+
test("Resend: second request for the same email mints a new token and invalidates the previous one", async () => {
|
|
210
211
|
await postSignupRequest("resend@example.com");
|
|
211
212
|
await postSignupRequest("resend@example.com");
|
|
212
213
|
|
|
213
214
|
expect(emailTransport.sent).toHaveLength(2);
|
|
214
215
|
const [first, second] = emailTransport.sent;
|
|
215
216
|
if (!first || !second) throw new Error("missing mails");
|
|
216
|
-
|
|
217
|
+
const firstToken = extractTokenFromMail(first.html);
|
|
218
|
+
const secondToken = extractTokenFromMail(second.html);
|
|
219
|
+
expect(secondToken).not.toBe(firstToken);
|
|
220
|
+
|
|
221
|
+
// The first mail's link must actually stop working (not just look
|
|
222
|
+
// different) — this is what catches a double-hash bug in invalidation.
|
|
223
|
+
const firstConfirm = await postSignupConfirm(firstToken, "irrelevant-pw-1234");
|
|
224
|
+
expect(firstConfirm.status).toBe(422);
|
|
225
|
+
const firstBody = (await firstConfirm.json()) as { error?: { details?: { reason?: string } } };
|
|
226
|
+
expect(firstBody.error?.details?.reason).toBe(AuthErrors.invalidSignupToken);
|
|
227
|
+
|
|
228
|
+
const secondConfirm = await postSignupConfirm(secondToken, "fresh-secure-pw-1234");
|
|
229
|
+
expect(secondConfirm.status).toBe(200);
|
|
217
230
|
});
|
|
218
231
|
|
|
219
232
|
test("malformed body → 200 (silent success, anti-enumeration)", async () => {
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// wie reset/verify/signup. Der Token geht NICHT an den Admin zurück (er soll
|
|
7
7
|
// die Annahme nicht impersonieren können).
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Re-invite for the same (tenantId, email): the existing row is reused
|
|
10
|
+
// regardless of its prior status (pending/cancelled/accepted), reset to
|
|
11
|
+
// status=pending, and a fresh token is minted every time — any token
|
|
12
|
+
// still live for that invitation is invalidated first, so the previous
|
|
13
|
+
// mail's link stops working (see invite-token-store.ts).
|
|
13
14
|
//
|
|
14
15
|
// Always-200 für unbekannten User: bei invitee-Email die nicht in users
|
|
15
16
|
// existiert wird trotzdem ein Invite erstellt — Branch-3-Accept-Flow
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
|
|
39
40
|
import type { AuthMailLocale } from "../email-templates";
|
|
40
41
|
import { renderInviteEmail } from "../email-templates";
|
|
41
|
-
import {
|
|
42
|
+
import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store";
|
|
42
43
|
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
43
44
|
|
|
44
45
|
const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
|
|
@@ -114,10 +115,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
114
115
|
if (existing) {
|
|
115
116
|
invitationId = existing["id"] as string; // @cast-boundary db-row
|
|
116
117
|
const existingVersion = existing["version"] as number; // @cast-boundary db-row
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
token =
|
|
118
|
+
// At most one live invite token per invitation: invalidate
|
|
119
|
+
// whatever's there before minting the new one.
|
|
120
|
+
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
121
|
+
token = generateToken();
|
|
121
122
|
|
|
122
123
|
const updateResult = await executor.update(
|
|
123
124
|
{
|
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
// existiert der User noch nicht). Ob die Email bereits ein Konto hat,
|
|
8
8
|
// entscheidet bewusst der Confirm-Schritt, nicht dieser.
|
|
9
9
|
//
|
|
10
|
-
// Resend
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// annoyance.
|
|
10
|
+
// Resend: if a token is still live for this email, we invalidate it and
|
|
11
|
+
// mint a fresh one — the user gets a second mail with a NEW activation
|
|
12
|
+
// link, and the first link stops working. Deliberate: at most one live
|
|
13
|
+
// signup token per email at any time (see signup-token-store.ts).
|
|
15
14
|
//
|
|
16
15
|
// Always-200 (enumeration-safe): das Response sieht für jede Email gleich
|
|
17
16
|
// aus, egal ob sie schon registriert ist oder nicht. Eine Email KANN bereits
|
|
@@ -32,7 +31,11 @@ import type { AuthMailLocale } from "../email-templates";
|
|
|
32
31
|
import { renderActivationEmail } from "../email-templates";
|
|
33
32
|
import { dispatchMagicLinkMail } from "../magic-link-mail";
|
|
34
33
|
import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
|
|
35
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
invalidateExistingSignupToken,
|
|
36
|
+
normalizeEmail,
|
|
37
|
+
storeSignupToken,
|
|
38
|
+
} from "../signup-token-store";
|
|
36
39
|
|
|
37
40
|
const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
|
|
38
41
|
|
|
@@ -104,10 +107,9 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
104
107
|
// lowercased haben.
|
|
105
108
|
const email = event.payload.email;
|
|
106
109
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
const existingToken = await getTokenForSignupEmail(ctx.redis, email);
|
|
110
|
+
// At most one live signup token per email: invalidate whatever's
|
|
111
|
+
// there before minting the new one (see signup-token-store.ts).
|
|
112
|
+
await invalidateExistingSignupToken(ctx.redis, email);
|
|
111
113
|
// 32 random bytes = 256 bits unguessable randomness, base64url
|
|
112
114
|
// encoded zu 43 chars. Math.random war früher ein Bug:
|
|
113
115
|
// xorshift128+ hat ~128 Bit State der nach ~5 beobachteten
|
|
@@ -115,7 +117,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
115
117
|
// signup-requests triggern und die Tokens fremder User
|
|
116
118
|
// vorhersagen. generateToken nutzt randomBytes aus node:crypto,
|
|
117
119
|
// dieselbe Quelle wie CSRF/Session-Tokens.
|
|
118
|
-
const token =
|
|
120
|
+
const token = generateToken();
|
|
119
121
|
|
|
120
122
|
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
121
123
|
const expiresAtIso = expiresAt.toString();
|
|
@@ -1,53 +1,80 @@
|
|
|
1
|
-
// Redis-backed
|
|
1
|
+
// Redis-backed token store for the tenant-invite magic-link flow.
|
|
2
2
|
//
|
|
3
|
-
// Subject
|
|
4
|
-
//
|
|
5
|
-
// random string
|
|
3
|
+
// Subject is the invitation row ID (DB-row owner: tenant-feature). We
|
|
4
|
+
// map token → invitationId in Redis, using the token as an opaque
|
|
5
|
+
// random string from generateToken (256-bit base64url, randomBytes).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// invite-create
|
|
11
|
-
//
|
|
7
|
+
// Unlike signup-token-store we don't map bidirectionally for reuse —
|
|
8
|
+
// resend-idempotency lives at the invitation-row level (an admin
|
|
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
|
+
// a second key to invalidate it before storing the new one).
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// Bidirectional is still useful for cancel: the admin knows row.id and
|
|
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.
|
|
16
17
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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.
|
|
21
26
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
27
|
+
// Bug pattern: TTL lives only in Redis. DB-row.expiresAt is UI display
|
|
28
|
+
// 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
|
+
// marks it "expired" (separate concern, tracked in U.3-cleanup).
|
|
31
|
+
//
|
|
32
|
+
// No collision with signup/reset/verify tokens: all invite keys carry
|
|
33
|
+
// the `invite:`-prefix.
|
|
24
34
|
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
25
36
|
import type Redis from "ioredis";
|
|
26
37
|
|
|
27
38
|
const TOKEN_KEY_PREFIX = "invite:by-token:";
|
|
28
39
|
const ID_KEY_PREFIX = "invite:by-id:";
|
|
29
40
|
const BURN_KEY_PREFIX = "invite:burn:";
|
|
30
41
|
|
|
42
|
+
// Same sha256-hex pattern as hashPatToken (personal-access-tokens/hash.ts)
|
|
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
|
+
}
|
|
49
|
+
|
|
31
50
|
function tokenKey(token: string): string {
|
|
32
|
-
return `${TOKEN_KEY_PREFIX}${token}`;
|
|
51
|
+
return `${TOKEN_KEY_PREFIX}${hashToken(token)}`;
|
|
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}`;
|
|
33
59
|
}
|
|
34
60
|
function idKey(invitationId: string): string {
|
|
35
61
|
return `${ID_KEY_PREFIX}${invitationId}`;
|
|
36
62
|
}
|
|
37
63
|
function burnKey(token: string): string {
|
|
38
|
-
return `${BURN_KEY_PREFIX}${token}`;
|
|
64
|
+
return `${BURN_KEY_PREFIX}${hashToken(token)}`;
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
42
68
|
* Idempotent — re-write derselben Token-Invitation-Kombi ist OK
|
|
43
|
-
* (refresh TTL für Resend).
|
|
69
|
+
* (refresh TTL für Resend). The by-id value is the token's hash, not
|
|
70
|
+
* the token — see file header. */
|
|
44
71
|
export async function storeInviteToken(
|
|
45
72
|
redis: Redis,
|
|
46
73
|
args: { invitationId: string; token: string; ttlSeconds: number },
|
|
47
74
|
): Promise<void> {
|
|
48
75
|
await Promise.all([
|
|
49
76
|
redis.set(tokenKey(args.token), args.invitationId, "EX", args.ttlSeconds),
|
|
50
|
-
redis.set(idKey(args.invitationId), args.token, "EX", args.ttlSeconds),
|
|
77
|
+
redis.set(idKey(args.invitationId), hashToken(args.token), "EX", args.ttlSeconds),
|
|
51
78
|
]);
|
|
52
79
|
}
|
|
53
80
|
|
|
@@ -57,13 +84,21 @@ export async function getInvitationIdForToken(redis: Redis, token: string): Prom
|
|
|
57
84
|
return redis.get(tokenKey(token));
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
|
|
87
|
+
/** Deletes a still-live invite token for this invitation, if one exists —
|
|
88
|
+
* both the forward entry (built from the hash stored in the by-id entry,
|
|
89
|
+
* never the raw token) and the by-id entry itself. Returns whether a live
|
|
90
|
+
* token existed. Two callers: invite-create on every resend (a fresh
|
|
91
|
+
* token + by-id entry follows right after, so this is "at most one live
|
|
92
|
+
* token per invitation"), and cancel-invitation (no replacement follows,
|
|
93
|
+
* so this is full cleanup). */
|
|
94
|
+
export async function invalidateExistingInviteToken(
|
|
63
95
|
redis: Redis,
|
|
64
96
|
invitationId: string,
|
|
65
|
-
): Promise<
|
|
66
|
-
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
const existingHash = await redis.get(idKey(invitationId));
|
|
99
|
+
if (existingHash === null) return false;
|
|
100
|
+
await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(idKey(invitationId))]);
|
|
101
|
+
return true;
|
|
67
102
|
}
|
|
68
103
|
|
|
69
104
|
/** Single-Use-Burn. Wenn zwei Tabs gleichzeitig den Accept-Link klicken,
|
|
@@ -1,31 +1,33 @@
|
|
|
1
|
-
// Redis-backed
|
|
1
|
+
// Redis-backed pre-activation token store for magic-link signup.
|
|
2
2
|
//
|
|
3
|
-
// Token
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// den Token ab.
|
|
3
|
+
// Token material: opaque random 256-bit from crypto.randomBytes (see
|
|
4
|
+
// signup-request.write.ts → generateToken() from framework/api),
|
|
5
|
+
// base64url-encoded to 43 chars. Not designed for human typing — the
|
|
6
|
+
// user clicks the mail link, nobody types the token.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// - by-token: confirm-handler
|
|
15
|
-
// - by-email: signup-request
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// Tokens, beide gültig, beide könnten zu zwei separaten Tenants
|
|
19
|
-
// führen wenn er beide klickt — unnötiges Risiko)
|
|
8
|
+
// Unlike reset/verify tokens (HMAC-signed, statelessly verifiable),
|
|
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
|
|
20
17
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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.
|
|
25
26
|
//
|
|
26
|
-
//
|
|
27
|
-
// `signup:`-
|
|
27
|
+
// No collision with reset/verify tokens: all signup keys carry the
|
|
28
|
+
// `signup:`-prefix.
|
|
28
29
|
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
29
31
|
import type Redis from "ioredis";
|
|
30
32
|
|
|
31
33
|
const TOKEN_KEY_PREFIX = "signup:by-token:";
|
|
@@ -40,26 +42,42 @@ export function normalizeEmail(email: string): string {
|
|
|
40
42
|
return email.toLowerCase();
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Same sha256-hex pattern as hashPatToken (personal-access-tokens/hash.ts)
|
|
46
|
+
// and preauthTokenKeyOf (framework/api/auth-routes.ts): the token is
|
|
47
|
+
// high-entropy, so a single fast hash is enough — no brute-force surface
|
|
48
|
+
// that would justify a slow password-hash.
|
|
49
|
+
function hashToken(token: string): string {
|
|
50
|
+
return createHash("sha256").update(token).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
function tokenKey(token: string): string {
|
|
44
|
-
return `${TOKEN_KEY_PREFIX}${token}`;
|
|
54
|
+
return `${TOKEN_KEY_PREFIX}${hashToken(token)}`;
|
|
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}`;
|
|
45
62
|
}
|
|
46
63
|
// @wrapper-known semantic-alias
|
|
47
64
|
function emailKey(email: string): string {
|
|
48
65
|
return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
|
|
49
66
|
}
|
|
50
67
|
function burnKey(token: string): string {
|
|
51
|
-
return `${BURN_KEY_PREFIX}${token}`;
|
|
68
|
+
return `${BURN_KEY_PREFIX}${hashToken(token)}`;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
55
|
-
* Idempotent — re-write derselben Token-Email-Kombi ist OK.
|
|
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. */
|
|
56
74
|
export async function storeSignupToken(
|
|
57
75
|
redis: Redis,
|
|
58
76
|
args: { email: string; token: string; ttlSeconds: number },
|
|
59
77
|
): Promise<void> {
|
|
60
78
|
await Promise.all([
|
|
61
79
|
redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
|
|
62
|
-
redis.set(emailKey(args.email), args.token, "EX", args.ttlSeconds),
|
|
80
|
+
redis.set(emailKey(args.email), hashToken(args.token), "EX", args.ttlSeconds),
|
|
63
81
|
]);
|
|
64
82
|
}
|
|
65
83
|
|
|
@@ -69,10 +87,19 @@ export async function getEmailForSignupToken(redis: Redis, token: string): Promi
|
|
|
69
87
|
return redis.get(tokenKey(token));
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
/** Deletes a still-live signup token for this email, if one exists — both
|
|
91
|
+
* the forward entry (built from the hash already stored in the by-email
|
|
92
|
+
* entry, never recovers the raw token) and the by-email entry itself.
|
|
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. */
|
|
98
|
+
export async function invalidateExistingSignupToken(redis: Redis, email: string): Promise<boolean> {
|
|
99
|
+
const existingHash = await redis.get(emailKey(email));
|
|
100
|
+
if (existingHash === null) return false;
|
|
101
|
+
await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(emailKey(email))]);
|
|
102
|
+
return true;
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
/** Single-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
|
|
@@ -16,10 +16,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
16
16
|
import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
// kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
|
|
19
|
-
import {
|
|
20
|
-
deleteInviteToken,
|
|
21
|
-
getTokenForInvitation,
|
|
22
|
-
} from "../../auth-email-password/invite-token-store";
|
|
19
|
+
import { invalidateExistingInviteToken } from "../../auth-email-password/invite-token-store";
|
|
23
20
|
import {
|
|
24
21
|
INVITATION_STATUS,
|
|
25
22
|
tenantInvitationEntity,
|
|
@@ -82,13 +79,7 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
82
79
|
// unavailable or the token already expired: not a problem, the DB
|
|
83
80
|
// row is the single source of truth for the UI.
|
|
84
81
|
if (ctx.redis) {
|
|
85
|
-
|
|
86
|
-
if (token) {
|
|
87
|
-
await deleteInviteToken(ctx.redis, {
|
|
88
|
-
invitationId: event.payload.invitationId,
|
|
89
|
-
token,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
82
|
+
await invalidateExistingInviteToken(ctx.redis, event.payload.invitationId);
|
|
92
83
|
}
|
|
93
84
|
|
|
94
85
|
return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: false } };
|