Skip to content

Commit

Permalink
Merge pull request #620 from amtrack/refactor/browserforce-login
Browse files Browse the repository at this point in the history
fix: simplify login by refreshing auth on start
  • Loading branch information
amtrack authored May 17, 2024
2 parents 91ef7cc + 183259c commit 60ecd09
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 89 deletions.
91 changes: 21 additions & 70 deletions src/browserforce.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Org } from '@salesforce/core';
import { type Ux } from '@salesforce/sf-plugins-core';
import pRetry, { AbortError } from 'p-retry';
import { Browser, Frame, Page, WaitForOptions, launch } from 'puppeteer';
import * as querystring from 'querystring';
import { parse } from 'url';

const POST_LOGIN_PATH = 'setup/forcecomHomepage.apexp';
import pRetry from 'p-retry';
import { Browser, Frame, launch, Page, WaitForOptions } from 'puppeteer';
import { LoginPage } from './pages/login';

const ERROR_DIV_SELECTOR = '#errorTitle';
const ERROR_DIVS_SELECTOR = 'div.errorMsg';
Expand All @@ -31,66 +28,47 @@ export class Browserforce {
],
headless: process.env.BROWSER_DEBUG === 'true' ? false : 'new'
});
await this.openPage(
`secur/frontdoor.jsp?sid=${this.org.getConnection().accessToken}&retURL=${encodeURIComponent(POST_LOGIN_PATH)}`
);
const page = await this.getNewPage();
try {
const loginPage = new LoginPage(page);
await loginPage.login(this.org);
} finally {
await page.close();
}
return this;
}

public async logout(): Promise<Browserforce> {
await this.browser.close();
if (this.browser) {
await this.browser.close();
}
return this;
}

public async throwPageErrors(page: Page): Promise<void> {
await throwPageErrors(page);
}

public async getNewPage(): Promise<Page> {
const page = await this.browser.newPage();
page.setDefaultNavigationTimeout(parseInt(process.env.BROWSERFORCE_NAVIGATION_TIMEOUT_MS ?? '90000', 10));
await page.setViewport({ width: 1024, height: 768 });
return page;
}

