lakebed 0.0.40 → 0.0.41

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.
@@ -4,16 +4,24 @@
4
4
  import { h } from "preact";
5
5
  import { useEffect, useState } from "preact/hooks";
6
6
  import { isExactLoopbackHostname } from "../_core/url-policy.js";
7
- import { AUTH_RESUME_STORAGE_KEY, AUTH_STORAGE_KEY, DEFAULT_AUTH_BASE_URL, GUEST_UPGRADE_STORAGE_KEY, PKCE_MAX_AGE_MS, PKCE_STORAGE_KEY, RETURN_TO_STORAGE_KEY, addAuthListener, authConfig, basePath, browserSessionStorage, browserStorage, brokerIdentityFromToken, createGoogleAuthFromToken, createSignedOutAuth, currentGuestName, decodeIdentityClaims, emitAuth, encoder, getAuth, identityIdFromTokenResponse, OBSOLETE_AUTH_STORAGE_KEY, isAuthIdentity, parseJson, readStoredIdentity, removeAuthListener, setAuth, setAuthToken, setAuthResumeStarted, getAuthResumeStarted } from "./internal.js";
7
+ import { AUTH_RESUME_STORAGE_KEY, AUTH_STORAGE_KEY, DEFAULT_AUTH_BASE_URL, GUEST_UPGRADE_STORAGE_KEY, PKCE_MAX_AGE_MS, PKCE_STORAGE_KEY, RETURN_TO_STORAGE_KEY, addAuthListener, canAccessApp, authConfig, basePath, browserSessionStorage, browserStorage, brokerIdentityFromToken, createGoogleAuthFromToken, createSignedOutAuth, currentGuestName, decodeIdentityClaims, emitAuth, encoder, getAuth, identityIdFromTokenResponse, OBSOLETE_AUTH_STORAGE_KEY, isAuthIdentity, parseJson, readStoredIdentity, removeAuthListener, setAuth, setAuthToken, setAuthResumeStarted, getAuthResumeStarted, storedAuthToken, tokenExpiresAt } from "./internal.js";
8
8
  import { navigate } from "./router.js";
9
- import { connect, disconnect, reconnect, resetConnectionRecovery, resetQueryCacheAfterAuthChange } from "./transport.js";
9
+ import { connect, disconnect, reconnect, reconnectDelayMs, reconnectLater, reconnectNow, refreshSocketToken, resetConnectionRecovery, resetQueryCacheAfterAuthChange } from "./transport.js";
10
10
  let authInitPromise = null;
11
11
  let authInitialized = false;
12
12
  let authRevision = 0;
13
13
  let authController = null;
14
14
  let pendingGuestUpgrade = null;
15
15
  let signOutPending = false;
16
+ // Automatic retries after a session check could not reach Lakebed.
17
+ let authRetryAttempts = 0;
18
+ let authRetryTimer = null;
19
+ let refreshInFlight = null;
16
20
  const TOKEN_RESPONSE_MAX_BYTES = 64 * 1024;
21
+ // Refresh an account token this long before it expires.
22
+ const TOKEN_REFRESH_AHEAD_MS = 5 * 60 * 1000;
23
+ const TOKEN_REFRESH_TIMEOUT_MS = 10_000;
24
+ const REFRESH_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,256}$/;
17
25
  const TOKEN_RESPONSE_MAX_CHUNKS = 1024;
18
26
  const TOKEN_RESPONSE_TIMEOUT_MS = 5_000;
19
27
  const OAUTH_CALLBACK_PARAMETERS = ["code", "state", "error", "error_description", "error_uri"];
@@ -26,6 +34,108 @@ class TokenResponseBodyError extends Error {
26
34
  }
27
35
  class TerminalTokenResponseError extends Error {
28
36
  }
