gameglue 1.2.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.spec.js CHANGED
@@ -43,9 +43,9 @@ describe('GameGlueAuth', () => {
43
43
  console.log = originalConsoleLog;
44
44
  console.error = originalConsoleError;
45
45
  jest.useRealTimers();
46
- // Clear storage by setting to undefined (mimics clearing)
47
- storage.set('gg-auth-token', undefined);
48
- storage.set('gg-refresh-token', undefined);
46
+ // Clear storage
47
+ storage.remove('gg-auth-token');
48
+ storage.remove('gg-refresh-token');
49
49
  });
50
50
 
51
51
  describe('constructor', () => {
@@ -96,59 +96,73 @@ describe('GameGlueAuth', () => {
96
96
  });
97
97
  });
98
98
 
99
- describe('setAccessToken / getAccessToken', () => {
99
+ describe('_setAccessToken / getAccessToken', () => {
100
100
  it('should store and retrieve access token', () => {
101
- auth.setAccessToken(validAccessToken);
101
+ auth._setAccessToken(validAccessToken);
102
102
  const retrieved = auth.getAccessToken();
103
103
 
104
104
  expect(retrieved).toBe(validAccessToken);
105
105
  });
106
106
 
107
107
  it('should set up token refresh timeout when setting token', () => {
108
- auth.setAccessToken(validAccessToken);
108
+ auth._setAccessToken(validAccessToken);
109
109
 
110
110
  // Check that a timeout was scheduled
111
111
  expect(jest.getTimerCount()).toBeGreaterThan(0);
112
112
  });
113
113
 
114
114
  it('should not set timeout for null token', () => {
115
- auth.setAccessToken(null);
115
+ auth._setAccessToken(null);
116
116
 
117
117
  expect(jest.getTimerCount()).toBe(0);
118
118
  });
119
119
  });
120
120
 
121
- describe('setRefreshToken / getRefreshToken', () => {
121
+ describe('_setRefreshToken / _getRefreshToken', () => {
122
122
  it('should store and retrieve refresh token', () => {
123
- auth.setRefreshToken(validRefreshToken);
124
- const retrieved = auth.getRefreshToken();
123
+ auth._setRefreshToken(validRefreshToken);
124
+ const retrieved = auth._getRefreshToken();
125
125
 
126
126
  expect(retrieved).toBe(validRefreshToken);
127
127
  });
128
128
  });
129
129
 
130
- describe('getUserId', () => {
130
+ describe('getUser', () => {
131
131
  it('should extract user ID from JWT', () => {
132
- auth.setAccessToken(validAccessToken);
132
+ auth._setAccessToken(validAccessToken);
133
133
 
134
- const userId = auth.getUserId();
134
+ const userId = auth.getUser();
135
135
 
136
136
  expect(userId).toBe('user-123');
137
137
  });
138
+
139
+ it('should throw error when not authenticated', () => {
140
+ expect(() => auth.getUser()).toThrow('Not authenticated');
141
+ });
138
142
  });
139
143
 
140
- describe('isTokenExpired', () => {
141
- it('should return false for valid token', () => {
142
- const result = auth.isTokenExpired(validAccessToken);
144
+ describe('_hasValidTokens', () => {
145
+ it('should return false when no token exists', () => {
146
+ const result = auth._hasValidTokens();
143
147
 
144
148
  expect(result).toBe(false);
145
149
  });
146
150
 
147
- it('should return true for expired token', () => {
148
- const result = auth.isTokenExpired(expiredAccessToken);
151
+ it('should return true for valid non-expired token', () => {
152
+ auth._setAccessToken(validAccessToken);
153
+
154
+ const result = auth._hasValidTokens();
149
155
 
150
156
  expect(result).toBe(true);
151
157
  });
158
+
159
+ it('should return false for expired token', () => {
160
+ auth._setAccessToken(expiredAccessToken);
161
+
162
+ const result = auth._hasValidTokens();
163
+
164
+ expect(result).toBe(false);
165
+ });
152
166
  });
153
167
 
154
168
  describe('isAuthenticated', () => {
@@ -159,51 +173,96 @@ describe('GameGlueAuth', () => {
159
173
  });
160
174
 
161
175
  it('should return true for valid non-expired token', async () => {
162
- auth.setAccessToken(validAccessToken);
176
+ auth._setAccessToken(validAccessToken);
163
177
 
164
178
  const result = await auth.isAuthenticated();
165
179
 
166
180
  expect(result).toBe(true);
167
181
  });
168
182
 
169
- it('should attempt refresh for expired token', async () => {
170
- auth.setAccessToken(expiredAccessToken);
171
- auth.setRefreshToken(validRefreshToken);
172
-
173
- // Mock successful refresh
174
- global.fetch.mockResolvedValueOnce({
175
- status: 200,
176
- json: () =>
177
- Promise.resolve({
178
- access_token: validAccessToken,
179
- refresh_token: validRefreshToken
180
- })
181
- });
183
+ it('should return false for expired token', async () => {
184
+ auth._setAccessToken(expiredAccessToken);
182
185
 
183
186
  const result = await auth.isAuthenticated();
184
187
 
185
- expect(global.fetch).toHaveBeenCalled();
188
+ expect(result).toBe(false);
186
189
  });
187
190
 
188
- it('should return false when refresh fails and token is still expired', async () => {
189
- auth.setAccessToken(expiredAccessToken);
190
- auth.setRefreshToken(validRefreshToken);
191
+ it('should process callback params if present in URL', async () => {
192
+ window.location.hash = '#state=abc123&code=xyz789';
191
193
 
192
- // Mock failed refresh
193
- global.fetch.mockResolvedValueOnce({
194
- status: 401,
195
- json: () => Promise.resolve({ error: 'invalid_grant' })
196
- });
194
+ const { OidcClient } = require('oidc-client-ts');
195
+ OidcClient.mockImplementation(() => ({
196
+ createSigninRequest: jest.fn(),
197
+ processSigninResponse: jest.fn().mockResolvedValue({
198
+ access_token: validAccessToken,
199
+ refresh_token: validRefreshToken
200
+ })
201
+ }));
197
202
 
198
- const result = await auth.isAuthenticated();
203
+ const authNew = new GameGlueAuth(mockConfig);
204
+ const result = await authNew.isAuthenticated();
199
205
 
200
- expect(result).toBe(false);
206
+ expect(result).toBe(true);
207
+ // URL should be cleared
208
+ expect(window.history.replaceState).toHaveBeenCalled();
201
209
  });
202
210
  });
203
211
 
204
- describe('attemptRefresh', () => {
212
+ describe('login', () => {
213
+ it('should call createSigninRequest', async () => {
214
+ const createSigninRequest = jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' });
215
+ const { OidcClient } = require('oidc-client-ts');
216
+ OidcClient.mockImplementation(() => ({
217
+ createSigninRequest,
218
+ processSigninResponse: jest.fn()
219
+ }));
220
+
221
+ const authNew = new GameGlueAuth(mockConfig);
222
+ authNew.login();
223
+
224
+ // Wait for promise to resolve
225
+ await Promise.resolve();
226
+
227
+ expect(createSigninRequest).toHaveBeenCalledWith({ state: { bar: 15 } });
228
+ });
229
+ });
230
+
231
+ describe('logout', () => {
232
+ it('should clear tokens from storage', () => {
233
+ auth._setAccessToken(validAccessToken);
234
+ auth._setRefreshToken(validRefreshToken);
235
+
236
+ auth.logout({ redirect: false });
237
+
238
+ // After remove, storage.get returns undefined in Node environment (in-memory map)
239
+ expect(auth.getAccessToken()).toBeUndefined();
240
+ expect(auth._getRefreshToken()).toBeUndefined();
241
+ });
242
+
243
+ it('should not clear tokens when redirect: false not passed', () => {
244
+ // We can't easily test the redirect behavior in jsdom, but we can verify
245
+ // the method accepts options and clears tokens regardless
246
+ auth._setAccessToken(validAccessToken);
247
+ auth._setRefreshToken(validRefreshToken);
248
+
249
+ // logout() clears tokens first, then redirects
250
+ auth.logout({ redirect: false });
251
+
252
+ expect(auth.getAccessToken()).toBeUndefined();
253
+ });
254
+
255
+ it('should accept options object', () => {
256
+ auth._setAccessToken(validAccessToken);
257
+
258
+ // Should not throw when called with different options
259
+ expect(() => auth.logout({ redirect: false })).not.toThrow();
260
+ });
261
+ });
262
+
263
+ describe('_attemptRefresh', () => {
205
264
  it('should call token endpoint with correct parameters', async () => {
206
- auth.setRefreshToken(validRefreshToken);
265
+ auth._setRefreshToken(validRefreshToken);
207
266
 
208
267
  global.fetch.mockResolvedValueOnce({
209
268
  status: 200,
@@ -214,7 +273,7 @@ describe('GameGlueAuth', () => {
214
273
  })
215
274
  });
216
275
 
217
- await auth.attemptRefresh();
276
+ await auth._attemptRefresh();
218
277
 
219
278
  expect(global.fetch).toHaveBeenCalledWith(
220
279
  'https://auth.gameglue.gg/realms/GameGlue/protocol/openid-connect/token',
@@ -228,7 +287,7 @@ describe('GameGlueAuth', () => {
228
287
  });
229
288
 
230
289
  it('should update tokens on successful refresh', async () => {
231
- auth.setRefreshToken(validRefreshToken);
290
+ auth._setRefreshToken(validRefreshToken);
232
291
 
233
292
  const newAccessToken = createMockJwt({ sub: 'user-456' }, 3600);
234
293
  const newRefreshToken = createMockJwt({ sub: 'user-456', type: 'refresh' }, 86400);
@@ -242,16 +301,16 @@ describe('GameGlueAuth', () => {
242
301
  })
243
302
  });
244
303
 
245
- await auth.attemptRefresh();
304
+ await auth._attemptRefresh();
246
305
 
247
306
  expect(auth.getAccessToken()).toBe(newAccessToken);
248
- expect(auth.getRefreshToken()).toBe(newRefreshToken);
307
+ expect(auth._getRefreshToken()).toBe(newRefreshToken);
249
308
  });
250
309
 
251
- it('should call refresh callback on successful refresh', async () => {
310
+ it('should call refresh callback with access token on successful refresh', async () => {
252
311
  const callback = jest.fn();
253
312
  auth.onTokenRefreshed(callback);
254
- auth.setRefreshToken(validRefreshToken);
313
+ auth._setRefreshToken(validRefreshToken);
255
314
 
256
315
  const responseData = {
257
316
  access_token: validAccessToken,
@@ -263,21 +322,21 @@ describe('GameGlueAuth', () => {
263
322
  json: () => Promise.resolve(responseData)
264
323
  });
265
324
 
266
- await auth.attemptRefresh();
325
+ await auth._attemptRefresh();
267
326
 
268
- expect(callback).toHaveBeenCalledWith(responseData);
327
+ expect(callback).toHaveBeenCalledWith(validAccessToken);
269
328
  });
270
329
 
271
330
  it('should handle network errors gracefully', async () => {
272
- auth.setRefreshToken(validRefreshToken);
331
+ auth._setRefreshToken(validRefreshToken);
273
332
  global.fetch.mockRejectedValueOnce(new Error('Network error'));
274
333
 
275
334
  // Should not throw
276
- await expect(auth.attemptRefresh()).resolves.toBeUndefined();
335
+ await expect(auth._attemptRefresh()).resolves.toBeUndefined();
277
336
  });
278
337
 
279
338
  it('should not update tokens on non-200 response', async () => {
280
- auth.setRefreshToken(validRefreshToken);
339
+ auth._setRefreshToken(validRefreshToken);
281
340
  const originalAccessToken = auth.getAccessToken();
282
341
 
283
342
  global.fetch.mockResolvedValueOnce({
@@ -285,9 +344,9 @@ describe('GameGlueAuth', () => {
285
344
  json: () => Promise.resolve({ error: 'invalid_grant' })
286
345
  });
287
346
 
288
- await auth.attemptRefresh();
347
+ await auth._attemptRefresh();
289
348
 
290
- // Access token should remain unchanged (undefined in this case)
349
+ // Access token should remain unchanged (null in this case)
291
350
  expect(auth.getAccessToken()).toBe(originalAccessToken);
292
351
  });
293
352
  });
@@ -301,58 +360,58 @@ describe('GameGlueAuth', () => {
301
360
  });
302
361
  });
303
362
 
304
- describe('setTokenRefreshTimeout', () => {
363
+ describe('_setTokenRefreshTimeout', () => {
305
364
  it('should schedule refresh before token expiration', () => {
306
365
  // Token expires in 1 hour (3600 seconds)
307
- auth.setTokenRefreshTimeout(validAccessToken);
366
+ auth._setTokenRefreshTimeout(validAccessToken);
308
367
 
309
368
  // Should have scheduled a timeout
310
369
  expect(jest.getTimerCount()).toBe(1);
311
370
  });
312
371
 
313
372
  it('should clear previous timeout when setting new one', () => {
314
- auth.setTokenRefreshTimeout(validAccessToken);
315
- auth.setTokenRefreshTimeout(validAccessToken);
373
+ auth._setTokenRefreshTimeout(validAccessToken);
374
+ auth._setTokenRefreshTimeout(validAccessToken);
316
375
 
317
376
  // Should only have one active timeout
318
377
  expect(jest.getTimerCount()).toBe(1);
319
378
  });
320
379
 
321
380
  it('should not schedule timeout for expired token', () => {
322
- auth.setTokenRefreshTimeout(expiredAccessToken);
381
+ auth._setTokenRefreshTimeout(expiredAccessToken);
323
382
 
324
383
  // Timer count may be 0 since the timeUntilExp is negative
325
384
  // The important thing is it doesn't throw
326
385
  });
327
386
  });
328
387
 
329
- describe('_shouldHandleRedirectResponse', () => {
388
+ describe('_hasCallbackParams', () => {
330
389
  it('should return true when hash contains state and code', () => {
331
390
  window.location.hash = '#state=abc123&code=xyz789';
332
391
 
333
- expect(auth._shouldHandleRedirectResponse()).toBe(true);
392
+ expect(auth._hasCallbackParams()).toBe(true);
334
393
  });
335
394
 
336
395
  it('should return true when hash contains state and error', () => {
337
396
  window.location.hash = '#state=abc123&error=access_denied';
338
397
 
339
- expect(auth._shouldHandleRedirectResponse()).toBe(true);
398
+ expect(auth._hasCallbackParams()).toBe(true);
340
399
  });
341
400
 
342
401
  it('should return false when hash does not contain state', () => {
343
402
  window.location.hash = '#code=xyz789';
344
403
 
345
- expect(auth._shouldHandleRedirectResponse()).toBe(false);
404
+ expect(auth._hasCallbackParams()).toBe(false);
346
405
  });
347
406
 
348
407
  it('should return false when hash is empty', () => {
349
408
  window.location.hash = '';
350
409
 
351
- expect(auth._shouldHandleRedirectResponse()).toBe(false);
410
+ expect(auth._hasCallbackParams()).toBe(false);
352
411
  });
353
412
  });
354
413
 
355
- describe('handleRedirectResponse', () => {
414
+ describe('_processCallback', () => {
356
415
  it('should process signin response and store tokens', async () => {
357
416
  const { OidcClient } = require('oidc-client-ts');
358
417
  OidcClient.mockImplementation(() => ({
@@ -363,14 +422,14 @@ describe('GameGlueAuth', () => {
363
422
  }));
364
423
 
365
424
  const authNew = new GameGlueAuth(mockConfig);
366
- await authNew.handleRedirectResponse();
425
+ await authNew._processCallback();
367
426
 
368
427
  expect(authNew.getAccessToken()).toBe(validAccessToken);
369
- expect(authNew.getRefreshToken()).toBe(validRefreshToken);
370
- expect(window.history.pushState).toHaveBeenCalled();
428
+ expect(authNew._getRefreshToken()).toBe(validRefreshToken);
429
+ expect(window.history.replaceState).toHaveBeenCalled();
371
430
  });
372
431
 
373
- it('should handle error response gracefully', async () => {
432
+ it('should throw on error response', async () => {
374
433
  const { OidcClient } = require('oidc-client-ts');
375
434
  OidcClient.mockImplementation(() => ({
376
435
  processSigninResponse: jest.fn().mockResolvedValue({
@@ -380,12 +439,43 @@ describe('GameGlueAuth', () => {
380
439
  }));
381
440
 
382
441
  const authNew = new GameGlueAuth(mockConfig);
383
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
384
442
 
385
- await authNew.handleRedirectResponse();
443
+ await expect(authNew._processCallback()).rejects.toThrow('access_denied');
444
+ });
445
+
446
+ it('should throw when no access token received', async () => {
447
+ const { OidcClient } = require('oidc-client-ts');
448
+ OidcClient.mockImplementation(() => ({
449
+ processSigninResponse: jest.fn().mockResolvedValue({
450
+ access_token: null
451
+ })
452
+ }));
453
+
454
+ const authNew = new GameGlueAuth(mockConfig);
455
+
456
+ await expect(authNew._processCallback()).rejects.toThrow('No access token received');
457
+ });
458
+
459
+ it('should handle concurrent calls (only process once)', async () => {
460
+ const { OidcClient } = require('oidc-client-ts');
461
+ const processSigninResponse = jest.fn().mockResolvedValue({
462
+ access_token: validAccessToken,
463
+ refresh_token: validRefreshToken
464
+ });
465
+ OidcClient.mockImplementation(() => ({
466
+ processSigninResponse
467
+ }));
468
+
469
+ const authNew = new GameGlueAuth(mockConfig);
470
+
471
+ // Call twice concurrently
472
+ await Promise.all([
473
+ authNew._processCallback(),
474
+ authNew._processCallback()
475
+ ]);
386
476
 
387
- expect(consoleSpy).toHaveBeenCalledWith('access_denied');
388
- consoleSpy.mockRestore();
477
+ // Should only have been called once due to locking
478
+ expect(processSigninResponse).toHaveBeenCalledTimes(1);
389
479
  });
390
480
  });
391
481
  });
package/src/index.js CHANGED
@@ -1,74 +1,112 @@
1
1
  import { GameGlueAuth } from './auth';
2
2
  import { io } from "socket.io-client";
3
- import { Listener} from "./listener";
3
+ import { Listener } from "./listener";
4
4
 
5
5
  const GAME_IDS = {
6
6
  'msfs': true,
7
7
  };
8
8
 
9
- class GameGlue extends GameGlueAuth {
9
+ const DEFAULT_SOCKET_URL = 'https://socks.gameglue.gg';
10
+
11
+ class GameGlue extends GameGlueAuth {
10
12
  constructor(cfg) {
11
13
  super(cfg);
12
- this._socket = false;
13
- }
14
-
15
- async auth() {
16
- await this.authenticate();
17
- if (await this.isAuthenticated()) {
18
- await this.initialize();
19
- }
20
- return this.getUserId();
21
- }
22
-
23
- async initialize() {
24
- return new Promise((resolve) => {
25
- const token = this.getAccessToken();
26
- // For local development, use 'http://localhost:3031'
27
- this._socket = io('https://socks.gameglue.gg', {
28
- transports: ['websocket'],
29
- auth: {
30
- token
31
- }
32
- });
33
- // TODO: Update this code to use the new refresh logic. Example in gg-client repo
34
- this._socket.on('connect', () => {
35
- resolve();
36
- });
37
- this.onTokenRefreshed(this.updateSocketAuth);
38
- });
39
- }
40
-
41
- updateSocketAuth(authToken) {
42
- this._socket.auth.token = authToken;
14
+ this._socket = null;
15
+ this._socketUrl = cfg.socketUrl || DEFAULT_SOCKET_URL;
16
+ this._connectPromise = null;
43
17
  }
44
-
18
+
19
+ /**
20
+ * Create a listener for game telemetry.
21
+ * Connects to socket server lazily on first call.
22
+ * @param {Object} config - { userId, gameId, fields? }
23
+ * @returns {Promise<Listener>}
24
+ */
45
25
  async createListener(config) {
46
26
  if (!config) throw new Error('Not a valid listener config');
47
27
  if (!config.gameId || !GAME_IDS[config.gameId]) throw new Error('Not a valid Game ID');
48
28
  if (!config.userId) throw new Error('User ID not supplied');
49
29
  if (config.fields && !Array.isArray(config.fields)) throw new Error('fields must be an array');
50
30
 
51
- // Ensure socket is initialized (handles page reload case)
52
- if (!this._socket) {
53
- await this.initialize();
54
- }
31
+ // Ensure socket is connected (lazy initialization)
32
+ await this._ensureConnected();
55
33
 
56
34
  const listener = new Listener(this._socket, config);
57
35
  const establishConnectionResponse = await listener.establishConnection();
58
- this._socket.io.on('reconnect_attempt', (d) => {
59
- console.log('Refresh Attempt');
60
- this.updateSocketAuth(this.getAccessToken());
36
+
37
+ // Handle reconnection
38
+ this._socket.io.on('reconnect_attempt', () => {
39
+ this._updateSocketAuth(this.getAccessToken());
61
40
  });
62
41
  this._socket.io.on('reconnect', () => {
63
42
  listener.establishConnection();
64
43
  });
65
-
44
+
66
45
  if (establishConnectionResponse.status !== 'success') {
67
46
  throw new Error(`There was a problem setting up the listener. Reason: ${establishConnectionResponse.reason}`);
68
47
  }
69
-
48
+
70
49
  return listener.setupEventListener();
71
50
  }
51
+
52
+ // ============ Internal Methods ============
53
+
54
+ async _ensureConnected() {
55
+ // Already connected
56
+ if (this._socket?.connected) {
57
+ return;
58
+ }
59
+
60
+ // Connection in progress - wait for it
61
+ if (this._connectPromise) {
62
+ await this._connectPromise;
63
+ return;
64
+ }
65
+
66
+ // Start new connection
67
+ this._connectPromise = this._connect();
68
+
69
+ try {
70
+ await this._connectPromise;
71
+ } finally {
72
+ this._connectPromise = null;
73
+ }
74
+ }
75
+
76
+ _connect() {
77
+ return new Promise((resolve, reject) => {
78
+ const token = this.getAccessToken();
79
+
80
+ if (!token) {
81
+ reject(new Error('Not authenticated - call isAuthenticated() first'));
82
+ return;
83
+ }
84
+
85
+ this._socket = io(this._socketUrl, {
86
+ transports: ['websocket'],
87
+ auth: { token }
88
+ });
89
+
90
+ this._socket.on('connect', () => {
91
+ resolve();
92
+ });
93
+
94
+ this._socket.on('connect_error', (err) => {
95
+ reject(new Error(`Socket connection failed: ${err.message}`));
96
+ });
97
+
98
+ // Update socket auth when token refreshes
99
+ this.onTokenRefreshed((newToken) => {
100
+ this._updateSocketAuth(newToken);
101
+ });
102
+ });
103
+ }
104
+
105
+ _updateSocketAuth(authToken) {
106
+ if (this._socket) {
107
+ this._socket.auth.token = authToken;
108
+ }
109
+ }
72
110
  }
73
111
 
74
112
  if (typeof window !== 'undefined') {
@@ -76,4 +114,4 @@ if (typeof window !== 'undefined') {
76
114
  }
77
115
 
78
116
  export default GameGlue;
79
- export { GameGlue };
117
+ export { GameGlue };
package/src/listener.js CHANGED
@@ -1,4 +1,4 @@
1
- const EventEmitter = require('event-emitter');
1
+ import EventEmitter from 'event-emitter';
2
2
 
3
3
  export class Listener {
4
4
  constructor(socket, config) {
@@ -39,7 +39,21 @@ export class Listener {
39
39
  }
40
40
 
41
41
  setupEventListener() {
42
- this._socket.on('update', this.emit.bind(this, 'update'));
42
+ this._socket.on('update', (payload) => {
43
+ // Apply client-side field filtering if fields are specified
44
+ if (this._fields && this._fields.length > 0 && payload?.data) {
45
+ const filteredData = {};
46
+ for (const field of this._fields) {
47
+ if (field in payload.data) {
48
+ filteredData[field] = payload.data[field];
49
+ }
50
+ }
51
+ this.emit('update', { ...payload, data: filteredData });
52
+ } else {
53
+ // No filtering - pass through full payload
54
+ this.emit('update', payload);
55
+ }
56
+ });
43
57
  return this;
44
58
  }
45
59