// path instead of url
public async openPage(urlPath: string, options?: WaitForOptions): Promise<Page> {
let page: Page;
const result = await pRetry(
async () => {
page = await this.browser.newPage();
page.setDefaultNavigationTimeout(parseInt(process.env.BROWSERFORCE_NAVIGATION_TIMEOUT_MS ?? '90000', 10));
await page.setViewport({ width: 1024, height: 768 });
page = await this.getNewPage();
const url = `${this.getInstanceUrl()}/${urlPath}`;
const parsedUrl = parse(urlPath);
const response = await page.goto(url, options);
if (response) {
if (!response.ok()) {
await this.throwPageErrors(page);
throw new Error(`${response.status()}: ${response.statusText()}`);
}
if (response.url().indexOf('/?ec=302') > 0) {
const salesforceUrls = [this.getInstanceUrl(), this.getLightningUrl()].filter((u) => u);
if (salesforceUrls.some((salesforceUrl) => response.url().startsWith(salesforceUrl))) {
// the url looks ok so it is a login error
throw new AbortError('login failed');
} else if (parsedUrl.pathname === 'secur/frontdoor.jsp' && parsedUrl.query?.includes('retURL=')) {
if (this.logger) {
this.logger.warn('trying frontdoor workaround...');
}
// try opening page directly without frontdoor as login might have already been successful
const qsUrl = querystring.parse(parsedUrl.query);
urlPath = Array.isArray(qsUrl.retURL) ? qsUrl.retURL[0] : qsUrl.retURL!;
throw new Error('frontdoor error');
} else {
// the url is not as expected
const redactedUrl = response
.url()
.replace(/sid=(.*)/, 'sid=<REDACTED>')
.replace(/sid%3D(.*)/, 'sid=<REDACTED>');
if (this.logger) {
this.logger.warn(
`expected ${this.getInstanceUrl()} or ${this.getLightningUrl()} but got: ${redactedUrl}`
);
this.logger.warn('refreshing auth...');
}
await this.org.refreshAuth();
throw new Error('redirection failed');
}
}
}
return page;
},
Expand Down Expand Up @@ -142,37 +120,10 @@ export class Browserforce {
return null;
}

public getInstanceDomain(): string {
const instanceUrl = this.getInstanceUrl();
// csN.salesforce.com
// acme--<sandboxName>.csN.my.salesforce.com
// NOT: test.salesforce.com login.salesforce.com
const matches = instanceUrl.match(/https:\/\/(.*)\.salesforce\.com/);
if (matches) {
const parts = matches[1].split('.');
if (parts.length === 3 && parts[2] === 'my') {
return `${parts[0]}.${parts[1]}`;
} else if (!['test', 'login'].includes(parts[0])) {
return parts[0];
}
}
throw new Error(`Could not determine the instance URL from: ${instanceUrl}`);
}

public getInstanceUrl(): string {
// sometimes the instanceUrl includes a trailing slash
return this.org.getConnection().instanceUrl?.replace(/\/$/, '');
}

public getLightningUrl(): string {
const myDomain = this.getMyDomain();
const instanceDomain = this.getInstanceDomain();
const myDomainOrInstance = myDomain || instanceDomain;
if (myDomainOrInstance) {
return `https://${myDomainOrInstance}.lightning.force.com`;
}
throw new Error(`Could not determine the lightning URL from: ${myDomainOrInstance} and ${instanceDomain}`);
}
}

export async function throwPageErrors(page: Page): Promise<void> {
Expand Down
47 changes: 47 additions & 0 deletions src/pages/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type Org } from '@salesforce/core';
import { type Page } from 'puppeteer';

const ERROR_DIV_SELECTOR = '#error';
const PATH = 'secur/frontdoor.jsp';
const POST_LOGIN_PATH = 'setup/forcecomHomepage.apexp';

export class LoginPage {
private page: Page;

constructor(page: Page) {
this.page = page;
}

async login(org: Org) {
try {
await org.refreshAuth();
} catch (_) {
throw new Error('login failed');
}
const conn = org.getConnection();
await this.page.goto(
`${conn.instanceUrl}/${PATH}?sid=${conn.accessToken}&retURL=${encodeURIComponent(POST_LOGIN_PATH)}`,
{
// should have waited at least 500ms for network connections, redirects should probably have happened already
waitUntil: ['load', 'networkidle2']
}
);
const url = new URL(this.page.url());
if (url.searchParams.has('startURL')) {
// when query param startURL exists, the login failed
// e.g. /?ec=302&startURL=https...
await this.throwPageErrors();
}
return this;
}

async throwPageErrors(): Promise<void> {
const errorHandle = await this.page.$(ERROR_DIV_SELECTOR);
if (errorHandle) {
const errorMessage = (await this.page.evaluate((div: HTMLDivElement) => div.innerText, errorHandle))?.trim();
if (errorMessage) {
throw new Error(errorMessage);
}
}
}
}
30 changes: 11 additions & 19 deletions test/browserforce.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { Ux } from '@salesforce/sf-plugins-core';
import { Org } from '@salesforce/core';
import { Ux } from '@salesforce/sf-plugins-core';
import assert from 'assert';
import { Browserforce } from '../src/browserforce';

describe('Browser', function () {
describe('Browserforce', function () {
this.slow('30s');
this.timeout('2m');
describe('login()', () => {
it('should successfully login with valid credentials', async () => {
// handled by e2e-setup.ts
assert.ok(true);
});

it('should fail login with invalid credentials', async () => {
const fakeOrg = await Org.create({});
fakeOrg.getConnection().accessToken = 'invalid';
const org = await Org.create({});
const conn = org.getConnection();
conn.logout();
conn.accessToken = 'invalid';
conn.refreshToken = 'invalid';
const ux = new Ux();
const bf = new Browserforce(fakeOrg, ux);
const bf = new Browserforce(org, ux);
await assert.rejects(async () => {
await bf.login();
}, /login failed/);
});
await bf.logout();
});
});
Expand All @@ -27,18 +31,6 @@ describe('Browser', function () {
assert.notDeepStrictEqual(myDomain, null);
});
});
describe('getInstanceDomain()', () => {
it('should determine an instance domain for a scratch org with my domain', async () => {
const instanceDomain = global.bf.getInstanceDomain();
assert.notDeepStrictEqual(instanceDomain, null);
});
});
describe('getLightningUrl()', () => {
it('should determine a LEX URL for a scratch org with my domain', async () => {
const lexUrl = global.bf.getLightningUrl();
assert.notDeepStrictEqual(lexUrl, null);
});
});
describe('waitForSelectorInFrameOrPage()', () => {
it('should query a selector in LEX and Classic UI', async () => {
const page = await global.bf.openPage('lightning/setup/ExternalStrings/home');
Expand Down

0 comments on commit 60ecd09

Please sign in to comment.