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.
- package/dist/_core/index.d.ts +2 -2
- package/dist/_core/index.d.ts.map +1 -1
- package/dist/_core/index.js +1 -1
- package/dist/_core/index.js.map +1 -1
- package/dist/_core/protocol.d.ts +16 -1
- package/dist/_core/protocol.d.ts.map +1 -1
- package/dist/_core/protocol.js +8 -0
- package/dist/_core/protocol.js.map +1 -1
- package/dist/cli/dev/server.d.ts.map +1 -1
- package/dist/cli/dev/server.js +37 -3
- package/dist/cli/dev/server.js.map +1 -1
- package/dist/client/auth.d.ts +32 -0
- package/dist/client/auth.d.ts.map +1 -1
- package/dist/client/auth.js +461 -13
- package/dist/client/auth.js.map +1 -1
- package/dist/client/internal.d.ts +7 -0
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +15 -3
- package/dist/client/internal.js.map +1 -1
- package/dist/client/storage.d.ts.map +1 -1
- package/dist/client/storage.js +10 -1
- package/dist/client/storage.js.map +1 -1
- package/dist/client/transport.d.ts +18 -0
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/client/transport.js +135 -4
- package/dist/client/transport.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/client/auth.js
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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();
|