Skip to content

Commit d33f033

Browse files
fix: verify signed online license entitlements
1 parent fb922cd commit d33f033

8 files changed

Lines changed: 202 additions & 4 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "License" ADD COLUMN "licenseAssertion" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,9 @@ model License {
375375
376376
lastSyncAt DateTime?
377377
lastSyncErrorCode String?
378+
/// Compact, signed online-license assertion returned by Lighthouse.
379+
/// Authorization must be derived from this assertion when it is present.
380+
licenseAssertion String?
378381
createdAt DateTime @default(now())
379382
updatedAt DateTime @updatedAt
380383
}

packages/shared/src/entitlements.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
55
env: {
66
SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key',
77
SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined,
8+
SOURCEBOT_INSTALL_ID: 'test-install',
89
} as Record<string, string | undefined>,
910
verifySignature: vi.fn(() => true),
1011
}));
@@ -78,11 +79,29 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
7879
yearlyPeakSeats: null,
7980
lastSyncAt: new Date(),
8081
lastSyncErrorCode: null,
82+
licenseAssertion: null,
8183
createdAt: new Date(),
8284
updatedAt: new Date(),
8385
...overrides,
8486
});
8587

88+
const onlineAssertion = (overrides: Record<string, unknown> = {}): string => {
89+
const payload = {
90+
version: 1,
91+
audience: 'sourcebot-online-license',
92+
licenseId: 'subscription-1',
93+
installId: 'test-install',
94+
status: 'active',
95+
entitlements: ['sso'],
96+
seats: 10,
97+
issuedAt: new Date(Date.now() - 60 * 1000).toISOString(),
98+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
99+
...overrides,
100+
};
101+
102+
return `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fake-signature`;
103+
};
104+
86105
beforeEach(() => {
87106
mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined;
88107
mocks.verifySignature.mockReturnValue(true);
@@ -178,6 +197,63 @@ describe('getEntitlements', () => {
178197
expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]);
179198
});
180199

200+
describe('signed online assertions', () => {
201+
test('uses entitlements from a valid assertion instead of mutable columns', () => {
202+
const license = makeLicense({
203+
status: 'active',
204+
entitlements: ['audit'],
205+
licenseAssertion: onlineAssertion({ entitlements: ['sso'] }),
206+
});
207+
208+
expect(getEntitlements(license)).toEqual(['sso']);
209+
});
210+
211+
test('does not fall back to mutable columns when the signature is invalid', () => {
212+
mocks.verifySignature.mockReturnValue(false);
213+
const license = makeLicense({
214+
status: 'active',
215+
entitlements: ['audit'],
216+
licenseAssertion: onlineAssertion(),
217+
});
218+
219+
expect(getEntitlements(license)).toEqual([]);
220+
});
221+
222+
test('rejects an assertion issued for another installation', () => {
223+
const license = makeLicense({
224+
status: 'active',
225+
entitlements: ['audit'],
226+
licenseAssertion: onlineAssertion({ installId: 'different-install' }),
227+
});
228+
229+
expect(getEntitlements(license)).toEqual([]);
230+
});
231+
232+
test('rejects an expired assertion even when lastSyncAt was forged', () => {
233+
const license = makeLicense({
234+
status: 'active',
235+
entitlements: ['audit'],
236+
lastSyncAt: new Date(),
237+
licenseAssertion: onlineAssertion({
238+
issuedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
239+
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
240+
}),
241+
});
242+
243+
expect(getEntitlements(license)).toEqual([]);
244+
});
245+
246+
test('rejects active mutable columns when the signed status is canceled', () => {
247+
const license = makeLicense({
248+
status: 'active',
249+
entitlements: ['audit'],
250+
licenseAssertion: onlineAssertion({ status: 'canceled' }),
251+
});
252+
253+
expect(getEntitlements(license)).toEqual([]);
254+
});
255+
});
256+
181257
test('returns all entitlements when offline key is valid', () => {
182258
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 });
183259
const result = getEntitlements(null);

packages/shared/src/entitlements.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
2727
'past_due',
2828
];
2929

30+
const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license';
31+
const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000;
32+
33+
// Compatibility switch for the first release that understands signed online
34+
// licenses. Set this to false in the enforcement release, after Lighthouse has
35+
// been returning assertions for at least one full online-license TTL.
36+
const ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES = true;
37+
3038
// @WARNING: when adding a new entitlement to this list, make sure
3139
// lighthouse/lambda/entitlements.ts is also updated && deployed
3240
// prior to rolling a new Sourcebot version.
@@ -47,6 +55,74 @@ const ALL_ENTITLEMENTS = [
4755
] as const;
4856
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];
4957

