Skip to content

Commit acd58bd

Browse files
authored
fix(FIR-44193): auth concurrency (#139)
1 parent d5aa9bd commit acd58bd

File tree

5 files changed

+154
-27
lines changed

5 files changed

+154
-27
lines changed

package-lock.json

Lines changed: 27 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"devDependencies": {
2525
"@types/jest": "^29.5.12",
2626
"@types/node-fetch": "^2.5.12",
27+
"@types/rwlock": "^5.0.6",
2728
"@typescript-eslint/eslint-plugin": "^5.4.0",
2829
"@typescript-eslint/parser": "^5.4.0",
2930
"allure-commandline": "^2.29.0",
@@ -46,6 +47,7 @@
4647
"abort-controller": "^3.0.0",
4748
"agentkeepalive": "^4.5.0",
4849
"json-bigint": "^1.0.0",
49-
"node-fetch": "^2.6.6"
50+
"node-fetch": "^2.6.6",
51+
"rwlock": "^5.0.0"
5052
}
5153
}

src/auth/index.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
UsernamePasswordAuth
88
} from "../types";
99
import { TokenKey, inMemoryCache, noneCache } from "../common/tokenCache";
10+
import ReadWriteLock from "rwlock";
1011

1112
type Login = {
1213
access_token: string;
@@ -22,6 +23,7 @@ export class Authenticator {
2223
options: ConnectionOptions;
2324

2425
accessToken?: string;
26+
private readonly rwlock = new ReadWriteLock();
2527

2628
constructor(context: Context, options: ConnectionOptions) {
2729
context.httpClient.authenticator = this;
@@ -157,21 +159,65 @@ export class Authenticator {
157159
);
158160
}
159161

160-
async authenticate() {
161-
const options = this.options.auth || this.options;
162-
const cachedToken = this.getCachedToken();
162+
async authenticate(): Promise<void> {
163+
// Try to get token from cache using read lock
164+
const cachedToken = await this.tryGetCachedToken();
163165
if (cachedToken) {
164166
this.accessToken = cachedToken;
165167
return;
166168
}
167169

170+
// No cached token, acquire write lock and authenticate
171+
await this.acquireWriteLockAndAuthenticate();
172+
}
173+
174+
private async tryGetCachedToken(): Promise<string | undefined> {
175+
return new Promise((resolve, reject) => {
176+
this.rwlock.readLock(releaseReadLock => {
177+
try {
178+
const cachedToken = this.getCachedToken();
179+
resolve(cachedToken);
180+
} catch (error) {
181+
reject(error instanceof Error ? error : new Error(String(error)));
182+
} finally {
183+
releaseReadLock();
184+
}
185+
});
186+
});
187+
}
188+
189+
private async acquireWriteLockAndAuthenticate(): Promise<void> {
190+
return new Promise((resolve, reject) => {
191+
this.rwlock.writeLock(async releaseWriteLock => {
192+
try {
193+
// Double-check cache in case another thread authenticated while waiting
194+
const cachedToken = this.getCachedToken();
195+
if (cachedToken) {
196+
this.accessToken = cachedToken;
197+
return resolve();
198+
}
199+
200+
await this.performAuthentication();
201+
202+
resolve();
203+
} catch (error) {
204+
reject(error instanceof Error ? error : new Error(String(error)));
205+
} finally {
206+
releaseWriteLock();
207+
}
208+
});
209+
});
210+
}
211+
212+
private async performAuthentication(): Promise<void> {
213+
const options = this.options.auth || this.options;
214+
168215
if (this.isUsernamePassword()) {
169-
await this.authenticateUsernamePassword(options as UsernamePasswordAuth);
170-
return;
216+
return this.authenticateUsernamePassword(options as UsernamePasswordAuth);
171217
}
218+
172219
if (this.isServiceAccount()) {
173-
await this.authenticateServiceAccount(options as ServiceAccountAuth);
174-
return;
220+
return this.authenticateServiceAccount(options as ServiceAccountAuth);
175221
}
176222

177223
throw new Error("Please provide valid auth credentials");

src/core/index.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { makeConnection } from "../connection";
22
import { Authenticator } from "../auth";
33
import { Context, ConnectionOptions, FireboltClientOptions } from "../types";
44
import { ResourceManager } from "../service";
5+
import { NodeHttpClient } from "../http/node";
56

67
export class FireboltCore {
78
private options: FireboltClientOptions;
@@ -13,24 +14,39 @@ export class FireboltCore {
1314
this.options = options;
1415
}
1516

16-
async connect(connectionOptions: ConnectionOptions) {
17-
const auth = new Authenticator(this.context, connectionOptions);
18-
const connection = makeConnection(this.context, connectionOptions);
17+
private async prepareConnection(connectionOptions: ConnectionOptions) {
18+
// Create a new httpClient instance for each connection
19+
const httpClient =
20+
this.options.dependencies?.httpClient || new NodeHttpClient();
21+
22+
// Create a new context with the new httpClient
23+
const connectionContext = {
24+
...this.context,
25+
httpClient
26+
};
27+
28+
const auth = new Authenticator(connectionContext, connectionOptions);
29+
const connection = makeConnection(connectionContext, connectionOptions);
1930
await auth.authenticate();
2031
await connection.resolveEngineEndpoint();
21-
const context = {
32+
33+
return { connection, connectionContext };
34+
}
35+
36+
async connect(connectionOptions: ConnectionOptions) {
37+
const { connection, connectionContext } = await this.prepareConnection(
38+
connectionOptions
39+
);
40+
const resourceContext = {
2241
connection,
23-
...this.context
42+
...connectionContext
2443
};
25-
this.resourceManager = new ResourceManager(context);
44+
this.resourceManager = new ResourceManager(resourceContext);
2645
return connection;
2746
}
2847

2948
async testConnection(connectionOptions: ConnectionOptions) {
30-
const auth = new Authenticator(this.context, connectionOptions);
31-
const connection = makeConnection(this.context, connectionOptions);
32-
await auth.authenticate();
33-
await connection.resolveEngineEndpoint();
49+
const { connection } = await this.prepareConnection(connectionOptions);
3450
await connection.testConnection();
3551
}
3652
}

test/unit/http.test.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,17 @@ describe.each([
136136
]
137137
])("token caching %s", (_, authUrl: string, auth: AuthOptions) => {
138138
const server = setupServer();
139-
const httpClient = new NodeHttpClient();
139+
let httpClient = new NodeHttpClient();
140140

141141
beforeAll(() => {
142142
server.listen();
143143
});
144144
afterAll(() => {
145145
server.close();
146146
});
147+
beforeEach(() => {
148+
httpClient = new NodeHttpClient();
149+
});
147150

148151
it("caches and reuses access token", async () => {
149152
const authenticator = new Authenticator(
@@ -163,7 +166,7 @@ describe.each([
163166
return res(
164167
ctx.json({
165168
access_token: "fake_access_token",
166-
expires_in: 2 ^ 30
169+
expires_in: 2 ** 30
167170
})
168171
);
169172
})
@@ -230,7 +233,7 @@ describe.each([
230233
return res(
231234
ctx.json({
232235
access_token: "fake_access_token",
233-
expires_in: 2 ^ 30
236+
expires_in: 2 ** 30
234237
})
235238
);
236239
}),
@@ -273,7 +276,7 @@ describe.each([
273276
return res(
274277
ctx.json({
275278
access_token: "fake_access_token",
276-
expires_in: 2 ^ 30
279+
expires_in: 2 ** 30
277280
})
278281
);
279282
})
@@ -295,10 +298,11 @@ describe.each([
295298
useCache: true
296299
}
297300
);
298-
301+
// Different Authenticator has to have different httpClient
302+
const httpClient2 = new NodeHttpClient();
299303
const authenticator2 = new Authenticator(
300304
{
301-
httpClient,
305+
httpClient: httpClient2,
302306
apiEndpoint: "api.fake2.firebolt.io",
303307
logger
304308
},
@@ -318,7 +322,7 @@ describe.each([
318322
return res(
319323
ctx.json({
320324
access_token: "fake_access_token",
321-
expires_in: 2 ^ 30
325+
expires_in: 2 ** 30
322326
})
323327
);
324328
}),
@@ -327,7 +331,7 @@ describe.each([
327331
return res(
328332
ctx.json({
329333
access_token: "fake_access_token",
330-
expires_in: 2 ^ 30
334+
expires_in: 2 ** 30
331335
})
332336
);
333337
})
@@ -342,4 +346,37 @@ describe.each([
342346
expect(authenticator2.accessToken).toEqual("fake_access_token");
343347
expect(calls).toEqual(2);
344348
});
349+
it("does not overwrite token when run concurrently", async () => {
350+
const authenticator = new Authenticator(
351+
{ httpClient, apiEndpoint, logger },
352+
{
353+
auth,
354+
account: "my_account",
355+
useCache: true
356+
}
357+
);
358+
359+
let calls = 0;
360+
361+
server.use(
362+
rest.post(authUrl, (req, res, ctx) => {
363+
calls++;
364+
return res(
365+
ctx.json({
366+
access_token: "fake_access_token",
367+
expires_in: 2 ** 30
368+
})
369+
);
370+
})
371+
);
372+
373+
authenticator.clearCache();
374+
375+
const promises = Array(10)
376+
.fill(null)
377+
.map(() => authenticator.authenticate());
378+
379+
await Promise.all(promises);
380+
expect(calls).toEqual(1);
381+
});
345382
});

0 commit comments

Comments
 (0)