37
+ /** Lakebed or the network could not answer. The session itself may be fine. */
38
+ function transientAuthError(message) {
39
+ return Object.assign(new Error(message), { transient: true });
40
+ }
41
+ function isTransientAuthError(error) {
42
+ if (!error || typeof error !== "object" || error instanceof SupersededAuthError) {
43
+ return false;
44
+ }
45
+ if ("transient" in error && error.transient === true) {
46
+ return true;
47
+ }
48
+ if ("status" in error && typeof error.status === "number") {
49
+ return error.status === 408 || error.status === 429 || error.status >= 500;
50
+ }
51
+ // A timeout, or a browser lock that could not be granted.
52
+ return "name" in error && (error.name === "AbortError" || error.name === "TimeoutError");
53
+ }
54
+ function parseRefreshGrant(value) {
55
+ if (!value || typeof value !== "object") {
56
+ return undefined;
57
+ }
58
+ const candidate = value;
59
+ if (typeof candidate.authBaseUrl !== "string" ||
60
+ typeof candidate.clientId !== "string" ||
61
+ typeof candidate.refreshToken !== "string" ||
62
+ !REFRESH_TOKEN_PATTERN.test(candidate.refreshToken) ||
63
+ candidate.clientId !== `origin:${window.location.origin}`) {
64
+ return undefined;
65
+ }
66
+ try {
67
+ const broker = new URL(candidate.authBaseUrl);
68
+ if (broker.protocol !== "https:" &&
69
+ !(broker.protocol === "http:" && isExactLoopbackHostname(broker.hostname))) {
70
+ return undefined;
71
+ }
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ return {
77
+ authBaseUrl: candidate.authBaseUrl,
78
+ clientId: candidate.clientId,
79
+ refreshToken: candidate.refreshToken
80
+ };
81
+ }
82
+ /** The refresh grant stored with the account token, if any. */
83
+ function readRefreshGrant() {
84
+ try {
85
+ const stored = parseJson(browserStorage()?.getItem(AUTH_STORAGE_KEY) ?? null);
86
+ return stored && typeof stored === "object" && "refresh" in stored
87
+ ? parseRefreshGrant(stored.refresh)
88
+ : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /** Revokes a refresh token at the broker. Best effort: sign-out does not wait for it. */
95
+ function revokeRefreshGrant(grant) {
96
+ if (!grant || typeof fetch !== "function") {
97
+ return;
98
+ }
99
+ void fetch(new URL("/revoke", grant.authBaseUrl), {
100
+ body: new URLSearchParams({ client_id: grant.clientId, token: grant.refreshToken }),
101
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
+ keepalive: true,
103
+ method: "POST"
104
+ }).catch(() => undefined);
105
+ }
106
+ // A token's expiry on this device's clock (see `tokenExpiresAt`), and its lifetime.
107
+ function tokenTiming(token) {
108
+ const claims = decodeIdentityClaims(token);
109
+ let stored;
110
+ try {
111
+ stored = parseJson(browserStorage()?.getItem(AUTH_STORAGE_KEY) ?? null);
112
+ }
113
+ catch {
114
+ stored = null;
115
+ }
116
+ const receivedAt = stored && typeof stored === "object" && "token" in stored && stored.token === token &&
117
+ "receivedAt" in stored
118
+ ? stored.receivedAt
119
+ : undefined;
120
+ const expiresAt = tokenExpiresAt(claims, receivedAt);
121
+ if (expiresAt === null || !claims) {
122
+ return null;
123
+ }
124
+ const lifetimeMs = typeof claims.iat === "number" && typeof claims.exp === "number"
125
+ ? (claims.exp - claims.iat) * 1000
126
+ : TOKEN_REFRESH_AHEAD_MS * 2;
127
+ return { expiresAt, lifetimeMs };
128
+ }
129
+ // True in the last five minutes of a token's life, or the last half of a short one.
130
+ function tokenNeedsRefresh(token) {
131
+ const timing = tokenTiming(token);
132
+ return (timing !== null &&
133
+ timing.expiresAt - Date.now() <= Math.min(TOKEN_REFRESH_AHEAD_MS, timing.lifetimeMs / 2));
134
+ }
135
+ function tokenExpired(token) {
136
+ const timing = tokenTiming(token);
137
+ return timing !== null && timing.expiresAt <= Date.now();
138
+ }
29
139
  class SupersededAuthError extends Error {
30
140
  constructor() {
31
141
  super("The session changed before sign-in finished.");
@@ -55,9 +165,20 @@ async function requestSession(operation, token, signal, guestUserId = null) {
55
165
  signal: requestSignal
56
166
  });
57
167
  // Keep concurrent tabs from replacing each other's first guest cookie.
58
- const response = typeof navigator !== "undefined" && navigator.locks
59
- ? await navigator.locks.request(`lakebed-auth:${basePath()}`, { signal: requestSignal }, sendRequest)
60
- : await sendRequest();
168
+ let response;
169
+ try {
170
+ response =
171
+ typeof navigator !== "undefined" && navigator.locks
172
+ ? await navigator.locks.request(`lakebed-auth:${basePath()}`, { signal: requestSignal }, sendRequest)
173
+ : await sendRequest();
174
+ }
175
+ catch (error) {
176
+ // fetch() rejects with a TypeError when the network fails.
177
+ if (error instanceof TypeError && !signal.aborted) {
178
+ throw transientAuthError("Could not reach this app. Retrying.");
179
+ }
180
+ throw error;
181
+ }
61
182
  if (!response.ok) {
62
183
  let message = operation === "upgrade"
63
184
  ? `Could not finish sign-in (${response.status}). Retry to keep your work.`
@@ -83,9 +204,19 @@ async function requestSession(operation, token, signal, guestUserId = null) {
83
204
  }
84
205
  throw Object.assign(new Error(message), { status: response.status });
85
206
  }
207
+ let bytes;
208
+ try {
209
+ bytes = await readTokenResponseBytes(response, requestSignal);
210
+ }
211
+ catch (error) {
212
+ if (error instanceof TokenResponseBodyError && error.kind === "size") {
213
+ throw new Error("Lakebed returned an invalid session. Try again.");
214
+ }
215
+ // The connection dropped or stalled while the session arrived.
216
+ throw transientAuthError("Lakebed stopped answering before the session arrived. Retrying.");
217
+ }
86
218
  let body;
87
219
  try {
88
- const bytes = await readTokenResponseBytes(response, requestSignal);
89
220
  body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
90
221
  }
91
222
  catch {
@@ -146,9 +277,11 @@ function readPendingGuestUpgrade(allowExpired = false) {
146
277
  clearPendingGuestUpgrade();
147
278
  return null;
148
279
  }
280
+ const refresh = parseRefreshGrant(candidate.refresh);
149
281
  const pending = {
150
282
  expiresIn: candidate.expiresIn,
151
283
  guestUserId: candidate.guestUserId,
284
+ ...(refresh ? { refresh } : {}),
152
285
  requestPii: candidate.requestPii,
153
286
  returnTo,
154
287
  token: candidate.token
@@ -169,7 +302,7 @@ async function finishGuestUpgrade(pending, revision, signal) {
169
302
  if (!identity || !auth.isSignedIn || auth.userId !== `google:${identity.subject}`) {
170
303
  throw new Error("Lakebed returned a different account. Sign-in did not finish.");
171
304
  }
172
- persistIdentity(identity.subject, pending.token, pending.expiresIn, pending.requestPii);
305
+ persistIdentity(identity.subject, pending.token, pending.expiresIn, pending.requestPii, pending.refresh);
173
306
  setAuthToken(pending.token);
174
307
  clearPendingGuestUpgrade();
175
308
  clearAuthResumeAttempt();
@@ -393,15 +526,20 @@ function createSignInUrl(options, bundle, requestPii) {
393
526
  url.searchParams.set("pii", String(requestPii));
394
527
  return url.toString();
395
528
  }
396
- function persistIdentity(userId, token, expiresIn, requestPii) {
529
+ function persistIdentity(userId, token, expiresIn, requestPii, refresh) {
397
530
  const storage = browserStorage();
398
531
  if (!storage) {
399
532
  return;
400
533
  }
401
534
  try {
535
+ const previous = readRefreshGrant();
536
+ if (previous && previous.refreshToken !== refresh?.refreshToken) {
537
+ revokeRefreshGrant(previous);
538
+ }
402
539
  storage.setItem(AUTH_STORAGE_KEY, JSON.stringify({
403
540
  expiresIn,
404
541
  receivedAt: Date.now(),
542
+ ...(refresh ? { refresh } : {}),
405
543
  requestPii,
406
544
  token,
407
545
  userId,
@@ -434,6 +572,7 @@ function clearStoredIdentity() {
434
572
  return;
435
573
  }
436
574
  try {
575
+ revokeRefreshGrant(readRefreshGrant());
437
576
  storage.removeItem(AUTH_STORAGE_KEY);
438
577
  storage.removeItem(OBSOLETE_AUTH_STORAGE_KEY);
439
578
  }
@@ -731,9 +870,15 @@ async function handleGoogleCallback(revision, signal) {
731
870
  clearTerminalTokenResponse();
732
871
  throw new Error("Google sign-in token response did not match the trusted broker.");
733
872
  }
873
+ const refresh = parseRefreshGrant({
874
+ authBaseUrl: parsedPkce.options.authBaseUrl,
875
+ clientId: parsedPkce.options.clientId,
876
+ refreshToken: token.refresh_token
877
+ });
734
878
  const pending = {
735
879
  expiresIn: token.expires_in,
736
880
  guestUserId: parsedPkce.guestUserId,
881
+ ...(refresh ? { refresh } : {}),
737
882
  requestPii: parsedPkce.requestPii,
738
883
  returnTo: parsedPkce.options.returnTo,
739
884
  token: token.id_token
@@ -745,6 +890,218 @@ async function handleGoogleCallback(revision, signal) {
745
890
  await finishGuestUpgrade(pending, revision, signal);
746
891
  return token;
747
892
  }
893
+ /**
894
+ * Gets a new account token from the broker with the stored refresh token, with
895
+ * no redirect. Tabs take turns through a lock, and a tab that waited reads the
896
+ * token the other tab stored. `rejectedToken` is a token the server refused:
897
+ * it is refreshed even when it does not look expired.
898
+ */
899
+ export function refreshAccountToken(rejectedToken) {
900
+ refreshInFlight ??= requestTokenRefresh(rejectedToken).finally(() => {
901
+ refreshInFlight = null;
902
+ });
903
+ return refreshInFlight;
904
+ }
905
+ async function requestTokenRefresh(rejectedToken) {
906
+ const refresh = async () => {
907
+ const stored = readStoredIdentity({ allowExpired: true });
908
+ if (!stored.token || !stored.userId) {
909
+ return "none";
910
+ }
911
+ if (!stored.expired && !tokenNeedsRefresh(stored.token) && stored.token !== rejectedToken) {
912
+ // Another tab refreshed while this one waited for the lock.
913
+ adoptRefreshedToken(stored.userId, stored.token);
914
+ return "refreshed";
915
+ }
916
+ const grant = readRefreshGrant();
917
+ if (!grant) {
918
+ return "none";
919
+ }
920
+ let body;
921
+ try {
922
+ const signal = AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS);
923
+ const response = await fetch(new URL("/token", grant.authBaseUrl), {
924
+ body: new URLSearchParams({
925
+ client_id: grant.clientId,
926
+ grant_type: "refresh_token",
927
+ refresh_token: grant.refreshToken
928
+ }),
929
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
930
+ method: "POST",
931
+ signal
932
+ });
933
+ if (response.status === 400 || response.status === 401) {
934
+ void response.body?.cancel().catch(() => undefined);
935
+ forgetRefreshGrant(grant);
936
+ return "rejected";
937
+ }
938
+ if (!response.ok) {
939
+ void response.body?.cancel().catch(() => undefined);
940
+ return "unavailable";
941
+ }
942
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await readTokenResponseBytes(response, signal)));
943
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
944
+ return "unavailable";
945
+ }
946
+ body = parsed;
947
+ }
948
+ catch {
949
+ return "unavailable";
950
+ }
951
+ const token = typeof body.id_token === "string" ? body.id_token : null;
952
+ const identity = brokerIdentityFromToken(token);
953
+ if (!token ||
954
+ !identity ||
955
+ identity.subject !== stored.userId ||
956
+ !createGoogleAuthFromToken(token) ||
957
+ identityIdFromTokenResponse({ subject: body.subject }) !== identity.subject ||
958
+ identityIdFromTokenResponse({ pairwise_sub: body.pairwise_sub }) !== identity.pairwiseSub) {
959
+ // A broker that returns another account must not sign this tab in as it.
960
+ forgetRefreshGrant(grant);
961
+ return "rejected";
962
+ }
963
+ if (readRefreshGrant()?.refreshToken !== grant.refreshToken) {
964
+ // Signed out, or another account signed in, while the request ran.
965
+ // Writing this token back would undo that.
966
+ return "none";
967
+ }
968
+ persistIdentity(identity.subject, token, body.expires_in, storedIdentityRequestsProfile(), grant);
969
+ adoptRefreshedToken(identity.subject, token);
970
+ return "refreshed";
971
+ };
972
+ try {
973
+ // A frozen tab can hold the lock. Waiting is bounded, then this tab tries later.
974
+ return typeof navigator !== "undefined" && navigator.locks
975
+ ? await navigator.locks.request(`lakebed-auth-refresh:${basePath()}`, { signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS * 2) }, refresh)
976
+ : await refresh();
977
+ }
978
+ catch {
979
+ return "unavailable";
980
+ }
981
+ }
982
+ /** This tab starts using a newer token of the account it already shows. */
983
+ function adoptRefreshedToken(subject, token) {
984
+ const auth = getAuth();
985
+ if (!auth.isSignedIn || auth.userId !== `google:${subject}` || storedAuthToken() === token) {
986
+ return;
987
+ }
988
+ setAuthToken(token);
989
+ refreshSocketToken(token);
990
+ }
991
+ /** Drops a refresh token the broker refused, and keeps the account token. */
992
+ function forgetRefreshGrant(grant) {
993
+ const storage = browserStorage();
994
+ try {
995
+ const stored = parseJson(storage?.getItem(AUTH_STORAGE_KEY) ?? null);
996
+ if (stored &&
997
+ typeof stored === "object" &&
998
+ "refresh" in stored &&
999
+ parseRefreshGrant(stored.refresh)?.refreshToken === grant.refreshToken) {
1000
+ const { refresh: _refresh, ...identity } = stored;
1001
+ storage?.setItem(AUTH_STORAGE_KEY, JSON.stringify(identity));
1002
+ }
1003
+ }
1004
+ catch {
1005
+ // The broker refuses this token anyway.
1006
+ }
1007
+ }
1008
+ /** True when this tab's account token expires soon and should be refreshed before use. */
1009
+ export function accountTokenNeedsRefresh() {
1010
+ return tokenNeedsRefresh(storedAuthToken());
1011
+ }
1012
+ /**
1013
+ * Refreshes this tab's account token when it expires soon. While the token
1014
+ * still works, the refresh runs in the background and the caller does not
1015
+ * wait for the broker. Returns a failure only when the token has already
1016
+ * expired, because a socket would refuse it.
1017
+ */
1018
+ export async function refreshAccountTokenIfNeeded() {
1019
+ if (!accountTokenNeedsRefresh()) {
1020
+ return undefined;
1021
+ }
1022
+ if (!tokenExpired(storedAuthToken())) {
1023
+ void refreshAccountToken();
1024
+ return undefined;
1025
+ }
1026
+ const outcome = await refreshAccountToken();
1027
+ if (outcome === "refreshed" || !tokenExpired(storedAuthToken())) {
1028
+ return undefined;
1029
+ }
1030
+ return outcome === "unavailable" ? "unavailable" : "rejected";
1031
+ }
1032
+ // An expired account token with a refresh token gets a new one without the
1033
+ // sign-in redirect. While the broker is out of reach, no redirect starts.
1034
+ async function refreshExpiredStoredIdentity() {
1035
+ if (getAuthResumeStarted() ||
1036
+ currentGuestName() !== null ||
1037
+ window.location.pathname === callbackPath() ||
1038
+ parseCallback()) {
1039
+ return;
1040
+ }
1041
+ const stored = readStoredIdentity({ allowExpired: true });
1042
+ if (!stored.token || !stored.expired || !readRefreshGrant()) {
1043
+ return;
1044
+ }
1045
+ if ((await refreshAccountToken()) === "unavailable") {
1046
+ throw transientAuthError("Could not reach Lakebed to restore your sign-in. Retrying.");
1047
+ }
1048
+ }
1049
+ function clearAuthRetry() {
1050
+ if (authRetryTimer !== null) {
1051
+ clearTimeout(authRetryTimer);
1052
+ authRetryTimer = null;
1053
+ }
1054
+ }
1055
+ // A session check that could not reach Lakebed runs again by itself: after a
1056
+ // backoff, or as soon as the tab wakes up or goes online.
1057
+ function scheduleAuthRetry() {
1058
+ if (authRetryTimer !== null) {
1059
+ return;
1060
+ }
1061
+ authRetryTimer = setTimeout(() => {
1062
+ authRetryTimer = null;
1063
+ void retryAuth();
1064
+ }, reconnectDelayMs(authRetryAttempts++));
1065
+ authRetryTimer.unref?.();
1066
+ }
1067
+ /**
1068
+ * Runs after the first socket that could not open. That is usually the
1069
+ * network, but a browser reports a rejected upgrade the same way. This asks
1070
+ * the session route without signing the tab out first. A changed or refused
1071
+ * session gets the full recheck. Otherwise the tab keeps its identity and
1072
+ * data, and the socket retries after a backoff.
1073
+ */
1074
+ export async function checkSessionAfterHandshakeFailure() {
1075
+ const revision = authRevision;
1076
+ const before = getAuth();
1077
+ let session;
1078
+ try {
1079
+ if ((await refreshAccountTokenIfNeeded()) === "unavailable") {
1080
+ // The token expired and the broker is out of reach too. Keep waiting.
1081
+ reconnectLater();
1082
+ return;
1083
+ }
1084
+ session = await requestSession("session", storedAuthToken() || undefined, new AbortController().signal);
1085
+ }
1086
+ catch (error) {
1087
+ if (revision !== authRevision) {
1088
+ return;
1089
+ }
1090
+ if (isTransientAuthError(error)) {
1091
+ reconnectLater();
1092
+ return;
1093
+ }
1094
+ return recheckAuthAfterDisconnect();
1095
+ }
1096
+ if (revision !== authRevision) {
1097
+ return;
1098
+ }
1099
+ if (session.userId === before.userId && session.isSignedIn === before.isSignedIn) {
1100
+ reconnectLater();
1101
+ return;
1102
+ }
1103
+ return recheckAuthAfterDisconnect();
1104
+ }
748
1105
  function authResumeAttemptKey(identity) {
749
1106
  const claims = decodeIdentityClaims(identity.token);
750
1107
  return ([identity.userId, claims?.jti, claims?.exp].filter(Boolean).join(":") ||
@@ -802,7 +1159,8 @@ function beginStoredGoogleSessionResume() {
802
1159
  return true;
803
1160
  }
804
1161
  // The session this page can start with, without a request: the stored account
805
- // token, or the guest session the app page shipped (`__LAKEBED_AUTH__.session`).
1162
+ // token (refreshed before use when it expired), or the guest session the app
1163
+ // page shipped (`__LAKEBED_AUTH__.session`).
806
1164
  // The WebSocket handshake checks it, and a rejected handshake rechecks with
807
1165
  // the session route. Sign-in callbacks, guest upgrades, expired tokens, and
808
1166
  // pages without a shipped session ask the session route first.
@@ -816,7 +1174,9 @@ function readStartingSession() {
816
1174
  }
817
1175
  const stored = readStoredIdentity({ allowExpired: true });
818
1176
  if (stored.token) {
819
- const auth = stored.expired ? null : createGoogleAuthFromToken(stored.token);
1177
+ // An expired token with a refresh token still starts the page signed in:
1178
+ // connect() refreshes it before the socket opens.
1179
+ const auth = stored.expired && !readRefreshGrant() ? null : createGoogleAuthFromToken(stored.token);
820
1180
  return auth ? { auth, token: stored.token } : null;
821
1181
  }
822
1182
  const session = authConfig().session;
@@ -860,6 +1220,8 @@ export function ensureAuthInitialized() {
860
1220
  await finishGuestUpgrade(pending, revision, signal);
861
1221
  return;
862
1222
  }
1223
+ await refreshExpiredStoredIdentity();
1224
+ assertAuthRevision(revision);
863
1225
  if (beginStoredGoogleSessionResume()) {
864
1226
  return;
865
1227
  }
@@ -881,9 +1243,38 @@ export function ensureAuthInitialized() {
881
1243
  error.status !== 401) {
882
1244
  throw error;
883
1245
  }
884
- clearStoredIdentity();
885
- token = undefined;
886
- session = await requestSession("session", undefined, signal);
1246
+ // The token expired or was revoked after it was read. A refresh tells which.
1247
+ const refresh = await refreshAccountToken(token);
1248
+ assertAuthRevision(revision);
1249
+ if (refresh === "unavailable") {
1250
+ throw transientAuthError("Could not reach Lakebed to check your sign-in. Retrying.");
1251
+ }
1252
+ const refused = token;
1253
+ let next = refresh === "refreshed" ? readStoredIdentity().token : undefined;
1254
+ if (refresh === "none" && tokenExpired(token) && beginStoredGoogleSessionResume()) {
1255
+ return;
1256
+ }
1257
+ try {
1258
+ if (!next) {
1259
+ throw Object.assign(new Error("The session route refused the account token."), { status: 401 });
1260
+ }
1261
+ session = await requestSession("session", next, signal);
1262
+ }
1263
+ catch (retryError) {
1264
+ assertAuthRevision(revision);
1265
+ if (!retryError || typeof retryError !== "object" || !("status" in retryError) || retryError.status !== 401) {
1266
+ throw retryError;
1267
+ }
1268
+ // Clear only the identity this tab tried. Another tab may have signed
1269
+ // in to a different account meanwhile.
1270
+ const stored = readStoredIdentity({ allowExpired: true }).token;
1271
+ if (stored === refused || stored === next) {
1272
+ clearStoredIdentity();
1273
+ }
1274
+ next = undefined;
1275
+ session = await requestSession("session", undefined, signal);
1276
+ }
1277
+ token = next;
887
1278
  }
888
1279
  assertAuthRevision(revision);
889
1280
  if (session.isSignedIn) {
@@ -893,6 +1284,7 @@ export function ensureAuthInitialized() {
893
1284
  }
894
1285
  setAuthToken(token ?? "");
895
1286
  }
1287
+ authRetryAttempts = 0;
896
1288
  setAuth(session);
897
1289
  emitAuth();
898
1290
  })()
@@ -901,11 +1293,15 @@ export function ensureAuthInitialized() {
901
1293
  return;
902
1294
  }
903
1295
  console.error("[lakebed] Session failed", error);
1296
+ const transient = isTransientAuthError(error);
904
1297
  setAuth({
905
1298
  ...createSignedOutAuth(),
906
1299
  error: error instanceof Error ? error.message : "Could not load your session. Try again."
907
1300
  });
908
1301
  emitAuth();
1302
+ if (transient) {
1303
+ scheduleAuthRetry();
1304
+ }
909
1305
  })
910
1306
  .finally(() => {
911
1307
  if (revision === authRevision) {
@@ -915,6 +1311,7 @@ export function ensureAuthInitialized() {
915
1311
  return authInitPromise;
916
1312
  }
917
1313
  export async function recheckAuthAfterDisconnect() {
1314
+ clearAuthRetry();
918
1315
  authController?.abort();
919
1316
  authRevision += 1;
920
1317
  signOutPending = false;
@@ -951,6 +1348,15 @@ export function stopAuthAfterConnectionFailure(message) {
951
1348
  if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
952
1349
  window.addEventListener("storage", (event) => {
953
1350
  if (event.key === AUTH_STORAGE_KEY || event.key === null) {
1351
+ // Another tab refreshed the token of the account this tab shows. Use it
1352
+ // in place. A full recheck would blank this tab's data for nothing.
1353
+ if (event.key === AUTH_STORAGE_KEY && event.newValue !== null && !signOutPending) {
1354
+ const stored = readStoredIdentity();
1355
+ if (stored.token && stored.userId && getAuth().isSignedIn && getAuth().userId === `google:${stored.userId}`) {
1356
+ adoptRefreshedToken(stored.userId, stored.token);
1357
+ return;
1358
+ }
1359
+ }
954
1360
  if (event.key === null || event.newValue === null) {
955
1361
  clearPendingGuestUpgrade();
956
1362
  clearSignInTransaction();
@@ -960,6 +1366,47 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio
960
1366
  void retryAuth();
961
1367
  }
962
1368
  });
1369
+ // Keeps a long-lived tab signed in and connected. Timers stop while a
1370
+ // laptop sleeps and run late in background tabs, so waking up checks too.
1371
+ const wake = () => {
1372
+ if (authRetryTimer !== null) {
1373
+ // A session check could not reach Lakebed. Try it now.
1374
+ clearAuthRetry();
1375
+ void retryAuth();
1376
+ return;
1377
+ }
1378
+ if (!canAccessApp()) {
1379
+ return;
1380
+ }
1381
+ const token = storedAuthToken();
1382
+ if (token && tokenNeedsRefresh(token)) {
1383
+ void refreshAccountToken().then(() => reconnectNow());
1384
+ return;
1385
+ }
1386
+ reconnectNow();
1387
+ };
1388
+ window.addEventListener("online", wake);
1389
+ window.addEventListener("focus", wake);
1390
+ window.addEventListener("pageshow", (event) => {
1391
+ if (event.persisted) {
1392
+ wake();
1393
+ }
1394
+ });
1395
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
1396
+ document.addEventListener("visibilitychange", () => {
1397
+ if (document.visibilityState === "visible") {
1398
+ wake();
1399
+ }
1400
+ });
1401
+ }
1402
+ const tokenCheck = setInterval(() => {
1403
+ const token = storedAuthToken();
1404
+ if (canAccessApp() && token && tokenNeedsRefresh(token)) {
1405
+ void refreshAccountToken();
1406
+ }
1407
+ }, 60_000);
1408
+ // Node test hosts must still exit. Browsers return a number here.
1409
+ tokenCheck.unref?.();
963
1410
  }
964
1411
  export function useAuth() {
965
1412
  const [value, setValue] = useState(getAuth());
@@ -1014,6 +1461,7 @@ export async function signInWithGoogle(options = {}) {
1014
1461
  return { bundle, url };
1015
1462
  }
1016
1463
  export async function signOut() {
1464
+ clearAuthRetry();
1017
1465
  authController?.abort();
1018
1466
  const revision = ++authRevision;
1019
1467
  authController = new AbortController();