Skip to content

Commit

Permalink
Add License verification and refresh (#845)
Browse files Browse the repository at this point in the history
This PR provides a range of functionality related to validating license:
- pass license token to simulator server for validation
- refresh license token every 24 hours 
- refresh license token if it expired 
- remove license token if it was corrupted, or when device was removed
in customer portal
- inform user about token corruption, and license
expiration/cancellation

This PR is dependent on
software-mansion-labs/simulator-server#183

### How Has This Been Tested: 
- Run IDE and register license 
- restart radon IDE and check with debugger if checks run as expected
- simulate wrong answers from simulator server to check other paths

---------

Co-authored-by: Krzysztof Magiera <[email protected]>
  • Loading branch information
filip131311 and kmagiera authored Dec 17, 2024
1 parent d501749 commit 3a613ec
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 37 deletions.
2 changes: 1 addition & 1 deletion packages/simulator-server
9 changes: 9 additions & 0 deletions packages/vscode-extension/src/devices/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { exec, ChildProcess, lineReader } from "../utilities/subprocess";
import { Logger } from "../Logger";
import { RecordingData, TouchPoint } from "../common/Project";
import { simulatorServerBinary } from "../utilities/simulatorServerBinary";
import { watchLicenseTokenChange } from "../utilities/license";

interface VideoRecordingPromiseHandlers {
resolve: (value: RecordingData) => void;
Expand All @@ -13,12 +14,14 @@ interface VideoRecordingPromiseHandlers {
export class Preview implements Disposable {
private videoRecordingPromises = new Map<string, VideoRecordingPromiseHandlers>();
private subprocess?: ChildProcess;
private tokenChangeListener?: Disposable;
public streamURL?: string;

constructor(private args: string[]) {}

dispose() {
this.subprocess?.kill();
this.tokenChangeListener?.dispose();
}

private sendCommandOrThrow(command: string) {
Expand Down Expand Up @@ -63,6 +66,12 @@ export class Preview implements Disposable {
});
this.subprocess = subprocess;

this.tokenChangeListener = watchLicenseTokenChange((token) => {
if (token) {
this.sendCommandOrThrow(`token ${token}\n`);
}
});

return new Promise<string>((resolve, reject) => {
subprocess.catch(reject).then(() => {
// we expect the preview server to produce a line with the URL
Expand Down
20 changes: 15 additions & 5 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import stripAnsi from "strip-ansi";
import { minimatch } from "minimatch";
import { isEqual } from "lodash";
import {
ActivateDeviceResult,
AppPermissionType,
DeviceSettings,
InspectData,
Expand Down Expand Up @@ -33,7 +32,12 @@ import { Devtools } from "./devtools";
import { AppEvent, DeviceSession, EventDelegate } from "./deviceSession";
import { BuildCache } from "../builders/BuildCache";
import { PanelLocation } from "../common/WorkspaceConfig";
import { activateDevice, getLicenseToken } from "../utilities/license";
import {
activateDevice,
watchLicenseTokenChange,
getLicenseToken,
refreshTokenPeriodically,
} from "../utilities/license";

const DEVICE_SETTINGS_KEY = "device_settings_v4";
const LAST_SELECTED_DEVICE_KEY = "last_selected_device";
Expand All @@ -56,6 +60,8 @@ export class Project
private isCachedBuildStale: boolean;

private fileWatcher: Disposable;
private licenseWatcher: Disposable;
private licenseUpdater: Disposable;

private deviceSession: DeviceSession | undefined;

Expand Down Expand Up @@ -96,6 +102,11 @@ export class Project
this.fileWatcher = watchProjectFiles(() => {
this.checkIfNativeChanged();
});
this.licenseUpdater = refreshTokenPeriodically();
this.licenseWatcher = watchLicenseTokenChange(async () => {
const hasActiveLicense = await this.hasActiveLicense();
this.eventEmitter.emit("licenseActivationChanged", hasActiveLicense);
});
}

//#region Build progress
Expand Down Expand Up @@ -278,6 +289,8 @@ export class Project
this.devtools?.dispose();
this.deviceManager.removeListener("deviceRemoved", this.removeDeviceListener);
this.fileWatcher.dispose();
this.licenseWatcher.dispose();
this.licenseUpdater.dispose();
}

private async reloadMetro() {
Expand Down Expand Up @@ -495,9 +508,6 @@ export class Project
public async activateLicense(activationKey: string) {
const computerName = os.hostname();
const activated = await activateDevice(activationKey, computerName);
if (activated === ActivateDeviceResult.succeeded) {
this.eventEmitter.emit("licenseActivationChanged", true);
}
return activated;
}

Expand Down
172 changes: 141 additions & 31 deletions packages/vscode-extension/src/utilities/license.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,54 @@
import fetch from "node-fetch";
import fetch, { Response } from "node-fetch";
import { extensionContext } from "./extensionContext";
import { exec } from "./subprocess";
import { Logger } from "../Logger";
import { simulatorServerBinary } from "./simulatorServerBinary";
import { ActivateDeviceResult } from "../common/Project";
import { throttleAsync } from "./throttle";

const TOKEN_KEY = "RNIDE_license_token_key";
const TOKEN_KEY_TIMESTAMP = "RNIDE_license_token_key_timestamp";
const BASE_CUSTOMER_PORTAL_URL = "https://portal.ide.swmansion.com/";

const LICENCE_TOKEN_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours – how often to refresh the token (given successful token verification)
const LICENCE_TOKEN_REFRESH_RETRY_INTERVAL = 1000 * 60; // 1 minute – how often to retry refreshing the token

export enum ServerResponseStatusCode {
success = "S001",
badRequest = "E001",
noSubscription = "E002",
allSeatTaken = "E003",
seatRemoved = "E004",
licenseExpired = "E005",
licenseCancelled = "E006",
noProductForSubscription = "E007",
internalError = "E101",
}

export enum SimServerLicenseValidationResult {
Success,
Corrupted,
Expired,
FingerprintMismatch,
}

async function saveTokenIfValid(response: Response) {
const responseBody = await response.json();
if (response.ok) {
const newToken = responseBody.token as string;
const checkResult = await checkLicenseToken(newToken);
if (checkResult === SimServerLicenseValidationResult.Success && newToken) {
await extensionContext.secrets.store(TOKEN_KEY, newToken);
await extensionContext.globalState.update(TOKEN_KEY_TIMESTAMP, Date.now());
return ServerResponseStatusCode.success;
} else {
Logger.warn("Fetched token is invalid, reason:", checkResult);
return ServerResponseStatusCode.noSubscription;
}
}
return responseBody.code as ServerResponseStatusCode;
}

export async function activateDevice(
licenseKey: string,
username: string
Expand All @@ -29,57 +70,126 @@ export async function activateDevice(
licenseKey,
};

let response;

try {
response = await fetch(url, {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const errorCode = await saveTokenIfValid(response);
switch (errorCode) {
case ServerResponseStatusCode.success:
return ActivateDeviceResult.succeeded;
case ServerResponseStatusCode.noSubscription:
return ActivateDeviceResult.keyVerificationFailed;
case ServerResponseStatusCode.allSeatTaken:
return ActivateDeviceResult.notEnoughSeats;
case ServerResponseStatusCode.badRequest:
default:
return ActivateDeviceResult.unableToVerify;
}
} catch (e) {
Logger.warn("Creating license token with license key failed", e);
return ActivateDeviceResult.connectionFailed;
}
}

const responseBody = await response.json();
export function refreshTokenPeriodically() {
const refreshIfNeeded = throttleAsync(async () => {
const lastRefreshTimestamp = extensionContext.globalState.get<number>(TOKEN_KEY_TIMESTAMP) || 0;
const timeSinceLastRefresh = Date.now() - lastRefreshTimestamp;
if (timeSinceLastRefresh > LICENCE_TOKEN_REFRESH_INTERVAL) {
const token = await getLicenseToken();
if (token) {
await refreshToken(token);
}
}
}, 1);
const intervalId = setInterval(refreshIfNeeded, LICENCE_TOKEN_REFRESH_RETRY_INTERVAL);
refreshIfNeeded(); // trigger initial call as setInterval will wait for the first interval to pass
return {
dispose: () => {
clearInterval(intervalId);
},
};
}

if (response.ok) {
const newToken = responseBody.token as string;
await extensionContext.secrets.store(TOKEN_KEY, newToken ?? "");
return ActivateDeviceResult.succeeded;
}
async function refreshToken(token: string) {
try {
const url = new URL("/api/refresh-token", BASE_CUSTOMER_PORTAL_URL);

if (
response.status === 404 &&
responseBody.error.startsWith("Could not find a subscription associated with license key")
) {
return ActivateDeviceResult.keyVerificationFailed;
}
const body = {
token: token,
};
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const errorCode = await saveTokenIfValid(response);

if (
response.status === 400 &&
responseBody.error.startsWith("All seats for a license with a key")
) {
return ActivateDeviceResult.notEnoughSeats;
switch (errorCode) {
case ServerResponseStatusCode.success:
case ServerResponseStatusCode.internalError:
// in case of internal error with license server, we don't want to remove the license
// obviously, we also don't remove it on success
return;
default:
// in all the other cases when we get erroneous response, we remove the license
Logger.warn("Saved license can no longer be used, reason:", errorCode);
await removeLicense();
return;
}
} catch (e) {
Logger.warn("Refreshing license token failed", e);
}

return ActivateDeviceResult.unableToVerify;
}

async function generateDeviceFingerprint() {
const simControllerBinary = simulatorServerBinary();
const { stdout } = await exec(simControllerBinary, ["fingerprint"]);
return stdout;
}

export async function removeLicense() {
await extensionContext.secrets.delete(TOKEN_KEY);
await extensionContext.globalState.update(TOKEN_KEY_TIMESTAMP, undefined);
}

export async function getLicenseToken() {
const token = await extensionContext.secrets.get(TOKEN_KEY);
return token;
return await extensionContext.secrets.get(TOKEN_KEY);
}

export function watchLicenseTokenChange(callback: (token: string | undefined) => void) {
getLicenseToken().then(callback);
return extensionContext.secrets.onDidChange((changeEvent) => {
if (changeEvent.key === TOKEN_KEY) {
getLicenseToken().then(callback);
}
});
}

export async function checkLicenseToken(token: string) {
const simControllerBinary = simulatorServerBinary();
const { stdout } = await exec(simControllerBinary, ["verify_token", token]);
if (stdout === "token_valid") {
return SimServerLicenseValidationResult.Success;
} else {
try {
const reason = stdout.split(" ", 2)[1];
switch (reason) {
case "expired":
return SimServerLicenseValidationResult.Expired;
case "fingerprint_mismatch":
return SimServerLicenseValidationResult.FingerprintMismatch;
}
} catch (e) {
Logger.error("Error parsing license token verification result", e);
}
return SimServerLicenseValidationResult.Corrupted;
}
}

async function generateDeviceFingerprint() {
const simControllerBinary = simulatorServerBinary();
const { stdout } = await exec(simControllerBinary, ["fingerprint"]);
return stdout;
}

0 comments on commit 3a613ec

Please sign in to comment.