diff --git a/src/InnerSpacetimeDBProvider.svelte b/src/InnerSpacetimeDBProvider.svelte index e1aa5c1..e8cb9d9 100644 --- a/src/InnerSpacetimeDBProvider.svelte +++ b/src/InnerSpacetimeDBProvider.svelte @@ -15,38 +15,73 @@ oidcToken?: string } = $props(); - // 1. Initialize the provider instance + // 1. Initialize the provider instance for this specific builder const db = createSpacetimeDBProvider(builder); + // 1.1 Connection Timeout + // If we stay in "connecting" state for too long without an identity or error, + // we'll force a timeout error to trigger recovery/logout. + let connectionTimeout = $state(null); + + $effect(() => { + if (!$db.identity && !$db.error) { + if (!connectionTimeout) { + connectionTimeout = setTimeout(() => { + console.warn("[Handshake] Connection attempt timed out after 10s."); + handleConnectError(new Error("Connection timeout: Server did not respond to handshake. This may be due to an expired token or network issues.")); + }, 10000); + } + } else { + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = null; + } + } + + return () => { + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = null; + } + }; + }); + // 2. Handshake Synchronization - // Ensures session persistence happens exactly once per connection - let hasSyncedThisConnection = $state(false); + // Ensures session persistence happens when a valid identity and token are available + let lastPersistedToken = $state(undefined); + let lastUsedOidcTokenForUpgrade = $state(oidcToken); $effect(() => { const conn = $db.connection; const isActive = $db.isActive; const identity = $db.identity; + const token = $db.token; - if (isActive && identity && conn) { - untrack(() => { - if (!hasSyncedThisConnection && $db.token) { - console.log("[Handshake] Established, syncing state..."); - handleConnect(conn as any, identity, $db.token); - hasSyncedThisConnection = true; - } - }); + if (isActive && identity && conn && token) { + // If the token from the server is different than the one we last saved, + // trigger persistence. This covers both initial connection and upgrades. + if (token !== lastPersistedToken) { + untrack(() => { + console.log("[Handshake] Identity and Token established, syncing persistence..."); + handleConnect(conn as any, identity, token, !!oidcToken); + lastPersistedToken = token; + }); + } } else if (!isActive) { - hasSyncedThisConnection = false; + lastPersistedToken = undefined; } }); - // 3. Late Token Update (OIDC Redirect Recovery) - // If we receive an OIDC token while already connected, upgrade the session + // 3. Background Token Upgrades (In-place) + // If we receive a new OIDC token while already connected, upgrade the session in-place. + // This is crucial for zero-flicker background updates. $effect(() => { - if (oidcToken && $db.isActive && $db.connection && !hasSyncedThisConnection) { - console.log("[Handshake] Upgrading session with OIDC token..."); + if (oidcToken && oidcToken !== lastUsedOidcTokenForUpgrade && $db.isActive && $db.connection) { + console.log("[Handshake] Background upgrade with new OIDC token (In-place)..."); untrack(() => { - // Upgrade the existing connection with the OIDC credentials + lastUsedOidcTokenForUpgrade = oidcToken; + // Upgrade the existing connection with the new credentials. + // SpacetimeDB will emit a new token, which the effect above will catch. ($db.connection as any).withToken(oidcToken); }); } @@ -59,7 +94,7 @@ } }); - // Update global status + // Update global connection status for UI visibility $effect(() => { if ($db.isActive) { connectionState.status = "connected"; diff --git a/src/SpacetimeProvider.svelte b/src/SpacetimeProvider.svelte index f8fec2d..19d77ef 100644 --- a/src/SpacetimeProvider.svelte +++ b/src/SpacetimeProvider.svelte @@ -1,6 +1,8 @@ - - {@render children()} - +{#if !builder || (auth.isLoading && !auth.user)} + +{:else} + {#key providerKey} + + {@render children()} + + {/key} +{/if} + + diff --git a/src/auth/AuthGate.svelte b/src/auth/AuthGate.svelte index 665b7e9..6b1411d 100644 --- a/src/auth/AuthGate.svelte +++ b/src/auth/AuthGate.svelte @@ -17,7 +17,6 @@ let stdbHost = $state(""); let stdbDbName = $state(""); - let storedConnections = $state([]); let combinedConnection = $state(""); let userWantsToConnect = $state(false); @@ -26,27 +25,25 @@ stdbHost = getStdbHost(); stdbDbName = getStdbDbName(); - storedConnections = TokenStore.listStoredConnections(); combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`; const isChanging = localStorage.getItem("zep_changing_server") === "true"; - const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state="); if (isChanging) { userWantsToConnect = false; - } else if (TokenStore.get(stdbHost, stdbDbName) || isInCallback) { - // Auto-connect ONLY if we have a SpacetimeDB token OR are in a redirect callback. - // We don't auto-connect just because of auth.isAuthenticated to prevent logout loops. + } else if (TokenStore.get(stdbHost, stdbDbName)) { + // Auto-connect if we have a stored SpacetimeDB token. userWantsToConnect = true; } }); - // Ensure we transition to connecting if we just finished an OIDC redirect + // Handle auto-connect for OIDC users once the session is loaded/settled $effect(() => { if (auth.isAuthenticated && !auth.isLoading) { - const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state="); - if (isInCallback) { + const isChanging = localStorage.getItem("zep_changing_server") === "true"; + if (!isChanging && !userWantsToConnect) { untrack(() => { + console.log("AuthGate: Auto-connecting active OIDC session."); userWantsToConnect = true; }); } @@ -200,7 +197,6 @@ id="stdb-connection" bind:value={combinedConnection} placeholder="connect.zep.chat:zep" - options={storedConnections} /> diff --git a/src/auth/auth.svelte.ts b/src/auth/auth.svelte.ts index d8eff79..6599435 100644 --- a/src/auth/auth.svelte.ts +++ b/src/auth/auth.svelte.ts @@ -20,6 +20,7 @@ class AuthStore { #userManager: UserManager; #user = $state(null); #isLoading = $state(true); + #isRefreshing = $state(false); #isProcessingCallback = false; constructor(settings: UserManagerSettings) { @@ -45,8 +46,33 @@ class AuthStore { this.#userManager .getUser() - .then((user) => { - this.#user = user; + .then(async (user) => { + if (user && !user.expired) { + console.log("[AuthStore] Found valid session in storage."); + this.#user = user; + } else if (user && user.expired) { + console.log("[AuthStore] Found expired session, attempting silent renew..."); + this.#isRefreshing = true; + try { + const renewedUser = await this.#userManager.signinSilent(); + this.#user = renewedUser; + console.log("[AuthStore] Silent renew successful on load."); + } catch (err) { + console.warn("[AuthStore] Silent renew failed on load, clearing expired session:", err); + this.#user = null; + // CRITICAL: If silent renew fails, remove the invalid user from storage to prevent re-auth loops on page reload. + await this.#userManager.removeUser(); + } finally { + this.#isRefreshing = false; + } + } else { + console.log("[AuthStore] No session found in storage."); + this.#user = null; + } + }) + .catch((err) => { + console.error("[AuthStore] Error retrieving user from storage:", err); + this.#user = null; }) .finally(() => { this.#isLoading = false; @@ -54,12 +80,34 @@ class AuthStore { } this.#userManager.events.addUserLoaded((user) => { + console.log(`[AuthStore] User loaded: ${user.profile.preferred_username || user.profile.sub} (ID Token present: ${!!user.id_token})`); this.#user = user; }); this.#userManager.events.addUserUnloaded(() => { + console.log("[AuthStore] User unloaded"); this.#user = null; }); + + this.#userManager.events.addAccessTokenExpiring(() => { + console.log("[AuthStore] Access token is expiring soon... triggering silent renew."); + }); + + this.#userManager.events.addAccessTokenExpired(() => { + console.warn("[AuthStore] Access token has expired!"); + }); + + this.#userManager.events.addUserSessionChanged(() => { + console.log("[AuthStore] User session changed at the provider."); + }); + + this.#userManager.events.addUserSignedOut(() => { + console.warn("[AuthStore] User signed out at the provider."); + }); + + this.#userManager.events.addSilentRenewError((err) => { + console.error("[AuthStore] Silent renew error:", err); + }); } get user() { @@ -70,6 +118,10 @@ class AuthStore { return this.#isLoading; } + get isRefreshing() { + return this.#isRefreshing; + } + get isAuthenticated() { return !!this.#user; } @@ -77,7 +129,11 @@ class AuthStore { async signinRedirect() { this.#isLoading = true; try { - await this.#userManager.signinRedirect(); + await this.#userManager.signinRedirect({ + extraQueryParams: { + prompt: "consent" + } + }); } catch (error) { console.error("Signin redirect error:", error); this.#isLoading = false; @@ -91,11 +147,15 @@ class AuthStore { try { const user = await this.#userManager.signinCallback(); this.#user = user; - window.history.replaceState({}, document.title, window.location.pathname); } catch (error) { console.error("Signin callback error:", error); } finally { + // Always clear the URL parameters to prevent infinite loops if the callback fails + if (window.location.search.includes("code=") && window.location.search.includes("state=")) { + window.history.replaceState({}, document.title, window.location.pathname); + } this.#isLoading = false; + this.#isProcessingCallback = false; } } @@ -110,6 +170,31 @@ class AuthStore { } } + /** + * Proactively forces a silent renewal of the OIDC session. + * Useful for recovering from 401 errors or preemptively refreshing tokens. + */ + async forceTokenRefresh() { + if (this.#isRefreshing) { + console.log("[AuthStore] Refresh already in progress, skipping redundant request."); + return true; + } + + console.log("[AuthStore] Forcing silent token refresh..."); + this.#isRefreshing = true; + try { + const user = await this.#userManager.signinSilent(); + this.#user = user; + console.log("[AuthStore] Silent refresh successful."); + return true; + } catch (error) { + console.error("[AuthStore] Silent refresh failed:", error); + return false; + } finally { + this.#isRefreshing = false; + } + } + async logout() { this.#isLoading = true; try { @@ -119,7 +204,12 @@ class AuthStore { const localKeysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key.startsWith("oidc."))) { + if (key && ( + key.includes("stdb_token:") || + key.includes("auth_token") || + key.startsWith("oidc.") || + key === "stdb_connection_data" + )) { localKeysToRemove.push(key); } } @@ -141,6 +231,10 @@ class AuthStore { sessionStorage.removeItem(key); }); + // 3. Clear OIDC internal state + await this.#userManager.removeUser(); + await this.#userManager.clearStaleState(); + if (this.#user) { await this.#userManager.signoutRedirect(); } else { diff --git a/src/config.ts b/src/config.ts index a088a98..ef90d46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import { connectionState } from "./connection.svelte"; export { HOST_KEY, DB_NAME_KEY, getEnv }; /** - * Normalizes the host URL for SpacetimeDB. + * Normalizes the host URL for SpacetimeDB URI construction. */ export const normalizeHost = (host: string) => { let normalized = host.trim().replace(/\/+$/, ""); @@ -18,44 +18,49 @@ export const normalizeHost = (host: string) => { export const TokenStore = { get: (host: string, dbName: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - return localStorage.getItem(key); + try { + const dataStr = localStorage.getItem("stdb_connection_data"); + if (!dataStr) return null; + const data = JSON.parse(dataStr); + + const normalizedStoredHost = normalizeHost(data.host); + const normalizedCurrentHost = normalizeHost(host); + + if (normalizedStoredHost === normalizedCurrentHost && data.dbName === dbName) { + // Simple expiration check: if the token is older than 30 days, ignore it + const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000; + if (data.timestamp && Date.now() - data.timestamp > thirtyDaysInMs) { + console.warn("[TokenStore] Stored token is older than 30 days, treating as expired."); + return null; + } + console.log(`[TokenStore] Retrieved valid token for ${data.host}:${data.dbName} (age: ${data.timestamp ? Math.round((Date.now() - data.timestamp) / 1000 / 60) : 'unknown'} mins)`); + return data.token; + } + } catch (e) { + console.error("[TokenStore] Error parsing connection data:", e); + } + return null; }, set: (host: string, dbName: string, token: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - console.log("TokenStore: Setting token for key:", key); - localStorage.setItem(key, token); + const normalizedHostStr = normalizeHost(host); + console.log(`[TokenStore] Persisting new token for ${normalizedHostStr}:${dbName}`); + localStorage.setItem("stdb_connection_data", JSON.stringify({ + host: normalizedHostStr, + dbName, + token, + timestamp: Date.now() + })); }, - clear: (host: string, dbName: string) => { - const key = `stdb_token:${normalizeHost(host)}:${dbName}`; - localStorage.removeItem(key); - }, - listStoredConnections: () => { - const connections: string[] = []; - const seen = new Set(); - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith("stdb_token:")) { - const parts = key.split(":"); - if (parts.length >= 4) { - const dbName = parts[parts.length - 1]; - const hostParts = parts.slice(2, parts.length - 1); - const host = hostParts.join(":").replace(/^\/\//, ""); - const connStr = `${host}:${dbName}`; - if (!seen.has(connStr)) { - connections.push(connStr); - seen.add(connStr); - } - } - } - } - return connections; + clear: () => { + console.log("[TokenStore] Clearing connection data."); + localStorage.removeItem("stdb_connection_data"); } }; export const getStdbHost = () => localStorage.getItem(HOST_KEY) || getEnv("VITE_SPACETIMEDB_HOST", "connect.zep.chat"); + export const getStdbDbName = () => localStorage.getItem(DB_NAME_KEY) || getEnv("VITE_SPACETIMEDB_DB_NAME", "zep"); @@ -72,18 +77,24 @@ export const stopActiveConnection = () => { let lastSyncedIdentity: string | null = null; -export const handleConnect = (conn: DbConnection, identity: any, token: string) => { +export const handleConnect = (conn: DbConnection, identity: any, token: string, isOIDC: boolean = false) => { const host = getStdbHost(); const dbName = getStdbDbName(); const identityHex = identity?.toHexString(); - console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}`); + console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}, OIDC: ${isOIDC}`); _connection = conn; - if (token) { - console.log(`[Handshake] Persisting SpacetimeDB token (len: ${token.length}) to local storage.`); + if (token && !isOIDC) { + console.log("[Handshake] Persisting Guest session token."); TokenStore.set(host, dbName, token); + } else if (isOIDC) { + // For OIDC users, we DON'T store the SpacetimeDB session token in localStorage. + // We rely entirely on the OIDC provider's tokens in sessionStorage. + // We clear any legacy tokens to ensure OIDC is the single source of truth. + console.log("[Handshake] OIDC session active, ensuring localStorage is clean of stale tokens."); + TokenStore.clear(); } connectionState.status = "connected"; @@ -101,20 +112,53 @@ export const handleConnect = (conn: DbConnection, identity: any, token: string) } }; -export const handleConnectError = (err: Error) => { +let lastRefreshAttempt = 0; + +export const handleConnectError = async (err: Error) => { const host = getStdbHost(); const dbName = getStdbDbName(); console.log("[Handshake] Error connecting to SpacetimeDB:", err); const errStr = err.message || ""; - if (errStr.includes("401") || errStr.toLowerCase().includes("unauthorized")) { - console.warn(`[Handshake] Unauthorized (401) for ${host}:${dbName}! Purging host session...`); - TokenStore.clear(host, dbName); + const isAuthError = + errStr.includes("401") || + errStr.toLowerCase().includes("unauthorized") || + errStr.toLowerCase().includes("timeout") || + errStr.toLowerCase().includes("identity") || + errStr.toLowerCase().includes("token"); - // Trigger full application logout (which now purges sessionStorage too) - import("./auth/auth.svelte").then(({ auth }) => { - auth.logout(); - }); + if (isAuthError) { + console.warn(`[Handshake] Auth or Timeout error for ${host}:${dbName}: ${errStr}`); + + const { auth } = await import("./auth/auth.svelte"); + + // Only clear TokenStore if we are NOT an OIDC user (Guest mode) + // or if we explicitly failed a refresh. + if (!auth.isAuthenticated) { + TokenStore.clear(); + } + + // If we are already refreshing or loading, don't trigger another one + if (auth.isLoading || auth.isRefreshing) { + console.log("[Handshake] Auth is already loading/refreshing, skipping redundant recovery."); + return; + } + + const now = Date.now(); + // Only attempt recovery if we haven't tried in the last 30 seconds to prevent loops + if (auth.isAuthenticated && (now - lastRefreshAttempt > 30000)) { + console.log("[Handshake] User is authenticated with OIDC, attempting silent refresh..."); + lastRefreshAttempt = now; + const success = await auth.forceTokenRefresh(); + if (success) { + console.log("[Handshake] Silent refresh triggered successfully. Connection should auto-rebuild."); + return; + } + } + + console.warn("[Handshake] Recovery failed, not applicable, or looping. Purging session..."); + // auth.logout() will reload the page and clear oidc state + auth.logout(); return; } connectionState.error = err.message; @@ -130,18 +174,20 @@ export const connectionBuilder = (oidcToken?: string) => { .withUri(host) .withDatabaseName(dbName); - const storedToken = TokenStore.get(host, dbName); - - // CRITICAL: OIDC Token MUST take precedence over stored guest tokens + // CRITICAL: If we have an OIDC token, use it exclusively. + // Stored tokens in TokenStore are ONLY for guest mode. if (oidcToken) { - console.log("[Builder] Using OIDC token for handshake (overriding stored token)"); + console.log("[Builder] Using OIDC token for handshake."); builder.withToken(oidcToken); - } else if (storedToken) { - console.log("[Builder] Using existing stored token for handshake"); - builder.withToken(storedToken); + } else { + const storedToken = TokenStore.get(host, dbName); + if (storedToken) { + console.log("[Builder] Using existing stored Guest token for handshake."); + builder.withToken(storedToken); + } } - builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token)); + builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token, !!oidcToken)); builder.onConnectError((_ctx, err) => handleConnectError(err)); return builder;