Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions packages/v1-ready/salesforce/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ const jsforce = require('jsforce');
const crypto = require('crypto');

class Api extends OAuth2Requester {
// URL-unreserved and outside the base64url alphabet.
static STATE_VERIFIER_DELIMITER = '~';

constructor(params) {
super(params);
this.jsforce = jsforce;
Expand Down Expand Up @@ -53,16 +56,29 @@ class Api extends OAuth2Requester {
this.conn.oauth2 = this.oauth2;
const url = this.oauth2.getAuthorizationUrl({ scope: this.scope });
const verifier = this.oauth2.codeVerifier;
if (verifier) {
const urlObj = new URL(url);
urlObj.searchParams.set('state', this._encryptVerifier(verifier));
return urlObj.toString();
const callerState = this.state || null;
if (!verifier) {
return callerState ? this._withState(url, callerState) : url;
}
return url;
const encoded = this._encryptVerifier(verifier);
return this._withState(
url,
callerState
? `${callerState}${Api.STATE_VERIFIER_DELIMITER}${encoded}`
: encoded
);
}

_withState(url, state) {
const urlObj = new URL(url);
urlObj.searchParams.set('state', state);
return urlObj.toString();
}

restoreVerifierFromState(encryptedState) {
const verifier = this._decryptVerifier(encryptedState);
restoreVerifierFromState(state) {
const at = String(state).lastIndexOf(Api.STATE_VERIFIER_DELIMITER);
const encrypted = at === -1 ? state : String(state).slice(at + 1);
const verifier = this._decryptVerifier(encrypted);
this.oauth2.codeVerifier = verifier;
this.conn.oauth2.codeVerifier = verifier;
}
Expand Down
41 changes: 41 additions & 0 deletions packages/v1-ready/salesforce/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jest.mock('jsforce', () => {
return {
OAuth2: jest.fn().mockImplementation((params) => ({
getAuthorizationUrl: mockGetAuthorizationUrl,
codeVerifier: params?.useVerifier ? 'test-code-verifier' : undefined,
_params: params,
})),
Connection: jest.fn().mockImplementation(() => mockConnection),
Expand Down Expand Up @@ -119,3 +120,43 @@ describe('Salesforce Api', () => {
});
});
});

describe('getAuthorizationUri state handling', () => {
const stateOf = (url) =>
new URL(url).searchParams.get('state');

it('preserves the caller state and appends the encrypted verifier', () => {
const api = new Api({ ...baseParams, state: 'testfirma.NONCE123' });
const state = stateOf(api.getAuthorizationUri());
expect(state.startsWith('testfirma.NONCE123')).toBe(true);
expect(state).not.toBe('testfirma.NONCE123');
});

it('keeps the caller state parseable by a first-dot split', () => {
const api = new Api({ ...baseParams, state: 'testfirma.NONCE123' });
const state = stateOf(api.getAuthorizationUri());
expect(state.split('.', 1)[0]).toBe('testfirma');
});

it('round-trips the verifier out of the composed state', () => {
const api = new Api({ ...baseParams, state: 'testfirma.NONCE123' });
const state = stateOf(api.getAuthorizationUri());
api.restoreVerifierFromState(state);
expect(api.oauth2.codeVerifier).toBe('test-code-verifier');
});

it('emits the bare encrypted verifier when the caller supplied no state', () => {
const api = new Api(baseParams);
const state = stateOf(api.getAuthorizationUri());
expect(state).toBeTruthy();
api.restoreVerifierFromState(state);
expect(api.oauth2.codeVerifier).toBe('test-code-verifier');
});

it('still restores from a legacy state that carries only the verifier', () => {
const api = new Api(baseParams);
const legacy = api._encryptVerifier('test-code-verifier');
api.restoreVerifierFromState(legacy);
expect(api.oauth2.codeVerifier).toBe('test-code-verifier');
});
});
Loading