| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|
| |
| |
| |
| |
| |
| 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, |
| ); |
| } |
|
|
| |
| |
| |
| |
| export async function signUserId(userId: string): Promise<string> { |
| return signPayload(userId); |
| } |
|
|
| |
| |
| |
| |
| export async function verifyUserId( |
| userId: string, |
| signature: string, |
| ): Promise<boolean> { |
| try { |
| const expected = await signUserId(userId); |
| return timingSafeEqualHex(expected, signature); |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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}`; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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}`; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|