oidc refinement

This commit is contained in:
2026-04-20 20:50:23 -04:00
parent 1daf837790
commit b1cff07cd6
5 changed files with 377 additions and 97 deletions
+53 -18
View File
@@ -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
View File
@@ -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>
+6 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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;