File size: 7,292 Bytes
97ee7cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
/**
 * HMAC signing/verification for checkout metadata identity.
 *
 * Prevents client-controlled userId from being blindly trusted by
 * the webhook. The createCheckout action signs the userId server-side;
 * the webhook verifies the signature before trusting metadata.wm_user_id.
 *
 * Uses DODO_IDENTITY_SIGNING_SECRET as the HMAC key — a dedicated secret
 * that is SEPARATE from DODO_PAYMENTS_WEBHOOK_SECRET. This ensures rotating
 * the webhook secret does not break identity verification, and vice versa.
 */

export const ANON_ID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ANON_CLAIM_TOKEN_VERSION = "v2";
const DEFAULT_ANON_CLAIM_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const MIN_ANON_CLAIM_TOKEN_TTL_MS = 60 * 60 * 1000;
const MAX_ANON_CLAIM_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;

// Business Pro seat-invite tokens (#4634/#4635). Fixed 14-day TTL — the locked
// pending-invite expiry (a pending grant counts against the owner's seat cap
// until it lapses, then frees the slot). Kept as a constant (not env-tunable)
// because it must stay in lockstep with the `businessProGrants.expiresAt` the
// issuing mutation stamps (U3).
const BUSINESS_INVITE_TOKEN_VERSION = "v1";
const BUSINESS_INVITE_TOKEN_TTL_MS = 14 * 24 * 60 * 60 * 1000;

function getSigningKey(): string {
  const key = process.env.DODO_IDENTITY_SIGNING_SECRET;
  if (!key) {
    throw new Error(
      "[identity-signing] DODO_IDENTITY_SIGNING_SECRET not set. " +
      "Set it in the Convex dashboard environment variables. " +
      "This is SEPARATE from DODO_PAYMENTS_WEBHOOK_SECRET — do not reuse."
    );
  }
  return key;
}

async function signPayload(payload: string): Promise<string> {
  const key = getSigningKey();
  const encoder = new TextEncoder();

  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    encoder.encode(key),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  const signature = await crypto.subtle.sign(
    "HMAC",
    cryptoKey,
    encoder.encode(payload),
  );

  return Array.from(new Uint8Array(signature))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

function timingSafeEqualHex(expected: string, actual: string): boolean {
  if (expected.length !== actual.length) return false;
  let result = 0;
  for (let i = 0; i < expected.length; i++) {
    result |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
  }
  return result === 0;
}

function getAnonClaimTokenTtlMs(): number {
  const raw = process.env.DODO_ANON_CLAIM_TOKEN_TTL_MS;
  if (!raw) return DEFAULT_ANON_CLAIM_TOKEN_TTL_MS;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed)) return DEFAULT_ANON_CLAIM_TOKEN_TTL_MS;
  return Math.min(
    Math.max(Math.trunc(parsed), MIN_ANON_CLAIM_TOKEN_TTL_MS),
    MAX_ANON_CLAIM_TOKEN_TTL_MS,
  );
}

/**
 * Creates an HMAC-SHA256 signature of the userId.
 * Returns a hex-encoded string suitable for metadata values.
 */
export async function signUserId(userId: string): Promise<string> {
  return signPayload(userId);
}

/**
 * Verifies that a userId + signature pair is valid.
 * Returns true if the signature matches, false otherwise.
 */
export async function verifyUserId(
  userId: string,
  signature: string,
): Promise<boolean> {
  try {
    const expected = await signUserId(userId);
    return timingSafeEqualHex(expected, signature);
  } catch {
    return false;
  }
}

/**
 * Creates a server-verifiable proof token for migrating anonymous checkout
 * records into a real Clerk account. The token is domain-separated from
 * wm_user_id_sig so it cannot be replayed as checkout identity metadata, and
 * expires after the checkout-to-sign-in linking window.
 */
export async function signAnonClaimToken(anonId: string): Promise<string> {
  if (!ANON_ID_V4_REGEX.test(anonId)) {
    throw new Error("[identity-signing] anonymous claim token requires a UUID-v4 anonId");
  }
  const expiresAt = Date.now() + getAnonClaimTokenTtlMs();
  const signature = await signPayload(`anon-claim:${ANON_CLAIM_TOKEN_VERSION}:${anonId}:${expiresAt}`);
  return `${ANON_CLAIM_TOKEN_VERSION}.${expiresAt}.${signature}`;
}

/**
 * Verifies a browser-held anonymous claim token without trusting the bare UUID.
 * Expired, malformed, legacy static, or wrong-anon tokens fail closed.
 */
export async function verifyAnonClaimToken(
  anonId: string,
  claimToken: string | undefined,
): Promise<boolean> {
  if (!claimToken || !ANON_ID_V4_REGEX.test(anonId)) return false;
  const [version, expiresAtRaw, signature, ...extra] = claimToken.split(".");
  if (version !== ANON_CLAIM_TOKEN_VERSION || extra.length > 0) return false;
  if (typeof expiresAtRaw !== "string" || typeof signature !== "string") return false;
  if (!/^\d+$/.test(expiresAtRaw) || !signature) return false;
  const expiresAt = Number(expiresAtRaw);
  if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) return false;
  try {
    const expected = await signPayload(`anon-claim:${ANON_CLAIM_TOKEN_VERSION}:${anonId}:${expiresAt}`);
    return timingSafeEqualHex(expected, signature);
  } catch {
    return false;
  }
}

/**
 * Signs a server-verifiable invite token for a business Pro seat grant. Mirrors
 * `signAnonClaimToken`: HMAC-SHA256 over a domain-separated payload
 * (`business-invite:` prefix so it cannot be replayed as an anon-claim token or
 * `wm_user_id_sig`), embedding the token version and expiry. The token binds to
 * the `businessProGrants` document id — the signature does not verify for any
 * other grantId — and expires after the 14-day pending-invite window.
 *
 * @throws If grantId is empty or contains a `.` (the token delimiter).
 */
export async function signBusinessInviteToken(grantId: string): Promise<string> {
  if (!grantId || grantId.length === 0) {
    throw new Error("[identity-signing] business invite token requires a non-empty grantId");
  }
  if (grantId.includes(".")) {
    throw new Error('[identity-signing] business invite grantId must not contain "."');
  }
  const expiresAt = Date.now() + BUSINESS_INVITE_TOKEN_TTL_MS;
  const signature = await signPayload(
    `business-invite:${BUSINESS_INVITE_TOKEN_VERSION}:${grantId}:${expiresAt}`,
  );
  return `${BUSINESS_INVITE_TOKEN_VERSION}.${expiresAt}.${signature}`;
}

/**
 * Verifies a business Pro seat-invite token against the expected grant id.
 * Expired, malformed, wrong-version, tampered, or wrong-grant tokens fail closed.
 */
export async function verifyBusinessInviteToken(
  grantId: string,
  token: string | undefined,
): Promise<boolean> {
  if (!token || !grantId || grantId.length === 0) return false;
  const [version, expiresAtRaw, signature, ...extra] = token.split(".");
  if (version !== BUSINESS_INVITE_TOKEN_VERSION || extra.length > 0) return false;
  if (typeof expiresAtRaw !== "string" || typeof signature !== "string") return false;
  if (!/^\d+$/.test(expiresAtRaw) || !signature) return false;
  const expiresAt = Number(expiresAtRaw);
  if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) return false;
  try {
    const expected = await signPayload(
      `business-invite:${BUSINESS_INVITE_TOKEN_VERSION}:${grantId}:${expiresAt}`,
    );
    return timingSafeEqualHex(expected, signature);
  } catch {
    return false;
  }
}