diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 6eeb41b6d5..8be93026b4 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -1561,8 +1561,9 @@ export class BitGoAPI implements BitGoBase { const authUrl = this.microservicesUrl('/api/auth/v1/accesstoken'); const request = this.post(authUrl); - if (!this._ecdhXprv) { - // without a private key, the user cannot decrypt the new access token the server will send + const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false; + if (!this._ecdhXprv && !strategyAuthenticated) { + // No ECDH key and no authenticated HMAC strategy — fall back to V1 Bearer auth. request.forceV1Auth = true; debug('forcing v1 auth for adding access token using token %s', this._token?.substr(0, 8)); } @@ -1576,8 +1577,14 @@ 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); - const responseDetails = await this.handleTokenIssuanceAsync(response.body); - response.body.token = responseDetails.token; + // 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.handleTokenIssuanceAsync(response.body); + response.body.token = responseDetails.token; + } return handleResponseResult()(response); } catch (e) { diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 7717fa7ac2..59a934ad6d 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -699,6 +699,94 @@ describe('Constructor', function () { setTokenStub.called.should.be.false(); }); }); + + describe('addAccessToken()', function () { + const validParams = { + label: 'test-token', + scope: ['wallet_view_all'], + duration: 3600, + }; + + it('should use HMAC auth when ecdhXprv is absent but hmacAuthStrategy is authenticated', async function () { + 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 + bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' }); + + const scope = nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xnewplaintoken', + label: 'test-token', + }); + + 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 + result.token.should.equal('v2xnewplaintoken'); + }); + + it('should return plain token from response body when ecdhXprv is absent but strategyAuthenticated', async function () { + const handleTokenSpy = sinon.spy(BitGoAPI.prototype, 'handleTokenIssuanceAsync'); + const { strategy } = makeStrategy({ + isAuthenticated: sinon.stub().returns(true), + }); + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xplaintoken', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + // handleTokenIssuanceAsync should NOT be called — no ECDH decryption needed + 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 () { + const { strategy } = makeStrategy({ + isAuthenticated: sinon.stub().returns(false), + }); + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + bitgo.authenticateWithAccessToken({ accessToken: 'v2xlegacytoken' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xlegacyresult', + label: 'test-token', + }); + + 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 () { + 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' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xresult', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + // Without isAuthenticated, should fall back to V1 auth + (result as any).warning.should.match(/protocol downgrade/); + }); + }); }); describe('constants parameter', function () {