From be4dbc43568fddf1684b2872d80728fbb5d61a25 Mon Sep 17 00:00:00 2001 From: "Gregor \"hrax\" Magdolen" Date: Sat, 27 Jul 2024 19:28:22 +0200 Subject: [PATCH] More TS refactoring of the core func --- modules/@types/instance-extended.d.ts | 27 +++++ modules/core/InstanceManager.ts | 49 +++++---- modules/core/OAuthClient.ts | 142 ++++++++++++++++++++------ modules/core/RESTClient.ts | 88 ++++++++++++++++ modules/core/RESTRequest.ts | 29 ------ modules/core/Request.ts | 90 +++++++++++++--- modules/core/UpdateXML.js | 62 ----------- modules/core/UpdateXML.ts | 24 +++++ modules/linter/Profile.js | 2 +- spec/coreSpec/InstanceSpec.js | 2 +- tsconfig.json | 16 ++- 11 files changed, 368 insertions(+), 163 deletions(-) create mode 100644 modules/@types/instance-extended.d.ts create mode 100644 modules/core/RESTClient.ts delete mode 100644 modules/core/RESTRequest.ts delete mode 100644 modules/core/UpdateXML.js create mode 100644 modules/core/UpdateXML.ts diff --git a/modules/@types/instance-extended.d.ts b/modules/@types/instance-extended.d.ts new file mode 100644 index 0000000..cdc8dee --- /dev/null +++ b/modules/@types/instance-extended.d.ts @@ -0,0 +1,27 @@ +export interface SNOAuthToken { + access_token: string; + refresh_token: string; + scope: string; + token_type: string; + expires_in: number; +} + +export interface InstanceOAuthData { + lastRetrieved: number; + token?: SNOAuthToken | null; +} + +export interface InstanceAuthenticationData extends InstanceOAuthData { + type: "oauth-token" | "oauth-password"; + clientID: string; + clientSecret: string; +} + +export interface InstanceConnectionData { + baseUrl: string; +} + +export interface InstanceConfig extends InstanceConnectionData { + name: string; + auth: InstanceAuthenticationData; +} \ No newline at end of file diff --git a/modules/core/InstanceManager.ts b/modules/core/InstanceManager.ts index 017969d..55816f8 100644 --- a/modules/core/InstanceManager.ts +++ b/modules/core/InstanceManager.ts @@ -1,26 +1,37 @@ +import { InstanceConfig } from "../@types/instance-extended"; -export type InstanceData = { - name: string, - base: string, - auth: { - type: "oauth-token" | "oauth-password", - clientID: string, - clientSecret: string, - token?: { - "access_token": string, - "refresh_token": string, - "scope": string, - "token_type": string, - "expires_in": number, - "loaded_at": Date +export default class InstanceManager { + private instance: InstanceConfig = { + name: "", + baseUrl: "", + auth: { + type: "oauth-token", + clientID: "", + clientSecret: "", + lastRetrieved: 0, + token: { + access_token: "", + refresh_token: "", + scope: "", + token_type: "", + expires_in: 0 + } } + }; + + static load(path: string): InstanceManager | null { + return null; } -} -export default class InstanceManager { - #instance: InstanceData; + static save(instance: InstanceManager): void { + + } + + constructor(instance: InstanceConfig) { + this.setInstanceData(instance); + } - constructor(instance: InstanceData) { - this.#instance = instance; + setInstanceData(instance: InstanceConfig): void { + this.instance = instance; } } \ No newline at end of file diff --git a/modules/core/OAuthClient.ts b/modules/core/OAuthClient.ts index 8d19343..8751f20 100644 --- a/modules/core/OAuthClient.ts +++ b/modules/core/OAuthClient.ts @@ -1,38 +1,62 @@ import crypto from "crypto"; import { URL, URLSearchParams } from "url"; -import { InstanceData } from "./InstanceManager"; -import Request, { RESPONSE_STATUS } from "./Request"; +import Request, { ResponseStatus, ResponseError, Response } from "./Request"; import pkg from "../../package.json"; import { RequestOptions } from "https"; +import { IncomingMessage } from "http"; +import { InstanceAuthenticationData, InstanceConfig, InstanceOAuthData, SNOAuthToken } from "../@types/instance-extended"; const CLIENT_BASE_URL: string = "/oauth_entity.do"; -const CLIENT_LIST_BASE_URL: string = "/oauth_entity.do"; +const CLIENT_LIST_BASE_URL: string = "/oauth_entity_list.do"; const AUTH_BASE_URL: string = "/oauth_auth.do"; const TOKEN_BASE_URL: string = "/oauth_token.do"; +// TODO: Allow to configure server port via ENV, just warn that it should be in redirects! const REDIRECT_URI: string = "http://localhost:696969/oauth_client"; const TOKEN_CONTENT_TYPE: string = "application/x-www-form-urlencoded" -export type OAuthTokenResponse = { - "access_token": string, - "refresh_token": string, - "scope": string, - "token_type": string, - "expires_in": number +export class OAuthRefreshTokenExpired extends Error { + constructor (message?: string, options?: ErrorOptions) { + super(message, options); + } +}; +export class OAuthTokenExpired extends Error { + constructor (message?: string, options?: ErrorOptions) { + super(message, options); + } +}; +export class OAuthCodeExpired extends Error { + constructor (message?: string, options?: ErrorOptions) { + super(message, options); + } +}; +export class OAuthUsernamePasswordIncorrect extends Error { + constructor (message?: string, options?: ErrorOptions) { + super(message, options); + } }; export default class OAuthClient { - #instance: InstanceData; + private instance: InstanceConfig; - // create Server? + // create Server for code? static generateRandomState(): string { return crypto.randomBytes(16).toString("hex"); } - constructor(instance: InstanceData) { - this.#instance = instance; + constructor(instance: InstanceConfig) { + this.instance = instance; + } + + private setToken(token: SNOAuthToken) { + this.instance.auth.lastRetrieved = Date.now(); + this.instance.auth.token = token; + } + + getInstanceConfig(): InstanceConfig { + return this.instance; } getNewClientURL(): string { @@ -45,7 +69,7 @@ export default class OAuthClient { "logo_url=" ]; - const url: URL = new URL(CLIENT_BASE_URL, this.#instance.base); + const url: URL = new URL(CLIENT_BASE_URL, this.instance.baseUrl); url.searchParams.set("sys_id", "-1"); url.searchParams.set("sysparm_transaction_scope", "global"); url.searchParams.set("sysparm_query", query.join("^")); @@ -57,7 +81,7 @@ export default class OAuthClient { "type=client", "name=" + pkg.name ]; - const url: URL = new URL(CLIENT_LIST_BASE_URL, this.#instance.base); + const url: URL = new URL(CLIENT_LIST_BASE_URL, this.instance.baseUrl); url.searchParams.set("sys_id", "-1"); url.searchParams.set("sysparm_transaction_scope", "global"); url.searchParams.set("sysparm_query", query.join("^")); @@ -65,23 +89,34 @@ export default class OAuthClient { } getAuthCodeURL(state: string): string { - const url: URL = new URL(AUTH_BASE_URL, this.#instance.base); + const url: URL = new URL(AUTH_BASE_URL, this.instance.baseUrl); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("client_id", this.#instance.auth.clientID); + url.searchParams.set("client_id", this.instance.auth.clientID); url.searchParams.set("state", state); return url.toString(); } - async requestTokenByCode(authCode: string): Promise { - const url: URL = new URL(TOKEN_BASE_URL, this.#instance.base); + isTokenExpired(): boolean { + const oauth: InstanceOAuthData = this.instance.auth; + if (oauth.token == null) { + return true; + } + const hadTokenFor = Date.now() - oauth.lastRetrieved + 10000; + const expiresIn = oauth.token.expires_in * 1000; + return hadTokenFor < expiresIn; + } + + async requestTokenByCode(code: string): Promise { + const oauth: InstanceAuthenticationData = this.instance.auth; + const url: URL = new URL(TOKEN_BASE_URL, this.instance.baseUrl); const body: URLSearchParams = new URLSearchParams(); body.set("grant_type", "authorization_code"); - body.set("code", authCode); + body.set("code", code); //body.set("redirect_uri", REDIRECT_URI); - body.set("client_id", this.#instance.auth.clientID); - body.set("client_secret", this.#instance.auth.clientSecret); + body.set("client_id", oauth.clientID); + body.set("client_secret", oauth.clientSecret); const options: RequestOptions = { method: "POST", @@ -91,18 +126,31 @@ export default class OAuthClient { } }; - return await Request.json(url, options, body.toString()); + const token: SNOAuthToken = await Request.json(url, options, body.toString()) + .catch((reason: any) => { + if (reason instanceof ResponseError) { + const response: IncomingMessage | null = (reason).response.http; + if (response != null && response.statusCode == ResponseStatus.UNAUTHORIZED) { + throw new OAuthCodeExpired(reason); + } + } + throw Error(reason); + }); + + this.setToken(token); + return token; } - async requestTokenByUsername(username: string, password: string): Promise { - const url: URL = new URL(TOKEN_BASE_URL, this.#instance.base); + async requestTokenByUsername(username: string, password: string): Promise { + const oauth: InstanceAuthenticationData = this.instance.auth; + const url: URL = new URL(TOKEN_BASE_URL, this.instance.baseUrl); const body: URLSearchParams = new URLSearchParams(); body.set("grant_type", "password"); body.set("username", username); body.set("password", password); - body.set("client_id", this.#instance.auth.clientID); - body.set("client_secret", this.#instance.auth.clientSecret); + body.set("client_id", oauth.clientID); + body.set("client_secret", oauth.clientSecret); const options: RequestOptions = { method: "POST", @@ -112,17 +160,31 @@ export default class OAuthClient { } }; - return await Request.json(url, options, body.toString()); + const token: SNOAuthToken = await Request.json(url, options, body.toString()) + .catch((reason: any) => { + if (reason instanceof ResponseError) { + // 401: username/password incorrect + const response: IncomingMessage | null = (reason).response.http; + if (response != null && response.statusCode == ResponseStatus.UNAUTHORIZED) { + throw new OAuthUsernamePasswordIncorrect(reason); + } + } + throw Error(reason); + }); + + this.setToken(token); + return token; } - async refreshToken(): Promise { - const url: URL = new URL(TOKEN_BASE_URL, this.#instance.base); + async refreshToken(): Promise { + const oauth: InstanceAuthenticationData = this.instance.auth; + const url: URL = new URL(TOKEN_BASE_URL, this.instance.baseUrl); const body: URLSearchParams = new URLSearchParams(); body.set("grant_type", "refresh_token"); - body.set("refresh_token", this.#instance.auth.token?.refresh_token!); - body.set("client_id", this.#instance.auth.clientID); - body.set("client_secret", this.#instance.auth.clientSecret); + body.set("refresh_token", oauth.token!.refresh_token); + body.set("client_id", oauth.clientID); + body.set("client_secret", oauth.clientSecret); const options: RequestOptions = { method: "POST", @@ -132,6 +194,18 @@ export default class OAuthClient { } }; - return await Request.json(url, options, body.toString()); + const token: SNOAuthToken = await Request.json(url, options, body.toString()) + .catch((reason: any) => { + if (reason instanceof ResponseError) { + const response: Response = (reason).response; + if (Response.isUnauthorized(response)) { + throw new OAuthRefreshTokenExpired(reason); + } + } + throw Error(reason); + }); + + this.setToken(token); + return token; } } \ No newline at end of file diff --git a/modules/core/RESTClient.ts b/modules/core/RESTClient.ts new file mode 100644 index 0000000..4030bd6 --- /dev/null +++ b/modules/core/RESTClient.ts @@ -0,0 +1,88 @@ +import http, { RequestOptions } from "https"; +import { URLSearchParams } from "url"; +import HttpsProxyAgent from "https-proxy-agent"; +import Assert from "../util/Assert.js"; +import { Tab } from "docx"; +import OAuthClient from "./OAuthClient"; +import { InstanceAuthenticationData, InstanceConfig } from "../@types/instance-extended.js"; +import Request, { Response } from "./Request"; + +export enum RESPONSE_STATUS { + OK = 200, + NOT_FOUND = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + ERROR = 500 +}; + +enum TableAPI { + UPDATE_XML_PATH = "/api/now/table/sys_update_xml", + UPDATE_SET_PATH = "/api/now/table/sys_update_set", + DICTIONARY_PATH = "/api/now/table/sys_dictionary", + DB_OBJECT_PATH = "/api/now/table/sys_db_object", + USER_PREFERENCE_PATH = "/api/now/table/sys_user_preference" +} + +export class RESTTableData { + +} + +export default class RESTClient { + private instance: InstanceConfig; + private oauthClient: OAuthClient; + + constructor(instance: InstanceConfig) { + this.instance = instance; + this.oauthClient = new OAuthClient(instance); + } + + /** + * + * @returns true if connection is succesfull; Rejects promise with an error message if connection fails + */ + async testConnection(): Promise { + const oauth: InstanceAuthenticationData = this.instance.auth; + const url: URL = new URL(TableAPI.USER_PREFERENCE_PATH, this.instance.baseUrl); + url.searchParams.set("sysparm_fields", "sys_id"); + url.searchParams.set("sysparm_limit", "1"); + url.searchParams.set("sysparm_no_count", "true"); + url.searchParams.set("sysparm_suppress_pagination_header", "true"); + + const options: RequestOptions = { + method: "GET", + headers: { + "content-type": "application/json", + "accept": "application/json" + } + }; + + if (oauth.token != null) { + if (this.oauthClient.isTokenExpired()) { + await this.oauthClient.refreshToken(); + // refresh config instance with a new token + this.instance = this.oauthClient.getInstanceConfig(); + } + options.headers!.authorization = `Bearer ${oauth.token.access_token}`; + } + + const response: Response = await Request.request(url, options) + .catch((reason: any) => { + + return new Response(null, ""); + }); + + if (response.http != null) { + // check for error codes and reject with an error message + //return Promise.reject("Reason"); + } + + return Promise.reject("Reason"); + } + + /*requestUpdateXMLByUpdateSetQuery + + requestUpdateXMLByUpdateSetIds + + requestTable*/ + +} \ No newline at end of file diff --git a/modules/core/RESTRequest.ts b/modules/core/RESTRequest.ts deleted file mode 100644 index 387f7ff..0000000 --- a/modules/core/RESTRequest.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { InstanceData } from "./InstanceManager"; - -import http from "https"; -import HttpsProxyAgent from "https-proxy-agent"; -import Assert from "../util/Assert.js"; - -export enum RESPONSE_STATUS { - OK = 200, - NOT_FOUND = 400, - UNAUTHORIZED = 401, - FORBIDDEN = 403, - ERROR = 500 -}; - -export default class RESTRequest { - #instance; - - constructor(instance: InstanceData) { - this.#instance = instance; - } - - #requestOAuthToken() { - - } - - #refreshOAuthToken() { - - } -} \ No newline at end of file diff --git a/modules/core/Request.ts b/modules/core/Request.ts index 47c7fa3..106037f 100644 --- a/modules/core/Request.ts +++ b/modules/core/Request.ts @@ -3,7 +3,7 @@ import agent, { HttpsProxyAgent } from "https-proxy-agent"; import Assert from "../util/Assert.js"; import { IncomingMessage } from "http"; -export enum RESPONSE_STATUS { +export enum ResponseStatus { OK = 200, NOT_FOUND = 400, UNAUTHORIZED = 401, @@ -15,7 +15,54 @@ export enum RESPONSE_STATUS { } */ +export class Response { + readonly http: IncomingMessage | null; + readonly data: string; + constructor(httpResponse: IncomingMessage | null, data: string) { + this.http = httpResponse; + this.data = data; + } + + private static isStatus(message: IncomingMessage | null, status: ResponseStatus) { + if (message == null) { + return false; + } + return message.statusCode === status; + } + + static isOK(response: Response): boolean { + return Response.isStatus(response.http, ResponseStatus.OK); + } + + static isNotFound(response: Response): boolean { + return Response.isStatus(response.http, ResponseStatus.NOT_FOUND); + } + + static isUnauthorized(response: Response): boolean { + return Response.isStatus(response.http, ResponseStatus.UNAUTHORIZED); + } + + static isForbidden(response: Response): boolean { + return Response.isStatus(response.http, ResponseStatus.FORBIDDEN); + } + + static isError(response: Response): boolean { + return Response.isStatus(response.http, ResponseStatus.ERROR); + } +} + +export class ResponseError extends Error { + readonly response: Response; + constructor(message: string, response: Response, e?: any) { + super(message, e); + this.response = response; + } +} + /** + * https.request wrapper + * + * Note: * See https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB0534905 * In the REST world, PUT and PATCH have different semantics. PUT means replace the entire resource with given data * (so null out fields if they are not provided in the request), while PATCH means replace only specified fields. @@ -29,33 +76,42 @@ export default class Request { return new HttpsProxyAgent(proxy); } - static async request(url: URL, options: RequestOptions, body?: string): Promise { + /** + * Unless Error is thrown, rejects promise with ResponseError + * + * @param url + * @param options + * @param body + * @returns + */ + static async request(url: URL, options: RequestOptions, body?: string): Promise { return new Promise((resolve, reject) => { const request = http.request( url, options, - (response: IncomingMessage) => { - let data: string = ""; - response.setEncoding(Request.ENCODING); + (res: IncomingMessage) => { + let data = ""; + res.setEncoding(Request.ENCODING); - response.on("data", (chunk: string) => { + res.on("data", (chunk: string) => { data += chunk; }); - response.on("end", () => { + res.on("end", () => { + const response = new Response(res, data); // If response status code is not 200 resolve reject promise with an error - if (response.statusCode !== RESPONSE_STATUS.OK) { - reject([response, data]); + if (!Response.isOK(response)) { + reject(response); return; } - resolve(data); + resolve(response); }); } ); request.on("error", (e) => { - reject([null, e]); + reject(`Unexpected error occured. Error: ${e}`); }); request.on('timeout', function () { @@ -75,8 +131,18 @@ export default class Request { }); } + /** + * Perform request and parse JSON from successfull response. Otherwise handle with Promise.catch + * @param url {URL} URL to perform request to + * @param options {RequestOptions} Request options + * @param body {string} optional; Request body + * @returns {T} parsed JSON + */ static async json(url: URL, options: RequestOptions, body?: string): Promise { - const data = await Request.request(url, options, body); + const {data: data} = await Request.request(url, options, body); + if (data == null) { + return null as T; + } return JSON.parse(data); } } \ No newline at end of file diff --git a/modules/core/UpdateXML.js b/modules/core/UpdateXML.js deleted file mode 100644 index 20e632f..0000000 --- a/modules/core/UpdateXML.js +++ /dev/null @@ -1,62 +0,0 @@ -const Assert = require("../util/Assert.js"); - -const UpdateXMLAction = { - INSERT_OR_UPDATE: "INSERT_OR_UPDATE", - DELETE: "DELETE" -}; -Object.freeze(UpdateXMLAction); - -class UpdateXML { - constructor(data) { - Assert.notNull(data, "Data must not be null."); - Assert.isObject(data, "Data must be an Object."); - Assert.objectContainsAllProperties(data, ["sysId", "name", "action", "createdBy", "createdOn", "updatedBy", "updatedOn", "type", "targetName", "updateSet", "payload"], "Data object must contain all of the following properties {0}."); - - const propertyConfig = { - configurable: false, - writable: false, - enumerable: true - }; - - // Immutable properties - Object.defineProperty(this, "name", Object.assign({}, propertyConfig, {value: data.name})); - Object.defineProperty(this, "id", Object.assign({}, propertyConfig, {value: data.sysId})); - // Object.defineProperty(this, "number", Object.assign({}, propertyConfig, { - // value: (() => { - // return customAlphabet("1234567890ABCDEF", 6)(); - // })() - // })); - Object.defineProperty(this, "action", Object.assign({}, propertyConfig, {value: data.action})); - Object.defineProperty(this, "type", Object.assign({}, propertyConfig, {value: data.type})); - Object.defineProperty(this, "targetName", Object.assign({}, propertyConfig, {value: data.targetName})); - Object.defineProperty(this, "updateSet", Object.assign({}, propertyConfig, {value: data.updateSet})); - Object.defineProperty(this, "payload", Object.assign({}, propertyConfig, {value: data.payload})); - Object.defineProperty(this, "createdBy", Object.assign({}, propertyConfig, {value: data.createdBy})); - Object.defineProperty(this, "createdOn", Object.assign({}, propertyConfig, {value: data.createdOn})); - Object.defineProperty(this, "updatedBy", Object.assign({}, propertyConfig, {value: data.updatedBy})); - Object.defineProperty(this, "updatedOn", Object.assign({}, propertyConfig, {value: data.updatedOn})); - - // These 2 properties should be scanned from the payload, if not provided set null - Object.defineProperty(this, "targetTable", Object.assign({}, propertyConfig, {value: data.targetTable || null})); - Object.defineProperty(this, "targetId", Object.assign({}, propertyConfig, {value: data.targetId || null})); - } - - toJSON() { - return { - name: this.name, - id: this.id, - action: this.action, - type: this.type, - targetName: this.targetName, - targetTable: this.targetTable, - targetId: this.targetId, - updateSet: this.updateSet, - createdBy: this.createdBy, - createdOn: this.createdOn, - updatedBy: this.updatedBy, - updatedOn: this.updatedOn - }; - } -} - -module.exports = UpdateXML; \ No newline at end of file diff --git a/modules/core/UpdateXML.ts b/modules/core/UpdateXML.ts new file mode 100644 index 0000000..d7d8659 --- /dev/null +++ b/modules/core/UpdateXML.ts @@ -0,0 +1,24 @@ +export enum UpdateXMLAction { + INSERT_OR_UPDATE = "INSERT_OR_UPDATE", + DELETE = "DELETE" +}; + +export default interface UpdateXML { + name: string; + id: string; + action: UpdateXMLAction; + type: string; + targetName: string; + updateSet: string; + payload: string; + createdBy: string; + createdOn: string; + updatedBy: string; + updatedOn: string; + + // These 2 properties should be scanned from the payload, if not provided set null + targetTable: string | null; + targetId: string | null; + + toJSON(): any +} \ No newline at end of file diff --git a/modules/linter/Profile.js b/modules/linter/Profile.js index 53d7f27..b7caf19 100644 --- a/modules/linter/Profile.js +++ b/modules/linter/Profile.js @@ -5,7 +5,7 @@ const os = require("os"); const path = require("path"); const Assert = require("../util/Assert.js"); -const Instance = require("../core/_Instance.js/index.js"); +const Instance = require("../core/_Instance.js"); const Request = require("../core/Request.js"); /** diff --git a/spec/coreSpec/InstanceSpec.js b/spec/coreSpec/InstanceSpec.js index 4c2ba0e..eee8b1b 100644 --- a/spec/coreSpec/InstanceSpec.js +++ b/spec/coreSpec/InstanceSpec.js @@ -1,5 +1,5 @@ /* eslint-disable no-magic-numbers */ -const Instance = require("../../modules/core/_Instance.js/index.js"); +const Instance = require("../../modules/core/_Instance.js"); const HashHelper = require("../../modules/util/HashHelper.js"); describe("Instance", () => { diff --git a/tsconfig.json b/tsconfig.json index 9550caf..a022299 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,22 @@ { "compilerOptions": { - "target": "es2022", "module": "commonjs", - "alwaysStrict": true, + "target": "es2022", + "outDir": "./build", + "typeRoots": ["./node_modules/@types", "./modules/@types"], + "declaration": true, + "sourceMap": true, + + /* Strict */ "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + + /* beginners settings? */ "resolveJsonModule": true, "esModuleInterop": true, "allowJs": true, "allowSyntheticDefaultImports": true, - "noImplicitAny": true, - "declaration": true, - "outDir": "./build", }, "include": ["./modules/**/*", "./spec/**/*"], "exclude": ["node_modules", "./spec/support/*.json"]