[Website] Add internal enterprise key reissue endpoint. (#21807)
Port of #21660 into the redone's enterprise backend. - Extract getLicenseeFromStripeCustomer into the platform/enterprise barrel, replacing the inline licensee-from-Stripe-customer block in the activate route; cover its branches with a unit test. - Add POST /api/enterprise/reissue: a support endpoint that re-signs an enterprise key from a subscriptionId, guarded by a timing-safe compare against ENTERPRISE_ADMIN_API_SECRET. Adapted to the redone — barrel imports, node:crypto named imports, and the sibling routes' 503 configured-check guard — while keeping the original's generic non-leaking 500. - Document ENTERPRISE_ADMIN_API_SECRET in .env.example.
This commit is contained in:
@@ -25,3 +25,7 @@ ENTERPRISE_JWT_PUBLIC_KEY=
|
||||
|
||||
# Optional: enterprise validity token lifetime in days (default 30).
|
||||
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=
|
||||
|
||||
# Shared secret guarding the internal enterprise key reissue endpoint
|
||||
# (POST /api/enterprise/reissue), used to regenerate an enterprise key.
|
||||
ENTERPRISE_ADMIN_API_SECRET=
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getStripeClient, signEnterpriseKey } from '@/platform/enterprise';
|
||||
import {
|
||||
getLicenseeFromStripeCustomer,
|
||||
getStripeClient,
|
||||
signEnterpriseKey,
|
||||
} from '@/platform/enterprise';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -67,11 +71,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);
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
getLicenseeFromStripeCustomer,
|
||||
getStripeClient,
|
||||
signEnterpriseKey,
|
||||
} from '@/platform/enterprise';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isSecretValid(providedSecret: string): boolean {
|
||||
const expectedSecret = process.env.ENTERPRISE_ADMIN_API_SECRET;
|
||||
|
||||
if (!expectedSecret || !providedSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const comparisonKey = randomBytes(32);
|
||||
const expectedDigest = createHmac('sha256', comparisonKey)
|
||||
.update(expectedSecret)
|
||||
.digest();
|
||||
const providedDigest = createHmac('sha256', comparisonKey)
|
||||
.update(providedSecret)
|
||||
.digest();
|
||||
|
||||
return timingSafeEqual(expectedDigest, providedDigest);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (
|
||||
!process.env.STRIPE_SECRET_KEY ||
|
||||
!process.env.ENTERPRISE_JWT_PRIVATE_KEY ||
|
||||
!process.env.ENTERPRISE_ADMIN_API_SECRET
|
||||
) {
|
||||
console.error(
|
||||
'[enterprise-reissue] 503 — STRIPE_SECRET_KEY, ENTERPRISE_JWT_PRIVATE_KEY and/or ENTERPRISE_ADMIN_API_SECRET are not configured',
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: 'Enterprise key reissue is not configured.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { getLicenseeFromStripeCustomer } from './get-licensee-from-stripe-customer';
|
||||
|
||||
type CustomerArg = Parameters<typeof getLicenseeFromStripeCustomer>[0];
|
||||
|
||||
const customer = (fields: Record<string, unknown>): CustomerArg =>
|
||||
fields as unknown as CustomerArg;
|
||||
|
||||
describe('getLicenseeFromStripeCustomer', () => {
|
||||
it('should return the customer name when present', () => {
|
||||
expect(
|
||||
getLicenseeFromStripeCustomer(
|
||||
customer({ deleted: false, email: 'ops@acme.com', name: 'Acme Inc' }),
|
||||
),
|
||||
).toBe('Acme Inc');
|
||||
});
|
||||
|
||||
it('should fall back to the email when the name is missing', () => {
|
||||
expect(
|
||||
getLicenseeFromStripeCustomer(
|
||||
customer({ deleted: false, email: 'ops@acme.com', name: null }),
|
||||
),
|
||||
).toBe('ops@acme.com');
|
||||
});
|
||||
|
||||
it('should return Unknown when both name and email are missing', () => {
|
||||
expect(
|
||||
getLicenseeFromStripeCustomer(
|
||||
customer({ deleted: false, email: null, name: null }),
|
||||
),
|
||||
).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should return Unknown for a deleted customer', () => {
|
||||
expect(
|
||||
getLicenseeFromStripeCustomer(
|
||||
customer({ deleted: true, name: 'Acme Inc' }),
|
||||
),
|
||||
).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should return Unknown for an unexpanded customer id string', () => {
|
||||
expect(getLicenseeFromStripeCustomer('cus_123')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should return Unknown when there is no customer', () => {
|
||||
expect(getLicenseeFromStripeCustomer(null)).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
type StripeCustomerField =
|
||||
| string
|
||||
| Stripe.Customer
|
||||
| Stripe.DeletedCustomer
|
||||
| null;
|
||||
|
||||
export function getLicenseeFromStripeCustomer(
|
||||
customer: StripeCustomerField,
|
||||
): string {
|
||||
if (customer && typeof customer !== 'string' && !customer.deleted) {
|
||||
return customer.name ?? customer.email ?? 'Unknown';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { getEnterprisePriceId } from './enterprise-price-id';
|
||||
export { getLicenseeFromStripeCustomer } from './get-licensee-from-stripe-customer';
|
||||
export { getStripeClient } from './stripe-client';
|
||||
export { getSubscriptionCurrentPeriodEnd } from './subscription-current-period-end';
|
||||
export { signEnterpriseKey } from './sign-enterprise-key';
|
||||
|
||||
Reference in New Issue
Block a user