oidc working

This commit is contained in:
2026-04-17 03:10:55 -04:00
parent 49a325ebee
commit 7b4260bc90
5 changed files with 184 additions and 95 deletions
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
import type { ConnectionBuilder } from "spacetimedb";
import { getStdbHost, getStdbDbName, handleConnect } from "./config";
let { builder, children, onCancel }: { builder: ConnectionBuilder<any>, children: any, onCancel?: () => void } = $props();
// Initialize the provider
const db = createSpacetimeDBProvider(builder);
// Fallback: Also register direct listener on the connection if possible
$effect(() => {
if ($db.connection) {
$db.connection.onConnect((conn: any, identity: any, token: string) => {
console.log("InnerProvider: connection.onConnect triggered, syncing state...");
handleConnect(conn, identity, token);
});
}
});
// Watch for successful connection and persist state
$effect(() => {
console.log("InnerProvider State:", {
isActive: $db.isActive,
hasIdentity: !!$db.identity,
hasToken: !!$db.token,
hasConn: !!$db.connection
});
if ($db.isActive && $db.identity && $db.connection) {
console.log("InnerProvider: Connection valid, syncing state...");
handleConnect($db.connection, $db.identity, $db.token || "");
}
});
const host = getStdbHost();
const dbName = getStdbDbName();
</script>
{#if $db.identity}
{@render children()}
{:else}
<div class="login-screen">
<div class="login-card" style="text-align: center;">
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Connecting to SpacetimeDB...</h1>
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
</div>
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
</div>
</div>
{#if onCancel}
<button
onclick={onCancel}
class="btn-secondary"
style="width: 100%;"
>
Cancel
</button>
{/if}
</div>
</div>
{/if}
+16 -37
View File
@@ -1,57 +1,36 @@
<script lang="ts"> <script lang="ts">
import { createSpacetimeDBProvider } from "spacetimedb/svelte"; import { connectionBuilder, handleConnect } from "./config";
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
import { auth } from "./auth/auth.svelte"; import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import InnerProvider from "./InnerProvider.svelte";
import type { ConnectionBuilder } from "spacetimedb";
let { children, onCancel } = $props<{ let { children, onCancel } = $props<{
children: any, children: any,
onCancel?: () => void onCancel?: () => void
}>(); }>();
const host = getStdbHost(); let builder = $state<ConnectionBuilder<any> | null>(null);
const dbName = getStdbDbName();
// Initialize SpacetimeDB provider
const db = createSpacetimeDBProvider(connectionBuilder(
auth.user?.id_token
));
$effect(() => { $effect(() => {
console.log("InnerSpacetimeProvider: $db.isActive:", $db.isActive); if (!auth.isLoading) {
console.log("InnerSpacetimeProvider: $db.identity:", $db.identity?.toHexString()); console.log("InnerSpacetimeProvider: Initializing connection...");
console.log("InnerSpacetimeProvider: $db.token:", $db.token ? "present" : "absent"); connectionState.status = "connecting";
builder = connectionBuilder(auth.user?.id_token);
}
}); });
</script> </script>
{#if $db.identity} {#if builder}
{@render children()} <InnerProvider {builder} {onCancel}>
{@render children()}
</InnerProvider>
{:else} {:else}
<div class="login-screen"> <div class="login-screen">
<div class="login-card" style="text-align: center;"> <div class="login-card" style="text-align: center;">
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i> <i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Connecting to SpacetimeDB...</h1> <h1>Authenticating...</h1>
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p> <p style="color: var(--text-muted); margin-top: 8px;">Waiting for identity provider callback.</p>
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
</div>
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
</div>
</div>
{#if onCancel}
<button
onclick={onCancel}
class="btn-secondary"
style="width: 100%;"
>
Cancel
</button>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
+24 -3
View File
@@ -30,14 +30,29 @@
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`; combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
const isChanging = localStorage.getItem("zep_changing_server") === "true"; const isChanging = localStorage.getItem("zep_changing_server") === "true";
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
if (isChanging) { if (isChanging) {
userWantsToConnect = false; userWantsToConnect = false;
} else if (TokenStore.get(stdbHost, stdbDbName)) { } else if (TokenStore.get(stdbHost, stdbDbName) || isInCallback) {
// Auto-connect if we have a token and NOT changing server // 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.
userWantsToConnect = true; userWantsToConnect = true;
} }
}); });
// Ensure we transition to connecting if we just finished an OIDC redirect
$effect(() => {
if (auth.isAuthenticated && !auth.isLoading) {
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
if (isInCallback) {
untrack(() => {
userWantsToConnect = true;
});
}
}
});
// Split combined connection if it changes // Split combined connection if it changes
$effect(() => { $effect(() => {
if (combinedConnection.includes(":")) { if (combinedConnection.includes(":")) {
@@ -59,7 +74,13 @@
combinedConnection = `${hostPart}:${stdbDbName}`; combinedConnection = `${hostPart}:${stdbDbName}`;
}); });
const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName)); let hasStoredToken = $state(false);
$effect(() => {
// Check for token on mount and when connection params change
const _ = combinedConnection;
hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName);
});
$effect(() => { $effect(() => {
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost); if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
+25 -4
View File
@@ -7,8 +7,8 @@ import { getEnv } from "../env";
// OIDC Configuration - User should replace these with their own provider values // OIDC Configuration - User should replace these with their own provider values
export const oidcConfig: UserManagerSettings = { export const oidcConfig: UserManagerSettings = {
authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"), authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"), client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
redirect_uri: window.location.origin, redirect_uri: window.location.origin,
scope: "openid profile email", scope: "openid profile email",
response_type: "code", response_type: "code",
@@ -18,6 +18,7 @@ class AuthStore {
#userManager: UserManager; #userManager: UserManager;
#user = $state<User | null | undefined>(null); #user = $state<User | null | undefined>(null);
#isLoading = $state(true); #isLoading = $state(true);
#isProcessingCallback = false;
constructor(settings: UserManagerSettings) { constructor(settings: UserManagerSettings) {
this.#userManager = new UserManager(settings); this.#userManager = new UserManager(settings);
@@ -27,8 +28,19 @@ class AuthStore {
window.location.search.includes("code=") && window.location.search.includes("code=") &&
window.location.search.includes("state=") window.location.search.includes("state=")
) { ) {
this.signinCallback(); if (!this.#isProcessingCallback) {
this.signinCallback();
}
} else { } else {
// Not in a callback, safe to clear any stale/redundant OIDC state keys
// that might have accumulated from partial attempts
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("oidc.")) {
localStorage.removeItem(key);
}
}
this.#userManager this.#userManager
.getUser() .getUser()
.then((user) => { .then((user) => {
@@ -71,10 +83,19 @@ class AuthStore {
} }
async signinCallback() { async signinCallback() {
if (this.#isProcessingCallback) return;
this.#isProcessingCallback = true;
this.#isLoading = true; this.#isLoading = true;
try { try {
const user = await this.#userManager.signinCallback(); const user = await this.#userManager.signinCallback();
this.#user = user; this.#user = user;
// Stage the id_token so SpacetimeDB can find it even if OIDC state is cleared
if (user.id_token) {
console.log("AuthStore: Staging OIDC id_token for SpacetimeDB handshake");
localStorage.setItem("zep_oidc_staging_token", user.id_token);
}
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
} catch (error) { } catch (error) {
console.error("Signin callback error:", error); console.error("Signin callback error:", error);
@@ -101,7 +122,7 @@ class AuthStore {
const keysToRemove: string[] = []; const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && key.includes("auth_token")) { if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
+48 -51
View File
@@ -45,35 +45,20 @@ export const TokenStore = {
const key = localStorage.key(i); const key = localStorage.key(i);
if (!key) continue; if (!key) continue;
let connStr: string | null = null;
if (key.startsWith("stdb_token:")) { if (key.startsWith("stdb_token:")) {
// New format: stdb_token:protocol://host[:port]:database
const parts = key.split(":"); const parts = key.split(":");
if (parts.length >= 4) { if (parts.length >= 4) {
const dbName = parts[parts.length - 1]; const dbName = parts[parts.length - 1];
const hostParts = parts.slice(2, parts.length - 1); const hostParts = parts.slice(2, parts.length - 1);
const host = hostParts.join(":").replace(/^\/\//, ""); const host = hostParts.join(":").replace(/^\/\//, "");
connStr = `${host}:${dbName}`; const connStr = `${host}:${dbName}`;
}
} else if (key.endsWith("/auth_token")) { if (!seen.has(connStr)) {
// Legacy format: protocol://host[:port]/database/auth_token connections.push(connStr);
// Example: http://localhost:3000/zep/auth_token seen.add(connStr);
const parts = key.split("/");
// parts: ["http:", "", "localhost:3000", "zep", "auth_token"]
if (parts.length >= 5) {
const dbName = parts[parts.length - 2];
const host = parts[2];
if (dbName && host) {
connStr = `${host}:${dbName}`;
} }
} }
} }
if (connStr && !seen.has(connStr)) {
connections.push(connStr);
seen.add(connStr);
}
} }
return connections; return connections;
} }
@@ -102,6 +87,39 @@ export const stopActiveConnection = () => {
} }
}; };
export const handleConnect = (conn: DbConnection, identity: any, token: string) => {
const host = getStdbHost();
const dbName = getStdbDbName();
console.log(`handleConnect: Connection established! Token received (len: ${token?.length || 0})`);
_connection = conn;
if (token) {
console.log("handleConnect: Persisting token to TokenStore...");
TokenStore.set(host, dbName, token);
// Clear staging token now that we have a permanent one
localStorage.removeItem("zep_oidc_staging_token");
} else {
console.warn("handleConnect: No token received from SpacetimeDB!");
}
connectionState.status = "connected";
connectionState.hasConnectedOnce = true;
connectionState.error = null;
// Call the auth update reducer to ensure OIDC info is synced
setTimeout(() => {
if (_connection) {
console.log("handleConnect: Requesting auth info update...");
// Use the typed reducer call if available
if ((_connection.reducers as any).updateAuthInfo) {
(_connection.reducers as any).updateAuthInfo({});
}
}
}, 100);
};
class ConnectionManager { class ConnectionManager {
#retryCount = 0; #retryCount = 0;
#reconnectTimeout: any = null; #reconnectTimeout: any = null;
@@ -197,50 +215,29 @@ export const connectionBuilder = (oidcToken?: string) => {
const host = normalizeHost(rawHost); const host = normalizeHost(rawHost);
const dbName = getStdbDbName(); const dbName = getStdbDbName();
console.log(`connectionBuilder: Using host: ${host} (raw: ${rawHost}), database: ${dbName}`); console.log(`connectionBuilder: Creating builder for host: ${host}, database: ${dbName}`);
connectionState.status = "connecting";
if (activeManager) {
console.log("connectionBuilder: Stopping previous activeManager");
activeManager.stop();
}
const manager = new ConnectionManager(host, dbName);
activeManager = manager;
const builder = DbConnection.builder() const builder = DbConnection.builder()
.withUri(host) .withUri(host)
.withDatabaseName(dbName); .withDatabaseName(dbName);
const storedToken = TokenStore.get(host, dbName); const storedToken = TokenStore.get(host, dbName);
console.log("connectionBuilder: oidcToken:", oidcToken ? "present" : "absent"); const stagingToken = localStorage.getItem("zep_oidc_staging_token");
console.log("connectionBuilder: storedToken:", storedToken ? "present" : "absent");
console.log("connectionBuilder: tokens -> oidc:", oidcToken ? "yes" : "no", "staging:", stagingToken ? "yes" : "no", "stored:", storedToken ? "yes" : "no");
if (oidcToken) { if (oidcToken) {
console.log("connectionBuilder: Calling withToken with oidcToken");
builder.withToken(oidcToken); builder.withToken(oidcToken);
} else if (stagingToken) {
console.log("connectionBuilder: Using staged OIDC token");
builder.withToken(stagingToken);
} else if (storedToken) { } else if (storedToken) {
console.log("connectionBuilder: Calling withToken with storedToken");
builder.withToken(storedToken); builder.withToken(storedToken);
} else {
console.log("connectionBuilder: No token, will connect anonymously");
} }
// Register our manager's listeners directly on the connection object. builder.onConnect((conn: any, identity: any, token: string) => {
// This ensures they coexist with the Svelte provider's listeners. handleConnect(conn, identity, token);
builder.onConnect((c: any, id: any, token: string) => { });
console.log("connectionBuilder: CONNECTION onConnect TRIGGERED!");
manager.onConnect(c as any, id, token);
});
builder.onDisconnect((_ctx: any, err: any) => {
console.log("connectionBuilder: CONNECTION onDisconnect TRIGGERED!", err);
manager.onDisconnect();
});
builder.onConnectError((ctx: any, err: any) => {
console.log("connectionBuilder: CONNECTION onConnectError TRIGGERED!", err);
manager.onConnectError(ctx, err);
});
return builder; return builder;
}; };