@pixelume/sdk 0.3.0 → 0.5.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/CHANGELOG.md +74 -1
- package/README.md +130 -33
- package/dist/contracts/index.d.ts +42 -2
- package/dist/contracts/index.js +107 -8
- package/dist/contracts/index.js.map +1 -1
- package/dist/core/PixelumeClient.d.ts +12 -2
- package/dist/core/PixelumeClient.js +17 -2
- package/dist/core/PixelumeClient.js.map +1 -1
- package/dist/core/config.d.ts +6 -1
- package/dist/core/config.js +26 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts +49 -2
- package/dist/core/errors.js +30 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/types.d.ts +41 -0
- package/dist/core/types.js.map +1 -1
- package/dist/http/HttpClient.js +1 -0
- package/dist/http/HttpClient.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/auth/AuthModule.d.ts +48 -4
- package/dist/modules/auth/AuthModule.js +375 -49
- package/dist/modules/auth/AuthModule.js.map +1 -1
- package/dist/modules/auth/SessionManager.d.ts +91 -0
- package/dist/modules/auth/SessionManager.js +475 -0
- package/dist/modules/auth/SessionManager.js.map +1 -0
- package/dist/modules/saves/SavesModule.d.ts +100 -0
- package/dist/modules/saves/SavesModule.js +238 -0
- package/dist/modules/saves/SavesModule.js.map +1 -0
- package/dist/ui/widget.js +74 -7
- package/dist/ui/widget.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,25 +1,68 @@
|
|
|
1
1
|
import { parseEmptyResponse, parseOAuthTokenResponse, parsePlayerEnvelope, } from '../../contracts/index.js';
|
|
2
|
-
import { PixelumeAuthError, PixelumeConfigError,
|
|
2
|
+
import { PixelumeAuthError, PixelumeConfigError, isPixelumeError, } from '../../core/errors.js';
|
|
3
|
+
import { SessionManager, browserResumeSource, } from './SessionManager.js';
|
|
3
4
|
const AUTHORIZATION_REQUEST_TTL_MS = 10 * 60_000;
|
|
4
|
-
const TOKEN_EXPIRY_SKEW_MS = 5_000;
|
|
5
5
|
const CALLBACK_PARAMETERS = ['code', 'state', 'error', 'error_description'];
|
|
6
|
+
/** `prompt=none` answers meaning "the central session cannot authorize without a screen". */
|
|
7
|
+
const SESSION_ENDED_ERRORS = new Set([
|
|
8
|
+
'login_required',
|
|
9
|
+
'consent_required',
|
|
10
|
+
'interaction_required',
|
|
11
|
+
]);
|
|
6
12
|
export class AuthModule {
|
|
7
13
|
#http;
|
|
8
14
|
#config;
|
|
9
15
|
#listeners = new Set();
|
|
10
16
|
#state = Object.freeze({ status: 'uninitialized', player: null });
|
|
11
|
-
#
|
|
17
|
+
#session;
|
|
12
18
|
#initializePromise = null;
|
|
13
19
|
#loginPromise = null;
|
|
14
20
|
#playerPromise = null;
|
|
15
21
|
#logoutPromise = null;
|
|
22
|
+
/** True while a callback of a silent attempt is being completed. */
|
|
23
|
+
#completingRestore = false;
|
|
24
|
+
#resume;
|
|
25
|
+
#stopRestoreWatch = null;
|
|
16
26
|
constructor(http, config) {
|
|
17
27
|
this.#http = http;
|
|
18
28
|
this.#config = config;
|
|
29
|
+
const browser = config.browser;
|
|
30
|
+
this.#resume =
|
|
31
|
+
browser !== undefined && typeof browser.subscribeResume === 'function'
|
|
32
|
+
? (onResume) => browser.subscribeResume?.(onResume) ?? (() => undefined)
|
|
33
|
+
: browserResumeSource();
|
|
34
|
+
this.#session = new SessionManager({
|
|
35
|
+
http,
|
|
36
|
+
clientId: () => this.#requireClientId(),
|
|
37
|
+
resume: this.#resume,
|
|
38
|
+
onEnded: (error) => {
|
|
39
|
+
this.#setState(anonymous('expired', error));
|
|
40
|
+
},
|
|
41
|
+
});
|
|
19
42
|
}
|
|
20
43
|
get state() {
|
|
21
44
|
return this.#state;
|
|
22
45
|
}
|
|
46
|
+
get session() {
|
|
47
|
+
return this.#session.state;
|
|
48
|
+
}
|
|
49
|
+
/** @internal Shared with feature modules that call Bearer-protected routes. */
|
|
50
|
+
get sessionManager() {
|
|
51
|
+
return this.#session;
|
|
52
|
+
}
|
|
53
|
+
subscribeSession(listener) {
|
|
54
|
+
return this.#session.subscribe(listener);
|
|
55
|
+
}
|
|
56
|
+
refresh(options = {}) {
|
|
57
|
+
return this.#session.refresh(options);
|
|
58
|
+
}
|
|
59
|
+
/** @internal Releases timers/listeners and drops credentials from memory (no revocation). */
|
|
60
|
+
dispose() {
|
|
61
|
+
this.#stopRestoreWatch?.();
|
|
62
|
+
this.#stopRestoreWatch = null;
|
|
63
|
+
this.#session.dispose();
|
|
64
|
+
this.#listeners.clear();
|
|
65
|
+
}
|
|
23
66
|
initialize() {
|
|
24
67
|
this.#initializePromise ??= this.#doInitialize();
|
|
25
68
|
return this.#initializePromise;
|
|
@@ -29,23 +72,18 @@ export class AuthModule {
|
|
|
29
72
|
return () => this.#listeners.delete(listener);
|
|
30
73
|
}
|
|
31
74
|
login(options = {}) {
|
|
32
|
-
const scope = options.scope;
|
|
33
|
-
if (scope !==
|
|
34
|
-
return Promise.reject(new PixelumeConfigError('Pixelume Auth
|
|
75
|
+
const scope = options.scope ?? this.#config.scope;
|
|
76
|
+
if (scope !== 'profile' && scope !== 'profile saves') {
|
|
77
|
+
return Promise.reject(new PixelumeConfigError('Pixelume Auth scope must be "profile" or "profile saves"'));
|
|
35
78
|
}
|
|
36
|
-
this.#loginPromise ??= this.#doLogin().finally(() => {
|
|
79
|
+
this.#loginPromise ??= this.#doLogin(scope, false).finally(() => {
|
|
37
80
|
this.#loginPromise = null;
|
|
38
81
|
});
|
|
39
82
|
return this.#loginPromise;
|
|
40
83
|
}
|
|
41
84
|
getPlayer(options = {}) {
|
|
42
|
-
if (this.#
|
|
85
|
+
if (!this.#session.active)
|
|
43
86
|
return Promise.resolve(null);
|
|
44
|
-
if (this.#token.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) {
|
|
45
|
-
this.#token = null;
|
|
46
|
-
this.#setState(anonymous('expired'));
|
|
47
|
-
return Promise.resolve(null);
|
|
48
|
-
}
|
|
49
87
|
this.#playerPromise ??= this.#fetchPlayer(options).finally(() => {
|
|
50
88
|
this.#playerPromise = null;
|
|
51
89
|
});
|
|
@@ -64,34 +102,92 @@ export class AuthModule {
|
|
|
64
102
|
if (callback !== null) {
|
|
65
103
|
await this.#handleCallback(browser, callback);
|
|
66
104
|
}
|
|
67
|
-
else if (this.#config.restoreSession && this.#readSessionHint(browser.sessionStorage)) {
|
|
68
|
-
this.#removeSessionHint(browser.sessionStorage);
|
|
69
|
-
await this.login();
|
|
70
|
-
}
|
|
71
105
|
else {
|
|
72
|
-
this.#
|
|
106
|
+
const persistent = this.#persistentStorage(browser);
|
|
107
|
+
if (this.#config.crossSessionRestore !== null && persistent !== null) {
|
|
108
|
+
await this.#restoreAcrossSessions(browser, persistent, this.#config.crossSessionRestore);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// The feature was turned off: do not leave a stale hint behind.
|
|
112
|
+
if (persistent !== null)
|
|
113
|
+
this.#removePersistentHint(persistent);
|
|
114
|
+
await this.#restoreSameTab(browser);
|
|
115
|
+
}
|
|
73
116
|
}
|
|
74
117
|
}
|
|
75
118
|
catch (error) {
|
|
76
119
|
const failure = isPixelumeError(error)
|
|
77
120
|
? error
|
|
78
121
|
: new PixelumeAuthError('browser_unavailable', { cause: error });
|
|
79
|
-
this.#
|
|
122
|
+
const offline = this.#completingRestore && (failure.kind === 'network' || failure.kind === 'timeout');
|
|
123
|
+
this.#setState(anonymous(offline ? 'offline' : 'error', failure));
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
this.#completingRestore = false;
|
|
80
127
|
}
|
|
81
128
|
return this.#state;
|
|
82
129
|
}
|
|
83
|
-
|
|
130
|
+
/** Behaviour without `restoreAcrossSessions` (unchanged since 0.2): same-tab reload only. */
|
|
131
|
+
async #restoreSameTab(browser) {
|
|
132
|
+
const scope = this.#config.restoreSession
|
|
133
|
+
? this.#readSessionHint(browser.sessionStorage)
|
|
134
|
+
: null;
|
|
135
|
+
if (scope === null) {
|
|
136
|
+
this.#setState(anonymous('visitor'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.#removeSessionHint(browser.sessionStorage);
|
|
140
|
+
await this.login({ scope });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* At most one silent (`prompt=none`) attempt per start, driven by the tab hint (reload) or the
|
|
144
|
+
* persistent hint (new tab, reopened browser). A per-tab marker stops a second attempt while a
|
|
145
|
+
* previous one did not come back (navigation error, back button), so it can never loop.
|
|
146
|
+
*/
|
|
147
|
+
async #restoreAcrossSessions(browser, persistent, options) {
|
|
148
|
+
const tabScope = this.#config.restoreSession
|
|
149
|
+
? this.#readSessionHint(browser.sessionStorage)
|
|
150
|
+
: null;
|
|
151
|
+
const hint = this.#readPersistentHint(persistent, options.maxAgeMs);
|
|
152
|
+
const remembered = tabScope ?? hint?.scope ?? null;
|
|
153
|
+
if (remembered === null) {
|
|
154
|
+
this.#setState(anonymous('visitor'));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (this.#recentRestoreAttempt(browser.sessionStorage)) {
|
|
158
|
+
this.#setState(anonymous('visitor'));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!isOnline(browser)) {
|
|
162
|
+
this.#setState(anonymous('offline'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Never ask silently for more than both the game and the previous grant allow.
|
|
166
|
+
const scope = remembered === 'profile saves' && this.#config.scope === 'profile saves'
|
|
167
|
+
? 'profile saves'
|
|
168
|
+
: 'profile';
|
|
169
|
+
this.#removeSessionHint(browser.sessionStorage);
|
|
170
|
+
await this.#doLogin(scope, true);
|
|
171
|
+
}
|
|
172
|
+
async #doLogin(scope, silent) {
|
|
84
173
|
const clientId = this.#requireClientId();
|
|
85
174
|
const redirectUri = this.#requireRedirectUri();
|
|
86
175
|
const browser = this.#browser();
|
|
87
176
|
let pending = this.#readPending(browser.sessionStorage);
|
|
88
|
-
if (
|
|
177
|
+
if (silent ||
|
|
178
|
+
pending === null ||
|
|
179
|
+
pending.redirectUri !== redirectUri ||
|
|
180
|
+
pending.silent === true ||
|
|
181
|
+
this.#isExpired(pending)) {
|
|
182
|
+
const returnTo = silent ? sameOriginPath(browser.currentUrl(), redirectUri) : null;
|
|
89
183
|
pending = {
|
|
90
184
|
version: 1,
|
|
91
185
|
state: randomBase64Url(browser.crypto),
|
|
92
186
|
verifier: randomBase64Url(browser.crypto),
|
|
93
187
|
redirectUri,
|
|
94
188
|
createdAt: Date.now(),
|
|
189
|
+
...(silent ? { silent: true, scope } : {}),
|
|
190
|
+
...(returnTo === null ? {} : { returnTo }),
|
|
95
191
|
};
|
|
96
192
|
this.#writePending(browser.sessionStorage, pending);
|
|
97
193
|
}
|
|
@@ -104,9 +200,14 @@ export class AuthModule {
|
|
|
104
200
|
state: pending.state,
|
|
105
201
|
code_challenge: challenge,
|
|
106
202
|
code_challenge_method: 'S256',
|
|
107
|
-
scope
|
|
203
|
+
scope,
|
|
204
|
+
...(silent ? { prompt: 'none' } : {}),
|
|
108
205
|
}).toString();
|
|
109
|
-
|
|
206
|
+
if (silent)
|
|
207
|
+
this.#writeRestoreAttempt(browser.sessionStorage);
|
|
208
|
+
this.#setState(Object.freeze(silent
|
|
209
|
+
? { status: 'redirecting', player: null, restoring: true }
|
|
210
|
+
: { status: 'redirecting', player: null }));
|
|
110
211
|
try {
|
|
111
212
|
browser.navigate(authorization.toString());
|
|
112
213
|
}
|
|
@@ -114,11 +215,36 @@ export class AuthModule {
|
|
|
114
215
|
this.#removePending(browser.sessionStorage);
|
|
115
216
|
throw new PixelumeAuthError('browser_unavailable', { cause });
|
|
116
217
|
}
|
|
218
|
+
if (silent)
|
|
219
|
+
this.#watchInterruptedRestore();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* If the page comes back without leaving (back/forward cache, navigation error then "back"),
|
|
223
|
+
* the silent attempt is over: become a visitor instead of staying in `redirecting`. The per-tab
|
|
224
|
+
* marker stays, so the next start does not try again right away.
|
|
225
|
+
*/
|
|
226
|
+
#watchInterruptedRestore() {
|
|
227
|
+
this.#stopRestoreWatch?.();
|
|
228
|
+
if (this.#resume === null)
|
|
229
|
+
return;
|
|
230
|
+
const stop = this.#resume(() => {
|
|
231
|
+
const current = this.#state;
|
|
232
|
+
if (current.status === 'redirecting' && current.restoring === true) {
|
|
233
|
+
this.#setState(anonymous('visitor'));
|
|
234
|
+
}
|
|
235
|
+
stop();
|
|
236
|
+
if (this.#stopRestoreWatch === stop)
|
|
237
|
+
this.#stopRestoreWatch = null;
|
|
238
|
+
});
|
|
239
|
+
this.#stopRestoreWatch = stop;
|
|
117
240
|
}
|
|
118
241
|
async #handleCallback(browser, callback) {
|
|
119
242
|
const pending = this.#readPending(browser.sessionStorage);
|
|
120
243
|
this.#removePending(browser.sessionStorage);
|
|
121
|
-
this.#
|
|
244
|
+
this.#removeRestoreAttempt(browser.sessionStorage);
|
|
245
|
+
const silent = pending?.silent === true && !this.#isExpired(pending);
|
|
246
|
+
this.#cleanCallback(browser, callback.url, silent ? (pending.returnTo ?? null) : null);
|
|
247
|
+
this.#completingRestore = silent;
|
|
122
248
|
if (callback.invalid)
|
|
123
249
|
throw new PixelumeAuthError('invalid_callback');
|
|
124
250
|
if (pending === null || this.#isExpired(pending)) {
|
|
@@ -136,6 +262,27 @@ export class AuthModule {
|
|
|
136
262
|
}
|
|
137
263
|
if (callback.error !== null) {
|
|
138
264
|
this.#removeSessionHint(browser.sessionStorage);
|
|
265
|
+
const persistent = this.#persistentStorage(browser);
|
|
266
|
+
if (silent) {
|
|
267
|
+
this.#completingRestore = false;
|
|
268
|
+
if (SESSION_ENDED_ERRORS.has(callback.error)) {
|
|
269
|
+
if (persistent !== null)
|
|
270
|
+
this.#removePersistentHint(persistent);
|
|
271
|
+
this.#setState(anonymous('session_ended'));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (callback.error === 'invalid_scope' && pending.scope === 'profile saves') {
|
|
275
|
+
// The game no longer allows `saves`: the next start tries `profile` only.
|
|
276
|
+
if (persistent !== null)
|
|
277
|
+
this.#downgradePersistentHint(persistent);
|
|
278
|
+
}
|
|
279
|
+
else if (persistent !== null) {
|
|
280
|
+
this.#removePersistentHint(persistent);
|
|
281
|
+
}
|
|
282
|
+
throw new PixelumeAuthError('restore_rejected');
|
|
283
|
+
}
|
|
284
|
+
if (persistent !== null)
|
|
285
|
+
this.#removePersistentHint(persistent);
|
|
139
286
|
if (callback.error === 'access_denied') {
|
|
140
287
|
this.#setState(anonymous('cancelled'));
|
|
141
288
|
return;
|
|
@@ -144,7 +291,9 @@ export class AuthModule {
|
|
|
144
291
|
}
|
|
145
292
|
if (callback.code === null)
|
|
146
293
|
throw new PixelumeAuthError('invalid_callback');
|
|
147
|
-
this.#setState(Object.freeze(
|
|
294
|
+
this.#setState(Object.freeze(silent
|
|
295
|
+
? { status: 'exchanging', player: null, restoring: true }
|
|
296
|
+
: { status: 'exchanging', player: null }));
|
|
148
297
|
const response = await this.#http.request({
|
|
149
298
|
method: 'POST',
|
|
150
299
|
path: '/v1/oauth/token',
|
|
@@ -158,57 +307,65 @@ export class AuthModule {
|
|
|
158
307
|
},
|
|
159
308
|
parse: parseOAuthTokenResponse,
|
|
160
309
|
});
|
|
161
|
-
this.#
|
|
162
|
-
value: response.data.access_token,
|
|
163
|
-
expiresAt: Date.now() + response.data.expires_in * 1_000,
|
|
164
|
-
};
|
|
310
|
+
this.#session.start(response.data);
|
|
165
311
|
const player = await this.getPlayer();
|
|
166
|
-
if (player !== null)
|
|
167
|
-
|
|
312
|
+
if (player !== null) {
|
|
313
|
+
const scope = response.data.scopes.includes('saves') ? 'profile saves' : 'profile';
|
|
314
|
+
this.#writeSessionHint(browser.sessionStorage, scope);
|
|
315
|
+
const persistent = this.#persistentStorage(browser);
|
|
316
|
+
if (this.#config.crossSessionRestore !== null && persistent !== null) {
|
|
317
|
+
this.#writePersistentHint(persistent, scope);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
168
320
|
}
|
|
169
321
|
async #fetchPlayer(options) {
|
|
170
|
-
const token = this.#token;
|
|
171
|
-
if (token === null)
|
|
172
|
-
return null;
|
|
173
322
|
try {
|
|
174
|
-
const response = await this.#
|
|
323
|
+
const response = await this.#session.authorizedRequest({
|
|
175
324
|
method: 'GET',
|
|
176
325
|
path: '/v1/oauth/userinfo',
|
|
177
|
-
credentials: 'omit',
|
|
178
|
-
headers: { authorization: `Bearer ${token.value}` },
|
|
179
326
|
signal: options.signal,
|
|
180
327
|
timeoutMs: options.timeoutMs,
|
|
181
328
|
parse: parsePlayerEnvelope,
|
|
182
329
|
});
|
|
183
330
|
const player = Object.freeze({ ...response.data.player });
|
|
331
|
+
const current = this.#state;
|
|
332
|
+
// Keep the same object when nothing changed, so renewals do not look like a new login.
|
|
333
|
+
if (current.status === 'authenticated' && samePlayer(current.player, player)) {
|
|
334
|
+
return current.player;
|
|
335
|
+
}
|
|
184
336
|
this.#setState(Object.freeze({ status: 'authenticated', player }));
|
|
185
337
|
return player;
|
|
186
338
|
}
|
|
187
339
|
catch (error) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.#setState(anonymous('expired'));
|
|
340
|
+
// The session ended (expired/revoked); the state already reflects it.
|
|
341
|
+
if (error instanceof PixelumeAuthError)
|
|
191
342
|
return null;
|
|
192
|
-
}
|
|
193
343
|
throw error;
|
|
194
344
|
}
|
|
195
345
|
}
|
|
196
346
|
async #doLogout(options) {
|
|
197
|
-
const token = this.#
|
|
198
|
-
this.#token = null;
|
|
347
|
+
const token = this.#session.reset();
|
|
199
348
|
const browser = this.#optionalBrowser();
|
|
200
349
|
if (browser) {
|
|
201
350
|
this.#removePending(browser.sessionStorage);
|
|
202
351
|
this.#removeSessionHint(browser.sessionStorage);
|
|
352
|
+
const persistent = this.#persistentStorage(browser);
|
|
353
|
+
if (persistent !== null)
|
|
354
|
+
this.#removePersistentHint(persistent);
|
|
203
355
|
}
|
|
204
356
|
this.#setState(anonymous('logged_out'));
|
|
205
357
|
if (token === null)
|
|
206
358
|
return;
|
|
359
|
+
// Revoking any token of the family revokes all of it; the refresh token is the preferred hint.
|
|
207
360
|
await this.#http.request({
|
|
208
361
|
method: 'POST',
|
|
209
362
|
path: '/v1/oauth/revoke',
|
|
210
363
|
credentials: 'omit',
|
|
211
|
-
form: {
|
|
364
|
+
form: {
|
|
365
|
+
token: token.refreshToken ?? token.accessToken,
|
|
366
|
+
token_type_hint: token.refreshToken === null ? 'access_token' : 'refresh_token',
|
|
367
|
+
client_id: this.#requireClientId(),
|
|
368
|
+
},
|
|
212
369
|
signal: options.signal,
|
|
213
370
|
timeoutMs: options.timeoutMs,
|
|
214
371
|
parse: parseEmptyResponse,
|
|
@@ -234,7 +391,12 @@ export class AuthModule {
|
|
|
234
391
|
invalid: !sameCallbackBase(current, expected) || (code === null) === (error === null),
|
|
235
392
|
};
|
|
236
393
|
}
|
|
237
|
-
|
|
394
|
+
/** Removes the callback fields; a silent attempt returns to the page that started it. */
|
|
395
|
+
#cleanCallback(browser, url, returnTo) {
|
|
396
|
+
if (returnTo !== null && isSafeReturnPath(returnTo)) {
|
|
397
|
+
browser.replaceUrl(returnTo);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
238
400
|
const clean = new URL(url.toString());
|
|
239
401
|
for (const key of CALLBACK_PARAMETERS)
|
|
240
402
|
clean.searchParams.delete(key);
|
|
@@ -269,6 +431,15 @@ export class AuthModule {
|
|
|
269
431
|
globalThis.history.replaceState(globalThis.history.state, '', url);
|
|
270
432
|
},
|
|
271
433
|
sessionStorage: globalThis.sessionStorage,
|
|
434
|
+
get persistentStorage() {
|
|
435
|
+
try {
|
|
436
|
+
return typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Blocked storage (privacy settings, sandboxed frames).
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
},
|
|
272
443
|
crypto: globalThis.crypto,
|
|
273
444
|
};
|
|
274
445
|
}
|
|
@@ -294,6 +465,99 @@ export class AuthModule {
|
|
|
294
465
|
#hintKey() {
|
|
295
466
|
return `pixelume.oauth.session.${this.#config.clientId ?? 'unconfigured'}`;
|
|
296
467
|
}
|
|
468
|
+
#persistentHintKey() {
|
|
469
|
+
return `pixelume.oauth.restore.${this.#config.clientId ?? 'unconfigured'}`;
|
|
470
|
+
}
|
|
471
|
+
#restoreAttemptKey() {
|
|
472
|
+
return `pixelume.oauth.restore-attempt.${this.#config.clientId ?? 'unconfigured'}`;
|
|
473
|
+
}
|
|
474
|
+
#persistentStorage(browser) {
|
|
475
|
+
try {
|
|
476
|
+
return browser.persistentStorage ?? null;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Persistent-hint helpers never throw: an unavailable `localStorage` only disables
|
|
484
|
+
* cross-session restoration, it must not break the login itself.
|
|
485
|
+
*/
|
|
486
|
+
#readPersistentHint(storage, maxAgeMs) {
|
|
487
|
+
try {
|
|
488
|
+
const raw = storage.getItem(this.#persistentHintKey());
|
|
489
|
+
if (raw === null)
|
|
490
|
+
return null;
|
|
491
|
+
const value = JSON.parse(raw);
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
if (!isPersistentHint(value) || value.at > now + 60_000 || value.at + maxAgeMs < now) {
|
|
494
|
+
storage.removeItem(this.#persistentHintKey());
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
this.#removePersistentHint(storage);
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
#writePersistentHint(storage, scope) {
|
|
505
|
+
const hint = { version: 1, scope, at: Date.now() };
|
|
506
|
+
try {
|
|
507
|
+
storage.setItem(this.#persistentHintKey(), JSON.stringify(hint));
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Quota or blocked storage: the next new tab simply starts as a visitor.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
#downgradePersistentHint(storage) {
|
|
514
|
+
try {
|
|
515
|
+
const raw = storage.getItem(this.#persistentHintKey());
|
|
516
|
+
const value = raw === null ? null : JSON.parse(raw);
|
|
517
|
+
if (!isPersistentHint(value))
|
|
518
|
+
return;
|
|
519
|
+
storage.setItem(this.#persistentHintKey(), JSON.stringify({ ...value, scope: 'profile' }));
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
this.#removePersistentHint(storage);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
#removePersistentHint(storage) {
|
|
526
|
+
try {
|
|
527
|
+
storage.removeItem(this.#persistentHintKey());
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// Nothing else to do.
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
#recentRestoreAttempt(storage) {
|
|
534
|
+
const raw = storage.getItem(this.#restoreAttemptKey());
|
|
535
|
+
if (raw === null)
|
|
536
|
+
return false;
|
|
537
|
+
const at = Number(raw);
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
if (!Number.isFinite(at) || at > now + 60_000 || at + AUTHORIZATION_REQUEST_TTL_MS < now) {
|
|
540
|
+
storage.removeItem(this.#restoreAttemptKey());
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
#writeRestoreAttempt(storage) {
|
|
546
|
+
try {
|
|
547
|
+
storage.setItem(this.#restoreAttemptKey(), String(Date.now()));
|
|
548
|
+
}
|
|
549
|
+
catch (cause) {
|
|
550
|
+
throw new PixelumeAuthError('storage_unavailable', { cause });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
#removeRestoreAttempt(storage) {
|
|
554
|
+
try {
|
|
555
|
+
storage.removeItem(this.#restoreAttemptKey());
|
|
556
|
+
}
|
|
557
|
+
catch (cause) {
|
|
558
|
+
throw new PixelumeAuthError('storage_unavailable', { cause });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
297
561
|
#readPending(storage) {
|
|
298
562
|
try {
|
|
299
563
|
const raw = storage.getItem(this.#pendingKey());
|
|
@@ -326,17 +590,23 @@ export class AuthModule {
|
|
|
326
590
|
throw new PixelumeAuthError('storage_unavailable', { cause });
|
|
327
591
|
}
|
|
328
592
|
}
|
|
593
|
+
/** The hint is non-secret: only "a session existed" and the scope that was granted. */
|
|
329
594
|
#readSessionHint(storage) {
|
|
330
595
|
try {
|
|
331
|
-
|
|
596
|
+
const value = storage.getItem(this.#hintKey());
|
|
597
|
+
if (value === '1' || value === 'profile')
|
|
598
|
+
return 'profile';
|
|
599
|
+
if (value === 'profile saves')
|
|
600
|
+
return 'profile saves';
|
|
601
|
+
return null;
|
|
332
602
|
}
|
|
333
603
|
catch (cause) {
|
|
334
604
|
throw new PixelumeAuthError('storage_unavailable', { cause });
|
|
335
605
|
}
|
|
336
606
|
}
|
|
337
|
-
#writeSessionHint(storage) {
|
|
607
|
+
#writeSessionHint(storage, scope) {
|
|
338
608
|
try {
|
|
339
|
-
storage.setItem(this.#hintKey(),
|
|
609
|
+
storage.setItem(this.#hintKey(), scope);
|
|
340
610
|
}
|
|
341
611
|
catch (cause) {
|
|
342
612
|
throw new PixelumeAuthError('storage_unavailable', { cause });
|
|
@@ -366,11 +636,63 @@ export class AuthModule {
|
|
|
366
636
|
function anonymous(reason, error = null) {
|
|
367
637
|
return Object.freeze({ status: 'anonymous', player: null, reason, error });
|
|
368
638
|
}
|
|
639
|
+
function samePlayer(left, right) {
|
|
640
|
+
return (left.id === right.id &&
|
|
641
|
+
left.displayName === right.displayName &&
|
|
642
|
+
left.avatarUrl === right.avatarUrl);
|
|
643
|
+
}
|
|
644
|
+
function isPersistentHint(value) {
|
|
645
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
646
|
+
return false;
|
|
647
|
+
const hint = value;
|
|
648
|
+
return (hint.version === 1 &&
|
|
649
|
+
(hint.scope === 'profile' || hint.scope === 'profile saves') &&
|
|
650
|
+
typeof hint.at === 'number' &&
|
|
651
|
+
Number.isFinite(hint.at));
|
|
652
|
+
}
|
|
653
|
+
function isOnline(browser) {
|
|
654
|
+
try {
|
|
655
|
+
if (typeof browser.isOnline === 'function')
|
|
656
|
+
return browser.isOnline();
|
|
657
|
+
const navigator = Reflect.get(globalThis, 'navigator');
|
|
658
|
+
return navigator?.onLine !== false;
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Path, query and hash of `current` when it is on the redirect URI's origin; null otherwise. */
|
|
665
|
+
function sameOriginPath(current, redirectUri) {
|
|
666
|
+
try {
|
|
667
|
+
const url = new URL(current);
|
|
668
|
+
if (url.origin !== new URL(redirectUri).origin)
|
|
669
|
+
return null;
|
|
670
|
+
for (const key of CALLBACK_PARAMETERS)
|
|
671
|
+
url.searchParams.delete(key);
|
|
672
|
+
const path = `${url.pathname}${url.search}${url.hash}`;
|
|
673
|
+
return isSafeReturnPath(path) ? path : null;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function isSafeReturnPath(value) {
|
|
680
|
+
return (value.startsWith('/') &&
|
|
681
|
+
!value.startsWith('//') &&
|
|
682
|
+
!value.includes('\\') &&
|
|
683
|
+
value.length <= 2048);
|
|
684
|
+
}
|
|
369
685
|
function isPendingAuthorization(value) {
|
|
370
686
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
371
687
|
return false;
|
|
372
688
|
const pending = value;
|
|
373
|
-
return (pending.
|
|
689
|
+
return ((pending.silent === undefined || pending.silent === true) &&
|
|
690
|
+
(pending.scope === undefined ||
|
|
691
|
+
pending.scope === 'profile' ||
|
|
692
|
+
pending.scope === 'profile saves') &&
|
|
693
|
+
(pending.returnTo === undefined ||
|
|
694
|
+
(typeof pending.returnTo === 'string' && isSafeReturnPath(pending.returnTo))) &&
|
|
695
|
+
pending.version === 1 &&
|
|
374
696
|
typeof pending.state === 'string' &&
|
|
375
697
|
/^[A-Za-z0-9_-]{43}$/u.test(pending.state) &&
|
|
376
698
|
typeof pending.verifier === 'string' &&
|
|
@@ -419,6 +741,10 @@ function sameState(left, right) {
|
|
|
419
741
|
if (left.status === 'anonymous' && right.status === 'anonymous') {
|
|
420
742
|
return left.reason === right.reason && left.error === right.error;
|
|
421
743
|
}
|
|
744
|
+
if ((left.status === 'redirecting' && right.status === 'redirecting') ||
|
|
745
|
+
(left.status === 'exchanging' && right.status === 'exchanging')) {
|
|
746
|
+
return left.restoring === right.restoring;
|
|
747
|
+
}
|
|
422
748
|
return true;
|
|
423
749
|
}
|
|
424
750
|
//# sourceMappingURL=AuthModule.js.map
|