@basictech/react 0.8.0-beta.2 → 0.8.0-beta.4
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 +12 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +591 -147
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +599 -148
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/readme.md +99 -97
- package/src/AuthContext.tsx +513 -411
- package/src/context.tsx +19 -1
- package/src/core/auth/AuthManager.ts +1239 -726
- package/src/sync/syncProtocol.js +49 -3
- package/src/utils/network.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -93,6 +93,7 @@ var init_syncProtocol = __esm({
|
|
|
93
93
|
var requestId = 0;
|
|
94
94
|
var acceptCallbacks = {};
|
|
95
95
|
var refreshTimer = null;
|
|
96
|
+
var pendingTokenUpdate = null;
|
|
96
97
|
log("Connecting to", url);
|
|
97
98
|
var ws = new WebSocket(url);
|
|
98
99
|
function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
@@ -115,6 +116,12 @@ var init_syncProtocol = __esm({
|
|
|
115
116
|
refreshTimer = null;
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
function sendTokenUpdate(token) {
|
|
120
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
121
|
+
pendingTokenUpdate = token;
|
|
122
|
+
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
118
125
|
function resolveGetToken() {
|
|
119
126
|
var fn = getTokenGetter(url);
|
|
120
127
|
if (!fn) throw new Error("No token getter registered for " + url);
|
|
@@ -130,10 +137,8 @@ var init_syncProtocol = __esm({
|
|
|
130
137
|
refreshTimer = setTimeout(async function() {
|
|
131
138
|
try {
|
|
132
139
|
var newToken = await resolveGetToken()({ forceRefresh: true });
|
|
133
|
-
if (
|
|
140
|
+
if (sendTokenUpdate(newToken)) {
|
|
134
141
|
log("Sending tokenUpdate on existing WebSocket");
|
|
135
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
|
|
136
|
-
scheduleTokenRefresh(newToken);
|
|
137
142
|
}
|
|
138
143
|
} catch (err) {
|
|
139
144
|
log("Proactive token refresh failed (non-fatal):", err);
|
|
@@ -159,14 +164,34 @@ var init_syncProtocol = __esm({
|
|
|
159
164
|
onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
|
|
160
165
|
}
|
|
161
166
|
};
|
|
167
|
+
function handleVisibilityResume() {
|
|
168
|
+
if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
|
|
169
|
+
log("Page became visible - refreshing token for WebSocket");
|
|
170
|
+
resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
|
|
171
|
+
sendTokenUpdate(newToken);
|
|
172
|
+
}).catch(function(err) {
|
|
173
|
+
log("Token refresh on visibility resume failed:", err);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typeof document !== "undefined") {
|
|
178
|
+
document.addEventListener("visibilitychange", handleVisibilityResume);
|
|
179
|
+
}
|
|
180
|
+
function cleanupVisibilityListener() {
|
|
181
|
+
if (typeof document !== "undefined") {
|
|
182
|
+
document.removeEventListener("visibilitychange", handleVisibilityResume);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
162
185
|
ws.onerror = function(event) {
|
|
163
186
|
clearRefreshTimer();
|
|
187
|
+
cleanupVisibilityListener();
|
|
164
188
|
ws.close();
|
|
165
189
|
log("ws.onerror", event);
|
|
166
190
|
onError(event?.message, RECONNECT_DELAY);
|
|
167
191
|
};
|
|
168
192
|
ws.onclose = function(event) {
|
|
169
193
|
clearRefreshTimer();
|
|
194
|
+
cleanupVisibilityListener();
|
|
170
195
|
onError("Socket closed: " + event.reason, RECONNECT_DELAY);
|
|
171
196
|
};
|
|
172
197
|
var isFirstRound = true;
|
|
@@ -203,11 +228,25 @@ var init_syncProtocol = __esm({
|
|
|
203
228
|
},
|
|
204
229
|
disconnect: function() {
|
|
205
230
|
clearRefreshTimer();
|
|
231
|
+
cleanupVisibilityListener();
|
|
206
232
|
ws.close();
|
|
207
233
|
}
|
|
208
234
|
});
|
|
209
235
|
isFirstRound = false;
|
|
210
236
|
}
|
|
237
|
+
} else if (requestFromServer.type == "tokenUpdateAck") {
|
|
238
|
+
if (requestFromServer.ok) {
|
|
239
|
+
scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
|
|
240
|
+
pendingTokenUpdate = null;
|
|
241
|
+
} else {
|
|
242
|
+
log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
|
|
243
|
+
pendingTokenUpdate = null;
|
|
244
|
+
ws.close(4001, requestFromServer.code || "token_update_failed");
|
|
245
|
+
onError(
|
|
246
|
+
requestFromServer.message || "Authentication refresh failed",
|
|
247
|
+
RECONNECT_DELAY
|
|
248
|
+
);
|
|
249
|
+
}
|
|
211
250
|
} else if (requestFromServer.type == "ack") {
|
|
212
251
|
var requestId2 = requestFromServer.requestId;
|
|
213
252
|
var acceptCallback = acceptCallbacks[requestId2.toString()];
|
|
@@ -242,7 +281,7 @@ var init_syncProtocol = __esm({
|
|
|
242
281
|
var version;
|
|
243
282
|
var init_package = __esm({
|
|
244
283
|
"package.json"() {
|
|
245
|
-
version = "0.8.0-beta.
|
|
284
|
+
version = "0.8.0-beta.4";
|
|
246
285
|
}
|
|
247
286
|
});
|
|
248
287
|
|
|
@@ -326,7 +365,7 @@ function cleanOAuthParamsFromUrl() {
|
|
|
326
365
|
const url = new URL(window.location.href);
|
|
327
366
|
url.searchParams.delete("code");
|
|
328
367
|
url.searchParams.delete("state");
|
|
329
|
-
window.history.
|
|
368
|
+
window.history.replaceState({}, document.title, url.pathname + url.search);
|
|
330
369
|
log("Cleaned OAuth parameters from URL");
|
|
331
370
|
}
|
|
332
371
|
}
|
|
@@ -386,6 +425,8 @@ var init_context = __esm({
|
|
|
386
425
|
BasicContext = (0, import_react.createContext)({
|
|
387
426
|
isReady: false,
|
|
388
427
|
isSignedIn: false,
|
|
428
|
+
authStatus: "bootstrapping",
|
|
429
|
+
authErrorCode: null,
|
|
389
430
|
user: null,
|
|
390
431
|
did: null,
|
|
391
432
|
scope: null,
|
|
@@ -1654,6 +1695,21 @@ async function resolveHandle(handle) {
|
|
|
1654
1695
|
// src/core/auth/AuthManager.ts
|
|
1655
1696
|
init_network();
|
|
1656
1697
|
init_config();
|
|
1698
|
+
var DEFINITIVE_TOKEN_ERRORS = /* @__PURE__ */ new Set([
|
|
1699
|
+
"invalid_grant",
|
|
1700
|
+
"invalid_client",
|
|
1701
|
+
"unauthorized_client"
|
|
1702
|
+
]);
|
|
1703
|
+
var USER_RECOVERY_RETRY_COOLDOWN_MS = 3e4;
|
|
1704
|
+
var SESSION_RECONCILE_THROTTLE_MS = 5e3;
|
|
1705
|
+
var DefinitiveAuthError = class extends Error {
|
|
1706
|
+
code;
|
|
1707
|
+
constructor(code) {
|
|
1708
|
+
super(`Definitive auth failure: ${code}`);
|
|
1709
|
+
this.name = "DefinitiveAuthError";
|
|
1710
|
+
this.code = code;
|
|
1711
|
+
}
|
|
1712
|
+
};
|
|
1657
1713
|
function generateCodeVerifier() {
|
|
1658
1714
|
const array = new Uint8Array(32);
|
|
1659
1715
|
crypto.getRandomValues(array);
|
|
@@ -1661,7 +1717,9 @@ function generateCodeVerifier() {
|
|
|
1661
1717
|
}
|
|
1662
1718
|
async function generateCodeChallenge(verifier) {
|
|
1663
1719
|
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
1664
|
-
log(
|
|
1720
|
+
log(
|
|
1721
|
+
"crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
|
|
1722
|
+
);
|
|
1665
1723
|
return { challenge: verifier, method: "plain" };
|
|
1666
1724
|
}
|
|
1667
1725
|
const encoder = new TextEncoder();
|
|
@@ -1682,6 +1740,8 @@ var AuthManager = class {
|
|
|
1682
1740
|
user = null;
|
|
1683
1741
|
isSignedIn = false;
|
|
1684
1742
|
isAuthReady = false;
|
|
1743
|
+
authStatus = "bootstrapping";
|
|
1744
|
+
authErrorCode = null;
|
|
1685
1745
|
did = null;
|
|
1686
1746
|
/** Space-separated scopes granted in the current access token */
|
|
1687
1747
|
tokenScope = null;
|
|
@@ -1698,6 +1758,9 @@ var AuthManager = class {
|
|
|
1698
1758
|
pendingRefresh = false;
|
|
1699
1759
|
isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
|
|
1700
1760
|
channel = null;
|
|
1761
|
+
nextUserRecoveryAt = 0;
|
|
1762
|
+
sessionCheckPromise = null;
|
|
1763
|
+
lastSessionCheckAt = 0;
|
|
1701
1764
|
constructor(config, storage, notify) {
|
|
1702
1765
|
this.config = config;
|
|
1703
1766
|
this.storage = storage;
|
|
@@ -1712,31 +1775,26 @@ var AuthManager = class {
|
|
|
1712
1775
|
this.channel.onmessage = (event) => {
|
|
1713
1776
|
if (event.data?.type === "token_refreshed") {
|
|
1714
1777
|
log("Received token refresh from another tab");
|
|
1715
|
-
|
|
1716
|
-
this.token = { ...this.token, access_token: event.data.accessToken };
|
|
1717
|
-
}
|
|
1718
|
-
if (event.data.did) this.did = event.data.did;
|
|
1719
|
-
if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
|
|
1720
|
-
this.notify();
|
|
1778
|
+
void this.handleExternalTokenRefresh(event.data);
|
|
1721
1779
|
}
|
|
1722
1780
|
if (event.data?.type === "signed_in") {
|
|
1723
|
-
log("Received sign-in from another tab,
|
|
1724
|
-
|
|
1725
|
-
window.location.reload();
|
|
1726
|
-
}
|
|
1781
|
+
log("Received sign-in from another tab, restoring session");
|
|
1782
|
+
void this.restoreStoredSession("cross-tab sign-in");
|
|
1727
1783
|
}
|
|
1728
1784
|
if (event.data?.type === "signed_out") {
|
|
1729
|
-
log("Received sign-out from another tab
|
|
1730
|
-
this.
|
|
1731
|
-
this.isSignedIn = false;
|
|
1732
|
-
this.token = null;
|
|
1733
|
-
this.did = null;
|
|
1734
|
-
this.tokenScope = null;
|
|
1785
|
+
log("Received sign-out from another tab");
|
|
1786
|
+
this.resetAuthState("signed_out");
|
|
1735
1787
|
this.notify();
|
|
1736
1788
|
if (typeof window !== "undefined") {
|
|
1737
1789
|
window.location.reload();
|
|
1738
1790
|
}
|
|
1739
1791
|
}
|
|
1792
|
+
if (event.data?.type === "session_invalidated") {
|
|
1793
|
+
log("Received session invalidation from another tab");
|
|
1794
|
+
void this.markReauthRequired(event.data.code || "invalid_grant", {
|
|
1795
|
+
broadcast: false
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1740
1798
|
};
|
|
1741
1799
|
} catch {
|
|
1742
1800
|
log("BroadcastChannel not available for cross-tab sync");
|
|
@@ -1756,6 +1814,9 @@ var AuthManager = class {
|
|
|
1756
1814
|
broadcastSignOut() {
|
|
1757
1815
|
this.channel?.postMessage({ type: "signed_out" });
|
|
1758
1816
|
}
|
|
1817
|
+
broadcastSessionInvalidated(code) {
|
|
1818
|
+
this.channel?.postMessage({ type: "session_invalidated", code });
|
|
1819
|
+
}
|
|
1759
1820
|
// ------------------------------------------------------------------
|
|
1760
1821
|
// Public API
|
|
1761
1822
|
// ------------------------------------------------------------------
|
|
@@ -1764,7 +1825,11 @@ var AuthManager = class {
|
|
|
1764
1825
|
* from refresh token, or load cached user for offline mode.
|
|
1765
1826
|
*/
|
|
1766
1827
|
async initialize() {
|
|
1767
|
-
|
|
1828
|
+
this.updateAuthStatus("bootstrapping");
|
|
1829
|
+
await this.storage.set(
|
|
1830
|
+
STORAGE_KEYS.DEBUG,
|
|
1831
|
+
this.config.debug ? "true" : "false"
|
|
1832
|
+
);
|
|
1768
1833
|
const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
|
|
1769
1834
|
if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
|
|
1770
1835
|
log("PDS URL changed, clearing stored tokens");
|
|
@@ -1776,7 +1841,7 @@ var AuthManager = class {
|
|
|
1776
1841
|
if (params.has("code")) {
|
|
1777
1842
|
const code = params.get("code");
|
|
1778
1843
|
if (!code) {
|
|
1779
|
-
this.
|
|
1844
|
+
this.updateAuthStatus("signed_out");
|
|
1780
1845
|
this.notify();
|
|
1781
1846
|
return;
|
|
1782
1847
|
}
|
|
@@ -1784,7 +1849,7 @@ var AuthManager = class {
|
|
|
1784
1849
|
const urlState = params.get("state");
|
|
1785
1850
|
if (!state || state !== urlState) {
|
|
1786
1851
|
log("error: auth state does not match");
|
|
1787
|
-
this.
|
|
1852
|
+
this.updateAuthStatus("signed_out");
|
|
1788
1853
|
this.notify();
|
|
1789
1854
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1790
1855
|
cleanOAuthParamsFromUrl();
|
|
@@ -1795,31 +1860,18 @@ var AuthManager = class {
|
|
|
1795
1860
|
this.freshSignIn = true;
|
|
1796
1861
|
this.exchangeToken(code, false).catch((error) => {
|
|
1797
1862
|
log("Error fetching token:", error);
|
|
1863
|
+
this.freshSignIn = false;
|
|
1864
|
+
void this.restoreCachedUser({
|
|
1865
|
+
hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
|
|
1866
|
+
});
|
|
1798
1867
|
});
|
|
1799
1868
|
} else {
|
|
1800
|
-
|
|
1801
|
-
if (refreshToken) {
|
|
1802
|
-
log("Found refresh token in storage, attempting to refresh access token");
|
|
1803
|
-
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
1804
|
-
log("Error fetching refresh token:", error);
|
|
1805
|
-
});
|
|
1806
|
-
} else {
|
|
1807
|
-
const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
1808
|
-
if (cachedUserInfo) {
|
|
1809
|
-
try {
|
|
1810
|
-
this.user = JSON.parse(cachedUserInfo);
|
|
1811
|
-
this.isSignedIn = true;
|
|
1812
|
-
log("Loaded cached user info for offline mode");
|
|
1813
|
-
} catch (error) {
|
|
1814
|
-
log("Error parsing cached user info:", error);
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
this.isAuthReady = true;
|
|
1818
|
-
this.notify();
|
|
1819
|
-
}
|
|
1869
|
+
await this.restoreStoredSession("initialize");
|
|
1820
1870
|
}
|
|
1821
1871
|
} catch (e) {
|
|
1822
1872
|
log("error getting token", e);
|
|
1873
|
+
this.updateAuthStatus("signed_out");
|
|
1874
|
+
this.notify();
|
|
1823
1875
|
}
|
|
1824
1876
|
}
|
|
1825
1877
|
/**
|
|
@@ -1829,7 +1881,7 @@ var AuthManager = class {
|
|
|
1829
1881
|
async getToken(options) {
|
|
1830
1882
|
log("getting token...");
|
|
1831
1883
|
if (!this.token) {
|
|
1832
|
-
const refreshToken = await this.
|
|
1884
|
+
const refreshToken = await this.getRefreshToken();
|
|
1833
1885
|
if (refreshToken) {
|
|
1834
1886
|
log("No token in memory, attempting to refresh from storage");
|
|
1835
1887
|
if (this.refreshPromise) {
|
|
@@ -1852,7 +1904,12 @@ var AuthManager = class {
|
|
|
1852
1904
|
} catch (error) {
|
|
1853
1905
|
log("Failed to refresh token from storage:", error);
|
|
1854
1906
|
if (this.isNetworkError(error)) {
|
|
1855
|
-
throw new Error(
|
|
1907
|
+
throw new Error(
|
|
1908
|
+
"Network offline - authentication will be retried when online"
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1912
|
+
throw error;
|
|
1856
1913
|
}
|
|
1857
1914
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1858
1915
|
}
|
|
@@ -1865,12 +1922,16 @@ var AuthManager = class {
|
|
|
1865
1922
|
const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
|
|
1866
1923
|
const shouldRefresh = isExpired || options?.forceRefresh === true;
|
|
1867
1924
|
if (shouldRefresh) {
|
|
1868
|
-
log(
|
|
1925
|
+
log(
|
|
1926
|
+
options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
|
|
1927
|
+
);
|
|
1869
1928
|
if (this.refreshPromise) {
|
|
1870
1929
|
log("Token refresh already in progress, waiting...");
|
|
1871
1930
|
try {
|
|
1872
1931
|
const newToken = await this.refreshPromise;
|
|
1873
|
-
|
|
1932
|
+
if (!newToken?.access_token)
|
|
1933
|
+
throw new Error("Token refresh returned empty access token");
|
|
1934
|
+
return newToken.access_token;
|
|
1874
1935
|
} catch (error) {
|
|
1875
1936
|
log("In-flight refresh failed:", error);
|
|
1876
1937
|
if (this.isNetworkError(error)) {
|
|
@@ -1880,24 +1941,31 @@ var AuthManager = class {
|
|
|
1880
1941
|
throw error;
|
|
1881
1942
|
}
|
|
1882
1943
|
}
|
|
1883
|
-
const refreshToken =
|
|
1944
|
+
const refreshToken = await this.getRefreshToken();
|
|
1884
1945
|
if (refreshToken) {
|
|
1885
1946
|
try {
|
|
1886
1947
|
const newToken = await this.exchangeToken(refreshToken, true);
|
|
1887
|
-
|
|
1948
|
+
if (!newToken?.access_token)
|
|
1949
|
+
throw new Error("Token refresh returned empty access token");
|
|
1950
|
+
return newToken.access_token;
|
|
1888
1951
|
} catch (error) {
|
|
1889
1952
|
log("Failed to refresh expired token:", error);
|
|
1890
1953
|
if (this.isNetworkError(error)) {
|
|
1891
1954
|
log("Network issue - using expired token until network is restored");
|
|
1892
1955
|
return this.token.access_token;
|
|
1893
1956
|
}
|
|
1957
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1958
|
+
throw error;
|
|
1959
|
+
}
|
|
1894
1960
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1895
1961
|
}
|
|
1896
1962
|
} else {
|
|
1897
1963
|
throw new Error("no refresh token available");
|
|
1898
1964
|
}
|
|
1899
1965
|
}
|
|
1900
|
-
|
|
1966
|
+
if (!this.token.access_token)
|
|
1967
|
+
throw new Error("Token exists but access_token is empty");
|
|
1968
|
+
return this.token.access_token;
|
|
1901
1969
|
}
|
|
1902
1970
|
async getSignInUrl(redirectUri, endpoints) {
|
|
1903
1971
|
log("getting sign in link...");
|
|
@@ -1905,8 +1973,13 @@ var AuthManager = class {
|
|
|
1905
1973
|
throw new Error("Project ID is required to generate sign-in link");
|
|
1906
1974
|
}
|
|
1907
1975
|
const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
|
|
1908
|
-
await this.storage.set(
|
|
1909
|
-
|
|
1976
|
+
await this.storage.set(
|
|
1977
|
+
STORAGE_KEYS.PDS_ENDPOINTS,
|
|
1978
|
+
JSON.stringify(pdsEndpoints)
|
|
1979
|
+
);
|
|
1980
|
+
const randomState = base64UrlEncode(
|
|
1981
|
+
crypto.getRandomValues(new Uint8Array(16))
|
|
1982
|
+
);
|
|
1910
1983
|
await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
|
|
1911
1984
|
const redirectUrl = redirectUri || window.location.href;
|
|
1912
1985
|
if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
|
|
@@ -1975,7 +2048,10 @@ var AuthManager = class {
|
|
|
1975
2048
|
if (state) {
|
|
1976
2049
|
const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
|
|
1977
2050
|
if (storedState && storedState !== state) {
|
|
1978
|
-
log("State parameter mismatch:", {
|
|
2051
|
+
log("State parameter mismatch:", {
|
|
2052
|
+
provided: state,
|
|
2053
|
+
stored: storedState
|
|
2054
|
+
});
|
|
1979
2055
|
return { success: false, error: "State parameter mismatch" };
|
|
1980
2056
|
}
|
|
1981
2057
|
}
|
|
@@ -1991,6 +2067,7 @@ var AuthManager = class {
|
|
|
1991
2067
|
}
|
|
1992
2068
|
} catch (error) {
|
|
1993
2069
|
log("signInWithCode error:", error);
|
|
2070
|
+
this.freshSignIn = false;
|
|
1994
2071
|
return {
|
|
1995
2072
|
success: false,
|
|
1996
2073
|
error: error.message || "Authentication failed"
|
|
@@ -2003,13 +2080,64 @@ var AuthManager = class {
|
|
|
2003
2080
|
*/
|
|
2004
2081
|
async signOut() {
|
|
2005
2082
|
log("signing out!");
|
|
2006
|
-
this.resetAuthState();
|
|
2083
|
+
this.resetAuthState("signed_out");
|
|
2007
2084
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
2008
2085
|
await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
|
|
2009
2086
|
await this.clearStoredAuth();
|
|
2010
2087
|
this.broadcastSignOut();
|
|
2011
2088
|
this.notify();
|
|
2012
2089
|
}
|
|
2090
|
+
async reconcileSession(reason = "manual", options) {
|
|
2091
|
+
if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (!this.isOnline) {
|
|
2095
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
2096
|
+
this.notify();
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS;
|
|
2100
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
2101
|
+
const now = Date.now();
|
|
2102
|
+
if (this.sessionCheckPromise) {
|
|
2103
|
+
return this.sessionCheckPromise;
|
|
2104
|
+
}
|
|
2105
|
+
if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
this.lastSessionCheckAt = now;
|
|
2109
|
+
let sessionCheck = null;
|
|
2110
|
+
sessionCheck = (async () => {
|
|
2111
|
+
try {
|
|
2112
|
+
const accessToken = await this.getToken(
|
|
2113
|
+
forceRefresh ? { forceRefresh: true } : void 0
|
|
2114
|
+
);
|
|
2115
|
+
const currentSession = await this.fetchCurrentSession(accessToken);
|
|
2116
|
+
if (currentSession?.active) {
|
|
2117
|
+
this.updateAuthStatus("authenticated");
|
|
2118
|
+
this.notify();
|
|
2119
|
+
if (!this.user) {
|
|
2120
|
+
await this.recoverMissingUserProfile(reason, accessToken);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
log(`Session reconciliation failed on ${reason}:`, error);
|
|
2125
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (this.isNetworkError(error)) {
|
|
2129
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
2130
|
+
this.notify();
|
|
2131
|
+
}
|
|
2132
|
+
} finally {
|
|
2133
|
+
if (this.sessionCheckPromise === sessionCheck) {
|
|
2134
|
+
this.sessionCheckPromise = null;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
})();
|
|
2138
|
+
this.sessionCheckPromise = sessionCheck;
|
|
2139
|
+
return sessionCheck;
|
|
2140
|
+
}
|
|
2013
2141
|
hasScope(scope) {
|
|
2014
2142
|
if (!this.tokenScope) return false;
|
|
2015
2143
|
return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
|
|
@@ -2025,33 +2153,62 @@ var AuthManager = class {
|
|
|
2025
2153
|
return requested.filter((s) => !granted.has(s));
|
|
2026
2154
|
}
|
|
2027
2155
|
/**
|
|
2028
|
-
* Register online/offline handlers that retry pending
|
|
2156
|
+
* Register online/offline and visibility handlers that retry pending
|
|
2157
|
+
* refreshes and proactively refresh tokens when the app resumes from
|
|
2158
|
+
* background (critical for PWAs and mobile browsers where timers are
|
|
2159
|
+
* frozen while backgrounded).
|
|
2029
2160
|
* Returns a cleanup function for useEffect teardown.
|
|
2030
2161
|
*/
|
|
2031
2162
|
setupNetworkListeners() {
|
|
2032
2163
|
const handleOnline = async () => {
|
|
2033
2164
|
log("Network came back online");
|
|
2034
2165
|
this.isOnline = true;
|
|
2035
|
-
if (this.pendingRefresh
|
|
2166
|
+
if (this.pendingRefresh) {
|
|
2036
2167
|
log("Retrying pending token refresh");
|
|
2037
2168
|
this.pendingRefresh = false;
|
|
2038
|
-
const refreshToken =
|
|
2169
|
+
const refreshToken = await this.getRefreshToken();
|
|
2039
2170
|
if (refreshToken) {
|
|
2040
2171
|
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
2041
2172
|
log("Retry refresh failed:", error);
|
|
2042
2173
|
});
|
|
2043
2174
|
}
|
|
2044
2175
|
}
|
|
2176
|
+
if (this.isSignedIn) {
|
|
2177
|
+
this.reconcileSession("online event", {
|
|
2178
|
+
forceRefresh: true,
|
|
2179
|
+
throttleMs: 0
|
|
2180
|
+
}).catch((error) => {
|
|
2181
|
+
log("Session reconciliation on online failed:", error);
|
|
2182
|
+
});
|
|
2183
|
+
} else if (this.user) {
|
|
2184
|
+
await this.restoreStoredSession("online restore");
|
|
2185
|
+
}
|
|
2045
2186
|
};
|
|
2046
2187
|
const handleOffline = () => {
|
|
2047
2188
|
log("Network went offline");
|
|
2048
2189
|
this.isOnline = false;
|
|
2049
2190
|
};
|
|
2191
|
+
const handleVisibilityChange = () => {
|
|
2192
|
+
if (document.visibilityState === "visible" && this.isSignedIn) {
|
|
2193
|
+
log("App became visible - reconciling auth session");
|
|
2194
|
+
this.reconcileSession("visibility resume", {
|
|
2195
|
+
forceRefresh: true
|
|
2196
|
+
}).catch((err) => {
|
|
2197
|
+
log("Session reconciliation on visibility resume failed:", err);
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2050
2201
|
window.addEventListener("online", handleOnline);
|
|
2051
2202
|
window.addEventListener("offline", handleOffline);
|
|
2203
|
+
if (typeof document !== "undefined") {
|
|
2204
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
2205
|
+
}
|
|
2052
2206
|
return () => {
|
|
2053
2207
|
window.removeEventListener("online", handleOnline);
|
|
2054
2208
|
window.removeEventListener("offline", handleOffline);
|
|
2209
|
+
if (typeof document !== "undefined") {
|
|
2210
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
2211
|
+
}
|
|
2055
2212
|
};
|
|
2056
2213
|
}
|
|
2057
2214
|
// ------------------------------------------------------------------
|
|
@@ -2090,12 +2247,18 @@ var AuthManager = class {
|
|
|
2090
2247
|
if (elapsed < 24 * 60 * 60 * 1e3) return;
|
|
2091
2248
|
}
|
|
2092
2249
|
try {
|
|
2093
|
-
await fetch(
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2250
|
+
await fetch(
|
|
2251
|
+
`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
|
|
2252
|
+
{
|
|
2253
|
+
method: "POST",
|
|
2254
|
+
headers: { "Content-Type": "application/json" },
|
|
2255
|
+
body: JSON.stringify({ token: accessToken })
|
|
2256
|
+
}
|
|
2257
|
+
);
|
|
2258
|
+
await this.storage.set(
|
|
2259
|
+
STORAGE_KEYS.LAST_CONNECT_REPORT,
|
|
2260
|
+
Date.now().toString()
|
|
2261
|
+
);
|
|
2099
2262
|
log("Reported connection to admin server");
|
|
2100
2263
|
} catch (err) {
|
|
2101
2264
|
log("Failed to report connection (non-blocking):", err);
|
|
@@ -2106,54 +2269,46 @@ var AuthManager = class {
|
|
|
2106
2269
|
*/
|
|
2107
2270
|
async processNewToken() {
|
|
2108
2271
|
if (!this.token) {
|
|
2109
|
-
this.
|
|
2272
|
+
this.updateAuthStatus("signed_out");
|
|
2110
2273
|
this.notify();
|
|
2111
2274
|
return;
|
|
2112
2275
|
}
|
|
2113
2276
|
try {
|
|
2114
2277
|
const decoded = (0, import_jwt_decode.jwtDecode)(this.token.access_token);
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
log("token is expired - refreshing ...");
|
|
2121
|
-
const refreshToken = this.token.refresh_token;
|
|
2122
|
-
if (!refreshToken) {
|
|
2123
|
-
log("Error: No refresh token available for expired token");
|
|
2124
|
-
this.isAuthReady = true;
|
|
2125
|
-
this.notify();
|
|
2126
|
-
return;
|
|
2127
|
-
}
|
|
2128
|
-
try {
|
|
2129
|
-
const newToken = await this.exchangeToken(refreshToken, true);
|
|
2130
|
-
await this.fetchUser(newToken?.access_token || "");
|
|
2131
|
-
} catch (error) {
|
|
2132
|
-
log("Failed to refresh token in processNewToken:", error);
|
|
2133
|
-
if (this.isNetworkError(error)) {
|
|
2134
|
-
log("Network issue - continuing with expired token until online");
|
|
2135
|
-
await this.fetchUser(this.token.access_token);
|
|
2136
|
-
} else {
|
|
2137
|
-
this.isAuthReady = true;
|
|
2138
|
-
this.notify();
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
} else {
|
|
2142
|
-
await this.fetchUser(this.token.access_token);
|
|
2143
|
-
}
|
|
2278
|
+
this.applyTokenClaims(decoded);
|
|
2279
|
+
this.updateAuthStatus("authenticated");
|
|
2280
|
+
this.notify();
|
|
2281
|
+
this.broadcastSessionUpdate();
|
|
2282
|
+
await this.fetchUser(this.token.access_token);
|
|
2144
2283
|
} catch (error) {
|
|
2145
2284
|
log("Error processing token:", error);
|
|
2146
|
-
this.
|
|
2285
|
+
this.updateAuthStatus("recovering");
|
|
2147
2286
|
this.notify();
|
|
2148
2287
|
}
|
|
2149
2288
|
}
|
|
2289
|
+
async restoreCachedUser(options) {
|
|
2290
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2291
|
+
if (cached && options?.hasRecoverableSession) {
|
|
2292
|
+
try {
|
|
2293
|
+
this.user = JSON.parse(cached);
|
|
2294
|
+
log("Restored cached user info for recoverable session");
|
|
2295
|
+
} catch {
|
|
2296
|
+
}
|
|
2297
|
+
} else {
|
|
2298
|
+
this.user = null;
|
|
2299
|
+
}
|
|
2300
|
+
this.updateAuthStatus(
|
|
2301
|
+
options?.hasRecoverableSession ? "recovering" : "signed_out"
|
|
2302
|
+
);
|
|
2303
|
+
this.notify();
|
|
2304
|
+
}
|
|
2150
2305
|
async fetchUser(accessToken) {
|
|
2151
2306
|
log("fetching user");
|
|
2152
2307
|
try {
|
|
2153
2308
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2154
2309
|
const response = await fetch(endpoints.userinfo_endpoint, {
|
|
2155
2310
|
method: "GET",
|
|
2156
|
-
headers: {
|
|
2311
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2157
2312
|
});
|
|
2158
2313
|
if (!response.ok) {
|
|
2159
2314
|
throw new Error(`Failed to fetch user info: ${response.status}`);
|
|
@@ -2164,24 +2319,22 @@ var AuthManager = class {
|
|
|
2164
2319
|
throw new Error(`User info error: ${user.error}`);
|
|
2165
2320
|
}
|
|
2166
2321
|
if (this.token?.refresh_token) {
|
|
2167
|
-
await this.storage.set(
|
|
2322
|
+
await this.storage.set(
|
|
2323
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2324
|
+
this.token.refresh_token
|
|
2325
|
+
);
|
|
2168
2326
|
}
|
|
2169
2327
|
await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
|
|
2170
2328
|
log("Cached user info in storage");
|
|
2171
2329
|
this.user = user;
|
|
2172
|
-
this.
|
|
2173
|
-
|
|
2174
|
-
if (this.freshSignIn) {
|
|
2175
|
-
this.freshSignIn = false;
|
|
2176
|
-
this.broadcastSignIn();
|
|
2177
|
-
} else {
|
|
2178
|
-
this.broadcastTokenRefresh();
|
|
2330
|
+
if (this.authStatus !== "reauth_required") {
|
|
2331
|
+
this.updateAuthStatus("authenticated");
|
|
2179
2332
|
}
|
|
2333
|
+
this.nextUserRecoveryAt = 0;
|
|
2180
2334
|
this.notify();
|
|
2181
2335
|
} catch (error) {
|
|
2182
2336
|
log("Failed to fetch user info:", error);
|
|
2183
|
-
this.
|
|
2184
|
-
this.notify();
|
|
2337
|
+
await this.handleUserFetchFailure();
|
|
2185
2338
|
}
|
|
2186
2339
|
}
|
|
2187
2340
|
/**
|
|
@@ -2208,7 +2361,9 @@ var AuthManager = class {
|
|
|
2208
2361
|
if (!this.isOnline) {
|
|
2209
2362
|
log("Network is offline, marking refresh as pending");
|
|
2210
2363
|
this.pendingRefresh = true;
|
|
2211
|
-
throw new Error(
|
|
2364
|
+
throw new Error(
|
|
2365
|
+
"Network offline - refresh will be retried when online"
|
|
2366
|
+
);
|
|
2212
2367
|
}
|
|
2213
2368
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2214
2369
|
let requestBody;
|
|
@@ -2218,26 +2373,36 @@ var AuthManager = class {
|
|
|
2218
2373
|
refresh_token: codeOrRefreshToken
|
|
2219
2374
|
};
|
|
2220
2375
|
if (this.config.projectId) {
|
|
2221
|
-
requestBody.client_id = normalizeClientId(
|
|
2376
|
+
requestBody.client_id = normalizeClientId(
|
|
2377
|
+
this.config.projectId,
|
|
2378
|
+
this.adminHostname
|
|
2379
|
+
);
|
|
2222
2380
|
}
|
|
2223
2381
|
} else {
|
|
2224
2382
|
requestBody = {
|
|
2225
2383
|
grant_type: "authorization_code",
|
|
2226
2384
|
code: codeOrRefreshToken
|
|
2227
2385
|
};
|
|
2228
|
-
const storedRedirectUri = await this.storage.get(
|
|
2386
|
+
const storedRedirectUri = await this.storage.get(
|
|
2387
|
+
STORAGE_KEYS.REDIRECT_URI
|
|
2388
|
+
);
|
|
2229
2389
|
if (storedRedirectUri) {
|
|
2230
2390
|
requestBody.redirect_uri = storedRedirectUri;
|
|
2231
2391
|
log("Including redirect_uri in token exchange:", storedRedirectUri);
|
|
2232
2392
|
} else {
|
|
2233
2393
|
log("Warning: No redirect_uri found in storage for token exchange");
|
|
2234
2394
|
}
|
|
2235
|
-
const codeVerifier = await this.storage.get(
|
|
2395
|
+
const codeVerifier = await this.storage.get(
|
|
2396
|
+
STORAGE_KEYS.CODE_VERIFIER
|
|
2397
|
+
);
|
|
2236
2398
|
if (codeVerifier) {
|
|
2237
2399
|
requestBody.code_verifier = codeVerifier;
|
|
2238
2400
|
}
|
|
2239
2401
|
if (this.config.projectId) {
|
|
2240
|
-
requestBody.client_id = normalizeClientId(
|
|
2402
|
+
requestBody.client_id = normalizeClientId(
|
|
2403
|
+
this.config.projectId,
|
|
2404
|
+
this.adminHostname
|
|
2405
|
+
);
|
|
2241
2406
|
}
|
|
2242
2407
|
}
|
|
2243
2408
|
log("Token exchange request body:", {
|
|
@@ -2253,7 +2418,9 @@ var AuthManager = class {
|
|
|
2253
2418
|
log("Network error fetching token:", error);
|
|
2254
2419
|
if (!this.isOnline) {
|
|
2255
2420
|
this.pendingRefresh = true;
|
|
2256
|
-
throw new Error(
|
|
2421
|
+
throw new Error(
|
|
2422
|
+
"Network offline - refresh will be retried when online"
|
|
2423
|
+
);
|
|
2257
2424
|
}
|
|
2258
2425
|
throw new Error("Network error during token refresh");
|
|
2259
2426
|
});
|
|
@@ -2262,36 +2429,52 @@ var AuthManager = class {
|
|
|
2262
2429
|
const decoded = (0, import_jwt_decode.jwtDecode)(token.access_token);
|
|
2263
2430
|
if (decoded.typ === "refresh") {
|
|
2264
2431
|
log("Error: received refresh token as access token");
|
|
2265
|
-
throw new Error(
|
|
2432
|
+
throw new Error(
|
|
2433
|
+
"Invalid token: received refresh token instead of access token"
|
|
2434
|
+
);
|
|
2266
2435
|
}
|
|
2267
2436
|
} catch (decodeError) {
|
|
2268
2437
|
if (decodeError.message.includes("Invalid token")) {
|
|
2269
2438
|
throw decodeError;
|
|
2270
2439
|
}
|
|
2271
|
-
log(
|
|
2440
|
+
log(
|
|
2441
|
+
"Warning: could not decode access token for type check:",
|
|
2442
|
+
decodeError
|
|
2443
|
+
);
|
|
2272
2444
|
}
|
|
2273
2445
|
}
|
|
2274
2446
|
if (token.error) {
|
|
2275
2447
|
log("error fetching token", token.error);
|
|
2276
2448
|
if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
|
|
2277
2449
|
this.pendingRefresh = true;
|
|
2278
|
-
throw new Error(
|
|
2450
|
+
throw new Error(
|
|
2451
|
+
"Network issue - refresh will be retried when online"
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
if (this.isDefinitiveTokenErrorCode(token.error)) {
|
|
2455
|
+
await this.markReauthRequired(token.error);
|
|
2456
|
+
throw new DefinitiveAuthError(token.error);
|
|
2279
2457
|
}
|
|
2280
|
-
await this.clearStoredAuth();
|
|
2281
|
-
this.resetAuthState();
|
|
2282
|
-
this.notify();
|
|
2283
2458
|
throw new Error(`Token refresh failed: ${token.error}`);
|
|
2284
2459
|
} else {
|
|
2460
|
+
if (!token.access_token) {
|
|
2461
|
+
throw new Error("Token response missing access token");
|
|
2462
|
+
}
|
|
2285
2463
|
this.token = token;
|
|
2286
2464
|
this.pendingRefresh = false;
|
|
2287
2465
|
if (token.refresh_token) {
|
|
2288
|
-
await this.storage.set(
|
|
2466
|
+
await this.storage.set(
|
|
2467
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2468
|
+
token.refresh_token
|
|
2469
|
+
);
|
|
2289
2470
|
log("Updated refresh token in storage");
|
|
2290
2471
|
}
|
|
2291
2472
|
if (!isRefreshToken) {
|
|
2292
2473
|
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2293
2474
|
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2294
|
-
log(
|
|
2475
|
+
log(
|
|
2476
|
+
"Cleaned up redirect_uri and code_verifier from storage after successful exchange"
|
|
2477
|
+
);
|
|
2295
2478
|
}
|
|
2296
2479
|
this.reportConnection(token.access_token).catch(() => {
|
|
2297
2480
|
});
|
|
@@ -2300,10 +2483,12 @@ var AuthManager = class {
|
|
|
2300
2483
|
return token;
|
|
2301
2484
|
} catch (error) {
|
|
2302
2485
|
log("Token refresh error:", error);
|
|
2303
|
-
if (
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2486
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2487
|
+
log("Preserving cleared auth state after definitive token rejection");
|
|
2488
|
+
} else if (this.isNetworkError(error)) {
|
|
2489
|
+
log("Recoverable network auth failure - preserving session state");
|
|
2490
|
+
} else {
|
|
2491
|
+
log("Recoverable auth failure - preserving session state");
|
|
2307
2492
|
}
|
|
2308
2493
|
throw error;
|
|
2309
2494
|
}
|
|
@@ -2327,13 +2512,15 @@ var AuthManager = class {
|
|
|
2327
2512
|
}
|
|
2328
2513
|
return tokenPromise;
|
|
2329
2514
|
}
|
|
2330
|
-
resetAuthState() {
|
|
2331
|
-
this.user = null;
|
|
2332
|
-
this.isSignedIn = false;
|
|
2515
|
+
resetAuthState(status = "signed_out") {
|
|
2516
|
+
this.user = status === "reauth_required" ? this.user : null;
|
|
2333
2517
|
this.token = null;
|
|
2334
|
-
|
|
2518
|
+
if (status !== "reauth_required") {
|
|
2519
|
+
this.did = null;
|
|
2520
|
+
}
|
|
2335
2521
|
this.tokenScope = null;
|
|
2336
|
-
this.
|
|
2522
|
+
this.nextUserRecoveryAt = 0;
|
|
2523
|
+
this.updateAuthStatus(status);
|
|
2337
2524
|
}
|
|
2338
2525
|
async clearStoredAuth() {
|
|
2339
2526
|
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
@@ -2344,11 +2531,221 @@ var AuthManager = class {
|
|
|
2344
2531
|
await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS);
|
|
2345
2532
|
}
|
|
2346
2533
|
isNetworkError(error) {
|
|
2534
|
+
if (error instanceof TypeError) return true;
|
|
2347
2535
|
if (error instanceof Error) {
|
|
2348
2536
|
return error.message.includes("offline") || error.message.includes("Network");
|
|
2349
2537
|
}
|
|
2350
2538
|
return false;
|
|
2351
2539
|
}
|
|
2540
|
+
async getRefreshToken() {
|
|
2541
|
+
const storedRefreshToken = await this.storage.get(
|
|
2542
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
2543
|
+
);
|
|
2544
|
+
if (storedRefreshToken) {
|
|
2545
|
+
log("Using refresh token from storage");
|
|
2546
|
+
if (this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
2547
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
2548
|
+
}
|
|
2549
|
+
return storedRefreshToken;
|
|
2550
|
+
}
|
|
2551
|
+
const memoryRefreshToken = this.token?.refresh_token ?? null;
|
|
2552
|
+
if (memoryRefreshToken) {
|
|
2553
|
+
log("Using refresh token from memory fallback");
|
|
2554
|
+
} else {
|
|
2555
|
+
log("No refresh token available in storage or memory");
|
|
2556
|
+
}
|
|
2557
|
+
return memoryRefreshToken;
|
|
2558
|
+
}
|
|
2559
|
+
async syncRefreshTokenFromStorage() {
|
|
2560
|
+
const storedRefreshToken = await this.storage.get(
|
|
2561
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
2562
|
+
);
|
|
2563
|
+
if (storedRefreshToken && this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
2564
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
2565
|
+
log("Synced refresh token from shared storage into memory");
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
applyTokenClaims(decoded) {
|
|
2569
|
+
this.did = decoded.sub || null;
|
|
2570
|
+
this.tokenScope = decoded.scope || null;
|
|
2571
|
+
}
|
|
2572
|
+
broadcastSessionUpdate() {
|
|
2573
|
+
if (this.freshSignIn) {
|
|
2574
|
+
this.freshSignIn = false;
|
|
2575
|
+
this.broadcastSignIn();
|
|
2576
|
+
} else {
|
|
2577
|
+
this.broadcastTokenRefresh();
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
async handleUserFetchFailure() {
|
|
2581
|
+
if (this.isCompatibleUser(this.user)) {
|
|
2582
|
+
log("Preserving existing user after userinfo failure");
|
|
2583
|
+
} else if (this.user) {
|
|
2584
|
+
log("Discarding stale in-memory user after userinfo failure");
|
|
2585
|
+
this.user = null;
|
|
2586
|
+
}
|
|
2587
|
+
if (!this.user) {
|
|
2588
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2589
|
+
if (cached) {
|
|
2590
|
+
try {
|
|
2591
|
+
const parsed = JSON.parse(cached);
|
|
2592
|
+
if (this.isCompatibleUser(parsed)) {
|
|
2593
|
+
this.user = parsed;
|
|
2594
|
+
log("Recovered cached user after userinfo failure");
|
|
2595
|
+
} else {
|
|
2596
|
+
log("Cached user did not match the active session");
|
|
2597
|
+
}
|
|
2598
|
+
} catch (error) {
|
|
2599
|
+
log("Failed to parse cached user after userinfo failure:", error);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
if (!this.user) {
|
|
2604
|
+
log("No compatible cached user available after userinfo failure");
|
|
2605
|
+
this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS;
|
|
2606
|
+
} else {
|
|
2607
|
+
this.nextUserRecoveryAt = 0;
|
|
2608
|
+
}
|
|
2609
|
+
if (this.authStatus === "bootstrapping") {
|
|
2610
|
+
this.updateAuthStatus(this.token ? "authenticated" : "recovering");
|
|
2611
|
+
}
|
|
2612
|
+
this.notify();
|
|
2613
|
+
}
|
|
2614
|
+
isCompatibleUser(user) {
|
|
2615
|
+
if (!user) return false;
|
|
2616
|
+
if (!this.did) return true;
|
|
2617
|
+
return user.sub === this.did;
|
|
2618
|
+
}
|
|
2619
|
+
async recoverMissingUserProfile(reason, accessToken) {
|
|
2620
|
+
if (!this.isSignedIn || this.user) return;
|
|
2621
|
+
const now = Date.now();
|
|
2622
|
+
if (this.nextUserRecoveryAt > now) {
|
|
2623
|
+
log(
|
|
2624
|
+
`Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`
|
|
2625
|
+
);
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
log(`Attempting user profile recovery on ${reason}`);
|
|
2629
|
+
const token = accessToken ?? await this.getToken();
|
|
2630
|
+
if (this.user) return;
|
|
2631
|
+
await this.fetchUser(token);
|
|
2632
|
+
}
|
|
2633
|
+
isDefinitiveTokenErrorCode(code) {
|
|
2634
|
+
return typeof code === "string" && DEFINITIVE_TOKEN_ERRORS.has(code);
|
|
2635
|
+
}
|
|
2636
|
+
isDefinitiveAuthFailure(error) {
|
|
2637
|
+
return error instanceof DefinitiveAuthError;
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
|
|
2641
|
+
* from the status so they stay consistent.
|
|
2642
|
+
*
|
|
2643
|
+
* `isSignedIn` is intentionally `true` during `reauth_required` so the
|
|
2644
|
+
* UI layer can still display user info while prompting re-authentication.
|
|
2645
|
+
* Consumers should check `authStatus` (or a future convenience getter)
|
|
2646
|
+
* when they need to distinguish "healthy session" from "needs re-auth".
|
|
2647
|
+
*/
|
|
2648
|
+
updateAuthStatus(status, errorCode = null) {
|
|
2649
|
+
this.authStatus = status;
|
|
2650
|
+
this.authErrorCode = errorCode;
|
|
2651
|
+
this.isSignedIn = status === "authenticated" || status === "recovering" || status === "reauth_required";
|
|
2652
|
+
this.isAuthReady = status !== "bootstrapping";
|
|
2653
|
+
}
|
|
2654
|
+
async clearStoredSessionTokens() {
|
|
2655
|
+
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
2656
|
+
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2657
|
+
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2658
|
+
}
|
|
2659
|
+
async restoreStoredSession(reason) {
|
|
2660
|
+
const refreshToken = await this.getRefreshToken();
|
|
2661
|
+
if (!refreshToken) {
|
|
2662
|
+
log(`No stored refresh token available during ${reason}`);
|
|
2663
|
+
await this.restoreCachedUser({ hasRecoverableSession: false });
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
log(`Restoring stored session during ${reason}`);
|
|
2667
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2668
|
+
if (!this.isOnline) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
2672
|
+
log(`Stored session refresh failed during ${reason}:`, error);
|
|
2673
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
async handleExternalTokenRefresh(data) {
|
|
2680
|
+
await this.syncRefreshTokenFromStorage();
|
|
2681
|
+
const refreshToken = await this.getRefreshToken();
|
|
2682
|
+
if (data.accessToken && refreshToken) {
|
|
2683
|
+
try {
|
|
2684
|
+
const decoded = (0, import_jwt_decode.jwtDecode)(data.accessToken);
|
|
2685
|
+
const expiresIn = decoded.exp != null ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1e3)) : 0;
|
|
2686
|
+
this.token = {
|
|
2687
|
+
access_token: data.accessToken,
|
|
2688
|
+
token_type: "Bearer",
|
|
2689
|
+
expires_in: expiresIn,
|
|
2690
|
+
refresh_token: refreshToken
|
|
2691
|
+
};
|
|
2692
|
+
this.applyTokenClaims(decoded);
|
|
2693
|
+
} catch (error) {
|
|
2694
|
+
log("Failed to decode token refreshed by another tab:", error);
|
|
2695
|
+
this.token = {
|
|
2696
|
+
access_token: data.accessToken,
|
|
2697
|
+
token_type: "Bearer",
|
|
2698
|
+
expires_in: 0,
|
|
2699
|
+
refresh_token: refreshToken
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
if (this.authStatus !== "reauth_required") {
|
|
2703
|
+
this.updateAuthStatus("authenticated");
|
|
2704
|
+
}
|
|
2705
|
+
} else if (refreshToken) {
|
|
2706
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2707
|
+
}
|
|
2708
|
+
if (data.did) this.did = data.did;
|
|
2709
|
+
if (data.tokenScope) this.tokenScope = data.tokenScope;
|
|
2710
|
+
this.notify();
|
|
2711
|
+
}
|
|
2712
|
+
async fetchCurrentSession(accessToken) {
|
|
2713
|
+
const endpoints = await this.getActivePdsEndpoints();
|
|
2714
|
+
const response = await fetch(`${endpoints.pds_url}/auth/session`, {
|
|
2715
|
+
method: "GET",
|
|
2716
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2717
|
+
});
|
|
2718
|
+
const data = await response.json().catch(() => ({}));
|
|
2719
|
+
if (response.status === 401 && data.reauth_required) {
|
|
2720
|
+
await this.markReauthRequired(data.error || "invalid_session");
|
|
2721
|
+
throw new DefinitiveAuthError(data.error || "invalid_session");
|
|
2722
|
+
}
|
|
2723
|
+
if (!response.ok) {
|
|
2724
|
+
throw new Error(`Failed to reconcile session: ${response.status}`);
|
|
2725
|
+
}
|
|
2726
|
+
return data;
|
|
2727
|
+
}
|
|
2728
|
+
async markReauthRequired(code, options) {
|
|
2729
|
+
log("Marking auth session as requiring reauthentication:", code);
|
|
2730
|
+
await this.clearStoredSessionTokens();
|
|
2731
|
+
if (!this.user) {
|
|
2732
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2733
|
+
if (cached) {
|
|
2734
|
+
try {
|
|
2735
|
+
this.user = JSON.parse(cached);
|
|
2736
|
+
} catch {
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
this.token = null;
|
|
2741
|
+
this.tokenScope = null;
|
|
2742
|
+
this.nextUserRecoveryAt = 0;
|
|
2743
|
+
this.updateAuthStatus("reauth_required", code);
|
|
2744
|
+
if (options?.broadcast !== false) {
|
|
2745
|
+
this.broadcastSessionInvalidated(code);
|
|
2746
|
+
}
|
|
2747
|
+
this.notify();
|
|
2748
|
+
}
|
|
2352
2749
|
};
|
|
2353
2750
|
|
|
2354
2751
|
// src/AuthContext.tsx
|
|
@@ -2597,6 +2994,8 @@ function snapshotAuth(mgr) {
|
|
|
2597
2994
|
isSignedIn: mgr.isSignedIn,
|
|
2598
2995
|
hasToken: !!mgr.token,
|
|
2599
2996
|
isAuthReady: mgr.isAuthReady,
|
|
2997
|
+
authStatus: mgr.authStatus,
|
|
2998
|
+
authErrorCode: mgr.authErrorCode,
|
|
2600
2999
|
user: mgr.user,
|
|
2601
3000
|
did: mgr.did,
|
|
2602
3001
|
tokenScope: mgr.tokenScope
|
|
@@ -2631,6 +3030,8 @@ function BasicProvider({
|
|
|
2631
3030
|
isSignedIn: false,
|
|
2632
3031
|
hasToken: false,
|
|
2633
3032
|
isAuthReady: false,
|
|
3033
|
+
authStatus: "bootstrapping",
|
|
3034
|
+
authErrorCode: null,
|
|
2634
3035
|
user: null,
|
|
2635
3036
|
did: null,
|
|
2636
3037
|
tokenScope: null
|
|
@@ -2653,9 +3054,11 @@ function BasicProvider({
|
|
|
2653
3054
|
const remoteDbRef = (0, import_react3.useRef)(null);
|
|
2654
3055
|
const [shouldConnect, setShouldConnect] = (0, import_react3.useState)(false);
|
|
2655
3056
|
const [dbStatus, setDbStatus] = (0, import_react3.useState)("OFFLINE" /* OFFLINE */);
|
|
2656
|
-
const [
|
|
3057
|
+
const [isDbReady, setIsDbReady] = (0, import_react3.useState)(false);
|
|
2657
3058
|
const [error, setError] = (0, import_react3.useState)(null);
|
|
2658
|
-
const [schemaDevInfo, setSchemaDevInfo] = (0, import_react3.useState)(
|
|
3059
|
+
const [schemaDevInfo, setSchemaDevInfo] = (0, import_react3.useState)(
|
|
3060
|
+
null
|
|
3061
|
+
);
|
|
2659
3062
|
const isDevMode = () => isDevelopment(debug);
|
|
2660
3063
|
const refreshSchemaStatus = (0, import_react3.useCallback)(async () => {
|
|
2661
3064
|
const s = schemaRef.current;
|
|
@@ -2695,10 +3098,16 @@ function BasicProvider({
|
|
|
2695
3098
|
(0, import_react3.useEffect)(() => {
|
|
2696
3099
|
const runVersionUpdater = async () => {
|
|
2697
3100
|
try {
|
|
2698
|
-
const versionUpdater = createVersionUpdater(
|
|
3101
|
+
const versionUpdater = createVersionUpdater(
|
|
3102
|
+
storageAdapter,
|
|
3103
|
+
version,
|
|
3104
|
+
getMigrations()
|
|
3105
|
+
);
|
|
2699
3106
|
const updateResult = await versionUpdater.checkAndUpdate();
|
|
2700
3107
|
if (updateResult.updated) {
|
|
2701
|
-
log(
|
|
3108
|
+
log(
|
|
3109
|
+
`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
|
|
3110
|
+
);
|
|
2702
3111
|
} else {
|
|
2703
3112
|
log(`App version ${updateResult.toVersion} is current`);
|
|
2704
3113
|
}
|
|
@@ -2720,8 +3129,13 @@ function BasicProvider({
|
|
|
2720
3129
|
const newStatus = getSyncStatus(status);
|
|
2721
3130
|
setDbStatus(newStatus);
|
|
2722
3131
|
if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
|
|
2723
|
-
log(
|
|
2724
|
-
|
|
3132
|
+
log(
|
|
3133
|
+
"Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
|
|
3134
|
+
);
|
|
3135
|
+
authRef.current.reconcileSession("sync retry", {
|
|
3136
|
+
forceRefresh: true,
|
|
3137
|
+
throttleMs: 0
|
|
3138
|
+
}).catch(() => {
|
|
2725
3139
|
});
|
|
2726
3140
|
}
|
|
2727
3141
|
});
|
|
@@ -2730,7 +3144,7 @@ function BasicProvider({
|
|
|
2730
3144
|
} else {
|
|
2731
3145
|
log("Sync is disabled");
|
|
2732
3146
|
}
|
|
2733
|
-
|
|
3147
|
+
setIsDbReady(true);
|
|
2734
3148
|
}
|
|
2735
3149
|
}
|
|
2736
3150
|
function initRemoteDb() {
|
|
@@ -2741,7 +3155,7 @@ function BasicProvider({
|
|
|
2741
3155
|
title: "Project ID Required",
|
|
2742
3156
|
message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
|
|
2743
3157
|
});
|
|
2744
|
-
|
|
3158
|
+
setIsDbReady(true);
|
|
2745
3159
|
return;
|
|
2746
3160
|
}
|
|
2747
3161
|
log("Initializing Basic Remote DB");
|
|
@@ -2753,11 +3167,20 @@ function BasicProvider({
|
|
|
2753
3167
|
debug,
|
|
2754
3168
|
onAuthError: (error2) => {
|
|
2755
3169
|
log("RemoteDB auth error:", error2);
|
|
2756
|
-
|
|
3170
|
+
if (error2.errorType === "forbidden") {
|
|
3171
|
+
log("403 Forbidden - user lacks required scope, not signing out");
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
|
|
3175
|
+
forceRefresh: error2.errorType !== "network",
|
|
3176
|
+
throttleMs: 0
|
|
3177
|
+
}).catch((reconcileError) => {
|
|
3178
|
+
log("RemoteDB auth recovery failed:", reconcileError);
|
|
3179
|
+
});
|
|
2757
3180
|
}
|
|
2758
3181
|
});
|
|
2759
3182
|
setDbStatus("ONLINE" /* ONLINE */);
|
|
2760
|
-
|
|
3183
|
+
setIsDbReady(true);
|
|
2761
3184
|
}
|
|
2762
3185
|
}
|
|
2763
3186
|
async function checkSchema() {
|
|
@@ -2783,7 +3206,7 @@ function BasicProvider({
|
|
|
2783
3206
|
title: "Basic Schema is invalid!",
|
|
2784
3207
|
message: errorMessage
|
|
2785
3208
|
});
|
|
2786
|
-
|
|
3209
|
+
setIsDbReady(true);
|
|
2787
3210
|
return null;
|
|
2788
3211
|
}
|
|
2789
3212
|
setSchemaDevInfo({
|
|
@@ -2800,7 +3223,9 @@ function BasicProvider({
|
|
|
2800
3223
|
await initSyncDb({ shouldConnect: true });
|
|
2801
3224
|
} else {
|
|
2802
3225
|
if (result.schemaStatus.status === "unpublished") {
|
|
2803
|
-
log(
|
|
3226
|
+
log(
|
|
3227
|
+
"Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
|
|
3228
|
+
);
|
|
2804
3229
|
} else {
|
|
2805
3230
|
log("Schema is invalid!", result.schemaStatus);
|
|
2806
3231
|
}
|
|
@@ -2824,12 +3249,12 @@ function BasicProvider({
|
|
|
2824
3249
|
if (dbMode === "remote" && project_id) {
|
|
2825
3250
|
initRemoteDb();
|
|
2826
3251
|
} else {
|
|
2827
|
-
|
|
3252
|
+
setIsDbReady(true);
|
|
2828
3253
|
}
|
|
2829
3254
|
}
|
|
2830
3255
|
}, []);
|
|
2831
3256
|
(0, import_react3.useEffect)(() => {
|
|
2832
|
-
if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
|
|
3257
|
+
if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
|
|
2833
3258
|
log("connecting to db...");
|
|
2834
3259
|
syncRef.current?.connect({
|
|
2835
3260
|
getToken: (opts) => authRef.current.getToken(opts),
|
|
@@ -2838,7 +3263,22 @@ function BasicProvider({
|
|
|
2838
3263
|
log("error connecting to db", e);
|
|
2839
3264
|
});
|
|
2840
3265
|
}
|
|
2841
|
-
}, [
|
|
3266
|
+
}, [
|
|
3267
|
+
authState.authStatus,
|
|
3268
|
+
authState.isSignedIn,
|
|
3269
|
+
authState.hasToken,
|
|
3270
|
+
shouldConnect
|
|
3271
|
+
]);
|
|
3272
|
+
(0, import_react3.useEffect)(() => {
|
|
3273
|
+
if (authState.authStatus !== "reauth_required" || !syncRef.current) {
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
log("Auth requires reauthentication - disconnecting sync without deleting local DB");
|
|
3277
|
+
setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
|
|
3278
|
+
syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
|
|
3279
|
+
log("Error disconnecting sync after auth invalidation:", disconnectError);
|
|
3280
|
+
});
|
|
3281
|
+
}, [authConfig.ws_url, authState.authStatus]);
|
|
2842
3282
|
const handleSignOut = async () => {
|
|
2843
3283
|
await authRef.current.signOut();
|
|
2844
3284
|
if (syncRef.current) {
|
|
@@ -2846,11 +3286,13 @@ function BasicProvider({
|
|
|
2846
3286
|
await syncRef.current.close();
|
|
2847
3287
|
await syncRef.current.delete({ disableAutoOpen: false });
|
|
2848
3288
|
syncRef.current = null;
|
|
2849
|
-
window?.location?.reload();
|
|
2850
3289
|
} catch (error2) {
|
|
2851
3290
|
console.error("Error during database cleanup:", error2);
|
|
2852
3291
|
}
|
|
2853
3292
|
}
|
|
3293
|
+
if (typeof window !== "undefined") {
|
|
3294
|
+
window.location.reload();
|
|
3295
|
+
}
|
|
2854
3296
|
};
|
|
2855
3297
|
const handleSignIn = async () => {
|
|
2856
3298
|
try {
|
|
@@ -2889,6 +3331,8 @@ function BasicProvider({
|
|
|
2889
3331
|
const contextValue = {
|
|
2890
3332
|
isReady: authState.isAuthReady,
|
|
2891
3333
|
isSignedIn: authState.isSignedIn,
|
|
3334
|
+
authStatus: authState.authStatus,
|
|
3335
|
+
authErrorCode: authState.authErrorCode,
|
|
2892
3336
|
user: authState.user,
|
|
2893
3337
|
did: authState.did,
|
|
2894
3338
|
scope: authState.tokenScope,
|
|
@@ -2914,7 +3358,7 @@ function BasicProvider({
|
|
|
2914
3358
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicContext.Provider, { value: contextValue, children: [
|
|
2915
3359
|
error && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ErrorDisplay, { error }),
|
|
2916
3360
|
devToolbar && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
|
|
2917
|
-
|
|
3361
|
+
isDbReady && authState.isAuthReady && children
|
|
2918
3362
|
] });
|
|
2919
3363
|
}
|
|
2920
3364
|
function ErrorDisplay({ error }) {
|