@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.
@@ -1,25 +1,68 @@
1
1
  import { parseEmptyResponse, parseOAuthTokenResponse, parsePlayerEnvelope, } from '../../contracts/index.js';
2
- import { PixelumeAuthError, PixelumeConfigError, PixelumeHttpError, isPixelumeError, } from '../../core/errors.js';
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
- #token = null;
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 !== undefined && scope !== 'profile') {
34
- return Promise.reject(new PixelumeConfigError('Pixelume Auth supports only the "profile" scope'));
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.#token === null)
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.#setState(anonymous('visitor'));
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.#setState(anonymous('error', failure));
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
- async #doLogin() {
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 (pending === null || pending.redirectUri !== redirectUri || this.#isExpired(pending)) {
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: 'profile',
203
+ scope,
204
+ ...(silent ? { prompt: 'none' } : {}),
108
205
  }).toString();
109
- this.#setState(Object.freeze({ status: 'redirecting', player: null }));
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.#cleanCallback(browser, callback.url);
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({ status: 'exchanging', player: null }));
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.#token = {
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
- this.#writeSessionHint(browser.sessionStorage);
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.#http.request({
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
- if (error instanceof PixelumeHttpError && error.status === 401) {
189
- this.#token = null;
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.#token;
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: { token: token.value, token_type_hint: 'access_token' },
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
- #cleanCallback(browser, url) {
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
- return storage.getItem(this.#hintKey()) === '1';
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(), '1');
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.version === 1 &&
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