Skip to content

Commit 3a61f6a

Browse files
Merge pull request #9015 from BitGo/ANT-963
feat: skip forceV1Auth when HMAC present for SSO
2 parents b0e8643 + c578250 commit 3a61f6a

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,8 +1561,9 @@ export class BitGoAPI implements BitGoBase {
15611561
const authUrl = this.microservicesUrl('/api/auth/v1/accesstoken');
15621562
const request = this.post(authUrl);
15631563

1564-
if (!this._ecdhXprv) {
1565-
// without a private key, the user cannot decrypt the new access token the server will send
1564+
const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false;
1565+
if (!this._ecdhXprv && !strategyAuthenticated) {
1566+
// No ECDH key and no authenticated HMAC strategy — fall back to V1 Bearer auth.
15661567
request.forceV1Auth = true;
15671568
debug('forcing v1 auth for adding access token using token %s', this._token?.substr(0, 8));
15681569
}
@@ -1576,8 +1577,14 @@ export class BitGoAPI implements BitGoBase {
15761577
// verify the authenticity of the server's response before proceeding any further
15771578
await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion);
15781579

1579-
const responseDetails = await this.handleTokenIssuanceAsync(response.body);
1580-
response.body.token = responseDetails.token;
1580+
// When ecdhXprv is available, the server returns an ECDH-encrypted token that
1581+
// must be decrypted. When the HMAC strategy is authenticated but ecdhXprv is
1582+
// absent (e.g. SSO/WebCrypto users), the server includes the plain token
1583+
// directly in response.body.token — no decryption step needed.
1584+
if (this._ecdhXprv) {
1585+
const responseDetails = await this.handleTokenIssuanceAsync(response.body);
1586+
response.body.token = responseDetails.token;
1587+
}
15811588

15821589
return handleResponseResult<AddAccessTokenResponse>()(response);
15831590
} catch (e) {

modules/sdk-api/test/unit/bitgoAPI.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,94 @@ describe('Constructor', function () {
699699
setTokenStub.called.should.be.false();
700700
});
701701
});
702+
703+
describe('addAccessToken()', function () {
704+
const validParams = {
705+
label: 'test-token',
706+
scope: ['wallet_view_all'],
707+
duration: 3600,
708+
};
709+
710+
it('should use HMAC auth when ecdhXprv is absent but hmacAuthStrategy is authenticated', async function () {
711+
const { strategy } = makeStrategy({
712+
isAuthenticated: sinon.stub().returns(true),
713+
});
714+
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
715+
// Do NOT set _ecdhXprv — simulates SSO/WebCrypto session
716+
// Set a v2x token so the request goes through the v2 auth path
717+
bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });
718+
719+
const scope = nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
720+
token: 'v2xnewplaintoken',
721+
label: 'test-token',
722+
});
723+
724+
const result = await bitgo.addAccessToken(validParams);
725+
726+
scope.isDone().should.be.true();
727+
// forceV1Auth should NOT have been set, so no downgrade warning
728+
(result as any).should.not.have.property('warning');
729+
// The plain token from the response body should be returned directly
730+
result.token.should.equal('v2xnewplaintoken');
731+
});
732+
733+
it('should return plain token from response body when ecdhXprv is absent but strategyAuthenticated', async function () {
734+
const handleTokenSpy = sinon.spy(BitGoAPI.prototype, 'handleTokenIssuanceAsync');
735+
const { strategy } = makeStrategy({
736+
isAuthenticated: sinon.stub().returns(true),
737+
});
738+
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
739+
bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });
740+
741+
nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
742+
token: 'v2xplaintoken',
743+
label: 'test-token',
744+
});
745+
746+
const result = await bitgo.addAccessToken(validParams);
747+
748+
// handleTokenIssuanceAsync should NOT be called — no ECDH decryption needed
749+
handleTokenSpy.called.should.be.false();
750+
result.token.should.equal('v2xplaintoken');
751+
752+
handleTokenSpy.restore();
753+
});
754+
755+
it('should still force V1 auth when neither ecdhXprv nor strategy is authenticated', async function () {
756+
const { strategy } = makeStrategy({
757+
isAuthenticated: sinon.stub().returns(false),
758+
});
759+
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
760+
bitgo.authenticateWithAccessToken({ accessToken: 'v2xlegacytoken' });
761+
762+
nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
763+
token: 'v2xlegacyresult',
764+
label: 'test-token',
765+
});
766+
767+
const result = await bitgo.addAccessToken(validParams);
768+
769+
// V1 auth path should add the downgrade warning
770+
(result as any).warning.should.match(/protocol downgrade/);
771+
});
772+
773+
it('should still force V1 auth when isAuthenticated is not defined on strategy', async function () {
774+
const { strategy } = makeStrategy();
775+
// strategy has no isAuthenticated method by default from makeStrategy
776+
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
777+
bitgo.authenticateWithAccessToken({ accessToken: 'v2xnoauthmethod' });
778+
779+
nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
780+
token: 'v2xresult',
781+
label: 'test-token',
782+
});
783+
784+
const result = await bitgo.addAccessToken(validParams);
785+
786+
// Without isAuthenticated, should fall back to V1 auth
787+
(result as any).warning.should.match(/protocol downgrade/);
788+
});
789+
});
702790
});
703791

704792
describe('constants parameter', function () {

0 commit comments

Comments
 (0)