58+
const onlineLicenseAssertionPayloadSchema = z.object({
59+
version: z.literal(1),
60+
audience: z.literal(ONLINE_LICENSE_ASSERTION_AUDIENCE),
61+
licenseId: z.string().min(1),
62+
installId: z.string().min(1),
63+
status: z.enum([
64+
'active',
65+
'trialing',
66+
'past_due',
67+
'unpaid',
68+
'canceled',
69+
'incomplete',
70+
'incomplete_expired',
71+
'paused',
72+
]),
73+
entitlements: z.array(z.enum(ALL_ENTITLEMENTS)),
74+
seats: z.number().int().nonnegative(),
75+
issuedAt: z.string().datetime(),
76+
expiresAt: z.string().datetime(),
77+
}).strict();
78+
79+
export type OnlineLicenseAssertionPayload = z.infer<typeof onlineLicenseAssertionPayloadSchema>;
80+
81+
/**
82+
* Verifies and decodes an online-license assertion. The signature covers the
83+
* encoded payload itself, avoiding cross-language JSON canonicalization.
84+
*/
85+
export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAssertionPayload | null => {
86+
try {
87+
const parts = assertion.split('.');
88+
if (parts.length !== 2) {
89+
return null;
90+
}
91+
92+
const [encodedPayload, signature] = parts;
93+
if (!encodedPayload || !signature) {
94+
return null;
95+
}
96+
97+
if (!verifySignature(encodedPayload, signature, env.SOURCEBOT_PUBLIC_KEY_PATH)) {
98+
logger.error('Online license assertion signature verification failed');
99+
return null;
100+
}
101+
102+
const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8');
103+
const payload = onlineLicenseAssertionPayloadSchema.parse(JSON.parse(decodedPayload));
104+
const issuedAt = new Date(payload.issuedAt).getTime();
105+
const expiresAt = new Date(payload.expiresAt).getTime();
106+
const now = Date.now();
107+
108+
if (
109+
payload.installId !== env.SOURCEBOT_INSTALL_ID ||
110+
issuedAt > now + ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS ||
111+
expiresAt <= now ||
112+
expiresAt <= issuedAt ||
113+
(expiresAt - issuedAt) > STALE_ONLINE_LICENSE_THRESHOLD_MS
114+
) {
115+
logger.error('Online license assertion claims are invalid');
116+
return null;
117+
}
118+
119+
return payload;
120+
} catch (error) {
121+
logger.error(`Failed to verify online license assertion: ${error}`);
122+
return null;
123+
}
124+
};
125+
50126
const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => {
51127
try {
52128
const decodedPayload = base64Decode(payload);
@@ -114,7 +190,9 @@ export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
114190
// so the warning has a chance to fire before entitlements are stripped.
115191
export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000;
116192

117-
const getValidOnlineLicense = (_license: License | null): License | null => {
193+
type ValidOnlineLicense = Pick<OnlineLicenseAssertionPayload, 'entitlements' | 'status'>;
194+
195+
const getValidLegacyOnlineLicense = (_license: License | null): ValidOnlineLicense | null => {
118196
if (
119197
_license &&
120198
_license.status &&
@@ -123,7 +201,32 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
123201
(Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS &&
124202
_license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE'
125203
) {
126-
return _license;
204+
return {
205+
entitlements: _license.entitlements as Entitlement[],
206+
status: _license.status as LicenseStatus,
207+
};
208+
}
209+
210+
return null;
211+
}
212+
213+
const getValidOnlineLicense = (_license: License | null): ValidOnlineLicense | null => {
214+
// A present but invalid assertion must never fall back to unsigned columns.
215+
if (_license?.licenseAssertion !== null && _license?.licenseAssertion !== undefined) {
216+
if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') {
217+
return null;
218+
}
219+
220+
const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion);
221+
if (assertion && ACTIVE_ONLINE_LICENSE_STATUSES.includes(assertion.status)) {
222+
return assertion;
223+
}
224+
225+
return null;
226+
}
227+
228+
if (ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES) {
229+
return getValidLegacyOnlineLicense(_license);
127230
}
128231

129232
return null;
@@ -208,4 +311,4 @@ export const getSeatCap = (): number | undefined => {
208311
}
209312

210313
return undefined;
211-
}
314+
}

packages/shared/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ export {
1212
getOfflineLicenseMetadata,
1313
STALE_ONLINE_LICENSE_THRESHOLD_MS,
1414
STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS,
15+
verifyOnlineLicenseAssertion,
1516
} from "./entitlements.js";
1617
export type {
1718
Entitlement,
1819
OfflineLicenseMetadata,
20+
OnlineLicenseAssertionPayload,
1921
} from "./entitlements.js";
2022
export type {
2123
RepoMetadata,

packages/web/src/app/(app)/components/banners/bannerResolver.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
6060
yearlyPeakSeats: null,
6161
lastSyncAt: NOW,
6262
lastSyncErrorCode: null,
63+
licenseAssertion: null,
6364
createdAt: NOW,
6465
updatedAt: NOW,
6566
...overrides,

packages/web/src/features/billing/servicePing.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
decryptActivationCode,
88
env,
99
SOURCEBOT_VERSION,
10-
isValidOfflineLicenseActive
10+
isValidOfflineLicenseActive,
11+
verifyOnlineLicenseAssertion,
1112
} from "@sourcebot/shared";
1213
import { client } from "./client";
1314
import { ServicePingRequest } from "./types";
@@ -127,6 +128,13 @@ export const syncWithLighthouse = async (orgId: number) => {
127128

128129
// If we have a license and Lighthouse returned license data, sync it
129130
if (license && response.license) {
131+
if (response.licenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) {
132+
// Never persist an assertion we cannot authenticate. In particular,
133+
// do not silently write only the legacy fields and create a
134+
// signature-downgrade path.
135+
throw new Error('Lighthouse returned an invalid online license assertion');
136+
}
137+
130138
const {
131139
entitlements,
132140
seats,
@@ -174,6 +182,7 @@ export const syncWithLighthouse = async (orgId: number) => {
174182
yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null,
175183
lastSyncAt: new Date(),
176184
lastSyncErrorCode: null,
185+
...(response.licenseAssertion && { licenseAssertion: response.licenseAssertion }),
177186
},
178187
});
179188

packages/web/src/features/billing/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export const servicePingResponseSchema = z.object({
102102
hasPaymentMethod: z.boolean(),
103103
yearlyTermStatus: yearlyTermStatusSchema.optional(),
104104
}).optional(),
105+
// Optional while older Lighthouse deployments are being upgraded.
106+
licenseAssertion: z.string().optional(),
105107
});
106108
export type ServicePingResponse = z.infer<typeof servicePingResponseSchema>;
107109

0 commit comments

Comments
 (0)