Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[license] Allow to limit some packages to a specific license plan #4651

Merged
merged 23 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/storybook/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { configureActions } from '@storybook/addon-actions';

// Remove the license warning from demonstration purposes
LicenseInfo.setLicenseKey(
'0f94d8b65161817ca5d7f7af8ac2f042T1JERVI6TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY1NDg1ODc1MzU1MCxLRVlWRVJTSU9OPTE=',
'e8d0d6124d6e21bee635aae6358c5bfcT1JERVI9TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY4MjUyNzMzNDQ1MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJlbWl1bQ==',
);

configureActions({
Expand Down
3 changes: 2 additions & 1 deletion packages/x-license-pro/src/cli/license-cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable no-console */
import * as yargs from 'yargs';
import { generateLicense, LicenseScope } from '../generateLicense/generateLicense';
import { generateLicense } from '../generateLicense/generateLicense';
import { base64Decode } from '../encoding/base64';
import { LicenseScope } from '../utils/licenseScope';

const oneDayInMs = 1000 * 60 * 60 * 24;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('License: generateLicense', () => {
expect(
generateLicense({ expiryDate: new Date(1591723879062), orderNumber: 'MUI-123' }),
).to.equal(
'90b5a76151089447618ede1917227a1bT1JERVI6TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJv',
'ffcb515c4564c351579ec2cb922939d2T1JERVI9TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJv',
);
});

Expand All @@ -18,7 +18,7 @@ describe('License: generateLicense', () => {
scope: 'pro',
}),
).to.equal(
'90b5a76151089447618ede1917227a1bT1JERVI6TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJv',
'ffcb515c4564c351579ec2cb922939d2T1JERVI9TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJv',
);
});

Expand All @@ -30,7 +30,7 @@ describe('License: generateLicense', () => {
scope: 'premium',
}),
).to.equal(
'0d79eeaf5facce7184422f22eeeb369aT1JERVI6TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJlbWl1bQ==',
'405dfca9f4f49c6f2ece584b3ab7f954T1JERVI9TVVJLTEyMyxFWFBJUlk9MTU5MTcyMzg3OTA2MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJlbWl1bQ==',
);
});
});
5 changes: 2 additions & 3 deletions packages/x-license-pro/src/generateLicense/generateLicense.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { md5 } from '../encoding/md5';
import { base64Encode } from '../encoding/base64';
import { LicenseScope } from '../utils/licenseScope';

const licenseVersion = '2';

export type LicenseScope = 'pro' | 'premium';

