diff --git a/packages/v1-ready/salesforce/api.js b/packages/v1-ready/salesforce/api.js index 13c7317..0ba369f 100644 --- a/packages/v1-ready/salesforce/api.js +++ b/packages/v1-ready/salesforce/api.js @@ -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; @@ -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; } diff --git a/packages/v1-ready/salesforce/test/api.test.js b/packages/v1-ready/salesforce/test/api.test.js index 322c9e7..ddf75da 100644 --- a/packages/v1-ready/salesforce/test/api.test.js +++ b/packages/v1-ready/salesforce/test/api.test.js @@ -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), @@ -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'); + }); +});