Skip to content
Open
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
12 changes: 5 additions & 7 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1449,9 +1449,11 @@ export class BitGoAPI implements BitGoBase {
const authUrl = this.microservicesUrl('/api/auth/v1/accesstoken');
const request = this.post(authUrl);

const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false;
if (!this._ecdhXprv && !strategyAuthenticated) {
// No ECDH key and no authenticated HMAC strategy — fall back to V1 Bearer auth.
if (!this._ecdhXprv) {
// No ECDH key — server cannot encrypt the response token for us. Fall back to V1
// so the server returns the plaintext token directly. This covers SSO/enterprise
// users (Okta, Entra) who never receive ecdhXprv at login because they have no
// BitGo password. HMAC request signing is orthogonal and continues to work.
request.forceV1Auth = true;
debug('forcing v1 auth for adding access token using token %s', this._token?.substr(0, 8));
}
Expand All @@ -1465,10 +1467,6 @@ export class BitGoAPI implements BitGoBase {
// verify the authenticity of the server's response before proceeding any further
await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion);

// When ecdhXprv is available, the server returns an ECDH-encrypted token that
// must be decrypted. When the HMAC strategy is authenticated but ecdhXprv is
// absent (e.g. SSO/WebCrypto users), the server includes the plain token
// directly in response.body.token — no decryption step needed.
if (this._ecdhXprv) {
const responseDetails = await this.handleTokenIssuance(response.body);
response.body.token = responseDetails.token;
Expand Down
24 changes: 12 additions & 12 deletions modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,13 +667,16 @@ describe('Constructor', function () {
duration: 3600,
};

it('should use HMAC auth when ecdhXprv is absent but hmacAuthStrategy is authenticated', async function () {
it('should force V1 auth and return plain token when ecdhXprv is absent, even if hmacAuthStrategy is authenticated (SSO/enterprise users)', async function () {
// Regression test for WCN-1790 / ANT-963: HMAC request signing and ECDH response
// encryption are orthogonal. forceV1Auth tells the server to return a plaintext token
// instead of ECDH-encrypting it. It must be set whenever ecdhXprv is absent,
// regardless of whether the HMAC strategy is active.
const { strategy } = makeStrategy({
isAuthenticated: sinon.stub().returns(true),
});
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
// Do NOT set _ecdhXprv — simulates SSO/WebCrypto session
// Set a v2x token so the request goes through the v2 auth path
// Do NOT set _ecdhXprv — simulates SSO/enterprise session (Okta, Entra, etc.)
bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });

const scope = nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
Expand All @@ -684,13 +687,12 @@ describe('Constructor', function () {
const result = await bitgo.addAccessToken(validParams);

scope.isDone().should.be.true();
// forceV1Auth should NOT have been set, so no downgrade warning
(result as any).should.not.have.property('warning');
// The plain token from the response body should be returned directly
// forceV1Auth MUST be set — server returns plaintext token since we have no decryption key
(result as any).warning.should.match(/protocol downgrade/);
result.token.should.equal('v2xnewplaintoken');
});

it('should return plain token from response body when ecdhXprv is absent but strategyAuthenticated', async function () {
it('should not call handleTokenIssuance when ecdhXprv is absent', async function () {
const handleTokenSpy = sinon.spy(BitGoAPI.prototype, 'handleTokenIssuance');
const { strategy } = makeStrategy({
isAuthenticated: sinon.stub().returns(true),
Expand All @@ -705,14 +707,14 @@ describe('Constructor', function () {

const result = await bitgo.addAccessToken(validParams);

// handleTokenIssuance should NOT be called — no ECDH decryption needed
// handleTokenIssuance must not be called — no ecdhXprv to decrypt with
handleTokenSpy.called.should.be.false();
result.token.should.equal('v2xplaintoken');

handleTokenSpy.restore();
});

it('should still force V1 auth when neither ecdhXprv nor strategy is authenticated', async function () {
it('should force V1 auth when ecdhXprv is absent and strategy is not authenticated', async function () {
const { strategy } = makeStrategy({
isAuthenticated: sinon.stub().returns(false),
});
Expand All @@ -726,13 +728,11 @@ describe('Constructor', function () {

const result = await bitgo.addAccessToken(validParams);

// V1 auth path should add the downgrade warning
(result as any).warning.should.match(/protocol downgrade/);
});

it('should still force V1 auth when isAuthenticated is not defined on strategy', async function () {
it('should force V1 auth when isAuthenticated is not defined on strategy', async function () {
const { strategy } = makeStrategy();
// strategy has no isAuthenticated method by default from makeStrategy
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
bitgo.authenticateWithAccessToken({ accessToken: 'v2xnoauthmethod' });

Expand Down
Loading