@mindconnect/mindconnect-nodejs
Version:
MindConnect Library for NodeJS (community based)
483 lines • 21.9 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const AsyncLock = require("async-lock");
const debug = require("debug");
const jwt = require("jsonwebtoken");
const node_fetch_1 = require("node-fetch");
require("url-search-params-polyfill");
const uuid = require("uuid");
const __1 = require("..");
const mindconnect_base_1 = require("./mindconnect-base");
const mindconnect_storage_1 = require("./mindconnect-storage");
const _ = require("lodash");
const log = debug("mindconnect-agentauth");
const rsaPemToJwk = require("rsa-pem-to-jwk");
class AgentAuth extends mindconnect_base_1.MindConnectBase {
/**
* Creates an instance of AgentAuth.
* @param {IMindConnectConfiguration} _configuration
* @param {number} [_tokenValidity=600] // this was required in previous versions of the implmentation , kept for compatibility.
* @param {string} [_basePath=process.cwd() + "/.mc/"]
* @memberof AgentAuth
*/
constructor(configuration, _tokenValidity = 600, basePath = process.cwd() + "/.mc/") {
super();
this._tokenValidity = _tokenValidity;
log(`constructor called with parameters: configuration: ${JSON.stringify(configuration)} path: ${basePath}`);
if (!configuration || !configuration.content)
throw new Error("Invalid configuration!");
if (typeof basePath === "string") {
this._storage = new mindconnect_storage_1.DefaultStorage(basePath);
}
else if (mindconnect_storage_1.IsConfigurationStorage(basePath)) {
this._storage = basePath;
}
else {
throw new Error("you have to specify either a directory or configuration storage");
}
this._configuration = this._storage.GetConfig(configuration);
this._profile = `${this._configuration.content.clientCredentialProfile}`;
if (["SHARED_SECRET", "RSA_3072"].indexOf(this._profile) < 0) {
throw new Error("Configuration profile not supported. The library only supports the shared_secret and RSA_3072 config profiles");
}
log(`Agent configuration with ${this._profile}`);
this.secretLock = new AsyncLock({});
}
/**
* Asynchronous method which saves the agent state in the .mc (or reconfigured) folder.
*
* @private
* @returns {Promise<object>}
* @memberof AgentAuth
*/
SaveConfig() {
return __awaiter(this, void 0, void 0, function* () {
if (!this._storage) {
throw new Error("Invalid storage configured");
}
return this._storage.SaveConfig(this._configuration);
});
}
/**
* Onboard the agent and return the onboarding state.
*
* @returns {Promise<OnBoardingState>}
* @memberof MindConnectAgent
*/
OnBoard() {
return __awaiter(this, void 0, void 0, function* () {
const headers = Object.assign(Object.assign({}, this._apiHeaders), { Authorization: `Bearer ${this._configuration.content.iat}` });
const url = `${this._configuration.content.baseUrl}/api/agentmanagement/v3/register`;
log(`Onboarding - Headers: ${JSON.stringify(headers)} Url: ${url} Profile: ${this.GetProfile()}`);
try {
let body = {};
if (this.GetProfile() === "RSA_3072") {
if (!this._publicJwk)
throw new Error("The RSA_3072 profile requires a certificate (did you call SetupAgentCerts before onboarding?)");
body = {
jwks: { keys: [this._publicJwk] }
};
}
const response = yield node_fetch_1.default(url, {
method: "POST",
body: JSON.stringify(body),
headers: headers,
agent: this._proxyHttpAgent
});
if (!response.ok) {
throw new Error(`${response.statusText} ${yield response.text()}`);
}
if (response.status === 201) {
const json = yield response.json();
this._configuration.response = json;
yield __1.retry(5, () => this.SaveConfig());
return __1.OnboardingStatus.StatusEnum.ONBOARDED;
}
else {
throw new Error(`invalid response ${JSON.stringify(response)}`);
}
// process body
}
catch (err) {
log(err);
throw new Error(`Network error occured ${err.message}`);
}
});
}
PushKey() {
if (!this._configuration.response)
throw new Error("This agent was not onboarded yet.");
this._configuration.recovery = this._configuration.recovery || [];
if (!_.some(this._configuration.recovery, this._configuration.response)) {
this._configuration.recovery.push(Object.assign({}, this._configuration.response));
}
this._configuration.recovery = _.takeRight(this._configuration.recovery, 5);
}
TryRecovery() {
return __awaiter(this, void 0, void 0, function* () {
this._configuration.recovery = this._configuration.recovery || [];
this.PushKey();
const backup = Object.assign({}, this._configuration.response);
let success = false;
let i = 0;
for (const currentKey of this._configuration.recovery.reverse()) {
try {
this._configuration.response = currentKey;
log(`recovery with ${i}`);
yield __1.retry(3, () => this.RotateKey());
log("success");
success = true;
break;
}
catch (err) {
log(`recovery with ${i++} failed`);
}
}
if (!success) {
log("Recovery failed!");
this._configuration.response = backup;
log(this._configuration.response);
}
yield __1.retry(5, () => this.SaveConfig());
return success;
});
}
/**
* This method rotates the client secret (reregisters the agent). It is called by RenewToken when the secret is expiring.
*
* @private
* @returns {Promise<boolean>}
* @memberof AgentAuth
*/
RotateKey() {
return __awaiter(this, void 0, void 0, function* () {
if (!this._configuration.response)
throw new Error("This agent was not onboarded yet.");
this.PushKey();
const headers = Object.assign(Object.assign({}, this._apiHeaders), { Authorization: `Bearer ${this._configuration.response.registration_access_token}` });
const url = this._configuration.response.registration_client_uri;
let body = { client_id: this._configuration.content.clientId }; // mindsphere 3.0. expects a body in the the put request
if (this.GetProfile() === "RSA_3072") {
if (!this._publicJwk)
throw new Error("The RSA_3072 profile requires a certificate (did you call SetupAgentCerts before key rotation?)");
body = Object.assign(Object.assign({}, body), { jwks: { keys: [this._publicJwk] } });
}
log(`Rotating Key - Headers: ${JSON.stringify(headers)} Url: ${url} Profile: ${this.GetProfile()}`);
try {
const response = yield node_fetch_1.default(url, {
method: "PUT",
body: JSON.stringify(body),
headers: headers,
agent: this._proxyHttpAgent
});
if (!response.ok) {
throw new Error(`${response.statusText} ${yield response.text()}`);
}
if (response.status >= 200 && response.status <= 299) {
const json = yield response.json();
this._configuration.response = json;
yield __1.retry(5, () => this.SaveConfig());
return true;
}
else {
throw new Error(`invalid response ${JSON.stringify(response)}`);
}
// process body
}
catch (err) {
log(err);
throw new Error(`Network error occured ${err.message}`);
}
});
}
/**
* Create Initial self-signed JWT Token which is needed to acquire the actual /exchange token.
*
* @private
* @param {number} [expiration=3600]
* @returns {URLSearchParams}
* @memberof AgentAuth
*/
CreateClientAssertion(expiration = 3600) {
if (!this._configuration.response) {
throw new Error("the device was not onborded or the response was deleted");
}
if (!this._configuration.content.clientId) {
throw new Error("client id must be defined!");
}
if (!this._configuration.content.tenant) {
throw new Error("tenant id must be defined!");
}
const now = Math.floor(Date.now() / 1000);
const jwtToken = {
iss: this._configuration.content.clientId,
sub: this._configuration.content.clientId,
aud: ["southgate"],
iat: now,
nbf: now,
exp: now + expiration,
jti: uuid.v4().toString(),
schemas: ["urn:siemens:mindsphere:v1"],
ten: this._configuration.content.tenant
};
log(jwtToken);
let token;
if (this.GetProfile() === "SHARED_SECRET") {
if (!this._configuration.response.client_secret)
throw new Error("There must be a shared secret in the response");
token = jwt.sign(jwtToken, this._configuration.response.client_secret);
}
else {
if (!this._privateCert) {
throw new Error("The RSA_3072 profile requires a certificate (did you call SetupAgentCerts before acquiring a token?)");
}
token = jwt.sign(jwtToken, this._privateCert, { algorithm: "RS384" });
}
log(token);
const formData = {
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: token
};
const result = new URLSearchParams();
for (const key of Object.keys(formData)) {
result.append(key, formData[key]);
}
log(result);
return result;
}
/**
* Acquires the /exchange token and stores it in _assertionResponse.
*
* @private
* @returns {Promise<boolean>}
* @memberof AgentAuth
*/
AquireToken() {
return __awaiter(this, void 0, void 0, function* () {
const url = `${this._configuration.content.baseUrl}/api/agentmanagement/v3/oauth/token`;
const headers = this._urlEncodedHeaders;
const body = this.CreateClientAssertion().toString();
log(`Acquire Token Headers ${JSON.stringify(headers)} Url: ${url} Body: ${body.toString()}`);
try {
const response = yield node_fetch_1.default(url, {
method: "POST",
body: body,
headers: headers,
agent: this._proxyHttpAgent
});
if (!response.ok) {
throw new Error(`${response.statusText} ${yield response.text()}`);
}
if (response.status >= 200 && response.status <= 299) {
const json = yield response.json();
log(`AcquireToken Response ${JSON.stringify(json)}`);
this._accessToken = json;
return true;
}
else {
throw new Error(`invalid response ${JSON.stringify(response)}`);
}
// process body
}
catch (err) {
log(err);
const hint = this.isSecretExpired()
? "the client secret has expired, you will have to onboard the agent again"
: "possible cause for this error is invalid date/time on the device";
throw new Error(`Network error occured ${err.message} (hint: ${hint}) see also: https://opensource.mindsphere.io/docs/mindconnect-nodejs/troubleshooting.html`);
}
});
}
isSecretExpired() {
var _a, _b, _c, _d;
if (!((_b = (_a = this._configuration) === null || _a === void 0 ? void 0 : _a.response) === null || _b === void 0 ? void 0 : _b.client_secret_expires_at)) {
return false;
}
if (isNaN((_d = (_c = this._configuration) === null || _c === void 0 ? void 0 : _c.response) === null || _d === void 0 ? void 0 : _d.client_secret_expires_at)) {
return false;
}
const now = Math.floor(Date.now() / 1000);
const secondsLeft = this._configuration.response.client_secret_expires_at - now;
return secondsLeft < 0;
}
GetCertificate() {
return __awaiter(this, void 0, void 0, function* () {
const url = `${this._configuration.content.baseUrl}/api/agentmanagement/v3/oauth/token_key`;
const headers = this._headers;
log(`Validate Token Headers ${JSON.stringify(headers)} Url: ${url}`);
try {
const response = yield node_fetch_1.default(url, {
method: "GET",
headers: headers,
agent: this._proxyHttpAgent
});
if (!response.ok) {
throw new Error(`${response.statusText} ${yield response.text()}`);
}
if (response.status >= 200 && response.status <= 299) {
const json = yield response.json();
log(`OauthPublicKeyResponse ${JSON.stringify(json)}`);
this._oauthPublicKey = json;
return json;
}
else {
throw new Error(`invalid response ${JSON.stringify(response)}`);
}
// process body
}
catch (err) {
log(err);
throw new Error(`Network error occured ${err.message}`);
}
});
}
/**
* Validates /exchange token on the client. If the certificate is not available retrieves certificate from /oauth/token_key endpoint
* acnd caches it in _oauthPublicKey property for the lifetime of the agent.
*
* @private
* @returns {Promise<boolean>}
* @memberof AgentAuth
*/
ValidateToken() {
return __awaiter(this, void 0, void 0, function* () {
if (!this._accessToken)
throw new Error("The token needs to be acquired first before validation.");
if (!this._oauthPublicKey) {
yield __1.retry(5, () => this.GetCertificate());
}
if (!this._oauthPublicKey) {
throw new Error("couldnt read client certificate!");
}
log(this._oauthPublicKey.value);
const publicKeyWithLineBreaks = this._oauthPublicKey.value
.replace("-----BEGIN PUBLIC KEY-----", "-----BEGIN PUBLIC KEY-----\n")
.replace("-----END PUBLIC KEY-----", "\n-----END PUBLIC KEY-----");
if (!this._accessToken.access_token)
throw new Error("Invalid access token");
const result = jwt.verify(this._accessToken.access_token, publicKeyWithLineBreaks);
return result ? true : false;
});
}
/**
* The /exchange token handling. Handles validation, secret renewal and token renewal. Should be called
* at the beginning of each operation which handles /exchange endpoint.
* @private
* @returns {Promise<boolean>}
* @memberof AgentAuth
*/
RenewToken() {
return __awaiter(this, void 0, void 0, function* () {
if (!this._configuration.response) {
throw new Error("the device was not onborded or the response was deleted");
}
if (this._accessToken) {
try {
yield this.ValidateToken();
}
catch (err) {
log("jwt exchange token expired - renewing");
this._accessToken = undefined;
if (err.name === "JsonWebTokenError" &&
err.message === "invalid signature") {
log("invalid certificate - renewing");
this._oauthPublicKey = undefined;
}
}
}
if (!this._configuration.response.client_secret_expires_at) {
throw new Error("Client secret expires at is undefined!");
}
const now = Math.floor(Date.now() / 1000);
const secondsLeft = this._configuration.response.client_secret_expires_at - now;
if (this._configuration.response.client_secret_expires_at - 25 * 3600 <=
now) {
log(`client secret will expire in ${secondsLeft} seconds - renewing`);
try {
yield this.secretLock.acquire("secretLock", () => __awaiter(this, void 0, void 0, function* () {
yield __1.retry(5, () => this.RotateKey());
this._accessToken = undefined; // delete the token it will need to be regenerated with the new key
}));
}
catch (err) {
log(`There is a problem rotating the client secrets. The client secret ${secondsLeft > 0 ? "will expire in" : "has expired since"} ${Math.abs(secondsLeft)} seconds. The error was ${err}`);
try {
log("trying recovery");
const recovery = yield this.TryRecovery();
const message = recovery ? "Recovery succedded" : "Recovery failed";
log(message);
}
catch (recoveryError) {
log(`Recovery failed with ${recoveryError.message}`);
}
}
}
if (!this._accessToken) {
yield __1.retry(5, () => this.AquireToken());
yield this.ValidateToken();
if (!this._accessToken)
throw new Error("Error aquiering the new token!");
log("New token acquired");
}
return true;
});
}
/**
* Returns the current agent token.
* This token can be used in e.g. in Postman to call mindspher APIs.
*
* @returns {(Promise<string>)}
*
* @memberOf AgentAuth
*/
GetAgentToken() {
return __awaiter(this, void 0, void 0, function* () {
yield this.RenewToken();
if (!this._accessToken || !this._accessToken.access_token)
throw new Error("Error getting the new token!");
return this._accessToken.access_token;
});
}
/**
* returns the security profile of the agent
*
* @returns "SHARED_SECRET" || "RSA_3072"
*
* @memberOf AgentAuth
*/
GetProfile() {
return this._profile;
}
/**
* Set up the certificate for RSA_3072 communication.
* You can generate a certificate e.g. using openssl
* openssl genrsa -out private.key 3072
*
* @param {(string | Buffer)} privateCert
*
* @memberOf AgentAuth
*/
SetupAgentCertificate(privateCert) {
if (this.GetProfile() !== "RSA_3072") {
throw new Error("The certificates are required only for RSA_3072 configuration!");
}
if (!privateCert) {
throw new Error("you need to create the certificate for the agent and provide the path to the agent");
}
this._privateCert = privateCert.toString();
this._publicJwk = rsaPemToJwk(this._privateCert, { kid: "mindconnect-key-1" }, "public");
log(this._publicJwk);
}
}
exports.AgentAuth = AgentAuth;
//# sourceMappingURL=agent-auth.js.map