Skip to content

Commit

Permalink
feat: consolidate authentication and service activities / implement a…
Browse files Browse the repository at this point in the history
…uthorize code flow (#1)

* feat: revisions based on feedback

* feat: clean up request

* fix: correct result properties

* feat: consolidate authorization in service
- test for expired token and refresh when necessary
- use authorization code flow

* chore: changed sObject display name

* fix: correct empty response logic

* fix: Updates based on feedback

* fix: improve regex matcher

* fix: updates from feedback

* fix: fix token test on Delete

* modified:   src/activities/SendSalesforceRequest.ts
	modified:   src/request.ts

* fix: correct success path for delete - ensure paths are correct

* fix: simplify request pattern and better handle token refresh
  • Loading branch information
cdoak-gh committed Jan 12, 2024
1 parent 2b63ca2 commit 4ad40f8
Show file tree
Hide file tree
Showing 13 changed files with 345 additions and 368 deletions.
6 changes: 3 additions & 3 deletions src/SalesforceRequestError.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
export class SalesforceRequestError extends Error {
readonly error?: Record<string, any>;
readonly errors?: Record<string, any>[];
readonly statusCode: number;

constructor(
statusCode: number,
error?: Record<string, any>,
errors?: Record<string, any>[],
message?: string
) {
super(message || "Salesforce request failed.");
this.error = error;
this.errors = errors;
this.statusCode = statusCode;
}
}
19 changes: 16 additions & 3 deletions src/SalesforceService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
export interface SalesforceService {
accessToken: string;
token: SalesforceToken;
instanceUrl: string;
version: string;

}
clientId: string;
redirectUri: string;
}

export interface SalesforceToken {
access_token: string;
refresh_token: string;
signature: string;
scope: string;
id_token: string;
instance_url: string;
id: string;
token_type: string;
issued_at: string;
}
17 changes: 11 additions & 6 deletions src/activities/CreateSalesforceObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ interface CreateSalesforceObjectInputs {
salesforceObject: Record<string, string | number | boolean | null | undefined>;

/**
* @description The name of the object. For example, Account.
* @displayName sObject
* @description The name of the salesforce sObject. For example, Account.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm
* @required
*/
objectType: string;
sObject: string;

}

