diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example index 473f15f729..8d0f6619ec 100644 --- a/packages/twenty-website/.env.example +++ b/packages/twenty-website/.env.example @@ -36,6 +36,10 @@ ENTERPRISE_JWT_PUBLIC_KEY= # Optional: short-lived validity token length in days (default 30) # ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS= +# Shared secret guarding the internal enterprise key reissue support endpoint, +# used to regenerate an enterprise key +ENTERPRISE_ADMIN_API_SECRET= + # Twenty workspace the partners marketplace reads partner data from # (server-side only) via the /s/partners REST endpoint. TWENTY_PARTNERS_API_URL= diff --git a/packages/twenty-website/src/app/api/enterprise/activate/route.ts b/packages/twenty-website/src/app/api/enterprise/activate/route.ts index 1ad37aac67..353017b54f 100644 --- a/packages/twenty-website/src/app/api/enterprise/activate/route.ts +++ b/packages/twenty-website/src/app/api/enterprise/activate/route.ts @@ -1,4 +1,5 @@ import { signEnterpriseKey } from '@/lib/enterprise/enterprise-jwt'; +import { getLicenseeFromStripeCustomer } from '@/lib/enterprise/stripe-customer-helpers'; import { getStripeClient } from '@/lib/enterprise/stripe-client'; import { NextResponse } from 'next/server'; @@ -59,11 +60,7 @@ export async function GET(request: Request) { ); } - const customer = session.customer; - const licensee = - customer && typeof customer !== 'string' && !customer.deleted - ? (customer.name ?? customer.email ?? 'Unknown') - : 'Unknown'; + const licensee = getLicenseeFromStripeCustomer(session.customer); const enterpriseKey = signEnterpriseKey(subscription.id, licensee); diff --git a/packages/twenty-website/src/app/api/enterprise/reissue/route.ts b/packages/twenty-website/src/app/api/enterprise/reissue/route.ts new file mode 100644 index 0000000000..7e5c339453 --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/reissue/route.ts @@ -0,0 +1,83 @@ +import * as crypto from 'crypto'; + +import { signEnterpriseKey } from '@/lib/enterprise/enterprise-jwt'; +import { getStripeClient } from '@/lib/enterprise/stripe-client'; +import { getLicenseeFromStripeCustomer } from '@/lib/enterprise/stripe-customer-helpers'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const isSecretValid = (providedSecret: string): boolean => { + const expectedSecret = process.env.ENTERPRISE_ADMIN_API_SECRET; + + if (!expectedSecret || !providedSecret) { + return false; + } + + const comparisonKey = crypto.randomBytes(32); + const expectedDigest = crypto + .createHmac('sha256', comparisonKey) + .update(expectedSecret) + .digest(); + const providedDigest = crypto + .createHmac('sha256', comparisonKey) + .update(providedSecret) + .digest(); + + return crypto.timingSafeEqual(expectedDigest, providedDigest); +}; + +export async function POST(request: Request) { + try { + let body: { subscriptionId?: unknown; secret?: unknown } = {}; + + try { + body = await request.json(); + } catch { + body = {}; + } + + const secret = typeof body.secret === 'string' ? body.secret : ''; + + if (!isSecretValid(secret)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const subscriptionId = + typeof body.subscriptionId === 'string' ? body.subscriptionId : ''; + + if (!subscriptionId) { + return NextResponse.json( + { error: 'Missing subscriptionId' }, + { status: 400 }, + ); + } + + const stripe = getStripeClient(); + + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['customer'], + }); + + const licensee = getLicenseeFromStripeCustomer(subscription.customer); + const enterpriseKey = signEnterpriseKey(subscription.id, licensee); + + const response = NextResponse.json({ + enterpriseKey, + licensee, + subscriptionId: subscription.id, + subscriptionStatus: subscription.status, + }); + + response.headers.set('Cache-Control', 'no-store'); + + return response; + } catch (error: unknown) { + console.error('Enterprise key reissue failed', error); + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/packages/twenty-website/src/lib/enterprise/stripe-customer-helpers.ts b/packages/twenty-website/src/lib/enterprise/stripe-customer-helpers.ts new file mode 100644 index 0000000000..40592e9455 --- /dev/null +++ b/packages/twenty-website/src/lib/enterprise/stripe-customer-helpers.ts @@ -0,0 +1,17 @@ +import type Stripe from 'stripe'; + +type StripeCustomerField = + | string + | Stripe.Customer + | Stripe.DeletedCustomer + | null; + +export const getLicenseeFromStripeCustomer = ( + customer: StripeCustomerField, +): string => { + if (customer && typeof customer !== 'string' && !customer.deleted) { + return customer.name ?? customer.email ?? 'Unknown'; + } + + return 'Unknown'; +};