gameglue 1.2.2 → 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/README.md +79 -16
- package/dist/gg.cjs.js +1 -1
- package/dist/gg.cjs.js.map +1 -1
- package/dist/gg.esm.js +1 -1
- package/dist/gg.esm.js.map +1 -1
- package/dist/gg.umd.js +1 -1
- package/dist/gg.umd.js.map +1 -1
- package/examples/flight-dashboard.html +69 -39
- package/package.json +1 -1
- package/src/auth.js +187 -86
- package/src/auth.spec.js +167 -77
- package/src/index.js +82 -44
- package/src/listener.js +16 -2
- package/src/listener.spec.js +139 -0
- package/src/test/setup.js +2 -1
- package/src/utils.js +3 -0
- package/src/utils.spec.js +14 -0
- package/dist/gg.sdk.js +0 -1
- /package/{babel.config.js → babel.config.cjs} +0 -0
- /package/{jest.config.js → jest.config.cjs} +0 -0
|
@@ -30,17 +30,37 @@
|
|
|
30
30
|
color: #64748b;
|
|
31
31
|
margin-bottom: 2rem;
|
|
32
32
|
}
|
|
33
|
+
.header {
|
|
34
|
+
display: flex;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
align-items: center;
|
|
37
|
+
margin-bottom: 1.5rem;
|
|
38
|
+
}
|
|
33
39
|
.status {
|
|
34
40
|
display: inline-block;
|
|
35
41
|
padding: 0.25rem 0.75rem;
|
|
36
42
|
border-radius: 9999px;
|
|
37
43
|
font-size: 0.875rem;
|
|
38
|
-
margin-bottom: 1.5rem;
|
|
39
44
|
}
|
|
40
45
|
.status.disconnected { background: #7f1d1d; color: #fca5a5; }
|
|
41
46
|
.status.connecting { background: #78350f; color: #fcd34d; }
|
|
42
47
|
.status.connected { background: #14532d; color: #86efac; }
|
|
43
48
|
|
|
49
|
+
.logout-btn {
|
|
50
|
+
background: transparent;
|
|
51
|
+
border: 1px solid #334155;
|
|
52
|
+
color: #94a3b8;
|
|
53
|
+
padding: 0.5rem 1rem;
|
|
54
|
+
border-radius: 0.5rem;
|
|
55
|
+
font-size: 0.875rem;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
transition: all 0.15s;
|
|
58
|
+
}
|
|
59
|
+
.logout-btn:hover {
|
|
60
|
+
background: #334155;
|
|
61
|
+
color: #f1f5f9;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
.card {
|
|
45
65
|
background: #1e293b;
|
|
46
66
|
border-radius: 0.75rem;
|
|
@@ -164,12 +184,15 @@
|
|
|
164
184
|
<!-- Auth Section (shown when not authenticated) -->
|
|
165
185
|
<div id="auth-section" class="card auth-section">
|
|
166
186
|
<p style="margin-bottom: 1rem; color: #94a3b8;">Connect to GameGlue to view flight telemetry and send commands</p>
|
|
167
|
-
<button class="auth-btn" onclick="
|
|
187
|
+
<button class="auth-btn" onclick="doLogin()">Connect with GameGlue</button>
|
|
168
188
|
</div>
|
|
169
189
|
|
|
170
190
|
<!-- Dashboard (shown when authenticated) -->
|
|
171
191
|
<div id="dashboard" class="hidden">
|
|
172
|
-
<
|
|
192
|
+
<div class="header">
|
|
193
|
+
<span id="status" class="status disconnected">Waiting for MSFS...</span>
|
|
194
|
+
<button class="logout-btn" onclick="doLogout()">Logout</button>
|
|
195
|
+
</div>
|
|
173
196
|
|
|
174
197
|
<!-- Telemetry Card -->
|
|
175
198
|
<div class="card">
|
|
@@ -259,47 +282,55 @@
|
|
|
259
282
|
</div>
|
|
260
283
|
|
|
261
284
|
<!-- Load GameGlue SDK from local build -->
|
|
262
|
-
<script src="../dist/gg.
|
|
285
|
+
<script src="../dist/gg.umd.js"></script>
|
|
263
286
|
<script>
|
|
264
287
|
// Configuration
|
|
265
288
|
const CLIENT_ID = 'gameglue-sdk-examples';
|
|
266
|
-
const REDIRECT_URI = window.location.href.split('?')[0];
|
|
289
|
+
const REDIRECT_URI = window.location.href.split('?')[0].split('#')[0];
|
|
290
|
+
|
|
291
|
+
// Create a single SDK instance
|
|
292
|
+
const ggClient = new GameGlue({
|
|
293
|
+
clientId: CLIENT_ID,
|
|
294
|
+
redirect_uri: REDIRECT_URI,
|
|
295
|
+
scopes: ['msfs:read', 'msfs:write']
|
|
296
|
+
});
|
|
267
297
|
|
|
268
|
-
let ggClient = null;
|
|
269
298
|
let listener = null;
|
|
270
299
|
|
|
271
|
-
//
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
300
|
+
// Login button handler
|
|
301
|
+
function doLogin() {
|
|
302
|
+
log('Redirecting to GameGlue login...');
|
|
303
|
+
ggClient.login();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Logout button handler
|
|
307
|
+
function doLogout() {
|
|
308
|
+
log('Logging out...');
|
|
309
|
+
ggClient.logout({ redirect: false });
|
|
310
|
+
document.getElementById('auth-section').classList.remove('hidden');
|
|
311
|
+
document.getElementById('dashboard').classList.add('hidden');
|
|
278
312
|
}
|
|
279
313
|
|
|
280
|
-
//
|
|
281
|
-
async function
|
|
314
|
+
// Check authentication and set up listener
|
|
315
|
+
async function init() {
|
|
282
316
|
try {
|
|
283
|
-
log('
|
|
284
|
-
const userId = await ggClient.auth();
|
|
317
|
+
log('Checking authentication...');
|
|
285
318
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
319
|
+
// isAuthenticated() handles OAuth callback automatically
|
|
320
|
+
const isAuthed = await ggClient.isAuthenticated();
|
|
321
|
+
|
|
322
|
+
if (!isAuthed) {
|
|
323
|
+
log('Not authenticated - please log in');
|
|
324
|
+
return;
|
|
290
325
|
}
|
|
291
|
-
} catch (err) {
|
|
292
|
-
log('Authentication failed: ' + err.message, 'error');
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
326
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const userId = ggClient.getUserId();
|
|
300
|
-
log('Already authenticated. User ID: ' + userId);
|
|
327
|
+
// Authenticated
|
|
328
|
+
const userId = ggClient.getUser();
|
|
329
|
+
log('Authenticated! User ID: ' + userId);
|
|
301
330
|
showDashboard();
|
|
302
331
|
await setupListener(userId);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
log('Error: ' + err.message, 'error');
|
|
303
334
|
}
|
|
304
335
|
}
|
|
305
336
|
|
|
@@ -383,19 +414,18 @@
|
|
|
383
414
|
|
|
384
415
|
function log(message, type = '') {
|
|
385
416
|
const logEl = document.getElementById('log');
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
417
|
+
if (logEl) {
|
|
418
|
+
const entry = document.createElement('div');
|
|
419
|
+
entry.className = 'log-entry ' + type;
|
|
420
|
+
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
|
421
|
+
logEl.appendChild(entry);
|
|
422
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
423
|
+
}
|
|
391
424
|
console.log(message);
|
|
392
425
|
}
|
|
393
426
|
|
|
394
427
|
// Initialize on page load
|
|
395
|
-
window.onload =
|
|
396
|
-
initGameGlue();
|
|
397
|
-
checkAuth();
|
|
398
|
-
};
|
|
428
|
+
window.onload = init;
|
|
399
429
|
</script>
|
|
400
430
|
</body>
|
|
401
431
|
</html>
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -2,107 +2,224 @@ import { OidcClient } from 'oidc-client-ts';
|
|
|
2
2
|
import { storage } from './utils';
|
|
3
3
|
import jwt_decode from 'jwt-decode';
|
|
4
4
|
|
|
5
|
+
const DEFAULT_AUTH_URL = 'https://auth.gameglue.gg/realms/GameGlue';
|
|
6
|
+
|
|
7
|
+
// Track if callback is being processed (prevents double-processing)
|
|
8
|
+
let _callbackPromise = null;
|
|
5
9
|
|
|
6
10
|
export class GameGlueAuth {
|
|
7
11
|
constructor(cfg) {
|
|
12
|
+
const authority = cfg.authUrl || DEFAULT_AUTH_URL;
|
|
8
13
|
this._oidcSettings = {
|
|
9
|
-
authority
|
|
14
|
+
authority,
|
|
10
15
|
client_id: cfg.clientId,
|
|
11
16
|
redirect_uri: removeTrailingSlashes(cfg.redirect_uri || window.location.href),
|
|
12
17
|
post_logout_redirect_uri: removeTrailingSlashes(window.location.href),
|
|
13
18
|
response_type: "code",
|
|
14
|
-
scope: `openid ${(cfg.scopes||[]).join(' ')}`,
|
|
19
|
+
scope: `openid ${(cfg.scopes || []).join(' ')}`,
|
|
15
20
|
response_mode: "fragment",
|
|
16
21
|
filterProtocolClaims: true
|
|
17
22
|
};
|
|
18
23
|
this._oidcClient = new OidcClient(this._oidcSettings);
|
|
19
|
-
this._refreshCallback = () => {}
|
|
24
|
+
this._refreshCallback = () => {};
|
|
20
25
|
this._refreshTimeout = null;
|
|
21
26
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if user is authenticated.
|
|
30
|
+
* If OAuth callback params are in URL, processes them first.
|
|
31
|
+
* Safe to call multiple times - idempotent.
|
|
32
|
+
* @returns {Promise<boolean>}
|
|
33
|
+
*/
|
|
34
|
+
async isAuthenticated() {
|
|
35
|
+
// If callback params present, process them first
|
|
36
|
+
if (this._hasCallbackParams()) {
|
|
37
|
+
await this._processCallback();
|
|
25
38
|
}
|
|
39
|
+
|
|
40
|
+
// Check for valid tokens
|
|
41
|
+
return this._hasValidTokens();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Redirect to OAuth login page.
|
|
46
|
+
* Does not return - navigates away.
|
|
47
|
+
*/
|
|
48
|
+
login() {
|
|
49
|
+
this._oidcClient.createSigninRequest({ state: { bar: 15 } }).then((req) => {
|
|
50
|
+
window.location = req.url;
|
|
51
|
+
}).catch((err) => {
|
|
52
|
+
console.error('Failed to create signin request:', err);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Log out the user.
|
|
58
|
+
* Clears local tokens and optionally redirects to Keycloak logout.
|
|
59
|
+
* @param {Object} options - { redirect?: boolean }
|
|
60
|
+
*/
|
|
61
|
+
logout(options = {}) {
|
|
62
|
+
// Clear local tokens
|
|
63
|
+
storage.remove('gg-auth-token');
|
|
64
|
+
storage.remove('gg-refresh-token');
|
|
26
65
|
clearTimeout(this._refreshTimeout);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
|
|
67
|
+
// Optionally redirect to Keycloak logout
|
|
68
|
+
if (options.redirect !== false) {
|
|
69
|
+
const logoutUrl = `${this._oidcSettings.authority}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURIComponent(this._oidcSettings.post_logout_redirect_uri)}`;
|
|
70
|
+
window.location.href = logoutUrl;
|
|
32
71
|
}
|
|
33
72
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the current user's ID.
|
|
76
|
+
* @throws {Error} if not authenticated
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
getUser() {
|
|
80
|
+
const token = this._getAccessToken();
|
|
81
|
+
if (!token) {
|
|
82
|
+
throw new Error('Not authenticated');
|
|
83
|
+
}
|
|
84
|
+
const decoded = jwt_decode(token);
|
|
85
|
+
return decoded.sub;
|
|
37
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the access token for API calls.
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
38
92
|
getAccessToken() {
|
|
39
|
-
|
|
40
|
-
this.setTokenRefreshTimeout(token);
|
|
41
|
-
return token;
|
|
93
|
+
return this._getAccessToken();
|
|
42
94
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Register callback for token refresh events.
|
|
98
|
+
* @param {Function} callback
|
|
99
|
+
*/
|
|
100
|
+
onTokenRefreshed(callback) {
|
|
101
|
+
this._refreshCallback = callback;
|
|
49
102
|
}
|
|
50
|
-
|
|
51
|
-
|
|
103
|
+
|
|
104
|
+
// ============ Internal Methods ============
|
|
105
|
+
|
|
106
|
+
_hasCallbackParams() {
|
|
107
|
+
return location.hash.includes("state=") &&
|
|
108
|
+
(location.hash.includes("code=") || location.hash.includes("error="));
|
|
52
109
|
}
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
|
|
111
|
+
_clearCallbackUrl() {
|
|
112
|
+
window.history.replaceState("", document.title, window.location.pathname + window.location.search);
|
|
55
113
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
114
|
+
|
|
115
|
+
async _processCallback() {
|
|
116
|
+
// If already processing, wait for that to complete
|
|
117
|
+
if (_callbackPromise) {
|
|
118
|
+
await _callbackPromise;
|
|
60
119
|
return;
|
|
61
120
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.
|
|
121
|
+
|
|
122
|
+
// Start processing
|
|
123
|
+
_callbackPromise = this._doProcessCallback();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await _callbackPromise;
|
|
127
|
+
} finally {
|
|
128
|
+
_callbackPromise = null;
|
|
129
|
+
}
|
|
65
130
|
}
|
|
66
|
-
|
|
67
|
-
|
|
131
|
+
|
|
132
|
+
async _doProcessCallback() {
|
|
133
|
+
try {
|
|
134
|
+
const response = await this._oidcClient.processSigninResponse(window.location.href);
|
|
135
|
+
|
|
136
|
+
if (response.error) {
|
|
137
|
+
this._clearCallbackUrl();
|
|
138
|
+
throw new Error(response.error);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!response.access_token) {
|
|
142
|
+
this._clearCallbackUrl();
|
|
143
|
+
throw new Error('No access token received');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this._setAccessToken(response.access_token);
|
|
147
|
+
this._setRefreshToken(response.refresh_token);
|
|
148
|
+
this._clearCallbackUrl();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// If we failed but tokens exist (another call succeeded), that's fine
|
|
151
|
+
if (this._hasValidTokens()) {
|
|
152
|
+
this._clearCallbackUrl();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this._clearCallbackUrl();
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
68
158
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// 2. If we don't have an access token, we're not authenticated
|
|
74
|
-
if (!access_token) {
|
|
159
|
+
|
|
160
|
+
_hasValidTokens() {
|
|
161
|
+
const token = this._getAccessToken();
|
|
162
|
+
if (!token) {
|
|
75
163
|
return false;
|
|
76
164
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return this.isAuthenticated(true);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const decoded = jwt_decode(token);
|
|
168
|
+
const expirationDate = new Date(decoded.exp * 1000);
|
|
169
|
+
return expirationDate > new Date();
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
85
172
|
}
|
|
86
|
-
|
|
87
|
-
// This line might be a little confusing. Basically it's just saying if we tried to refresh the token,
|
|
88
|
-
// but it's STILL expired, return false, otherwise return true.
|
|
89
|
-
return !(isExpired && refreshAttempted);
|
|
90
173
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
174
|
+
|
|
175
|
+
_getAccessToken() {
|
|
176
|
+
const token = storage.get('gg-auth-token');
|
|
177
|
+
if (token) {
|
|
178
|
+
this._setTokenRefreshTimeout(token);
|
|
179
|
+
}
|
|
180
|
+
return token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_setAccessToken(token) {
|
|
184
|
+
this._setTokenRefreshTimeout(token);
|
|
185
|
+
return storage.set('gg-auth-token', token);
|
|
95
186
|
}
|
|
96
|
-
|
|
187
|
+
|
|
188
|
+
_setRefreshToken(token) {
|
|
189
|
+
return storage.set('gg-refresh-token', token);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_getRefreshToken() {
|
|
193
|
+
return storage.get('gg-refresh-token');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_setTokenRefreshTimeout(token) {
|
|
197
|
+
if (!token) return;
|
|
198
|
+
|
|
199
|
+
clearTimeout(this._refreshTimeout);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const timeUntilExp = (jwt_decode(token).exp * 1000) - Date.now() - 5000;
|
|
203
|
+
if (timeUntilExp > 0) {
|
|
204
|
+
this._refreshTimeout = setTimeout(() => {
|
|
205
|
+
this._attemptRefresh();
|
|
206
|
+
}, timeUntilExp);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Invalid token, ignore
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _attemptRefresh() {
|
|
97
214
|
const url = `${this._oidcSettings.authority}/protocol/openid-connect/token`;
|
|
98
215
|
const client_id = this._oidcSettings.client_id;
|
|
99
|
-
const refresh_token = this.
|
|
216
|
+
const refresh_token = this._getRefreshToken();
|
|
100
217
|
const grant_type = 'refresh_token';
|
|
101
|
-
|
|
218
|
+
|
|
102
219
|
try {
|
|
103
|
-
const response =
|
|
220
|
+
const response = await fetch(url, {
|
|
104
221
|
method: 'POST',
|
|
105
|
-
headers:{
|
|
222
|
+
headers: {
|
|
106
223
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
107
224
|
},
|
|
108
225
|
body: new URLSearchParams({
|
|
@@ -111,31 +228,15 @@ export class GameGlueAuth {
|
|
|
111
228
|
refresh_token
|
|
112
229
|
})
|
|
113
230
|
});
|
|
231
|
+
|
|
114
232
|
if (response.status === 200) {
|
|
115
233
|
const resObj = await response.json();
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
this._refreshCallback(resObj);
|
|
234
|
+
this._setAccessToken(resObj.access_token);
|
|
235
|
+
this._setRefreshToken(resObj.refresh_token);
|
|
236
|
+
this._refreshCallback(resObj.access_token);
|
|
119
237
|
}
|
|
120
|
-
} catch(e) {
|
|
121
|
-
console.
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
_triggerAuthRedirect() {
|
|
125
|
-
this._oidcClient.createSigninRequest({ state: { bar: 15 } }).then(function(req) {
|
|
126
|
-
window.location = req.url;
|
|
127
|
-
}).catch(function(err) {
|
|
128
|
-
console.error(err);
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
async authenticate() {
|
|
132
|
-
if (this._shouldHandleRedirectResponse()) {
|
|
133
|
-
await this.handleRedirectResponse();
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
let isAuthenticated = await this.isAuthenticated();
|
|
137
|
-
if (!isAuthenticated) {
|
|
138
|
-
await this._triggerAuthRedirect();
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error('Token refresh failed:', e);
|
|
139
240
|
}
|
|
140
241
|
}
|
|
141
242
|
}
|
|
@@ -145,4 +246,4 @@ function removeTrailingSlashes(url) {
|
|
|
145
246
|
return url.replace(/\/+$/, '');
|
|
146
247
|
}
|
|
147
248
|
return url;
|
|
148
|
-
}
|
|
249
|
+
}
|