@basictech/react 0.8.0-beta.3 → 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 +6 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +537 -141
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +545 -142
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/readme.md +99 -97
- package/src/AuthContext.tsx +513 -415
- package/src/context.tsx +19 -1
- package/src/core/auth/AuthManager.ts +1235 -746
- package/src/sync/syncProtocol.js +23 -7
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);
|
|
@@ -141,10 +146,7 @@ var init_syncProtocol = __esm({
|
|
|
141
146
|
if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
|
|
142
147
|
log("Page became visible - refreshing token for WebSocket");
|
|
143
148
|
resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
|
|
144
|
-
|
|
145
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
|
|
146
|
-
scheduleTokenRefresh(newToken);
|
|
147
|
-
}
|
|
149
|
+
sendTokenUpdate(newToken);
|
|
148
150
|
}).catch(function(err) {
|
|
149
151
|
log("Token refresh on visibility resume failed:", err);
|
|
150
152
|
});
|
|
@@ -210,6 +212,19 @@ var init_syncProtocol = __esm({
|
|
|
210
212
|
});
|
|
211
213
|
isFirstRound = false;
|
|
212
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
|
+
}
|
|
213
228
|
} else if (requestFromServer.type == "ack") {
|
|
214
229
|
var requestId2 = requestFromServer.requestId;
|
|
215
230
|
var acceptCallback = acceptCallbacks[requestId2.toString()];
|
|
@@ -244,7 +259,7 @@ var init_syncProtocol = __esm({
|
|
|
244
259
|
var version;
|
|
245
260
|
var init_package = __esm({
|
|
246
261
|
"package.json"() {
|
|
247
|
-
version = "0.8.0-beta.
|
|
262
|
+
version = "0.8.0-beta.4";
|
|
248
263
|
}
|
|
249
264
|
});
|
|
250
265
|
|
|
@@ -387,6 +402,8 @@ var init_context = __esm({
|
|
|
387
402
|
BasicContext = createContext({
|
|
388
403
|
isReady: false,
|
|
389
404
|
isSignedIn: false,
|
|
405
|
+
authStatus: "bootstrapping",
|
|
406
|
+
authErrorCode: null,
|
|
390
407
|
user: null,
|
|
391
408
|
did: null,
|
|
392
409
|
scope: null,
|
|
@@ -1042,7 +1059,14 @@ var init_BasicDevToolbar = __esm({
|
|
|
1042
1059
|
});
|
|
1043
1060
|
|
|
1044
1061
|
// src/AuthContext.tsx
|
|
1045
|
-
import {
|
|
1062
|
+
import {
|
|
1063
|
+
useCallback as useCallback2,
|
|
1064
|
+
useEffect,
|
|
1065
|
+
useRef,
|
|
1066
|
+
useState as useState2,
|
|
1067
|
+
Suspense,
|
|
1068
|
+
lazy
|
|
1069
|
+
} from "react";
|
|
1046
1070
|
|
|
1047
1071
|
// src/sync/index.ts
|
|
1048
1072
|
init_config();
|
|
@@ -1636,6 +1660,21 @@ async function resolveHandle(handle) {
|
|
|
1636
1660
|
// src/core/auth/AuthManager.ts
|
|
1637
1661
|
init_network();
|
|
1638
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
|
+
};
|
|
1639
1678
|
function generateCodeVerifier() {
|
|
1640
1679
|
const array = new Uint8Array(32);
|
|
1641
1680
|
crypto.getRandomValues(array);
|
|
@@ -1643,7 +1682,9 @@ function generateCodeVerifier() {
|
|
|
1643
1682
|
}
|
|
1644
1683
|
async function generateCodeChallenge(verifier) {
|
|
1645
1684
|
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
1646
|
-
log(
|
|
1685
|
+
log(
|
|
1686
|
+
"crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
|
|
1687
|
+
);
|
|
1647
1688
|
return { challenge: verifier, method: "plain" };
|
|
1648
1689
|
}
|
|
1649
1690
|
const encoder = new TextEncoder();
|
|
@@ -1664,6 +1705,8 @@ var AuthManager = class {
|
|
|
1664
1705
|
user = null;
|
|
1665
1706
|
isSignedIn = false;
|
|
1666
1707
|
isAuthReady = false;
|
|
1708
|
+
authStatus = "bootstrapping";
|
|
1709
|
+
authErrorCode = null;
|
|
1667
1710
|
did = null;
|
|
1668
1711
|
/** Space-separated scopes granted in the current access token */
|
|
1669
1712
|
tokenScope = null;
|
|
@@ -1680,6 +1723,9 @@ var AuthManager = class {
|
|
|
1680
1723
|
pendingRefresh = false;
|
|
1681
1724
|
isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
|
|
1682
1725
|
channel = null;
|
|
1726
|
+
nextUserRecoveryAt = 0;
|
|
1727
|
+
sessionCheckPromise = null;
|
|
1728
|
+
lastSessionCheckAt = 0;
|
|
1683
1729
|
constructor(config, storage, notify) {
|
|
1684
1730
|
this.config = config;
|
|
1685
1731
|
this.storage = storage;
|
|
@@ -1694,31 +1740,26 @@ var AuthManager = class {
|
|
|
1694
1740
|
this.channel.onmessage = (event) => {
|
|
1695
1741
|
if (event.data?.type === "token_refreshed") {
|
|
1696
1742
|
log("Received token refresh from another tab");
|
|
1697
|
-
|
|
1698
|
-
this.token = { ...this.token, access_token: event.data.accessToken };
|
|
1699
|
-
}
|
|
1700
|
-
if (event.data.did) this.did = event.data.did;
|
|
1701
|
-
if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
|
|
1702
|
-
this.notify();
|
|
1743
|
+
void this.handleExternalTokenRefresh(event.data);
|
|
1703
1744
|
}
|
|
1704
1745
|
if (event.data?.type === "signed_in") {
|
|
1705
|
-
log("Received sign-in from another tab,
|
|
1706
|
-
|
|
1707
|
-
window.location.reload();
|
|
1708
|
-
}
|
|
1746
|
+
log("Received sign-in from another tab, restoring session");
|
|
1747
|
+
void this.restoreStoredSession("cross-tab sign-in");
|
|
1709
1748
|
}
|
|
1710
1749
|
if (event.data?.type === "signed_out") {
|
|
1711
|
-
log("Received sign-out from another tab
|
|
1712
|
-
this.
|
|
1713
|
-
this.isSignedIn = false;
|
|
1714
|
-
this.token = null;
|
|
1715
|
-
this.did = null;
|
|
1716
|
-
this.tokenScope = null;
|
|
1750
|
+
log("Received sign-out from another tab");
|
|
1751
|
+
this.resetAuthState("signed_out");
|
|
1717
1752
|
this.notify();
|
|
1718
1753
|
if (typeof window !== "undefined") {
|
|
1719
1754
|
window.location.reload();
|
|
1720
1755
|
}
|
|
1721
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
|
+
}
|
|
1722
1763
|
};
|
|
1723
1764
|
} catch {
|
|
1724
1765
|
log("BroadcastChannel not available for cross-tab sync");
|
|
@@ -1738,6 +1779,9 @@ var AuthManager = class {
|
|
|
1738
1779
|
broadcastSignOut() {
|
|
1739
1780
|
this.channel?.postMessage({ type: "signed_out" });
|
|
1740
1781
|
}
|
|
1782
|
+
broadcastSessionInvalidated(code) {
|
|
1783
|
+
this.channel?.postMessage({ type: "session_invalidated", code });
|
|
1784
|
+
}
|
|
1741
1785
|
// ------------------------------------------------------------------
|
|
1742
1786
|
// Public API
|
|
1743
1787
|
// ------------------------------------------------------------------
|
|
@@ -1746,7 +1790,11 @@ var AuthManager = class {
|
|
|
1746
1790
|
* from refresh token, or load cached user for offline mode.
|
|
1747
1791
|
*/
|
|
1748
1792
|
async initialize() {
|
|
1749
|
-
|
|
1793
|
+
this.updateAuthStatus("bootstrapping");
|
|
1794
|
+
await this.storage.set(
|
|
1795
|
+
STORAGE_KEYS.DEBUG,
|
|
1796
|
+
this.config.debug ? "true" : "false"
|
|
1797
|
+
);
|
|
1750
1798
|
const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
|
|
1751
1799
|
if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
|
|
1752
1800
|
log("PDS URL changed, clearing stored tokens");
|
|
@@ -1758,7 +1806,7 @@ var AuthManager = class {
|
|
|
1758
1806
|
if (params.has("code")) {
|
|
1759
1807
|
const code = params.get("code");
|
|
1760
1808
|
if (!code) {
|
|
1761
|
-
this.
|
|
1809
|
+
this.updateAuthStatus("signed_out");
|
|
1762
1810
|
this.notify();
|
|
1763
1811
|
return;
|
|
1764
1812
|
}
|
|
@@ -1766,7 +1814,7 @@ var AuthManager = class {
|
|
|
1766
1814
|
const urlState = params.get("state");
|
|
1767
1815
|
if (!state || state !== urlState) {
|
|
1768
1816
|
log("error: auth state does not match");
|
|
1769
|
-
this.
|
|
1817
|
+
this.updateAuthStatus("signed_out");
|
|
1770
1818
|
this.notify();
|
|
1771
1819
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1772
1820
|
cleanOAuthParamsFromUrl();
|
|
@@ -1777,34 +1825,18 @@ var AuthManager = class {
|
|
|
1777
1825
|
this.freshSignIn = true;
|
|
1778
1826
|
this.exchangeToken(code, false).catch((error) => {
|
|
1779
1827
|
log("Error fetching token:", error);
|
|
1828
|
+
this.freshSignIn = false;
|
|
1829
|
+
void this.restoreCachedUser({
|
|
1830
|
+
hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
|
|
1831
|
+
});
|
|
1780
1832
|
});
|
|
1781
1833
|
} else {
|
|
1782
|
-
|
|
1783
|
-
if (refreshToken) {
|
|
1784
|
-
log("Found refresh token in storage, attempting to refresh access token");
|
|
1785
|
-
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
1786
|
-
log("Error fetching refresh token:", error);
|
|
1787
|
-
if (this.isNetworkError(error)) {
|
|
1788
|
-
await this.restoreCachedUser();
|
|
1789
|
-
}
|
|
1790
|
-
});
|
|
1791
|
-
} else {
|
|
1792
|
-
const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
1793
|
-
if (cachedUserInfo) {
|
|
1794
|
-
try {
|
|
1795
|
-
this.user = JSON.parse(cachedUserInfo);
|
|
1796
|
-
this.isSignedIn = true;
|
|
1797
|
-
log("Loaded cached user info for offline mode");
|
|
1798
|
-
} catch (error) {
|
|
1799
|
-
log("Error parsing cached user info:", error);
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
this.isAuthReady = true;
|
|
1803
|
-
this.notify();
|
|
1804
|
-
}
|
|
1834
|
+
await this.restoreStoredSession("initialize");
|
|
1805
1835
|
}
|
|
1806
1836
|
} catch (e) {
|
|
1807
1837
|
log("error getting token", e);
|
|
1838
|
+
this.updateAuthStatus("signed_out");
|
|
1839
|
+
this.notify();
|
|
1808
1840
|
}
|
|
1809
1841
|
}
|
|
1810
1842
|
/**
|
|
@@ -1814,7 +1846,7 @@ var AuthManager = class {
|
|
|
1814
1846
|
async getToken(options) {
|
|
1815
1847
|
log("getting token...");
|
|
1816
1848
|
if (!this.token) {
|
|
1817
|
-
const refreshToken = await this.
|
|
1849
|
+
const refreshToken = await this.getRefreshToken();
|
|
1818
1850
|
if (refreshToken) {
|
|
1819
1851
|
log("No token in memory, attempting to refresh from storage");
|
|
1820
1852
|
if (this.refreshPromise) {
|
|
@@ -1837,7 +1869,12 @@ var AuthManager = class {
|
|
|
1837
1869
|
} catch (error) {
|
|
1838
1870
|
log("Failed to refresh token from storage:", error);
|
|
1839
1871
|
if (this.isNetworkError(error)) {
|
|
1840
|
-
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;
|
|
1841
1878
|
}
|
|
1842
1879
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1843
1880
|
}
|
|
@@ -1850,12 +1887,15 @@ var AuthManager = class {
|
|
|
1850
1887
|
const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
|
|
1851
1888
|
const shouldRefresh = isExpired || options?.forceRefresh === true;
|
|
1852
1889
|
if (shouldRefresh) {
|
|
1853
|
-
log(
|
|
1890
|
+
log(
|
|
1891
|
+
options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
|
|
1892
|
+
);
|
|
1854
1893
|
if (this.refreshPromise) {
|
|
1855
1894
|
log("Token refresh already in progress, waiting...");
|
|
1856
1895
|
try {
|
|
1857
1896
|
const newToken = await this.refreshPromise;
|
|
1858
|
-
if (!newToken?.access_token)
|
|
1897
|
+
if (!newToken?.access_token)
|
|
1898
|
+
throw new Error("Token refresh returned empty access token");
|
|
1859
1899
|
return newToken.access_token;
|
|
1860
1900
|
} catch (error) {
|
|
1861
1901
|
log("In-flight refresh failed:", error);
|
|
@@ -1866,11 +1906,12 @@ var AuthManager = class {
|
|
|
1866
1906
|
throw error;
|
|
1867
1907
|
}
|
|
1868
1908
|
}
|
|
1869
|
-
const refreshToken =
|
|
1909
|
+
const refreshToken = await this.getRefreshToken();
|
|
1870
1910
|
if (refreshToken) {
|
|
1871
1911
|
try {
|
|
1872
1912
|
const newToken = await this.exchangeToken(refreshToken, true);
|
|
1873
|
-
if (!newToken?.access_token)
|
|
1913
|
+
if (!newToken?.access_token)
|
|
1914
|
+
throw new Error("Token refresh returned empty access token");
|
|
1874
1915
|
return newToken.access_token;
|
|
1875
1916
|
} catch (error) {
|
|
1876
1917
|
log("Failed to refresh expired token:", error);
|
|
@@ -1878,13 +1919,17 @@ var AuthManager = class {
|
|
|
1878
1919
|
log("Network issue - using expired token until network is restored");
|
|
1879
1920
|
return this.token.access_token;
|
|
1880
1921
|
}
|
|
1922
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1923
|
+
throw error;
|
|
1924
|
+
}
|
|
1881
1925
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1882
1926
|
}
|
|
1883
1927
|
} else {
|
|
1884
1928
|
throw new Error("no refresh token available");
|
|
1885
1929
|
}
|
|
1886
1930
|
}
|
|
1887
|
-
if (!this.token.access_token)
|
|
1931
|
+
if (!this.token.access_token)
|
|
1932
|
+
throw new Error("Token exists but access_token is empty");
|
|
1888
1933
|
return this.token.access_token;
|
|
1889
1934
|
}
|
|
1890
1935
|
async getSignInUrl(redirectUri, endpoints) {
|
|
@@ -1893,8 +1938,13 @@ var AuthManager = class {
|
|
|
1893
1938
|
throw new Error("Project ID is required to generate sign-in link");
|
|
1894
1939
|
}
|
|
1895
1940
|
const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
|
|
1896
|
-
await this.storage.set(
|
|
1897
|
-
|
|
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
|
+
);
|
|
1898
1948
|
await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
|
|
1899
1949
|
const redirectUrl = redirectUri || window.location.href;
|
|
1900
1950
|
if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
|
|
@@ -1963,7 +2013,10 @@ var AuthManager = class {
|
|
|
1963
2013
|
if (state) {
|
|
1964
2014
|
const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
|
|
1965
2015
|
if (storedState && storedState !== state) {
|
|
1966
|
-
log("State parameter mismatch:", {
|
|
2016
|
+
log("State parameter mismatch:", {
|
|
2017
|
+
provided: state,
|
|
2018
|
+
stored: storedState
|
|
2019
|
+
});
|
|
1967
2020
|
return { success: false, error: "State parameter mismatch" };
|
|
1968
2021
|
}
|
|
1969
2022
|
}
|
|
@@ -1979,6 +2032,7 @@ var AuthManager = class {
|
|
|
1979
2032
|
}
|
|
1980
2033
|
} catch (error) {
|
|
1981
2034
|
log("signInWithCode error:", error);
|
|
2035
|
+
this.freshSignIn = false;
|
|
1982
2036
|
return {
|
|
1983
2037
|
success: false,
|
|
1984
2038
|
error: error.message || "Authentication failed"
|
|
@@ -1991,13 +2045,64 @@ var AuthManager = class {
|
|
|
1991
2045
|
*/
|
|
1992
2046
|
async signOut() {
|
|
1993
2047
|
log("signing out!");
|
|
1994
|
-
this.resetAuthState();
|
|
2048
|
+
this.resetAuthState("signed_out");
|
|
1995
2049
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1996
2050
|
await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
|
|
1997
2051
|
await this.clearStoredAuth();
|
|
1998
2052
|
this.broadcastSignOut();
|
|
1999
2053
|
this.notify();
|
|
2000
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
|
+
}
|
|
2001
2106
|
hasScope(scope) {
|
|
2002
2107
|
if (!this.tokenScope) return false;
|
|
2003
2108
|
return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
|
|
@@ -2023,16 +2128,26 @@ var AuthManager = class {
|
|
|
2023
2128
|
const handleOnline = async () => {
|
|
2024
2129
|
log("Network came back online");
|
|
2025
2130
|
this.isOnline = true;
|
|
2026
|
-
if (this.pendingRefresh
|
|
2131
|
+
if (this.pendingRefresh) {
|
|
2027
2132
|
log("Retrying pending token refresh");
|
|
2028
2133
|
this.pendingRefresh = false;
|
|
2029
|
-
const refreshToken =
|
|
2134
|
+
const refreshToken = await this.getRefreshToken();
|
|
2030
2135
|
if (refreshToken) {
|
|
2031
2136
|
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
2032
2137
|
log("Retry refresh failed:", error);
|
|
2033
2138
|
});
|
|
2034
2139
|
}
|
|
2035
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
|
+
}
|
|
2036
2151
|
};
|
|
2037
2152
|
const handleOffline = () => {
|
|
2038
2153
|
log("Network went offline");
|
|
@@ -2040,9 +2155,11 @@ var AuthManager = class {
|
|
|
2040
2155
|
};
|
|
2041
2156
|
const handleVisibilityChange = () => {
|
|
2042
2157
|
if (document.visibilityState === "visible" && this.isSignedIn) {
|
|
2043
|
-
log("App became visible -
|
|
2044
|
-
this.
|
|
2045
|
-
|
|
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);
|
|
2046
2163
|
});
|
|
2047
2164
|
}
|
|
2048
2165
|
};
|
|
@@ -2095,12 +2212,18 @@ var AuthManager = class {
|
|
|
2095
2212
|
if (elapsed < 24 * 60 * 60 * 1e3) return;
|
|
2096
2213
|
}
|
|
2097
2214
|
try {
|
|
2098
|
-
await fetch(
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
+
);
|
|
2104
2227
|
log("Reported connection to admin server");
|
|
2105
2228
|
} catch (err) {
|
|
2106
2229
|
log("Failed to report connection (non-blocking):", err);
|
|
@@ -2111,32 +2234,37 @@ var AuthManager = class {
|
|
|
2111
2234
|
*/
|
|
2112
2235
|
async processNewToken() {
|
|
2113
2236
|
if (!this.token) {
|
|
2114
|
-
this.
|
|
2237
|
+
this.updateAuthStatus("signed_out");
|
|
2115
2238
|
this.notify();
|
|
2116
2239
|
return;
|
|
2117
2240
|
}
|
|
2118
2241
|
try {
|
|
2119
2242
|
const decoded = jwtDecode(this.token.access_token);
|
|
2120
|
-
|
|
2121
|
-
|
|
2243
|
+
this.applyTokenClaims(decoded);
|
|
2244
|
+
this.updateAuthStatus("authenticated");
|
|
2245
|
+
this.notify();
|
|
2246
|
+
this.broadcastSessionUpdate();
|
|
2122
2247
|
await this.fetchUser(this.token.access_token);
|
|
2123
2248
|
} catch (error) {
|
|
2124
2249
|
log("Error processing token:", error);
|
|
2125
|
-
this.
|
|
2250
|
+
this.updateAuthStatus("recovering");
|
|
2126
2251
|
this.notify();
|
|
2127
2252
|
}
|
|
2128
2253
|
}
|
|
2129
|
-
async restoreCachedUser() {
|
|
2254
|
+
async restoreCachedUser(options) {
|
|
2130
2255
|
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2131
|
-
if (cached) {
|
|
2256
|
+
if (cached && options?.hasRecoverableSession) {
|
|
2132
2257
|
try {
|
|
2133
2258
|
this.user = JSON.parse(cached);
|
|
2134
|
-
|
|
2135
|
-
log("Restored cached user info for offline mode");
|
|
2259
|
+
log("Restored cached user info for recoverable session");
|
|
2136
2260
|
} catch {
|
|
2137
2261
|
}
|
|
2262
|
+
} else {
|
|
2263
|
+
this.user = null;
|
|
2138
2264
|
}
|
|
2139
|
-
this.
|
|
2265
|
+
this.updateAuthStatus(
|
|
2266
|
+
options?.hasRecoverableSession ? "recovering" : "signed_out"
|
|
2267
|
+
);
|
|
2140
2268
|
this.notify();
|
|
2141
2269
|
}
|
|
2142
2270
|
async fetchUser(accessToken) {
|
|
@@ -2145,7 +2273,7 @@ var AuthManager = class {
|
|
|
2145
2273
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2146
2274
|
const response = await fetch(endpoints.userinfo_endpoint, {
|
|
2147
2275
|
method: "GET",
|
|
2148
|
-
headers: {
|
|
2276
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2149
2277
|
});
|
|
2150
2278
|
if (!response.ok) {
|
|
2151
2279
|
throw new Error(`Failed to fetch user info: ${response.status}`);
|
|
@@ -2156,28 +2284,22 @@ var AuthManager = class {
|
|
|
2156
2284
|
throw new Error(`User info error: ${user.error}`);
|
|
2157
2285
|
}
|
|
2158
2286
|
if (this.token?.refresh_token) {
|
|
2159
|
-
await this.storage.set(
|
|
2287
|
+
await this.storage.set(
|
|
2288
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2289
|
+
this.token.refresh_token
|
|
2290
|
+
);
|
|
2160
2291
|
}
|
|
2161
2292
|
await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
|
|
2162
2293
|
log("Cached user info in storage");
|
|
2163
2294
|
this.user = user;
|
|
2164
|
-
this.
|
|
2165
|
-
|
|
2166
|
-
if (this.freshSignIn) {
|
|
2167
|
-
this.freshSignIn = false;
|
|
2168
|
-
this.broadcastSignIn();
|
|
2169
|
-
} else {
|
|
2170
|
-
this.broadcastTokenRefresh();
|
|
2295
|
+
if (this.authStatus !== "reauth_required") {
|
|
2296
|
+
this.updateAuthStatus("authenticated");
|
|
2171
2297
|
}
|
|
2298
|
+
this.nextUserRecoveryAt = 0;
|
|
2172
2299
|
this.notify();
|
|
2173
2300
|
} catch (error) {
|
|
2174
2301
|
log("Failed to fetch user info:", error);
|
|
2175
|
-
|
|
2176
|
-
await this.restoreCachedUser();
|
|
2177
|
-
} else {
|
|
2178
|
-
this.isAuthReady = true;
|
|
2179
|
-
this.notify();
|
|
2180
|
-
}
|
|
2302
|
+
await this.handleUserFetchFailure();
|
|
2181
2303
|
}
|
|
2182
2304
|
}
|
|
2183
2305
|
/**
|
|
@@ -2204,7 +2326,9 @@ var AuthManager = class {
|
|
|
2204
2326
|
if (!this.isOnline) {
|
|
2205
2327
|
log("Network is offline, marking refresh as pending");
|
|
2206
2328
|
this.pendingRefresh = true;
|
|
2207
|
-
throw new Error(
|
|
2329
|
+
throw new Error(
|
|
2330
|
+
"Network offline - refresh will be retried when online"
|
|
2331
|
+
);
|
|
2208
2332
|
}
|
|
2209
2333
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2210
2334
|
let requestBody;
|
|
@@ -2214,26 +2338,36 @@ var AuthManager = class {
|
|
|
2214
2338
|
refresh_token: codeOrRefreshToken
|
|
2215
2339
|
};
|
|
2216
2340
|
if (this.config.projectId) {
|
|
2217
|
-
requestBody.client_id = normalizeClientId(
|
|
2341
|
+
requestBody.client_id = normalizeClientId(
|
|
2342
|
+
this.config.projectId,
|
|
2343
|
+
this.adminHostname
|
|
2344
|
+
);
|
|
2218
2345
|
}
|
|
2219
2346
|
} else {
|
|
2220
2347
|
requestBody = {
|
|
2221
2348
|
grant_type: "authorization_code",
|
|
2222
2349
|
code: codeOrRefreshToken
|
|
2223
2350
|
};
|
|
2224
|
-
const storedRedirectUri = await this.storage.get(
|
|
2351
|
+
const storedRedirectUri = await this.storage.get(
|
|
2352
|
+
STORAGE_KEYS.REDIRECT_URI
|
|
2353
|
+
);
|
|
2225
2354
|
if (storedRedirectUri) {
|
|
2226
2355
|
requestBody.redirect_uri = storedRedirectUri;
|
|
2227
2356
|
log("Including redirect_uri in token exchange:", storedRedirectUri);
|
|
2228
2357
|
} else {
|
|
2229
2358
|
log("Warning: No redirect_uri found in storage for token exchange");
|
|
2230
2359
|
}
|
|
2231
|
-
const codeVerifier = await this.storage.get(
|
|
2360
|
+
const codeVerifier = await this.storage.get(
|
|
2361
|
+
STORAGE_KEYS.CODE_VERIFIER
|
|
2362
|
+
);
|
|
2232
2363
|
if (codeVerifier) {
|
|
2233
2364
|
requestBody.code_verifier = codeVerifier;
|
|
2234
2365
|
}
|
|
2235
2366
|
if (this.config.projectId) {
|
|
2236
|
-
requestBody.client_id = normalizeClientId(
|
|
2367
|
+
requestBody.client_id = normalizeClientId(
|
|
2368
|
+
this.config.projectId,
|
|
2369
|
+
this.adminHostname
|
|
2370
|
+
);
|
|
2237
2371
|
}
|
|
2238
2372
|
}
|
|
2239
2373
|
log("Token exchange request body:", {
|
|
@@ -2249,7 +2383,9 @@ var AuthManager = class {
|
|
|
2249
2383
|
log("Network error fetching token:", error);
|
|
2250
2384
|
if (!this.isOnline) {
|
|
2251
2385
|
this.pendingRefresh = true;
|
|
2252
|
-
throw new Error(
|
|
2386
|
+
throw new Error(
|
|
2387
|
+
"Network offline - refresh will be retried when online"
|
|
2388
|
+
);
|
|
2253
2389
|
}
|
|
2254
2390
|
throw new Error("Network error during token refresh");
|
|
2255
2391
|
});
|
|
@@ -2258,39 +2394,52 @@ var AuthManager = class {
|
|
|
2258
2394
|
const decoded = jwtDecode(token.access_token);
|
|
2259
2395
|
if (decoded.typ === "refresh") {
|
|
2260
2396
|
log("Error: received refresh token as access token");
|
|
2261
|
-
throw new Error(
|
|
2397
|
+
throw new Error(
|
|
2398
|
+
"Invalid token: received refresh token instead of access token"
|
|
2399
|
+
);
|
|
2262
2400
|
}
|
|
2263
2401
|
} catch (decodeError) {
|
|
2264
2402
|
if (decodeError.message.includes("Invalid token")) {
|
|
2265
2403
|
throw decodeError;
|
|
2266
2404
|
}
|
|
2267
|
-
log(
|
|
2405
|
+
log(
|
|
2406
|
+
"Warning: could not decode access token for type check:",
|
|
2407
|
+
decodeError
|
|
2408
|
+
);
|
|
2268
2409
|
}
|
|
2269
2410
|
}
|
|
2270
2411
|
if (token.error) {
|
|
2271
2412
|
log("error fetching token", token.error);
|
|
2272
2413
|
if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
|
|
2273
2414
|
this.pendingRefresh = true;
|
|
2274
|
-
throw new Error(
|
|
2415
|
+
throw new Error(
|
|
2416
|
+
"Network issue - refresh will be retried when online"
|
|
2417
|
+
);
|
|
2275
2418
|
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
this.resetAuthState();
|
|
2280
|
-
this.notify();
|
|
2419
|
+
if (this.isDefinitiveTokenErrorCode(token.error)) {
|
|
2420
|
+
await this.markReauthRequired(token.error);
|
|
2421
|
+
throw new DefinitiveAuthError(token.error);
|
|
2281
2422
|
}
|
|
2282
2423
|
throw new Error(`Token refresh failed: ${token.error}`);
|
|
2283
2424
|
} else {
|
|
2425
|
+
if (!token.access_token) {
|
|
2426
|
+
throw new Error("Token response missing access token");
|
|
2427
|
+
}
|
|
2284
2428
|
this.token = token;
|
|
2285
2429
|
this.pendingRefresh = false;
|
|
2286
2430
|
if (token.refresh_token) {
|
|
2287
|
-
await this.storage.set(
|
|
2431
|
+
await this.storage.set(
|
|
2432
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
2433
|
+
token.refresh_token
|
|
2434
|
+
);
|
|
2288
2435
|
log("Updated refresh token in storage");
|
|
2289
2436
|
}
|
|
2290
2437
|
if (!isRefreshToken) {
|
|
2291
2438
|
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2292
2439
|
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2293
|
-
log(
|
|
2440
|
+
log(
|
|
2441
|
+
"Cleaned up redirect_uri and code_verifier from storage after successful exchange"
|
|
2442
|
+
);
|
|
2294
2443
|
}
|
|
2295
2444
|
this.reportConnection(token.access_token).catch(() => {
|
|
2296
2445
|
});
|
|
@@ -2299,12 +2448,12 @@ var AuthManager = class {
|
|
|
2299
2448
|
return token;
|
|
2300
2449
|
} catch (error) {
|
|
2301
2450
|
log("Token refresh error:", error);
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
if (
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
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");
|
|
2308
2457
|
}
|
|
2309
2458
|
throw error;
|
|
2310
2459
|
}
|
|
@@ -2328,13 +2477,15 @@ var AuthManager = class {
|
|
|
2328
2477
|
}
|
|
2329
2478
|
return tokenPromise;
|
|
2330
2479
|
}
|
|
2331
|
-
resetAuthState() {
|
|
2332
|
-
this.user = null;
|
|
2333
|
-
this.isSignedIn = false;
|
|
2480
|
+
resetAuthState(status = "signed_out") {
|
|
2481
|
+
this.user = status === "reauth_required" ? this.user : null;
|
|
2334
2482
|
this.token = null;
|
|
2335
|
-
|
|
2483
|
+
if (status !== "reauth_required") {
|
|
2484
|
+
this.did = null;
|
|
2485
|
+
}
|
|
2336
2486
|
this.tokenScope = null;
|
|
2337
|
-
this.
|
|
2487
|
+
this.nextUserRecoveryAt = 0;
|
|
2488
|
+
this.updateAuthStatus(status);
|
|
2338
2489
|
}
|
|
2339
2490
|
async clearStoredAuth() {
|
|
2340
2491
|
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
@@ -2351,6 +2502,215 @@ var AuthManager = class {
|
|
|
2351
2502
|
}
|
|
2352
2503
|
return false;
|
|
2353
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
|
+
}
|
|
2354
2714
|
};
|
|
2355
2715
|
|
|
2356
2716
|
// src/AuthContext.tsx
|
|
@@ -2599,6 +2959,8 @@ function snapshotAuth(mgr) {
|
|
|
2599
2959
|
isSignedIn: mgr.isSignedIn,
|
|
2600
2960
|
hasToken: !!mgr.token,
|
|
2601
2961
|
isAuthReady: mgr.isAuthReady,
|
|
2962
|
+
authStatus: mgr.authStatus,
|
|
2963
|
+
authErrorCode: mgr.authErrorCode,
|
|
2602
2964
|
user: mgr.user,
|
|
2603
2965
|
did: mgr.did,
|
|
2604
2966
|
tokenScope: mgr.tokenScope
|
|
@@ -2633,6 +2995,8 @@ function BasicProvider({
|
|
|
2633
2995
|
isSignedIn: false,
|
|
2634
2996
|
hasToken: false,
|
|
2635
2997
|
isAuthReady: false,
|
|
2998
|
+
authStatus: "bootstrapping",
|
|
2999
|
+
authErrorCode: null,
|
|
2636
3000
|
user: null,
|
|
2637
3001
|
did: null,
|
|
2638
3002
|
tokenScope: null
|
|
@@ -2655,9 +3019,11 @@ function BasicProvider({
|
|
|
2655
3019
|
const remoteDbRef = useRef(null);
|
|
2656
3020
|
const [shouldConnect, setShouldConnect] = useState2(false);
|
|
2657
3021
|
const [dbStatus, setDbStatus] = useState2("OFFLINE" /* OFFLINE */);
|
|
2658
|
-
const [
|
|
3022
|
+
const [isDbReady, setIsDbReady] = useState2(false);
|
|
2659
3023
|
const [error, setError] = useState2(null);
|
|
2660
|
-
const [schemaDevInfo, setSchemaDevInfo] = useState2(
|
|
3024
|
+
const [schemaDevInfo, setSchemaDevInfo] = useState2(
|
|
3025
|
+
null
|
|
3026
|
+
);
|
|
2661
3027
|
const isDevMode = () => isDevelopment(debug);
|
|
2662
3028
|
const refreshSchemaStatus = useCallback2(async () => {
|
|
2663
3029
|
const s = schemaRef.current;
|
|
@@ -2697,10 +3063,16 @@ function BasicProvider({
|
|
|
2697
3063
|
useEffect(() => {
|
|
2698
3064
|
const runVersionUpdater = async () => {
|
|
2699
3065
|
try {
|
|
2700
|
-
const versionUpdater = createVersionUpdater(
|
|
3066
|
+
const versionUpdater = createVersionUpdater(
|
|
3067
|
+
storageAdapter,
|
|
3068
|
+
version,
|
|
3069
|
+
getMigrations()
|
|
3070
|
+
);
|
|
2701
3071
|
const updateResult = await versionUpdater.checkAndUpdate();
|
|
2702
3072
|
if (updateResult.updated) {
|
|
2703
|
-
log(
|
|
3073
|
+
log(
|
|
3074
|
+
`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
|
|
3075
|
+
);
|
|
2704
3076
|
} else {
|
|
2705
3077
|
log(`App version ${updateResult.toVersion} is current`);
|
|
2706
3078
|
}
|
|
@@ -2722,8 +3094,13 @@ function BasicProvider({
|
|
|
2722
3094
|
const newStatus = getSyncStatus(status);
|
|
2723
3095
|
setDbStatus(newStatus);
|
|
2724
3096
|
if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
|
|
2725
|
-
log(
|
|
2726
|
-
|
|
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(() => {
|
|
2727
3104
|
});
|
|
2728
3105
|
}
|
|
2729
3106
|
});
|
|
@@ -2732,7 +3109,7 @@ function BasicProvider({
|
|
|
2732
3109
|
} else {
|
|
2733
3110
|
log("Sync is disabled");
|
|
2734
3111
|
}
|
|
2735
|
-
|
|
3112
|
+
setIsDbReady(true);
|
|
2736
3113
|
}
|
|
2737
3114
|
}
|
|
2738
3115
|
function initRemoteDb() {
|
|
@@ -2743,7 +3120,7 @@ function BasicProvider({
|
|
|
2743
3120
|
title: "Project ID Required",
|
|
2744
3121
|
message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
|
|
2745
3122
|
});
|
|
2746
|
-
|
|
3123
|
+
setIsDbReady(true);
|
|
2747
3124
|
return;
|
|
2748
3125
|
}
|
|
2749
3126
|
log("Initializing Basic Remote DB");
|
|
@@ -2759,11 +3136,16 @@ function BasicProvider({
|
|
|
2759
3136
|
log("403 Forbidden - user lacks required scope, not signing out");
|
|
2760
3137
|
return;
|
|
2761
3138
|
}
|
|
2762
|
-
|
|
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
|
+
});
|
|
2763
3145
|
}
|
|
2764
3146
|
});
|
|
2765
3147
|
setDbStatus("ONLINE" /* ONLINE */);
|
|
2766
|
-
|
|
3148
|
+
setIsDbReady(true);
|
|
2767
3149
|
}
|
|
2768
3150
|
}
|
|
2769
3151
|
async function checkSchema() {
|
|
@@ -2789,7 +3171,7 @@ function BasicProvider({
|
|
|
2789
3171
|
title: "Basic Schema is invalid!",
|
|
2790
3172
|
message: errorMessage
|
|
2791
3173
|
});
|
|
2792
|
-
|
|
3174
|
+
setIsDbReady(true);
|
|
2793
3175
|
return null;
|
|
2794
3176
|
}
|
|
2795
3177
|
setSchemaDevInfo({
|
|
@@ -2806,7 +3188,9 @@ function BasicProvider({
|
|
|
2806
3188
|
await initSyncDb({ shouldConnect: true });
|
|
2807
3189
|
} else {
|
|
2808
3190
|
if (result.schemaStatus.status === "unpublished") {
|
|
2809
|
-
log(
|
|
3191
|
+
log(
|
|
3192
|
+
"Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
|
|
3193
|
+
);
|
|
2810
3194
|
} else {
|
|
2811
3195
|
log("Schema is invalid!", result.schemaStatus);
|
|
2812
3196
|
}
|
|
@@ -2830,12 +3214,12 @@ function BasicProvider({
|
|
|
2830
3214
|
if (dbMode === "remote" && project_id) {
|
|
2831
3215
|
initRemoteDb();
|
|
2832
3216
|
} else {
|
|
2833
|
-
|
|
3217
|
+
setIsDbReady(true);
|
|
2834
3218
|
}
|
|
2835
3219
|
}
|
|
2836
3220
|
}, []);
|
|
2837
3221
|
useEffect(() => {
|
|
2838
|
-
if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
|
|
3222
|
+
if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
|
|
2839
3223
|
log("connecting to db...");
|
|
2840
3224
|
syncRef.current?.connect({
|
|
2841
3225
|
getToken: (opts) => authRef.current.getToken(opts),
|
|
@@ -2844,7 +3228,22 @@ function BasicProvider({
|
|
|
2844
3228
|
log("error connecting to db", e);
|
|
2845
3229
|
});
|
|
2846
3230
|
}
|
|
2847
|
-
}, [
|
|
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]);
|
|
2848
3247
|
const handleSignOut = async () => {
|
|
2849
3248
|
await authRef.current.signOut();
|
|
2850
3249
|
if (syncRef.current) {
|
|
@@ -2852,11 +3251,13 @@ function BasicProvider({
|
|
|
2852
3251
|
await syncRef.current.close();
|
|
2853
3252
|
await syncRef.current.delete({ disableAutoOpen: false });
|
|
2854
3253
|
syncRef.current = null;
|
|
2855
|
-
window?.location?.reload();
|
|
2856
3254
|
} catch (error2) {
|
|
2857
3255
|
console.error("Error during database cleanup:", error2);
|
|
2858
3256
|
}
|
|
2859
3257
|
}
|
|
3258
|
+
if (typeof window !== "undefined") {
|
|
3259
|
+
window.location.reload();
|
|
3260
|
+
}
|
|
2860
3261
|
};
|
|
2861
3262
|
const handleSignIn = async () => {
|
|
2862
3263
|
try {
|
|
@@ -2895,6 +3296,8 @@ function BasicProvider({
|
|
|
2895
3296
|
const contextValue = {
|
|
2896
3297
|
isReady: authState.isAuthReady,
|
|
2897
3298
|
isSignedIn: authState.isSignedIn,
|
|
3299
|
+
authStatus: authState.authStatus,
|
|
3300
|
+
authErrorCode: authState.authErrorCode,
|
|
2898
3301
|
user: authState.user,
|
|
2899
3302
|
did: authState.did,
|
|
2900
3303
|
scope: authState.tokenScope,
|
|
@@ -2920,7 +3323,7 @@ function BasicProvider({
|
|
|
2920
3323
|
return /* @__PURE__ */ jsxs2(BasicContext.Provider, { value: contextValue, children: [
|
|
2921
3324
|
error && isDevMode() && /* @__PURE__ */ jsx2(ErrorDisplay, { error }),
|
|
2922
3325
|
devToolbar && isDevMode() && /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(BasicDevToolbar2, { debug }) }),
|
|
2923
|
-
|
|
3326
|
+
isDbReady && authState.isAuthReady && children
|
|
2924
3327
|
] });
|
|
2925
3328
|
}
|
|
2926
3329
|
function ErrorDisplay({ error }) {
|