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
17 changes: 9 additions & 8 deletions modules/bitgo/test/unit/bitgo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe('BitGo Prototype Methods', function () {
});
});

describe('Authenticate in Microservices', () => {
describe('Authenticate via v2 login', () => {
let bitgo;
const authenticateRequest = {
username: 'test@bitgo.com',
Expand All @@ -125,10 +125,10 @@ describe('BitGo Prototype Methods', function () {
forceSMS: false,
};

it('goes to microservices', async function () {
it('goes to the v2 user login endpoint', async function () {
bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri: 'https://microservices.uri' } as any);
const scope = nock(BitGoJS.Environments[bitgo.getEnv()].uri)
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
user: {
username: 'test@bitgo.com',
Expand All @@ -140,10 +140,10 @@ describe('BitGo Prototype Methods', function () {
scope.isDone().should.be.true();
});

it('goes to microservices even when microservicesUri is not specified', async function () {
it('goes to the v2 user login endpoint when microservicesUri is not specified', async function () {
bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
const scope = nock(BitGoJS.Environments[bitgo.getEnv()].uri)
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
user: {
username: 'test@bitgo.com',
Expand Down Expand Up @@ -379,7 +379,7 @@ describe('BitGo Prototype Methods', function () {

before(async function () {
nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
access_token: 'access_token',
user: { username: 'update_pw_tester@bitgo.com' },
Expand Down Expand Up @@ -685,7 +685,7 @@ describe('BitGo Prototype Methods', function () {

it('should get the ecdhKeychain if ensureEcdhKeychain is set and user already has ecdhKeychain', async function () {
nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
access_token: 'access_token',
user: { username: 'auth-test@bitgo.com' },
Expand All @@ -709,9 +709,10 @@ describe('BitGo Prototype Methods', function () {
should.exist(response.user.ecdhKeychain);
response.user.ecdhKeychain.should.equal('some-existing-xpub');
});

it('should create the ecdhKeychain if ensureEcdhKeychain is set and the user does not already have ecdhKeychain', async function () {
nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
access_token: 'access_token',
user: { username: 'auth-test@bitgo.com' },
Expand Down
2 changes: 1 addition & 1 deletion modules/express/src/fetchEncryptedPrivKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const walletWithPrv: WalletWithPrv = esWalletWithPrv ? JSON.parse(esWalletWithPr
// ];

export async function fetchKeys(ids: WalletIds, token: string, accessToken?: string): Promise<Record<string, string>> {
bg.authenticateWithAccessToken({ accessToken: token });
await bg.authenticateWithAccessToken({ accessToken: token });

// get the encrypted user privKey for each walletId and store in the JSON output
const output: Output = {};
Expand Down
11 changes: 7 additions & 4 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,11 +1053,14 @@ export class BitGoAPI implements BitGoBase {
}

/**
* Synchronous method for activating an access token.
* Activate an access token and sync it to the HMAC auth strategy.
* Resolves once the token has been registered with the strategy, so callers
* can safely issue signed requests immediately after.
*/
authenticateWithAccessToken({ accessToken }: AccessTokenOptions): void {
async authenticateWithAccessToken({ accessToken }: AccessTokenOptions): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the public method contract from synchronous void to Promise<void>. Consumers that call authenticateWithAccessToken() before issuing signed requests must now await it, and callers that continue ignoring the return value can race asynchronous HMAC token setup. Please treat this as a breaking SDK change: mark the commit accordingly (for example, fix(sdk-api)!: ... plus a BREAKING CHANGE: footer) and audit/migrate all public callers, including the example scripts.

debug('now authenticating with access token %s', accessToken.substring(0, 8));
this._token = accessToken;
await this._hmacAuthStrategy.setToken?.(this._token);
}

/**
Expand Down Expand Up @@ -1152,14 +1155,14 @@ export class BitGoAPI implements BitGoBase {
return new Error('already logged in');
}

const authUrl = this.microservicesUrl('/api/auth/v1/session');
const authUrl = this.url('/user/login', 2);
const request = this.post(authUrl);

if (forceV1Auth) {
request.forceV1Auth = true;
// tell the server that the client was forced to downgrade the authentication protocol
authParams.forceV1Auth = true;
debug('forcing v1 auth for call to authenticate');
debug('forcing v1 auth on request');
}
const response: superagent.Response = await request.send(authParams);
// extract body and user information
Expand Down
51 changes: 41 additions & 10 deletions modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ describe('Constructor', function () {
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });

nock(ROOT)
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
user: { username: 'test@example.com' },
access_token: 'v2xmyaccesstoken',
Expand Down Expand Up @@ -518,7 +518,7 @@ describe('Constructor', function () {
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });

nock(ROOT)
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(200, {
user: { username: 'test@example.com' },
access_token: 'v2xmytoken',
Expand All @@ -539,6 +539,36 @@ describe('Constructor', function () {

keyReady.should.be.true();
});

it('handles an ECDH-encrypted token response (no access_token) through the v2 login flow', async function () {
const { strategy, setTokenStub } = makeStrategy();
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });

// Stub token issuance so the test does not depend on real crypto. The point is
// to assert the v2 login response with encryptedToken reaches handleTokenIssuance
// and that the decrypted token is then synced to the strategy.
const handleTokenIssuanceStub = sinon
.stub(bitgo, 'handleTokenIssuance')
.resolves({ token: 'v2xdecryptedtoken' });

nock(ROOT)
.post('/api/v2/user/login')
.reply(200, {
user: { username: 'test@example.com' },
encryptedToken: 'encrypted-token-value',
encryptedECDHXprv: 'encrypted-ecdh-xprv',
derivationPath: 'm/999999/0/1',
});

await bitgo.authenticate({ username: 'test@example.com', password: 'hunter2' });

// The SDK must have taken the ECDH path (not the plain access_token path).
handleTokenIssuanceStub.calledOnce.should.be.true();
handleTokenIssuanceStub.firstCall.args[0].encryptedToken.should.equal('encrypted-token-value');
// The decrypted token must be synced to the strategy.
setTokenStub.calledOnce.should.be.true();
setTokenStub.firstCall.args[0].should.equal('v2xdecryptedtoken');
});
});

describe('authenticateWithPasskey()', function () {
Expand Down Expand Up @@ -577,7 +607,7 @@ describe('Constructor', function () {
const { strategy, clearTokenStub } = makeStrategy();
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });

bitgo.authenticateWithAccessToken({ accessToken: 'v2xsometoken' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xsometoken' });
(bitgo as any)._token.should.equal('v2xsometoken');

await bitgo.clearAsync();
Expand Down Expand Up @@ -641,13 +671,14 @@ describe('Constructor', function () {
});

describe('sync token-setting methods', function () {
it('authenticateWithAccessToken does not call setToken (synchronous — caller must invoke setToken on the strategy manually)', function () {
it('authenticateWithAccessToken calls setToken (HMAC strategy must be kept in sync for v2/v3 signing)', async function () {
const { strategy, setTokenStub } = makeStrategy();
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });

bitgo.authenticateWithAccessToken({ accessToken: 'v2xsynctoken' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xsynctoken' });

setTokenStub.called.should.be.false();
setTokenStub.calledOnce.should.be.true();
setTokenStub.firstCall.args[0].should.equal('v2xsynctoken');
});

it('fromJSON does not call setToken (synchronous — caller must invoke setToken on the strategy manually)', function () {
Expand Down Expand Up @@ -677,7 +708,7 @@ describe('Constructor', function () {
});
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
// Do NOT set _ecdhXprv — simulates SSO/enterprise session (Okta, Entra, etc.)
bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });

const scope = nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
token: 'v2xnewplaintoken',
Expand Down Expand Up @@ -725,7 +756,7 @@ describe('Constructor', function () {
isAuthenticated: sinon.stub().returns(true),
});
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' });

nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
token: 'v2xplaintoken',
Expand All @@ -746,7 +777,7 @@ describe('Constructor', function () {
isAuthenticated: sinon.stub().returns(false),
});
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
bitgo.authenticateWithAccessToken({ accessToken: 'v2xlegacytoken' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xlegacytoken' });

nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
token: 'v2xlegacyresult',
Expand All @@ -761,7 +792,7 @@ describe('Constructor', function () {
it('should force V1 auth when isAuthenticated is not defined on strategy', async function () {
const { strategy } = makeStrategy();
const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
bitgo.authenticateWithAccessToken({ accessToken: 'v2xnoauthmethod' });
await bitgo.authenticateWithAccessToken({ accessToken: 'v2xnoauthmethod' });

nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, {
token: 'v2xresult',
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-api/test/unit/hmacStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe('BitGoAPI HMAC Strategy Injection', function () {
});

nock(TEST_URI)
.post('/api/auth/v1/session')
.post('/api/v2/user/login')
.reply(
200,
{ access_token: TEST_TOKEN, user: { username: 'test@test.com' } },
Expand Down
2 changes: 1 addition & 1 deletion scripts/upgrade-wallet-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async function main() {
parseArgs();

const bitgo = new BitGo({ env });
bitgo.authenticateWithAccessToken({ accessToken });
await bitgo.authenticateWithAccessToken({ accessToken });

const wallet = await bitgo.coin(coin).wallets().get({ id: walletId });
const result = await wallet.upgradeEncryption({
Expand Down
Loading