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;
}
}
|