@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.mjs
CHANGED
|
@@ -71,6 +71,7 @@ var init_syncProtocol = __esm({
|
|
|
71
71
|
var requestId = 0;
|
|
72
72
|
var acceptCallbacks = {};
|
|
73
73
|
var refreshTimer = null;
|
|
74
|
+
var pendingTokenUpdate = null;
|
|
74
75
|
log("Connecting to", url);
|
|
75
76
|
var ws = new WebSocket(url);
|
|
76
77
|
function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
@@ -93,6 +94,12 @@ var init_syncProtocol = __esm({
|
|
|
93
94
|
refreshTimer = null;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
97
|
+
function sendTokenUpdate(token) {
|
|
98
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
99
|
+
pendingTokenUpdate = token;
|
|
100
|
+
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
96
103
|
function resolveGetToken() {
|
|
97
104
|
var fn = getTokenGetter(url);
|
|
98
105
|
if (!fn) throw new Error("No token getter registered for " + url);
|
|
@@ -108,10 +115,8 @@ var init_syncProtocol = __esm({
|
|
|
108
115
|
refreshTimer = setTimeout(async function() {
|
|
109
116
|
try {
|
|
110
117
|
var newToken = await resolveGetToken()({ forceRefresh: true });
|
|
111
|
-
if (
|
|
118
|
+
if (sendTokenUpdate(newToken)) {
|
|
112
119
|
log("Sending tokenUpdate on existing WebSocket");
|
|
113
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
|
|
114
|
-
scheduleTokenRefresh(newToken);
|
|
115
120
|
}
|
|
116
121
|
} catch (err) {
|
|
117
122
|
log("Proactive token refresh failed (non-fatal):", err);
|
|
@@ -137,14 +142,34 @@ var init_syncProtocol = __esm({
|
|
|
137
142
|
onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
|
|
138
143
|
}
|
|
139
144
|
};
|
|
145
|
+
function handleVisibilityResume() {
|
|
146
|
+
if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
|
|
147
|
+
log("Page became visible - refreshing token for WebSocket");
|
|
148
|
+
resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
|
|
149
|
+
sendTokenUpdate(newToken);
|
|
150
|
+
}).catch(function(err) {
|
|
151
|
+
log("Token refresh on visibility resume failed:", err);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (typeof document !== "undefined") {
|
|
156
|
+
document.addEventListener("visibilitychange", handleVisibilityResume);
|
|
157
|
+
}
|
|
158
|
+
function cleanupVisibilityListener() {
|
|
159
|
+
if (typeof document !== "undefined") {
|
|
160
|
+
document.removeEventListener("visibilitychange", handleVisibilityResume);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
140
163
|
ws.onerror = function(event) {
|
|
141
164
|
clearRefreshTimer();
|
|
165
|
+
cleanupVisibilityListener();
|
|
142
166
|
ws.close();
|
|
143
167
|
log("ws.onerror", event);
|
|
144
168
|
onError(event?.message, RECONNECT_DELAY);
|
|
145
169
|
};
|
|
146
170
|
ws.onclose = function(event) {
|
|
147
171
|
clearRefreshTimer();
|
|
172
|
+
cleanupVisibilityListener();
|
|
148
173
|
onError("Socket closed: " + event.reason, RECONNECT_DELAY);
|
|
149
174
|
};
|
|
150
175
|
var isFirstRound = true;
|
|
@@ -181,11 +206,25 @@ var init_syncProtocol = __esm({
|
|
|
181
206
|
},
|
|
182
207
|
disconnect: function() {
|
|
183
208
|
clearRefreshTimer();
|
|
209
|
+
cleanupVisibilityListener();
|
|
184
210
|
ws.close();
|
|
185
211
|
}
|
|
186
212
|
});
|
|
187
213
|
isFirstRound = false;
|
|
188
214
|
}
|
|
215
|
+
} else if (requestFromServer.type == "tokenUpdateAck") {
|
|
216
|
+
if (requestFromServer.ok) {
|
|
217
|
+
scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
|
|
218
|
+
pendingTokenUpdate = null;
|
|
219
|
+
} else {
|
|
220
|
+
log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
|
|
221
|
+
pendingTokenUpdate = null;
|
|
222
|
+
ws.close(4001, requestFromServer.code || "token_update_failed");
|
|
223
|
+
onError(
|
|
224
|
+
requestFromServer.message || "Authentication refresh failed",
|
|
225
|
+
RECONNECT_DELAY
|
|
226
|
+
);
|
|
227
|
+
}
|
|
189
228
|
} else if (requestFromServer.type == "ack") {
|
|
190
229
|
var requestId2 = requestFromServer.requestId;
|
|
191
230
|
var acceptCallback = acceptCallbacks[requestId2.toString()];
|
|
@@ -220,7 +259,7 @@ var init_syncProtocol = __esm({
|
|
|
220
259
|
var version;
|
|
221
260
|
var init_package = __esm({
|
|
222
261
|
"package.json"() {
|
|
223
|
-
version = "0.8.0-beta.
|
|
262
|
+
version = "0.8.0-beta.4";
|
|
224
263
|
}
|
|
225
264
|
});
|
|
226
265
|
|
|
@@ -305,7 +344,7 @@ function cleanOAuthParamsFromUrl() {
|
|
|
305
344
|
const url = new URL(window.location.href);
|
|
306
345
|
url.searchParams.delete("code");
|
|
307
346
|
url.searchParams.delete("state");
|
|
308
|
-
window.history.
|
|
347
|
+
window.history.replaceState({}, document.title, url.pathname + url.search);
|
|
309
348
|
log("Cleaned OAuth parameters from URL");
|
|
310
349
|
}
|
|
311
350
|
}
|
|
@@ -363,6 +402,8 @@ var init_context = __esm({
|
|
|
363
402
|
BasicContext = createContext({
|
|
364
403
|
isReady: false,
|
|
365
404
|
isSignedIn: false,
|
|
405
|
+
authStatus: "bootstrapping",
|
|
406
|
+
authErrorCode: null,
|
|
366
407
|
user: null,
|
|
367
408
|
did: null,
|
|
368
409
|
scope: null,
|
|
@@ -1018,7 +1059,14 @@ var init_BasicDevToolbar = __esm({
|
|
|
1018
1059
|
});
|
|
1019
1060
|
|
|
1020
1061
|
// src/AuthContext.tsx
|
|
1021
|
-
import {
|
|
1062
|
+
import {
|
|
1063
|
+
useCallback as useCallback2,
|
|
1064
|
+
useEffect,
|
|
1065
|
+
useRef,
|
|
1066
|
+
useState as useState2,
|
|
1067
|
+
Suspense,
|
|
1068
|
+
lazy
|
|
1069
|
+
} from "react";
|
|
1022
1070
|
|
|
1023
1071
|
// src/sync/index.ts
|
|
1024
1072
|
init_config();
|
|
@@ -1612,6 +1660,21 @@ async function resolveHandle(handle) {
|
|
|
1612
1660
|
// src/core/auth/AuthManager.ts
|
|
1613
1661
|
init_network();
|
|
1614
1662
|
init_config();
|
|
1663
|
+
var DEFINITIVE_TOKEN_ERRORS = /* @__PURE__ */ new Set([
|
|
1664
|
+
"invalid_grant",
|
|
1665
|
+
"invalid_client",
|
|
1666
|
+
"unauthorized_client"
|
|
1667
|
+
]);
|
|
1668
|
+
var USER_RECOVERY_RETRY_COOLDOWN_MS = 3e4;
|
|
1669
|
+
var SESSION_RECONCILE_THROTTLE_MS = 5e3;
|
|
1670
|
+
var DefinitiveAuthError = class extends Error {
|
|
1671
|
+
code;
|
|
1672
|
+
constructor(code) {
|
|
1673
|
+
super(`Definitive auth failure: ${code}`);
|
|
1674
|
+
this.name = "DefinitiveAuthError";
|
|
1675
|
+
this.code = code;
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1615
1678
|
function generateCodeVerifier() {
|
|
1616
1679
|
const array = new Uint8Array(32);
|
|
1617
1680
|
crypto.getRandomValues(array);
|
|
@@ -1619,7 +1682,9 @@ function generateCodeVerifier() {
|
|
|
1619
1682
|
}
|
|
1620
1683
|
async function generateCodeChallenge(verifier) {
|
|
1621
1684
|
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
1622
|
-
log(
|
|
1685
|
+
log(
|
|
1686
|
+
"crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
|
|
1687
|
+
);
|
|
1623
1688
|
return { challenge: verifier, method: "plain" };
|
|
1624
1689
|
}
|
|
1625
1690
|
const encoder = new TextEncoder();
|
|
@@ -1640,6 +1705,8 @@ var AuthManager = class {
|
|
|
1640
1705
|
user = null;
|
|
1641
1706
|
isSignedIn = false;
|
|
1642
1707
|
isAuthReady = false;
|
|
1708
|
+
authStatus = "bootstrapping";
|
|
1709
|
+
authErrorCode = null;
|
|
1643
1710
|
did = null;
|
|
1644
1711
|
/** Space-separated scopes granted in the current access token */
|
|
1645
1712
|
tokenScope = null;
|
|
@@ -1656,6 +1723,9 @@ var AuthManager = class {
|
|
|
1656
1723
|
pendingRefresh = false;
|
|
1657
1724
|
isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
|
|
1658
1725
|
channel = null;
|
|
1726
|
+
nextUserRecoveryAt = 0;
|
|
1727
|
+
sessionCheckPromise = null;
|
|
1728
|
+
lastSessionCheckAt = 0;
|
|
1659
1729
|
constructor(config, storage, notify) {
|
|
1660
1730
|
this.config = config;
|
|
1661
1731
|
this.storage = storage;
|
|
@@ -1670,31 +1740,26 @@ var AuthManager = class {
|
|
|
1670
1740
|
this.channel.onmessage = (event) => {
|
|
1671
1741
|
if (event.data?.type === "token_refreshed") {
|
|
1672
1742
|
log("Received token refresh from another tab");
|
|
1673
|
-
|
|
1674
|
-
this.token = { ...this.token, access_token: event.data.accessToken };
|
|
1675
|
-
}
|
|
1676
|
-
if (event.data.did) this.did = event.data.did;
|
|
1677
|
-
if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
|
|
1678
|
-
this.notify();
|
|
1743
|
+
void this.handleExternalTokenRefresh(event.data);
|
|
1679
1744
|
}
|
|
1680
1745
|
if (event.data?.type === "signed_in") {
|
|
1681
|
-
log("Received sign-in from another tab,
|
|
1682
|
-
|
|
1683
|
-
window.location.reload();
|
|
1684
|
-
}
|
|
1746
|
+
log("Received sign-in from another tab, restoring session");
|
|
1747
|
+
void this.restoreStoredSession("cross-tab sign-in");
|
|
1685
1748
|
}
|
|
1686
1749
|
if (event.data?.type === "signed_out") {
|
|
1687
|
-
log("Received sign-out from another tab
|
|
1688
|
-
this.
|
|
1689
|
-
this.isSignedIn = false;
|
|
1690
|
-
this.token = null;
|
|
1691
|
-
this.did = null;
|
|
1692
|
-
this.tokenScope = null;
|
|
1750
|
+
log("Received sign-out from another tab");
|
|
1751
|
+
this.resetAuthState("signed_out");
|
|
1693
1752
|
this.notify();
|
|
1694
1753
|
if (typeof window !== "undefined") {
|
|
1695
1754
|
window.location.reload();
|
|
1696
1755
|
}
|
|
1697
1756
|
}
|
|
1757
|
+
if (event.data?.type === "session_invalidated") {
|
|
1758
|
+
log("Received session invalidation from another tab");
|
|
1759
|
+
void this.markReauthRequired(event.data.code || "invalid_grant", {
|
|
1760
|
+
broadcast: false
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1698
1763
|
};
|
|
1699
1764
|
} catch {
|
|
1700
1765
|
log("BroadcastChannel not available for cross-tab sync");
|
|
@@ -1714,6 +1779,9 @@ var AuthManager = class {
|
|
|
1714
1779
|
broadcastSignOut() {
|
|
1715
1780
|
this.channel?.postMessage({ type: "signed_out" });
|
|
1716
1781
|
}
|
|
1782
|
+
broadcastSessionInvalidated(code) {
|
|
1783
|
+
this.channel?.postMessage({ type: "session_invalidated", code });
|
|
1784
|
+
}
|
|
1717
1785
|
// ------------------------------------------------------------------
|
|
1718
1786
|
// Public API
|
|
1719
1787
|
// ------------------------------------------------------------------
|
|
@@ -1722,7 +1790,11 @@ var AuthManager = class {
|
|
|
1722
1790
|
* from refresh token, or load cached user for offline mode.
|
|
1723
1791
|
*/
|
|
1724
1792
|
async initialize() {
|
|
1725
|
-
|
|
1793
|
+
this.updateAuthStatus("bootstrapping");
|
|
1794
|
+
await this.storage.set(
|
|
1795
|
+
STORAGE_KEYS.DEBUG,
|
|
1796
|
+
this.config.debug ? "true" : "false"
|
|
1797
|
+
);
|
|
1726
1798
|
const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
|
|
1727
1799
|
if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
|
|
1728
1800
|
log("PDS URL changed, clearing stored tokens");
|
|
@@ -1734,7 +1806,7 @@ var AuthManager = class {
|
|
|
1734
1806
|
if (params.has("code")) {
|
|
1735
1807
|
const code = params.get("code");
|
|
1736
1808
|
if (!code) {
|
|
1737
|
-
this.
|
|
1809
|
+
this.updateAuthStatus("signed_out");
|
|
1738
1810
|
this.notify();
|
|
1739
1811
|
return;
|
|
1740
1812
|
}
|
|
@@ -1742,7 +1814,7 @@ var AuthManager = class {
|
|
|
1742
1814
|
const urlState = params.get("state");
|
|
1743
1815
|
if (!state || state !== urlState) {
|
|
1744
1816
|
log("error: auth state does not match");
|
|
1745
|
-
this.
|
|
1817
|
+
this.updateAuthStatus("signed_out");
|
|
1746
1818
|
this.notify();
|
|
1747
1819
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1748
1820
|
cleanOAuthParamsFromUrl();
|
|
@@ -1753,31 +1825,18 @@ var AuthManager = class {
|
|
|
1753
1825
|
this.freshSignIn = true;
|
|
1754
1826
|
this.exchangeToken(code, false).catch((error) => {
|
|
1755
1827
|
log("Error fetching token:", error);
|
|
1828
|
+
this.freshSignIn = false;
|
|
1829
|
+
void this.restoreCachedUser({
|
|
1830
|
+
hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
|
|
1831
|
+
});
|
|
1756
1832
|
});
|
|
1757
1833
|
} else {
|
|
1758
|
-
|
|
1759
|
-
if (refreshToken) {
|
|
1760
|
-
log("Found refresh token in storage, attempting to refresh access token");
|
|
1761
|
-
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
1762
|
-
log("Error fetching refresh token:", error);
|
|
1763
|
-
});
|
|
1764
|
-
} else {
|
|
1765
|
-
const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
1766
|
-
if (cachedUserInfo) {
|
|
1767
|
-
try {
|
|
1768
|
-
this.user = JSON.parse(cachedUserInfo);
|
|
1769
|
-
this.isSignedIn = true;
|
|
1770
|
-
log("Loaded cached user info for offline mode");
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
log("Error parsing cached user info:", error);
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
this.isAuthReady = true;
|
|
1776
|
-
this.notify();
|
|
1777
|
-
}
|
|
1834
|
+
await this.restoreStoredSession("initialize");
|
|
1778
1835
|
}
|
|
1779
1836
|
} catch (e) {
|
|
1780
1837
|
log("error getting token", e);
|
|
1838
|
+
this.updateAuthStatus("signed_out");
|
|
1839
|
+
this.notify();
|
|
1781
1840
|
}
|
|
1782
1841
|
}
|
|
1783
1842
|
/**
|
|
@@ -1787,7 +1846,7 @@ var AuthManager = class {
|
|
|
1787
1846
|
async getToken(options) {
|
|
1788
1847
|
log("getting token...");
|
|
1789
1848
|
if (!this.token) {
|
|
1790
|
-
const refreshToken = await this.
|
|
1849
|
+
const refreshToken = await this.getRefreshToken();
|
|
1791
1850
|
if (refreshToken) {
|
|
1792
1851
|
log("No token in memory, attempting to refresh from storage");
|
|
1793
1852
|
if (this.refreshPromise) {
|
|
@@ -1810,7 +1869,12 @@ var AuthManager = class {
|
|
|
1810
1869
|
} catch (error) {
|
|
1811
1870
|
log("Failed to refresh token from storage:", error);
|
|
1812
1871
|
if (this.isNetworkError(error)) {
|
|
1813
|
-
throw new Error(
|
|
1872
|
+
throw new Error(
|
|
1873
|
+
"Network offline - authentication will be retried when online"
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1877
|
+
throw error;
|
|
1814
1878
|
}
|
|
1815
1879
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1816
1880
|
}
|
|
@@ -1823,12 +1887,16 @@ var AuthManager = class {
|
|
|
1823
1887
|
const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
|
|
1824
1888
|
const shouldRefresh = isExpired || options?.forceRefresh === true;
|
|
1825
1889
|
if (shouldRefresh) {
|
|
1826
|
-
log(
|
|
1890
|
+
log(
|
|
1891
|
+
options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
|
|
1892
|
+
);
|
|
1827
1893
|
if (this.refreshPromise) {
|
|
1828
1894
|
log("Token refresh already in progress, waiting...");
|
|
1829
1895
|
try {
|
|
1830
1896
|
const newToken = await this.refreshPromise;
|
|
1831
|
-
|
|
1897
|
+
if (!newToken?.access_token)
|
|
1898
|
+
throw new Error("Token refresh returned empty access token");
|
|
1899
|
+
return newToken.access_token;
|
|
1832
1900
|
} catch (error) {
|
|
1833
1901
|
log("In-flight refresh failed:", error);
|
|
1834
1902
|
if (this.isNetworkError(error)) {
|
|
@@ -1838,24 +1906,31 @@ var AuthManager = class {
|
|
|
1838
1906
|
throw error;
|
|
1839
1907
|
}
|
|
1840
1908
|
}
|
|
1841
|
-
const refreshToken =
|
|
1909
|
+
const refreshToken = await this.getRefreshToken();
|
|
1842
1910
|
if (refreshToken) {
|
|
1843
1911
|
try {
|
|
1844
1912
|
const newToken = await this.exchangeToken(refreshToken, true);
|
|
1845
|
-
|
|
1913
|
+
if (!newToken?.access_token)
|
|
1914
|
+
throw new Error("Token refresh returned empty access token");
|
|
1915
|
+
return newToken.access_token;
|
|
1846
1916
|
} catch (error) {
|
|
1847
1917
|
log("Failed to refresh expired token:", error);
|
|
1848
1918
|
if (this.isNetworkError(error)) {
|
|
1849
1919
|
log("Network issue - using expired token until network is restored");
|
|
1850
1920
|
return this.token.access_token;
|
|
1851
1921
|
}
|
|
1922
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1923
|
+
throw error;
|
|
1924
|
+
}
|
|
1852
1925
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1853
1926
|
}
|
|
1854
1927
|
} else {
|
|
1855
1928
|
throw new Error("no refresh token available");
|
|
1856
1929
|
}
|
|
1857
1930
|
}
|
|
1858
|
-
|
|
1931
|
+
if (!this.token.access_token)
|
|
1932
|
+
throw new Error("Token exists but access_token is empty");
|
|
1933
|
+
return this.token.access_token;
|
|
1859
1934
|
}
|
|
1860
1935
|
async getSignInUrl(redirectUri, endpoints) {
|
|
1861
1936
|
log("getting sign in link...");
|
|
@@ -1863,8 +1938,13 @@ var AuthManager = class {
|
|
|
1863
1938
|
throw new Error("Project ID is required to generate sign-in link");
|
|
1864
1939
|
}
|
|
1865
1940
|
const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
|
|
1866
|
-
await this.storage.set(
|
|
1867
|
-
|
|
1941
|
+
await this.storage.set(
|
|
1942
|
+
STORAGE_KEYS.PDS_ENDPOINTS,
|
|
1943
|
+
JSON.stringify(pdsEndpoints)
|
|
1944
|
+
);
|
|
1945
|
+
const randomState = base64UrlEncode(
|
|
1946
|
+
crypto.getRandomValues(new Uint8Array(16))
|
|
1947
|
+
);
|
|
1868
1948
|
await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
|
|
1869
1949
|
const redirectUrl = redirectUri || window.location.href;
|
|
1870
1950
|
if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
|
|
@@ -1933,7 +2013,10 @@ var AuthManager = class {
|
|
|
1933
2013
|
if (state) {
|
|
1934
2014
|
const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
|
|
1935
2015
|
if (storedState && storedState !== state) {
|
|
1936
|
-
log("State parameter mismatch:", {
|
|
2016
|
+
log("State parameter mismatch:", {
|
|
2017
|
+
provided: state,
|
|
2018
|
+
stored: storedState
|
|
2019
|
+
});
|
|
1937
2020
|
return { success: false, error: "State parameter mismatch" };
|
|
1938
2021
|
}
|
|
1939
2022
|
}
|
|
@@ -1949,6 +2032,7 @@ var AuthManager = class {
|
|
|
1949
2032
|
}
|
|
1950
2033
|
} catch (error) {
|
|
1951
2034
|
log("signInWithCode error:", error);
|
|
2035
|
+
this.freshSignIn = false;
|
|
1952
2036
|
return {
|
|
1953
2037
|
success: false,
|
|
1954
2038
|
error: error.message || "Authentication failed"
|
|
@@ -1961,13 +2045,64 @@ var AuthManager = class {
|
|
|
1961
2045
|
*/
|
|
1962
2046
|
async signOut() {
|
|
1963
2047
|
log("signing out!");
|
|
1964
|
-
this.resetAuthState();
|
|
2048
|
+
this.resetAuthState("signed_out");
|
|
1965
2049
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1966
2050
|
await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
|
|
1967
2051
|
await this.clearStoredAuth();
|
|
1968
2052
|
this.broadcastSignOut();
|
|
1969
2053
|
this.notify();
|
|
1970
2054
|
}
|
|
2055
|
+
async reconcileSession(reason = "manual", options) {
|
|
2056
|
+
if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
if (!this.isOnline) {
|
|
2060
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
2061
|
+
this.notify();
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS;
|
|
2065
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
2066
|
+
const now = Date.now();
|
|
2067
|
+
if (this.sessionCheckPromise) {
|
|
2068
|
+
return this.sessionCheckPromise;
|
|
2069
|
+
}
|
|
2070
|
+
if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
this.lastSessionCheckAt = now;
|
|
2074
|
+
let sessionCheck = null;
|
|
2075
|
+
sessionCheck = (async () => {
|
|
2076
|
+
try {
|
|
2077
|
+
const accessToken = await this.getToken(
|
|
2078
|
+
forceRefresh ? { forceRefresh: true } : void 0
|
|
2079
|
+
);
|
|
2080
|
+
const currentSession = await this.fetchCurrentSession(accessToken);
|
|
2081
|
+
if (currentSession?.active) {
|
|
2082
|
+
this.updateAuthStatus("authenticated");
|
|
2083
|
+
this.notify();
|
|
2084
|
+
if (!this.user) {
|
|
2085
|
+
await this.recoverMissingUserProfile(reason, accessToken);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
log(`Session reconciliation failed on ${reason}:`, error);
|
|
2090
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
if (this.isNetworkError(error)) {
|
|
2094
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
2095
|
+
this.notify();
|
|
2096
|
+
}
|
|
2097
|
+
} finally {
|
|
2098
|
+
if (this.sessionCheckPromise === sessionCheck) {
|
|
2099
|
+
this.sessionCheckPromise = null;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
})();
|
|
2103
|
+
this.sessionCheckPromise = sessionCheck;
|
|
2104
|
+
return sessionCheck;
|
|
2105
|
+
}
|
|
1971
2106
|
hasScope(scope) {
|
|
1972
2107
|
if (!this.tokenScope) return false;
|
|
1973
2108
|
return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
|
|
@@ -1983,33 +2118,62 @@ var AuthManager = class {
|
|
|
1983
2118
|
return requested.filter((s) => !granted.has(s));
|
|
1984
2119
|
}
|
|
1985
2120
|
/**
|
|
1986
|
-
* Register online/offline handlers that retry pending
|
|
2121
|
+
* Register online/offline and visibility handlers that retry pending
|
|
2122
|
+
* refreshes and proactively refresh tokens when the app resumes from
|
|
2123
|
+
* background (critical for PWAs and mobile browsers where timers are
|
|
2124
|
+
* frozen while backgrounded).
|
|
1987
2125
|
* Returns a cleanup function for useEffect teardown.
|
|
1988
2126
|
*/
|
|
1989
2127
|
setupNetworkListeners() {
|
|
1990
2128
|
const handleOnline = async () => {
|
|
1991
2129
|
log("Network came back online");
|
|
1992
2130
|
this.isOnline = true;
|
|
1993
|
-
if (this.pendingRefresh
|
|
2131
|
+
if (this.pendingRefresh) {
|
|
1994
2132
|
log("Retrying pending token refresh");
|
|
1995
2133
|
this.pendingRefresh = false;
|
|
1996
|
-
const refreshToken =
|
|
2134
|
+
const refreshToken = await this.getRefreshToken();
|
|
1997
2135
|
if (refreshToken) {
|
|
1998
2136
|
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
1999
2137
|
log("Retry refresh failed:", error);
|
|
2000
2138
|
});
|
|
2001
2139
|
}
|
|
2002
2140
|
}
|
|
2141
|
+
if (this.isSignedIn) {
|
|
2142
|
+
this.reconcileSession("online event", {
|
|
2143
|
+
forceRefresh: true,
|
|
2144
|
+
throttleMs: 0
|
|
2145
|
+
}).catch((error) => {
|
|
2146
|
+
log("Session reconciliation on online failed:", error);
|
|
2147
|
+
});
|
|
2148
|
+
} else if (this.user) {
|
|
2149
|
+
await this.restoreStoredSession("online restore");
|
|
2150
|
+
}
|
|
2003
2151
|
};
|
|
2004
2152
|
const handleOffline = () => {
|
|
2005
2153
|
log("Network went offline");
|
|
2006
2154
|
this.isOnline = false;
|
|
2007
2155
|
};
|
|
2156
|
+
const handleVisibilityChange = () => {
|
|
2157
|
+
if (document.visibilityState === "visible" && this.isSignedIn) {
|
|
2158
|
+
log("App became visible - reconciling auth session");
|
|
2159
|
+
this.reconcileSession("visibility resume", {
|
|
2160
|
+
forceRefresh: true
|
|
2161
|
+
}).catch((err) => {
|
|
2162
|
+
log("Session reconciliation on visibility resume failed:", err);
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2008
2166
|
window.addEventListener("online", handleOnline);
|
|
2009
2167
|
window.addEventListener("offline", handleOffline);
|
|
2168
|
+
if (typeof document !== "undefined") {
|
|
2169
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
2170
|
+
}
|
|
2010
2171
|
return () => {
|
|
2011
2172
|
window.removeEventListener("online", handleOnline);
|
|
2012
2173
|
window.removeEventListener("offline", handleOffline);
|
|
2174
|
+
if (typeof document !== "undefined") {
|
|
2175
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
2176
|
+
}
|
|
2013
2177
|
};
|
|
2014
2178
|
}
|
|
2015
2179
|
// ------------------------------------------------------------------
|
|
@@ -2048,12 +2212,18 @@ var AuthManager = class {
|
|
|
2048
2212
|
if (elapsed < 24 * 60 * 60 * 1e3) return;
|
|
2049
2213
|
}
|
|
2050
2214
|
try {
|
|
2051
|
-
await fetch(
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2215
|
+
await fetch(
|
|
2216
|
+
`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
|
|
2217
|
+
{
|
|
2218
|
+
method: "POST",
|
|
2219
|
+
headers: { "Content-Type": "application/json" },
|
|
2220
|
+
body: JSON.stringify({ token: accessToken })
|
|
2221
|
+
}
|
|
2222
|
+
);
|
|
2223
|
+
await this.storage.set(
|
|
2224
|
+
STORAGE_KEYS.LAST_CONNECT_REPORT,
|
|
2225
|
+
Date.now().toString()
|
|
2226
|
+
);
|
|
2057
2227
|
log("Reported connection to admin server");
|
|
2058
2228
|
} catch (err) {
|
|
2059
2229
|
log("Failed to report connection (non-blocking):", err);
|
|
@@ -2064,54 +2234,46 @@ var AuthManager = class {
|
|
|
2064
2234
|
*/
|
|
2065
2235
|
async processNewToken() {
|
|
2066
2236
|
if (!this.token) {
|
|
2067
|
-
this.
|
|
2237
|
+
this.updateAuthStatus("signed_out");
|
|
2068
2238
|
this.notify();
|
|
2069
2239
|
return;
|
|
2070
2240
|
}
|
|
2071
2241
|
try {
|
|
2072
2242
|
const decoded = jwtDecode(this.token.access_token);
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
log("token is expired - refreshing ...");
|
|
2079
|
-
const refreshToken = this.token.refresh_token;
|
|
2080
|
-
if (!refreshToken) {
|
|
2081
|
-
log("Error: No refresh token available for expired token");
|
|
2082
|
-
this.isAuthReady = true;
|
|
2083
|
-
this.notify();
|
|
2084
|
-
return;
|
|
2085
|
-
}
|
|
2086
|
-
try {
|
|
2087
|
-
const newToken = await this.exchangeToken(refreshToken, true);
|
|
2088
|
-
await this.fetchUser(newToken?.access_token || "");
|
|
2089
|
-
} catch (error) {
|
|
2090
|
-
log("Failed to refresh token in processNewToken:", error);
|
|
2091
|
-
if (this.isNetworkError(error)) {
|
|
2092
|
-
log("Network issue - continuing with expired token until online");
|
|
2093
|
-
await this.fetchUser(this.token.access_token);
|
|
2094
|
-
} else {
|
|
2095
|
-
this.isAuthReady = true;
|
|
2096
|
-
this.notify();
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
} else {
|
|
2100
|
-
await this.fetchUser(this.token.access_token);
|
|
2101
|
-
}
|
|
2243
|
+
this.applyTokenClaims(decoded);
|
|
2244
|
+
this.updateAuthStatus("authenticated");
|
|
2245
|
+
this.notify();
|
|
2246
|
+
this.broadcastSessionUpdate();
|
|
2247
|
+
await this.fetchUser(this.token.access_token);
|
|
2102
2248
|
} catch (error) {
|
|
2103
2249
|
log("Error processing token:", error);
|
|
2104
|
-
this.
|
|
2250
|
+
this.updateAuthStatus("recovering");
|
|
2105
2251
|
this.notify();
|
|
2106
2252
|
}
|
|
2107
2253
|
}
|
|
2254
|
+
async restoreCachedUser(options) {
|
|
2255
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2256
|
+
if (cached && options?.hasRecoverableSession) {
|
|
2257
|
+
try {
|
|
2258
|
+
this.user = JSON.parse(cached);
|
|
2259
|
+
log("Restored cached user info for recoverable session");
|
|
2260
|
+
} catch {
|
|
2261
|
+
}
|
|
2262
|
+
} else {
|
|
2263
|
+
this.user = null;
|
|
2264
|
+
}
|
|
2265
|
+
this.updateAuthStatus(
|
|
2266
|
+
options?.hasRecoverableSession ? "recovering" : "signed_out"
|
|
2267
|
+
);
|
|
2268
|
+
this.notify();
|
|
2269
|
+
}
|
|
2108
2270
|
async fetchUser(accessToken) {
|
|
2109
2271
|
log("fetching user");
|
|
2110
2272
|
try {
|
|
2111
2273
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2112
2274
|
const response = await fetch(endpoints.userinfo_endpoint, {
|
|
2113
2275
|
method: "GET",
|
|
2114
|
-
headers: {
|
|
2276
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2115
2277
|
});
|
|
2116
2278
|
if (!response.ok) {
|
|
2117
2279
|
throw new Error(`Failed to fetch user info: ${response.status}`);
|
|
@@ -2122,24 +2284,22 @@ var AuthManager = class {
|
|
|
2122
2284
|
throw new Error(`User info error: ${user.error}`);
|
|
2123
2285
|
}
|
|
2124
2286
|
if (this.token?.refresh_token) {
|
|
2125
|
-
await this.storage.set(
|
|
2287
|
+
await this.storage.set(
|
|
2288
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2289
|
+
this.token.refresh_token
|
|
2290
|
+
);
|
|
2126
2291
|
}
|
|
2127
2292
|
await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
|
|
2128
2293
|
log("Cached user info in storage");
|
|
2129
2294
|
this.user = user;
|
|
2130
|
-
this.
|
|
2131
|
-
|
|
2132
|
-
if (this.freshSignIn) {
|
|
2133
|
-
this.freshSignIn = false;
|
|
2134
|
-
this.broadcastSignIn();
|
|
2135
|
-
} else {
|
|
2136
|
-
this.broadcastTokenRefresh();
|
|
2295
|
+
if (this.authStatus !== "reauth_required") {
|
|
2296
|
+
this.updateAuthStatus("authenticated");
|
|
2137
2297
|
}
|
|
2298
|
+
this.nextUserRecoveryAt = 0;
|
|
2138
2299
|
this.notify();
|
|
2139
2300
|
} catch (error) {
|
|
2140
2301
|
log("Failed to fetch user info:", error);
|
|
2141
|
-
this.
|
|
2142
|
-
this.notify();
|
|
2302
|
+
await this.handleUserFetchFailure();
|
|
2143
2303
|
}
|
|
2144
2304
|
}
|
|
2145
2305
|
/**
|
|
@@ -2166,7 +2326,9 @@ var AuthManager = class {
|
|
|
2166
2326
|
if (!this.isOnline) {
|
|
2167
2327
|
log("Network is offline, marking refresh as pending");
|
|
2168
2328
|
this.pendingRefresh = true;
|
|
2169
|
-
throw new Error(
|
|
2329
|
+
throw new Error(
|
|
2330
|
+
"Network offline - refresh will be retried when online"
|
|
2331
|
+
);
|
|
2170
2332
|
}
|
|
2171
2333
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2172
2334
|
let requestBody;
|
|
@@ -2176,26 +2338,36 @@ var AuthManager = class {
|
|
|
2176
2338
|
refresh_token: codeOrRefreshToken
|
|
2177
2339
|
};
|
|
2178
2340
|
if (this.config.projectId) {
|
|
2179
|
-
requestBody.client_id = normalizeClientId(
|
|
2341
|
+
requestBody.client_id = normalizeClientId(
|
|
2342
|
+
this.config.projectId,
|
|
2343
|
+
this.adminHostname
|
|
2344
|
+
);
|
|
2180
2345
|
}
|
|
2181
2346
|
} else {
|
|
2182
2347
|
requestBody = {
|
|
2183
2348
|
grant_type: "authorization_code",
|
|
2184
2349
|
code: codeOrRefreshToken
|
|
2185
2350
|
};
|
|
2186
|
-
const storedRedirectUri = await this.storage.get(
|
|
2351
|
+
const storedRedirectUri = await this.storage.get(
|
|
2352
|
+
STORAGE_KEYS.REDIRECT_URI
|
|
2353
|
+
);
|
|
2187
2354
|
if (storedRedirectUri) {
|
|
2188
2355
|
requestBody.redirect_uri = storedRedirectUri;
|
|
2189
2356
|
log("Including redirect_uri in token exchange:", storedRedirectUri);
|
|
2190
2357
|
} else {
|
|
2191
2358
|
log("Warning: No redirect_uri found in storage for token exchange");
|
|
2192
2359
|
}
|
|
2193
|
-
const codeVerifier = await this.storage.get(
|
|
2360
|
+
const codeVerifier = await this.storage.get(
|
|
2361
|
+
STORAGE_KEYS.CODE_VERIFIER
|
|
2362
|
+
);
|
|
2194
2363
|
if (codeVerifier) {
|
|
2195
2364
|
requestBody.code_verifier = codeVerifier;
|
|
2196
2365
|
}
|
|
2197
2366
|
if (this.config.projectId) {
|
|
2198
|
-
requestBody.client_id = normalizeClientId(
|
|
2367
|
+
requestBody.client_id = normalizeClientId(
|
|
2368
|
+
this.config.projectId,
|
|
2369
|
+
this.adminHostname
|
|
2370
|
+
);
|
|
2199
2371
|
}
|
|
2200
2372
|
}
|
|
2201
2373
|
log("Token exchange request body:", {
|
|
@@ -2211,7 +2383,9 @@ var AuthManager = class {
|
|
|
2211
2383
|
log("Network error fetching token:", error);
|
|
2212
2384
|
if (!this.isOnline) {
|
|
2213
2385
|
this.pendingRefresh = true;
|
|
2214
|
-
throw new Error(
|
|
2386
|
+
throw new Error(
|
|
2387
|
+
"Network offline - refresh will be retried when online"
|
|
2388
|
+
);
|
|
2215
2389
|
}
|
|
2216
2390
|
throw new Error("Network error during token refresh");
|
|
2217
2391
|
});
|
|
@@ -2220,36 +2394,52 @@ var AuthManager = class {
|
|
|
2220
2394
|
const decoded = jwtDecode(token.access_token);
|
|
2221
2395
|
if (decoded.typ === "refresh") {
|
|
2222
2396
|
log("Error: received refresh token as access token");
|
|
2223
|
-
throw new Error(
|
|
2397
|
+
throw new Error(
|
|
2398
|
+
"Invalid token: received refresh token instead of access token"
|
|
2399
|
+
);
|
|
2224
2400
|
}
|
|
2225
2401
|
} catch (decodeError) {
|
|
2226
2402
|
if (decodeError.message.includes("Invalid token")) {
|
|
2227
2403
|
throw decodeError;
|
|
2228
2404
|
}
|
|
2229
|
-
log(
|
|
2405
|
+
log(
|
|
2406
|
+
"Warning: could not decode access token for type check:",
|
|
2407
|
+
decodeError
|
|
2408
|
+
);
|
|
2230
2409
|
}
|
|
2231
2410
|
}
|
|
2232
2411
|
if (token.error) {
|
|
2233
2412
|
log("error fetching token", token.error);
|
|
2234
2413
|
if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
|
|
2235
2414
|
this.pendingRefresh = true;
|
|
2236
|
-
throw new Error(
|
|
2415
|
+
throw new Error(
|
|
2416
|
+
"Network issue - refresh will be retried when online"
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
if (this.isDefinitiveTokenErrorCode(token.error)) {
|
|
2420
|
+
await this.markReauthRequired(token.error);
|
|
2421
|
+
throw new DefinitiveAuthError(token.error);
|
|
2237
2422
|
}
|
|
2238
|
-
await this.clearStoredAuth();
|
|
2239
|
-
this.resetAuthState();
|
|
2240
|
-
this.notify();
|
|
2241
2423
|
throw new Error(`Token refresh failed: ${token.error}`);
|
|
2242
2424
|
} else {
|
|
2425
|
+
if (!token.access_token) {
|
|
2426
|
+
throw new Error("Token response missing access token");
|
|
2427
|
+
}
|
|
2243
2428
|
this.token = token;
|
|
2244
2429
|
this.pendingRefresh = false;
|
|
2245
2430
|
if (token.refresh_token) {
|
|
2246
|
-
await this.storage.set(
|
|
2431
|
+
await this.storage.set(
|
|
2432
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2433
|
+
token.refresh_token
|
|
2434
|
+
);
|
|
2247
2435
|
log("Updated refresh token in storage");
|
|
2248
2436
|
}
|
|
2249
2437
|
if (!isRefreshToken) {
|
|
2250
2438
|
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2251
2439
|
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2252
|
-
log(
|
|
2440
|
+
log(
|
|
2441
|
+
"Cleaned up redirect_uri and code_verifier from storage after successful exchange"
|
|
2442
|
+
);
|
|
2253
2443
|
}
|
|
2254
2444
|
this.reportConnection(token.access_token).catch(() => {
|
|
2255
2445
|
});
|
|
@@ -2258,10 +2448,12 @@ var AuthManager = class {
|
|
|
2258
2448
|
return token;
|
|
2259
2449
|
} catch (error) {
|
|
2260
2450
|
log("Token refresh error:", error);
|
|
2261
|
-
if (
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2451
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2452
|
+
log("Preserving cleared auth state after definitive token rejection");
|
|
2453
|
+
} else if (this.isNetworkError(error)) {
|
|
2454
|
+
log("Recoverable network auth failure - preserving session state");
|
|
2455
|
+
} else {
|
|
2456
|
+
log("Recoverable auth failure - preserving session state");
|
|
2265
2457
|
}
|
|
2266
2458
|
throw error;
|
|
2267
2459
|
}
|
|
@@ -2285,13 +2477,15 @@ var AuthManager = class {
|
|
|
2285
2477
|
}
|
|
2286
2478
|
return tokenPromise;
|
|
2287
2479
|
}
|
|
2288
|
-
resetAuthState() {
|
|
2289
|
-
this.user = null;
|
|
2290
|
-
this.isSignedIn = false;
|
|
2480
|
+
resetAuthState(status = "signed_out") {
|
|
2481
|
+
this.user = status === "reauth_required" ? this.user : null;
|
|
2291
2482
|
this.token = null;
|
|
2292
|
-
|
|
2483
|
+
if (status !== "reauth_required") {
|
|
2484
|
+
this.did = null;
|
|
2485
|
+
}
|
|
2293
2486
|
this.tokenScope = null;
|
|
2294
|
-
this.
|
|
2487
|
+
this.nextUserRecoveryAt = 0;
|
|
2488
|
+
this.updateAuthStatus(status);
|
|
2295
2489
|
}
|
|
2296
2490
|
async clearStoredAuth() {
|
|
2297
2491
|
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
@@ -2302,11 +2496,221 @@ var AuthManager = class {
|
|
|
2302
2496
|
await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS);
|
|
2303
2497
|
}
|
|
2304
2498
|
isNetworkError(error) {
|
|
2499
|
+
if (error instanceof TypeError) return true;
|
|
2305
2500
|
if (error instanceof Error) {
|
|
2306
2501
|
return error.message.includes("offline") || error.message.includes("Network");
|
|
2307
2502
|
}
|
|
2308
2503
|
return false;
|
|
2309
2504
|
}
|
|
2505
|
+
async getRefreshToken() {
|
|
2506
|
+
const storedRefreshToken = await this.storage.get(
|
|
2507
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
2508
|
+
);
|
|
2509
|
+
if (storedRefreshToken) {
|
|
2510
|
+
log("Using refresh token from storage");
|
|
2511
|
+
if (this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
2512
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
2513
|
+
}
|
|
2514
|
+
return storedRefreshToken;
|
|
2515
|
+
}
|
|
2516
|
+
const memoryRefreshToken = this.token?.refresh_token ?? null;
|
|
2517
|
+
if (memoryRefreshToken) {
|
|
2518
|
+
log("Using refresh token from memory fallback");
|
|
2519
|
+
} else {
|
|
2520
|
+
log("No refresh token available in storage or memory");
|
|
2521
|
+
}
|
|
2522
|
+
return memoryRefreshToken;
|
|
2523
|
+
}
|
|
2524
|
+
async syncRefreshTokenFromStorage() {
|
|
2525
|
+
const storedRefreshToken = await this.storage.get(
|
|
2526
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
2527
|
+
);
|
|
2528
|
+
if (storedRefreshToken && this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
2529
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
2530
|
+
log("Synced refresh token from shared storage into memory");
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
applyTokenClaims(decoded) {
|
|
2534
|
+
this.did = decoded.sub || null;
|
|
2535
|
+
this.tokenScope = decoded.scope || null;
|
|
2536
|
+
}
|
|
2537
|
+
broadcastSessionUpdate() {
|
|
2538
|
+
if (this.freshSignIn) {
|
|
2539
|
+
this.freshSignIn = false;
|
|
2540
|
+
this.broadcastSignIn();
|
|
2541
|
+
} else {
|
|
2542
|
+
this.broadcastTokenRefresh();
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
async handleUserFetchFailure() {
|
|
2546
|
+
if (this.isCompatibleUser(this.user)) {
|
|
2547
|
+
log("Preserving existing user after userinfo failure");
|
|
2548
|
+
} else if (this.user) {
|
|
2549
|
+
log("Discarding stale in-memory user after userinfo failure");
|
|
2550
|
+
this.user = null;
|
|
2551
|
+
}
|
|
2552
|
+
if (!this.user) {
|
|
2553
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2554
|
+
if (cached) {
|
|
2555
|
+
try {
|
|
2556
|
+
const parsed = JSON.parse(cached);
|
|
2557
|
+
if (this.isCompatibleUser(parsed)) {
|
|
2558
|
+
this.user = parsed;
|
|
2559
|
+
log("Recovered cached user after userinfo failure");
|
|
2560
|
+
} else {
|
|
2561
|
+
log("Cached user did not match the active session");
|
|
2562
|
+
}
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
log("Failed to parse cached user after userinfo failure:", error);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
if (!this.user) {
|
|
2569
|
+
log("No compatible cached user available after userinfo failure");
|
|
2570
|
+
this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS;
|
|
2571
|
+
} else {
|
|
2572
|
+
this.nextUserRecoveryAt = 0;
|
|
2573
|
+
}
|
|
2574
|
+
if (this.authStatus === "bootstrapping") {
|
|
2575
|
+
this.updateAuthStatus(this.token ? "authenticated" : "recovering");
|
|
2576
|
+
}
|
|
2577
|
+
this.notify();
|
|
2578
|
+
}
|
|
2579
|
+
isCompatibleUser(user) {
|
|
2580
|
+
if (!user) return false;
|
|
2581
|
+
if (!this.did) return true;
|
|
2582
|
+
return user.sub === this.did;
|
|
2583
|
+
}
|
|
2584
|
+
async recoverMissingUserProfile(reason, accessToken) {
|
|
2585
|
+
if (!this.isSignedIn || this.user) return;
|
|
2586
|
+
const now = Date.now();
|
|
2587
|
+
if (this.nextUserRecoveryAt > now) {
|
|
2588
|
+
log(
|
|
2589
|
+
`Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`
|
|
2590
|
+
);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
log(`Attempting user profile recovery on ${reason}`);
|
|
2594
|
+
const token = accessToken ?? await this.getToken();
|
|
2595
|
+
if (this.user) return;
|
|
2596
|
+
await this.fetchUser(token);
|
|
2597
|
+
}
|
|
2598
|
+
isDefinitiveTokenErrorCode(code) {
|
|
2599
|
+
return typeof code === "string" && DEFINITIVE_TOKEN_ERRORS.has(code);
|
|
2600
|
+
}
|
|
2601
|
+
isDefinitiveAuthFailure(error) {
|
|
2602
|
+
return error instanceof DefinitiveAuthError;
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
|
|
2606
|
+
* from the status so they stay consistent.
|
|
2607
|
+
*
|
|
2608
|
+
* `isSignedIn` is intentionally `true` during `reauth_required` so the
|
|
2609
|
+
* UI layer can still display user info while prompting re-authentication.
|
|
2610
|
+
* Consumers should check `authStatus` (or a future convenience getter)
|
|
2611
|
+
* when they need to distinguish "healthy session" from "needs re-auth".
|
|
2612
|
+
*/
|
|
2613
|
+
updateAuthStatus(status, errorCode = null) {
|
|
2614
|
+
this.authStatus = status;
|
|
2615
|
+
this.authErrorCode = errorCode;
|
|
2616
|
+
this.isSignedIn = status === "authenticated" || status === "recovering" || status === "reauth_required";
|
|
2617
|
+
this.isAuthReady = status !== "bootstrapping";
|
|
2618
|
+
}
|
|
2619
|
+
async clearStoredSessionTokens() {
|
|
2620
|
+
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
2621
|
+
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2622
|
+
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2623
|
+
}
|
|
2624
|
+
async restoreStoredSession(reason) {
|
|
2625
|
+
const refreshToken = await this.getRefreshToken();
|
|
2626
|
+
if (!refreshToken) {
|
|
2627
|
+
log(`No stored refresh token available during ${reason}`);
|
|
2628
|
+
await this.restoreCachedUser({ hasRecoverableSession: false });
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
log(`Restoring stored session during ${reason}`);
|
|
2632
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2633
|
+
if (!this.isOnline) {
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
2637
|
+
log(`Stored session refresh failed during ${reason}:`, error);
|
|
2638
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
async handleExternalTokenRefresh(data) {
|
|
2645
|
+
await this.syncRefreshTokenFromStorage();
|
|
2646
|
+
const refreshToken = await this.getRefreshToken();
|
|
2647
|
+
if (data.accessToken && refreshToken) {
|
|
2648
|
+
try {
|
|
2649
|
+
const decoded = jwtDecode(data.accessToken);
|
|
2650
|
+
const expiresIn = decoded.exp != null ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1e3)) : 0;
|
|
2651
|
+
this.token = {
|
|
2652
|
+
access_token: data.accessToken,
|
|
2653
|
+
token_type: "Bearer",
|
|
2654
|
+
expires_in: expiresIn,
|
|
2655
|
+
refresh_token: refreshToken
|
|
2656
|
+
};
|
|
2657
|
+
this.applyTokenClaims(decoded);
|
|
2658
|
+
} catch (error) {
|
|
2659
|
+
log("Failed to decode token refreshed by another tab:", error);
|
|
2660
|
+
this.token = {
|
|
2661
|
+
access_token: data.accessToken,
|
|
2662
|
+
token_type: "Bearer",
|
|
2663
|
+
expires_in: 0,
|
|
2664
|
+
refresh_token: refreshToken
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
if (this.authStatus !== "reauth_required") {
|
|
2668
|
+
this.updateAuthStatus("authenticated");
|
|
2669
|
+
}
|
|
2670
|
+
} else if (refreshToken) {
|
|
2671
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2672
|
+
}
|
|
2673
|
+
if (data.did) this.did = data.did;
|
|
2674
|
+
if (data.tokenScope) this.tokenScope = data.tokenScope;
|
|
2675
|
+
this.notify();
|
|
2676
|
+
}
|
|
2677
|
+
async fetchCurrentSession(accessToken) {
|
|
2678
|
+
const endpoints = await this.getActivePdsEndpoints();
|
|
2679
|
+
const response = await fetch(`${endpoints.pds_url}/auth/session`, {
|
|
2680
|
+
method: "GET",
|
|
2681
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2682
|
+
});
|
|
2683
|
+
const data = await response.json().catch(() => ({}));
|
|
2684
|
+
if (response.status === 401 && data.reauth_required) {
|
|
2685
|
+
await this.markReauthRequired(data.error || "invalid_session");
|
|
2686
|
+
throw new DefinitiveAuthError(data.error || "invalid_session");
|
|
2687
|
+
}
|
|
2688
|
+
if (!response.ok) {
|
|
2689
|
+
throw new Error(`Failed to reconcile session: ${response.status}`);
|
|
2690
|
+
}
|
|
2691
|
+
return data;
|
|
2692
|
+
}
|
|
2693
|
+
async markReauthRequired(code, options) {
|
|
2694
|
+
log("Marking auth session as requiring reauthentication:", code);
|
|
2695
|
+
await this.clearStoredSessionTokens();
|
|
2696
|
+
if (!this.user) {
|
|
2697
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2698
|
+
if (cached) {
|
|
2699
|
+
try {
|
|
2700
|
+
this.user = JSON.parse(cached);
|
|
2701
|
+
} catch {
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
this.token = null;
|
|
2706
|
+
this.tokenScope = null;
|
|
2707
|
+
this.nextUserRecoveryAt = 0;
|
|
2708
|
+
this.updateAuthStatus("reauth_required", code);
|
|
2709
|
+
if (options?.broadcast !== false) {
|
|
2710
|
+
this.broadcastSessionInvalidated(code);
|
|
2711
|
+
}
|
|
2712
|
+
this.notify();
|
|
2713
|
+
}
|
|
2310
2714
|
};
|
|
2311
2715
|
|
|
2312
2716
|
// src/AuthContext.tsx
|
|
@@ -2555,6 +2959,8 @@ function snapshotAuth(mgr) {
|
|
|
2555
2959
|
isSignedIn: mgr.isSignedIn,
|
|
2556
2960
|
hasToken: !!mgr.token,
|
|
2557
2961
|
isAuthReady: mgr.isAuthReady,
|
|
2962
|
+
authStatus: mgr.authStatus,
|
|
2963
|
+
authErrorCode: mgr.authErrorCode,
|
|
2558
2964
|
user: mgr.user,
|
|
2559
2965
|
did: mgr.did,
|
|
2560
2966
|
tokenScope: mgr.tokenScope
|
|
@@ -2589,6 +2995,8 @@ function BasicProvider({
|
|
|
2589
2995
|
isSignedIn: false,
|
|
2590
2996
|
hasToken: false,
|
|
2591
2997
|
isAuthReady: false,
|
|
2998
|
+
authStatus: "bootstrapping",
|
|
2999
|
+
authErrorCode: null,
|
|
2592
3000
|
user: null,
|
|
2593
3001
|
did: null,
|
|
2594
3002
|
tokenScope: null
|
|
@@ -2611,9 +3019,11 @@ function BasicProvider({
|
|
|
2611
3019
|
const remoteDbRef = useRef(null);
|
|
2612
3020
|
const [shouldConnect, setShouldConnect] = useState2(false);
|
|
2613
3021
|
const [dbStatus, setDbStatus] = useState2("OFFLINE" /* OFFLINE */);
|
|
2614
|
-
const [
|
|
3022
|
+
const [isDbReady, setIsDbReady] = useState2(false);
|
|
2615
3023
|
const [error, setError] = useState2(null);
|
|
2616
|
-
const [schemaDevInfo, setSchemaDevInfo] = useState2(
|
|
3024
|
+
const [schemaDevInfo, setSchemaDevInfo] = useState2(
|
|
3025
|
+
null
|
|
3026
|
+
);
|
|
2617
3027
|
const isDevMode = () => isDevelopment(debug);
|
|
2618
3028
|
const refreshSchemaStatus = useCallback2(async () => {
|
|
2619
3029
|
const s = schemaRef.current;
|
|
@@ -2653,10 +3063,16 @@ function BasicProvider({
|
|
|
2653
3063
|
useEffect(() => {
|
|
2654
3064
|
const runVersionUpdater = async () => {
|
|
2655
3065
|
try {
|
|
2656
|
-
const versionUpdater = createVersionUpdater(
|
|
3066
|
+
const versionUpdater = createVersionUpdater(
|
|
3067
|
+
storageAdapter,
|
|
3068
|
+
version,
|
|
3069
|
+
getMigrations()
|
|
3070
|
+
);
|
|
2657
3071
|
const updateResult = await versionUpdater.checkAndUpdate();
|
|
2658
3072
|
if (updateResult.updated) {
|
|
2659
|
-
log(
|
|
3073
|
+
log(
|
|
3074
|
+
`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
|
|
3075
|
+
);
|
|
2660
3076
|
} else {
|
|
2661
3077
|
log(`App version ${updateResult.toVersion} is current`);
|
|
2662
3078
|
}
|
|
@@ -2678,8 +3094,13 @@ function BasicProvider({
|
|
|
2678
3094
|
const newStatus = getSyncStatus(status);
|
|
2679
3095
|
setDbStatus(newStatus);
|
|
2680
3096
|
if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
|
|
2681
|
-
log(
|
|
2682
|
-
|
|
3097
|
+
log(
|
|
3098
|
+
"Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
|
|
3099
|
+
);
|
|
3100
|
+
authRef.current.reconcileSession("sync retry", {
|
|
3101
|
+
forceRefresh: true,
|
|
3102
|
+
throttleMs: 0
|
|
3103
|
+
}).catch(() => {
|
|
2683
3104
|
});
|
|
2684
3105
|
}
|
|
2685
3106
|
});
|
|
@@ -2688,7 +3109,7 @@ function BasicProvider({
|
|
|
2688
3109
|
} else {
|
|
2689
3110
|
log("Sync is disabled");
|
|
2690
3111
|
}
|
|
2691
|
-
|
|
3112
|
+
setIsDbReady(true);
|
|
2692
3113
|
}
|
|
2693
3114
|
}
|
|
2694
3115
|
function initRemoteDb() {
|
|
@@ -2699,7 +3120,7 @@ function BasicProvider({
|
|
|
2699
3120
|
title: "Project ID Required",
|
|
2700
3121
|
message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
|
|
2701
3122
|
});
|
|
2702
|
-
|
|
3123
|
+
setIsDbReady(true);
|
|
2703
3124
|
return;
|
|
2704
3125
|
}
|
|
2705
3126
|
log("Initializing Basic Remote DB");
|
|
@@ -2711,11 +3132,20 @@ function BasicProvider({
|
|
|
2711
3132
|
debug,
|
|
2712
3133
|
onAuthError: (error2) => {
|
|
2713
3134
|
log("RemoteDB auth error:", error2);
|
|
2714
|
-
|
|
3135
|
+
if (error2.errorType === "forbidden") {
|
|
3136
|
+
log("403 Forbidden - user lacks required scope, not signing out");
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
|
|
3140
|
+
forceRefresh: error2.errorType !== "network",
|
|
3141
|
+
throttleMs: 0
|
|
3142
|
+
}).catch((reconcileError) => {
|
|
3143
|
+
log("RemoteDB auth recovery failed:", reconcileError);
|
|
3144
|
+
});
|
|
2715
3145
|
}
|
|
2716
3146
|
});
|
|
2717
3147
|
setDbStatus("ONLINE" /* ONLINE */);
|
|
2718
|
-
|
|
3148
|
+
setIsDbReady(true);
|
|
2719
3149
|
}
|
|
2720
3150
|
}
|
|
2721
3151
|
async function checkSchema() {
|
|
@@ -2741,7 +3171,7 @@ function BasicProvider({
|
|
|
2741
3171
|
title: "Basic Schema is invalid!",
|
|
2742
3172
|
message: errorMessage
|
|
2743
3173
|
});
|
|
2744
|
-
|
|
3174
|
+
setIsDbReady(true);
|
|
2745
3175
|
return null;
|
|
2746
3176
|
}
|
|
2747
3177
|
setSchemaDevInfo({
|
|
@@ -2758,7 +3188,9 @@ function BasicProvider({
|
|
|
2758
3188
|
await initSyncDb({ shouldConnect: true });
|
|
2759
3189
|
} else {
|
|
2760
3190
|
if (result.schemaStatus.status === "unpublished") {
|
|
2761
|
-
log(
|
|
3191
|
+
log(
|
|
3192
|
+
"Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
|
|
3193
|
+
);
|
|
2762
3194
|
} else {
|
|
2763
3195
|
log("Schema is invalid!", result.schemaStatus);
|
|
2764
3196
|
}
|
|
@@ -2782,12 +3214,12 @@ function BasicProvider({
|
|
|
2782
3214
|
if (dbMode === "remote" && project_id) {
|
|
2783
3215
|
initRemoteDb();
|
|
2784
3216
|
} else {
|
|
2785
|
-
|
|
3217
|
+
setIsDbReady(true);
|
|
2786
3218
|
}
|
|
2787
3219
|
}
|
|
2788
3220
|
}, []);
|
|
2789
3221
|
useEffect(() => {
|
|
2790
|
-
if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
|
|
3222
|
+
if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
|
|
2791
3223
|
log("connecting to db...");
|
|
2792
3224
|
syncRef.current?.connect({
|
|
2793
3225
|
getToken: (opts) => authRef.current.getToken(opts),
|
|
@@ -2796,7 +3228,22 @@ function BasicProvider({
|
|
|
2796
3228
|
log("error connecting to db", e);
|
|
2797
3229
|
});
|
|
2798
3230
|
}
|
|
2799
|
-
}, [
|
|
3231
|
+
}, [
|
|
3232
|
+
authState.authStatus,
|
|
3233
|
+
authState.isSignedIn,
|
|
3234
|
+
authState.hasToken,
|
|
3235
|
+
shouldConnect
|
|
3236
|
+
]);
|
|
3237
|
+
useEffect(() => {
|
|
3238
|
+
if (authState.authStatus !== "reauth_required" || !syncRef.current) {
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
log("Auth requires reauthentication - disconnecting sync without deleting local DB");
|
|
3242
|
+
setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
|
|
3243
|
+
syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
|
|
3244
|
+
log("Error disconnecting sync after auth invalidation:", disconnectError);
|
|
3245
|
+
});
|
|
3246
|
+
}, [authConfig.ws_url, authState.authStatus]);
|
|
2800
3247
|
const handleSignOut = async () => {
|
|
2801
3248
|
await authRef.current.signOut();
|
|
2802
3249
|
if (syncRef.current) {
|
|
@@ -2804,11 +3251,13 @@ function BasicProvider({
|
|
|
2804
3251
|
await syncRef.current.close();
|
|
2805
3252
|
await syncRef.current.delete({ disableAutoOpen: false });
|
|
2806
3253
|
syncRef.current = null;
|
|
2807
|
-
window?.location?.reload();
|
|
2808
3254
|
} catch (error2) {
|
|
2809
3255
|
console.error("Error during database cleanup:", error2);
|
|
2810
3256
|
}
|
|
2811
3257
|
}
|
|
3258
|
+
if (typeof window !== "undefined") {
|
|
3259
|
+
window.location.reload();
|
|
3260
|
+
}
|
|
2812
3261
|
};
|
|
2813
3262
|
const handleSignIn = async () => {
|
|
2814
3263
|
try {
|
|
@@ -2847,6 +3296,8 @@ function BasicProvider({
|
|
|
2847
3296
|
const contextValue = {
|
|
2848
3297
|
isReady: authState.isAuthReady,
|
|
2849
3298
|
isSignedIn: authState.isSignedIn,
|
|
3299
|
+
authStatus: authState.authStatus,
|
|
3300
|
+
authErrorCode: authState.authErrorCode,
|
|
2850
3301
|
user: authState.user,
|
|
2851
3302
|
did: authState.did,
|
|
2852
3303
|
scope: authState.tokenScope,
|
|
@@ -2872,7 +3323,7 @@ function BasicProvider({
|
|
|
2872
3323
|
return /* @__PURE__ */ jsxs2(BasicContext.Provider, { value: contextValue, children: [
|
|
2873
3324
|
error && isDevMode() && /* @__PURE__ */ jsx2(ErrorDisplay, { error }),
|
|
2874
3325
|
devToolbar && isDevMode() && /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(BasicDevToolbar2, { debug }) }),
|
|
2875
|
-
|
|
3326
|
+
isDbReady && authState.isAuthReady && children
|
|
2876
3327
|
] });
|
|
2877
3328
|
}
|
|
2878
3329
|
function ErrorDisplay({ error }) {
|