Expand All @@ -35,24 +37,27 @@ interface CreateSalesforceObjectOutputs {
* @category Salesforce
* @defaultName sfCreate
* @description Creates a Salesforce object.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info_post.htm
* @clientOnly
* @supportedApps EXB, GWV, WAB
*/
export default class CreateSalesforceObject implements IActivityHandler {

async execute(inputs: CreateSalesforceObjectInputs): Promise<CreateSalesforceObjectOutputs> {
const { salesforceService, salesforceObject, objectType } = inputs;
const { salesforceService, salesforceObject, sObject } = inputs;

if (!salesforceService) {
throw new Error("salesforceService is required");
}
if (!salesforceObject) {
throw new Error("salesforceObject is required");
}
if (!objectType) {
throw new Error("objectType is required");
if (!sObject) {
throw new Error("sObject is required");
}
const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}`;
const encodedSObject = encodeURIComponent(sObject);

const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}`;
const response = await post(salesforceService, path, salesforceObject);
return {
result: response,
Expand Down
176 changes: 159 additions & 17 deletions src/activities/CreateSalesforceService.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import type { IActivityHandler } from "@vertigis/workflow";
import { SalesforceService } from "../SalesforceService";
import { SalesforceService, SalesforceToken } from "../SalesforceService";
import { SalesforceRequestError } from "../SalesforceRequestError";

/** An interface that defines the inputs of the activity. */
interface CreateSalesforceServiceInputs {
/**
* @description An Salesforce access token.
* @displayName URL
* @description The full url to your organization's salesforce instance. (e.g. https://acme.my.salesforce.com)
* @required
*/
token: string;
/**
url: string;

/**
* @description The version of Salesforce to access.
* @required
*/
version: string;
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
version: "59.0" | string | number;

/**
* @displayName URL
* @description The URL to the salesforce service. This only needs to be provided when testing against a Salesforce sandbox.
* @displayName Client ID
* @description The Client ID of the OAuth app to sign in to.
* @required
*/
url?: string;
clientId: string;

/**
* @displayName Redirect Url
* @description The redirect URI to which the OAuth 2.0 server will send its response.
* @required
*/
redirectUri: string;


/** @description The redirect page timeout in milliseconds (optional).
*/
timeout?: number;


}

Expand All @@ -38,18 +57,141 @@ interface CreateSalesforceServiceOutputs {
* @supportedApps EXB, GWV, WAB
*/
export default class CreateSalesforceService implements IActivityHandler {
execute(inputs: CreateSalesforceServiceInputs): CreateSalesforceServiceOutputs {
const {token, url = "https://login.salesforce.com/services", version} = inputs;
async execute(inputs: CreateSalesforceServiceInputs): Promise<CreateSalesforceServiceOutputs> {
const { url, version, clientId, redirectUri, timeout } = inputs;

if (!token) {
throw new Error("token is required");
}
if (!version) {
throw new Error("version is required");
}
const saleforceUri = url.replace(/\/*$/, "");
return {
service: {instanceUrl: saleforceUri, accessToken: token, version: version },
};
if (!url) {
throw new Error("url is required");
}
if (!clientId) {
throw new Error("clientId is required");
}
if (!redirectUri) {
throw new Error("redirectUri is required");
}

const salesforceUri = url.replace(/\/*$/, "");
const authorizationUri = `${salesforceUri}/services/oauth2/authorize`;
const tokenUri = `${salesforceUri}/services/oauth2/token`;

const formattedVersion = `${version}${Number.isInteger(version) ? ".0" : ""}`;

// Assemble OAuth URL
const authorizeUrl = new URL(authorizationUri);
authorizeUrl.searchParams.append("client_id", clientId);
authorizeUrl.searchParams.append("redirect_uri", redirectUri);
authorizeUrl.searchParams.append("response_type", "code");
authorizeUrl.searchParams.append("state", generateRandomState());

const code = await authenticate(authorizeUrl, redirectUri, timeout);
const token = await getToken(tokenUri,
{
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
client_id: clientId
});
if (token) {
return {
service: {
token: token,
instanceUrl: url,
version: formattedVersion,
clientId: clientId,
redirectUri: redirectUri,
}
}
}

throw new Error(`Authentication failed when trying to access: ${url}`);
}
}

async function authenticate(uri: URL, redirectUri: string, timeout?: number): Promise<string> {

// Compute window dimensions and position
const windowArea = {
width: Math.floor(window.outerWidth * 0.8),
height: Math.floor(window.outerHeight * 0.8),
left: 0,
top: 0,
};
windowArea.left = Math.floor(window.screenX + ((window.outerWidth - windowArea.width) / 2));
windowArea.top = Math.floor(window.screenY + ((window.outerHeight - windowArea.height) / 2));
const windowOpts = `toolbar=0,scrollbars=1,status=1,resizable=1,location=1,menuBar=0,width=${windowArea.width},height=${windowArea.height},left=${windowArea.left},top=${windowArea.top}`;

const authWindow = window.open(uri, "oauth-popup", windowOpts);
return new Promise<any>((resolve, reject) => {
let timeoutHandle: number = 0;

const checkClosedHandle = setInterval(() => {
if (authWindow?.closed) {
return "canceled";
}
}, 500);
const onMessage = (e: MessageEvent<any>) => {
window.clearInterval(checkClosedHandle);
window.clearTimeout(timeoutHandle);
// Ensure the message origin matches the expected redirect URI
if (e.data && typeof e.data === "string" && redirectUri.startsWith(e.origin)) {
const parsedUrl = new URL(e.data);
const code = parsedUrl.searchParams.get("code");
const error = parsedUrl.searchParams.get("error");
window.clearInterval(checkClosedHandle);
window.removeEventListener("message", onMessage);
if (error) {
reject(error);
} if (code) {
resolve(code);
} else {
reject("OAuth callback did not provide code");
}
}
};
window.addEventListener("message", onMessage, { once: false });

timeoutHandle = window.setTimeout(() => {
window.clearInterval(checkClosedHandle);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
window.removeEventListener("message", onMessage);
try {
authWindow?.close();
} catch {
//do nothing
}
return reject("timeout");
}, timeout || 60000);

});

}



function generateRandomState(): string {
const array = new Uint32Array(10);
window.crypto.getRandomValues(array);
return array.join("");
}

async function getToken<T = SalesforceToken>(
url: string,
body?: Record<string, string>,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(body),
});

if (!response.ok) {
throw new SalesforceRequestError(response.status);
}

return await response.json();
}
18 changes: 12 additions & 6 deletions src/activities/DeleteSalesforceObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,41 @@ interface DeleteSalesforceObjectInputs {
id: string;

/**
* @description The name of the object. For example, Account.
* @displayName sObject
* @description The name of the salesforce sObject. For example, Account.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm
* @required
*/
objectType: string;
sObject: string;
}

/**
* @category Salesforce
* @defaultName sfDelete
* @description Deletes the record specified by the provided type and ID.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_retrieve_delete.htm
* @clientOnly
* @supportedApps EXB, GWV, WAB
*/
export default class DeleteSalesforceObject implements IActivityHandler {
/** Perform the execution logic of the activity. */
async execute(inputs: DeleteSalesforceObjectInputs): Promise<void> {

const { salesforceService, id, objectType } = inputs;
const { salesforceService, id, sObject } = inputs;

if (!salesforceService) {
throw new Error("salesforceService is required");
}
if (!id) {
throw new Error("id is required");
}
if (!objectType) {
throw new Error("objectType is required");
if (!sObject) {
throw new Error("sObject is required");
}
const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}/${id}`;

const encodedSObject = encodeURIComponent(sObject);
const encodedId = encodeURIComponent(id);
const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}/${encodedId}`;
await httpDelete(salesforceService, path);

}
Expand Down
19 changes: 11 additions & 8 deletions src/activities/GetSalesforceObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ interface GetSalesforceObjectInputs {
id: string;

/**
* @description The name of the object. For example, Account.
* @displayName sObject
* @description The name of the salesforce sObject. For example, Account.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm
* @required
*/
objectType: string;
sObject: string;

/**
* @description The list of fields to be returned with the object.
Expand All @@ -29,7 +31,6 @@ interface GetSalesforceObjectInputs {

}

/** An interface that defines the outputs of the activity. */
interface GetSalesforceObjectOutputs {
/**
* @description The salesforce object.
Expand All @@ -41,26 +42,28 @@ interface GetSalesforceObjectOutputs {
* @category Salesforce
* @defaultName sfObject
* @description Gets a salesforce object given its Url. You can specify the fields you want to retrieve with the optional fields parameter. If you don’t use the fields parameter, the request retrieves all standard and custom fields from the record.
* @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_retrieve_get.htm
* @clientOnly
* @supportedApps EXB, GWV, WAB
*/
export default class GetSalesforceObject implements IActivityHandler {
/** Perform the execution logic of the activity. */
async execute(inputs: GetSalesforceObjectInputs): Promise<GetSalesforceObjectOutputs> {

const { salesforceService, objectType, id, fields } = inputs;
const { salesforceService, sObject, id, fields } = inputs;

if (!salesforceService) {
throw new Error("salesforceService is required");
}
if (!objectType) {
throw new Error("objectType is required");
if (!sObject) {
throw new Error("sObject is required");
}
if (!id) {
throw new Error("id is required");
}
const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}/${id}`;

const encodedSObject = encodeURIComponent(sObject);
const encodedId = encodeURIComponent(id);
const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}/${encodedId}`;
const query = fields ? {
fields: fields?.join(","),
} : undefined;
Expand Down
Loading

0 comments on commit 4ad40f8

Please sign in to comment.