export interface LicenseDetails {
orderNumber: string;
expiryDate: Date;
Expand All @@ -13,7 +12,7 @@ export interface LicenseDetails {
}

function getClearLicenseString(details: LicenseDetails) {
return `ORDER:${
return `ORDER=${
details.orderNumber
},EXPIRY=${details.expiryDate.getTime()},KEYVERSION=${licenseVersion},SCOPE=${details.scope}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
showNotFoundLicenseError,
} from '../utils/licenseErrorMessageUtils';
import { LicenseStatus } from '../utils/licenseStatus';
import { LicenseScope } from '../utils/licenseScope';

export type MuiCommercialPackageName =
| 'x-data-grid-pro'
Expand All @@ -27,7 +28,11 @@ export function useLicenseVerifier(
return sharedLicenseStatuses[packageName]!.status;
}

const licenseStatus = verifyLicense(releaseInfo, licenseKey);
const acceptedScopes: LicenseScope[] = packageName.includes('premium')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic would have to become smarter if we release other plans

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So to be sure that I understand it - the goal here is to if you have premium you also get pro right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes
I first did a scale const scopes = ['community', 'pro', 'premium'] and then compare the index of the current plan with the one of the license
But it would not work with more granular scopes if we have some in the future
Right now it's a very basic check so both options work fine.

? ['premium']
: ['pro', 'premium'];

const licenseStatus = verifyLicense(releaseInfo, licenseKey, acceptedScopes);

sharedLicenseStatuses[packageName] = { key: licenseStatus, status: licenseStatus };

Expand Down
1 change: 1 addition & 0 deletions packages/x-license-pro/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './licenseErrorMessageUtils';
export * from './licenseInfo';
export * from './licenseStatus';
export * from './licenseScope';
1 change: 1 addition & 0 deletions packages/x-license-pro/src/utils/licenseScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type LicenseScope = 'pro' | 'premium';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I was thinking to rework this to an enum because we are also using the strings themselves and it would be nice to avoid typos. It will also help if we have more licenses in the future. What do you think?

Copy link
Member Author

@flaviendelangle flaviendelangle Apr 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure

Here we type the value, so we are sure that TS will complain if we put scope = "pri", just like for the enum.
And it will be obvious that a change to the type is breaking.

But if we use an enum, the typing is on the key of the enum, not its values.

So if by mistake we switch from

enum LicenseScope {
  pro = 'pro',
  premium = 'premium'
}

To

enum LicenseScope {
  pro, // defaults to 0
  premium, // defaults to 1
}

Then all our code will still work, but we will have done a breaking change for the licenses generated.


With that being said, I think both are viable as long as we follow the rule that an enum whose value can be stringified in any way must have its keys equal to its values.
In my previous company we had a ton of them on the plan generator specification and it never caused any issue.

108 changes: 85 additions & 23 deletions packages/x-license-pro/src/verifyLicense/verifyLicense.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,98 @@ const oneYear = oneDayInMS * 365;
const RELEASE_INFO = generateReleaseInfo();

describe('License: verifyLicense', () => {
const validLicense = generateLicense({
expiryDate: new Date(new Date().getTime() + oneYear),
orderNumber: 'MUI-123',
});
describe('key version: 1', () => {
const license =
'0f94d8b65161817ca5d7f7af8ac2f042T1JERVI6TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY1NDg1ODc1MzU1MCxLRVlWRVJTSU9OPTE=';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have the v1 generation anymore but it's worth testing it
This key is the doc key


it('should log an error when ReleaseInfo is not valid', () => {
expect(() => verifyLicense('__RELEASE_INFO__', validLicense)).to.throw(
'MUI: The release information is invalid. Not able to validate license.',
);
});
it('should log an error when ReleaseInfo is not valid', () => {
expect(() => verifyLicense('__RELEASE_INFO__', license, ['pro', 'premium'])).to.throw(
'MUI: The release information is invalid. Not able to validate license.',
);
});

it('should verify License properly', () => {
expect(verifyLicense(RELEASE_INFO, license, ['pro', 'premium'])).to.equal(
LicenseStatus.Valid,
);
});

it('should check expired License properly', () => {
const expiredLicense = generateLicense({
expiryDate: new Date(new Date().getTime() - oneDayInMS),
orderNumber: 'MUI-123',
});

it('should verify License properly', () => {
expect(verifyLicense(RELEASE_INFO, validLicense)).to.equal(LicenseStatus.Valid);
expect(verifyLicense(RELEASE_INFO, expiredLicense, ['pro', 'premium'])).to.equal(
LicenseStatus.Expired,
);
});

it('should return Invalid for invalid license', () => {
expect(
verifyLicense(
RELEASE_INFO,
'b43ff5f9ac93f021855ff59ff0ba5220TkFNRTpNYC1VSSBTQVMsREVWRUxPUEVSX0NPVU5UPTEwLEVYUElSWT0xNTkxNzIzMDY3MDQyLFZFUlNJT049MS4yLjM',
['pro', 'premium'],
),
).to.equal(LicenseStatus.Invalid);
});
});

it('should check expired License properly', () => {
const expiredLicense = generateLicense({
expiryDate: new Date(new Date().getTime() - oneDayInMS),
describe('key version: 2', () => {
const licensePro = generateLicense({
expiryDate: new Date(new Date().getTime() + oneYear),
orderNumber: 'MUI-123',
scope: 'pro',
});

expect(verifyLicense(RELEASE_INFO, expiredLicense)).to.equal(LicenseStatus.Expired);
});
const licensePremium = generateLicense({
expiryDate: new Date(new Date().getTime() + oneYear),
orderNumber: 'MUI-123',
scope: 'premium',
});

it('should return Invalid for invalid license', () => {
expect(
verifyLicense(
RELEASE_INFO,
'b43ff5f9ac93f021855ff59ff0ba5220TkFNRTpNYC1VSSBTQVMsREVWRUxPUEVSX0NPVU5UPTEwLEVYUElSWT0xNTkxNzIzMDY3MDQyLFZFUlNJT049MS4yLjM',
),
).to.equal(LicenseStatus.Invalid);
it('should log an error when ReleaseInfo is not valid', () => {
expect(() => verifyLicense('__RELEASE_INFO__', licensePro, ['pro', 'premium'])).to.throw(
'MUI: The release information is invalid. Not able to validate license.',
);
});

it('should accept pro license for pro features', () => {
expect(verifyLicense(RELEASE_INFO, licensePro, ['pro', 'premium'])).to.equal(
LicenseStatus.Valid,
);
});

it('should accept premium license for premium features', () => {
expect(verifyLicense(RELEASE_INFO, licensePremium, ['premium'])).to.equal(
LicenseStatus.Valid,
);
});

it('should not accept pro license for premium feature', () => {
expect(verifyLicense(RELEASE_INFO, licensePro, ['premium'])).to.equal(LicenseStatus.Invalid);
});

it('should check expired License properly', () => {
const expiredLicense = generateLicense({
expiryDate: new Date(new Date().getTime() - oneDayInMS),
orderNumber: 'MUI-123',
});

expect(verifyLicense(RELEASE_INFO, expiredLicense, ['pro', 'premium'])).to.equal(
LicenseStatus.Expired,
);
});

it('should return Invalid for invalid license', () => {
expect(
verifyLicense(
RELEASE_INFO,
'b43ff5f9ac93f021855ff59ff0ba5220TkFNRTpNYC1VSSBTQVMsREVWRUxPUEVSX0NPVU5UPTEwLEVYUElSWT0xNTkxNzIzMDY3MDQyLFZFUlNJT049MS4yLjM',
['pro', 'premium'],
),
).to.equal(LicenseStatus.Invalid);
});
});
});
105 changes: 93 additions & 12 deletions packages/x-license-pro/src/verifyLicense/verifyLicense.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { base64Decode, base64Encode } from '../encoding/base64';
import { md5 } from '../encoding/md5';
import { LicenseStatus } from '../utils/licenseStatus';
import { LicenseScope } from '../utils/licenseScope';

export function generateReleaseInfo() {
const today = new Date();
Expand All @@ -11,7 +12,79 @@ export function generateReleaseInfo() {

const expiryReg = /^.*EXPIRY=([0-9]+),.*$/;

export function verifyLicense(releaseInfo: string, encodedLicense: string | undefined) {
interface MuiLicense {
scope: LicenseScope | null;
expiryTimestamp: number | null;
}

/**
* Format: ORDER:${orderNumber},EXPIRY=${expiryTimestamp},KEYVERSION=1
*/
const decodeLicenseVersion1 = (license: string): MuiLicense => {
let expiryTimestamp: number | null;
try {
expiryTimestamp = parseInt(license.match(expiryReg)![1], 10);
if (!expiryTimestamp || Number.isNaN(expiryTimestamp)) {
expiryTimestamp = null;
}
} catch (err) {
expiryTimestamp = null;
}

return {
scope: 'pro',
expiryTimestamp,
};
};

/**
* Format: ORDER=${orderNumber},EXPIRY=${expiryTimestamp},KEYVERSION=2,SCOPE=${scope}`;
*/
const decodeLicenseVersion2 = (license: string): MuiLicense => {
const licenseInfo: MuiLicense = {
scope: null,
expiryTimestamp: null,
};

license
.split(',')
.map((token) => token.split('='))
.filter((el) => el.length === 2)
.forEach(([key, value]) => {
if (key === 'SCOPE') {
licenseInfo.scope = value as LicenseScope;
}

if (key === 'EXPIRY') {
const expiryTimestamp = parseInt(value, 10);
if (expiryTimestamp && !Number.isNaN(expiryTimestamp)) {
licenseInfo.expiryTimestamp = expiryTimestamp;
}
}
});

return licenseInfo;
};

const decodeLicense = (encodedLicense: string): MuiLicense | null => {
const license = base64Decode(encodedLicense);

if (license.includes('KEYVERSION=1')) {
return decodeLicenseVersion1(license);
}

if (license.includes('KEYVERSION=2')) {
return decodeLicenseVersion2(license);
}

return null;
};

export function verifyLicense(
releaseInfo: string,
encodedLicense: string | undefined,
acceptedScopes: LicenseScope[],
) {
if (!releaseInfo) {
throw new Error('MUI: The release information is missing. Not able to validate license.');
}
Expand All @@ -27,16 +100,15 @@ export function verifyLicense(releaseInfo: string, encodedLicense: string | unde
return LicenseStatus.Invalid;
}

const clearLicense = base64Decode(encoded);
let expiryTimestamp = 0;
try {
expiryTimestamp = parseInt(clearLicense.match(expiryReg)![1], 10);
if (!expiryTimestamp || Number.isNaN(expiryTimestamp)) {
console.error('Error checking license. Expiry timestamp not found or invalid!');
return LicenseStatus.Invalid;
}
} catch (err) {
console.error('Error extracting license expiry timestamp.', err);
const license = decodeLicense(encoded);

if (license == null) {
console.error('Error checking license. Key version not found!');
return LicenseStatus.Invalid;
}

if (license.expiryTimestamp == null) {
console.error('Error checking license. Expiry timestamp not found or invalid!');
return LicenseStatus.Invalid;
}

Expand All @@ -45,9 +117,18 @@ export function verifyLicense(releaseInfo: string, encodedLicense: string | unde
throw new Error('MUI: The release information is invalid. Not able to validate license.');
}

if (expiryTimestamp < pkgTimestamp) {
if (license.expiryTimestamp < pkgTimestamp) {
return LicenseStatus.Expired;
}

if (license.scope == null) {
console.error('Error checking license. scope not found!');
return LicenseStatus.Invalid;
}

if (!acceptedScopes.includes(license.scope)) {
return LicenseStatus.Invalid;
}

return LicenseStatus.Valid;
}
2 changes: 1 addition & 1 deletion test/regressions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ addons.setChannel(mockChannel());

// Remove the license warning from demonstration purposes
LicenseInfo.setLicenseKey(
'0f94d8b65161817ca5d7f7af8ac2f042T1JERVI6TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY1NDg1ODc1MzU1MCxLRVlWRVJTSU9OPTE=',
'e8d0d6124d6e21bee635aae6358c5bfcT1JERVI9TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY4MjUyNzMzNDQ1MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJlbWl1bQ==',
);

const blacklist = [
Expand Down
2 changes: 1 addition & 1 deletion test/utils/licenseKey.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LicenseInfo } from '@mui/x-data-grid-pro';

LicenseInfo.setLicenseKey(
'0f94d8b65161817ca5d7f7af8ac2f042T1JERVI6TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY1NDg1ODc1MzU1MCxLRVlWRVJTSU9OPTE=',
'e8d0d6124d6e21bee635aae6358c5bfcT1JERVI9TVVJLVN0b3J5Ym9vayxFWFBJUlk9MTY4MjUyNzMzNDQ1MixLRVlWRVJTSU9OPTIsU0NPUEU9cHJlbWl1bQ==',
);