oidc refinement
This commit is contained in:
@@ -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<any>(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<string | undefined>(undefined);
|
||||
let lastUsedOidcTokenForUpgrade = $state<string | undefined>(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";
|
||||
|
||||
+123
-14
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { auth } from "./auth/auth.svelte";
|
||||
import { getStdbHost, getStdbDbName, connectionBuilder } from "./config";
|
||||
import { connectionState } from "./connection.svelte";
|
||||
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
|
||||
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
|
||||
|
||||
let { children, onCancel } = $props<{
|
||||
@@ -8,22 +10,129 @@
|
||||
onCancel?: () => void
|
||||
}>();
|
||||
|
||||
// 1. Permanent reactive builder
|
||||
// We initialize once and the InnerProvider handles the internal SDK lifecycle.
|
||||
// Check if we already have an OIDC token available (e.g. from a previous sync or current auth state)
|
||||
const builder = connectionBuilder(auth.user?.id_token);
|
||||
// 1. Connection Builder Lifecycle
|
||||
// We MUST wait for OIDC to finish its loading/silent-renew phase
|
||||
// before we construct the initial SpacetimeDB connection.
|
||||
let builder = $state<any>(null);
|
||||
let lastUsedOidcToken = $state<string | undefined>(undefined);
|
||||
let providerKey = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Hold off until OIDC is settled for the first time
|
||||
if (auth.isLoading) return;
|
||||
|
||||
const currentToken = auth.user?.id_token;
|
||||
|
||||
// 1. Initial creation
|
||||
if (!builder) {
|
||||
console.log(`[SpacetimeProvider] Initializing connection builder. OIDC present: ${!!currentToken}`);
|
||||
untrack(() => {
|
||||
builder = connectionBuilder(currentToken);
|
||||
lastUsedOidcToken = currentToken;
|
||||
providerKey += 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Identity transition (Logged out -> Logged in)
|
||||
// If we were a guest (or null) and now have a token, we SHOULD remount
|
||||
// to ensure the OIDC credentials take over completely.
|
||||
if (currentToken && !lastUsedOidcToken) {
|
||||
console.log("[SpacetimeProvider] Transitioning from Guest/None to OIDC session. Remounting...");
|
||||
untrack(() => {
|
||||
builder = connectionBuilder(currentToken);
|
||||
lastUsedOidcToken = currentToken;
|
||||
providerKey += 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Background Refresh (Token -> New Token)
|
||||
// If it's just a refresh, we DON'T remount. We let InnerSpacetimeDBProvider
|
||||
// handle the in-place upgrade via withToken.
|
||||
if (currentToken && currentToken !== lastUsedOidcToken) {
|
||||
console.log("[SpacetimeProvider] Background token refresh detected. Upgrading in-place.");
|
||||
untrack(() => {
|
||||
lastUsedOidcToken = currentToken;
|
||||
// Notice we DON'T increment providerKey here
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Logout (Token -> Null)
|
||||
if (!currentToken && lastUsedOidcToken) {
|
||||
console.log("[SpacetimeProvider] User logged out. Remounting for Guest mode.");
|
||||
untrack(() => {
|
||||
builder = connectionBuilder(undefined);
|
||||
lastUsedOidcToken = undefined;
|
||||
providerKey += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive labels for the loading screen
|
||||
const host = getStdbHost();
|
||||
const dbName = getStdbDbName();
|
||||
</script>
|
||||
|
||||
<InnerSpacetimeDBProvider
|
||||
{builder}
|
||||
{onCancel}
|
||||
{host}
|
||||
{dbName}
|
||||
oidcToken={auth.user?.id_token}
|
||||
>
|
||||
{@render children()}
|
||||
</InnerSpacetimeDBProvider>
|
||||
{#if !builder || (auth.isLoading && !auth.user)}
|
||||
<div class="login-screen">
|
||||
<div class="login-card" style="text-align: center;">
|
||||
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||
<h1>Authenticating...</h1>
|
||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Synchronizing your session credentials.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#key providerKey}
|
||||
<InnerSpacetimeDBProvider
|
||||
{builder}
|
||||
{onCancel}
|
||||
{host}
|
||||
{dbName}
|
||||
oidcToken={auth.user?.id_token}
|
||||
>
|
||||
{@render children()}
|
||||
</InnerSpacetimeDBProvider>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--background-secondary) 0%,
|
||||
var(--background-tertiary) 100%
|
||||
);
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--background-primary);
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
color: var(--header-primary);
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: var(--text-normal);
|
||||
margin-bottom: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
let stdbHost = $state("");
|
||||
let stdbDbName = $state("");
|
||||
let storedConnections = $state<string[]>([]);
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+99
-5
@@ -20,6 +20,7 @@ class AuthStore {
|
||||
#userManager: UserManager;
|
||||
#user = $state<User | null | undefined>(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 {
|
||||
|
||||
+96
-50
@@ -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<string>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user