From 8ce8ec2455aba1bcc01d88581cd9bef163afa741 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 24 Sep 2026 17:14:55 -0400 Subject: [PATCH] Let short-lived auth tokens survive reconnects in the TypeScript SDK `withToken` only took a string, and the ConnectionManager re-applies the session's token on every automatic rebuild. For third-party OIDC tokens such as Clerk session JWTs (about a minute of lifetime) that token is the original JWT, so once it expired every reconnect failed with 401 and retried forever. React apps also had no way to switch identity while mounted: `retain()` ignores a new builder once a connection is live. `withToken` now also accepts a `TokenProvider`, a sync or async function that is called on every connection attempt: the initial build, automatic reconnects, resume and zombie revival, and `rebuild()`. Its value is that attempt's token (`undefined` connects anonymously) and `conn.token` reflects it. A throw or rejection reaches `onConnectError`, so the manager retries it with backoff. The manager no longer resumes the session token over a builder with a provider; string tokens resume as before. `TokenProvider` is exported from the package root. The React context gains `reconnect(builder)`, calling `ConnectionManager.rebuild()` like the Svelte provider does. The Clerk guide now passes a provider instead of a token fetched once. --- .../src/react/SpacetimeDBProvider.ts | 11 ++- .../src/react/connection_state.ts | 12 ++- .../src/sdk/connection_manager.ts | 6 +- .../src/sdk/db_connection_builder.ts | 25 +++++- .../src/sdk/db_connection_impl.ts | 40 ++++++--- .../tests/connection_manager_liveness.test.ts | 4 + .../connection_manager_reconnect.test.ts | 90 ++++++++++++++++++- .../tests/db_connection.test.ts | 46 ++++++++++ .../00500-authentication/00300-Clerk.md | 16 ++-- 9 files changed, 223 insertions(+), 27 deletions(-) diff --git a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts index a574e21d4a8..74e1ec2c3f4 100644 --- a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts +++ b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts @@ -64,9 +64,16 @@ export function SpacetimeDBProvider< [key] ); + const reconnect = React.useCallback( + (builder: DbConnectionBuilder) => { + ConnectionManager.rebuild(key, builder); + }, + [key] + ); + const contextValue = React.useMemo( - () => ({ ...state, getConnection }), - [state, getConnection] + () => ({ ...state, getConnection, reconnect }), + [state, getConnection, reconnect] ); React.useEffect(() => { diff --git a/crates/bindings-typescript/src/react/connection_state.ts b/crates/bindings-typescript/src/react/connection_state.ts index 5ad565f9be6..714c507237a 100644 --- a/crates/bindings-typescript/src/react/connection_state.ts +++ b/crates/bindings-typescript/src/react/connection_state.ts @@ -1,6 +1,16 @@ -import type { DbConnectionImpl } from '../sdk/db_connection_impl'; +import type { + DbConnectionBuilder, + DbConnectionImpl, +} from '../sdk/db_connection_impl'; import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager'; export type ConnectionState = ManagerConnectionState & { getConnection(): DbConnectionImpl | null; + /** + * Tear down the current connection and reconnect using a fresh builder, for + * example to switch identity after sign-in or sign-out. The builder should + * carry the new token and the same uri + database name. Hooks re-bind to the + * new connection automatically. + */ + reconnect(builder: DbConnectionBuilder): void; }; diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 2cf34ea2c36..32f8f18198c 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -315,6 +315,10 @@ class ConnectionManagerImpl { * * Pass `resumeSession: false` when the caller is deliberately changing * identity — see {@link rebuild} — so the builder's own token wins. + * + * A builder with a token provider is never overwritten: the provider is + * called for every attempt and already decides the identity, and resuming + * its last token would reuse a short-lived credential after it expired. */ #buildManagedConnection>( managed: ManagedConnection, @@ -322,7 +326,7 @@ class ConnectionManagerImpl { { resumeSession = true }: { resumeSession?: boolean } = {} ): T { managed.builder = builder; - if (resumeSession && managed.state.token) { + if (resumeSession && managed.state.token && !builder.hasTokenProvider()) { builder.withToken(managed.state.token); } const connection = builder.build(); diff --git a/crates/bindings-typescript/src/sdk/db_connection_builder.ts b/crates/bindings-typescript/src/sdk/db_connection_builder.ts index 282cab02d37..d9e196b65de 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_builder.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_builder.ts @@ -1,4 +1,8 @@ -import { DbConnectionImpl, type ConnectionEvent } from './db_connection_impl'; +import { + DbConnectionImpl, + type ConnectionEvent, + type TokenProvider, +} from './db_connection_impl'; import { EventEmitter } from './event_emitter'; import type { DbConnectionConfig, @@ -22,7 +26,7 @@ export class DbConnectionBuilder> { #uri?: URL; #nameOrAddress?: string; #identity?: Identity; - #token?: string; + #token?: string | TokenProvider; #emitter: EventEmitter = new EventEmitter(); #compression: 'gzip' | 'brotli' | 'none' = 'gzip'; #lightMode: boolean = false; @@ -76,13 +80,28 @@ export class DbConnectionBuilder> { * is optional. You can store the token returned by the `onConnect` callback * to use in future connections. * + * Pass a function instead of a string for short-lived tokens, such as OIDC + * session JWTs. It is called, and may return a promise, on every connection + * attempt, including automatic reconnects by the framework providers, so + * each attempt authenticates with a fresh token. Returning `undefined` + * connects anonymously. + * * @returns The `DbConnectionBuilder` instance. */ - withToken(token?: string): this { + withToken(token?: string | TokenProvider): this { this.#token = token; return this; } + /** + * Whether the token was set as a {@link TokenProvider}. The + * `ConnectionManager` does not resume a session's token over a provider, + * which already decides the identity for each attempt. + */ + hasTokenProvider(): boolean { + return typeof this.#token === 'function'; + } + withWSFn(createWSFn: WebSocketFactory): this { this.#createWSFn = createWSFn; return this; diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 12dfca7afbf..4bec1209f96 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -98,11 +98,20 @@ export type { export type ConnectionEvent = 'connect' | 'disconnect' | 'connectError'; +/** + * Supplies the auth token for one connection attempt. See + * {@link DbConnectionBuilder.withToken}. + */ +export type TokenProvider = () => + | string + | undefined + | Promise; + export type DbConnectionConfig = { uri: URL; nameOrAddress: string; identity?: Identity; - token?: string; + token?: string | TokenProvider; emitter: EventEmitter; createWSFn: WebSocketFactory; compression: 'gzip' | 'brotli' | 'none'; @@ -327,7 +336,6 @@ export class DbConnectionImpl } this.identity = identity; - this.token = token; this.#remoteModule = remoteModule; this.#emitter = emitter; @@ -388,15 +396,23 @@ export class DbConnectionImpl this.reducers = this.#makeReducers(remoteModule); this.procedures = this.#makeProcedures(remoteModule); - this.wsPromise = createWSFn({ - url, - nameOrAddress, - wsProtocol: [...PREFERRED_WS_PROTOCOLS], - authToken: token, - compression: compression, - lightMode: lightMode, - confirmedReads: confirmedReads, - }) + // A token provider is asked for a fresh token on every attempt, so + // short-lived credentials such as OIDC session JWTs survive reconnects. + // Resolving it inside the chain routes a throw or rejection to + // `onConnectError` rather than out of `build()`. A plain string token is + // assigned synchronously, as before. + this.wsPromise = (async () => { + this.token = typeof token === 'function' ? await token() : token; + return createWSFn({ + url, + nameOrAddress, + wsProtocol: [...PREFERRED_WS_PROTOCOLS], + authToken: this.token, + compression: compression, + lightMode: lightMode, + confirmedReads: confirmedReads, + }); + })() .then(v => { this.ws = v; @@ -423,7 +439,7 @@ export class DbConnectionImpl }) .catch(e => { stdbLogger('error', 'Error connecting to SpacetimeDB WS'); - this.#emitter.emit('connectError', this, e); + this.#emitter.emit('connectError', this, toError(e)); return undefined; }); diff --git a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts index 5c2e1e75c98..fabb9436824 100644 --- a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts @@ -99,6 +99,10 @@ class MockBuilder { return this; } + hasTokenProvider(): boolean { + return false; + } + build(): MockConnection { const connection = new MockConnection(); connection.token = this.token; diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index ee981ccf2f9..806119974eb 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { ConnectionId } from '../src'; +import { ConnectionId, type TokenProvider } from '../src'; +import { ServerMessage } from '../src/sdk/client_api/types.ts'; import { CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, connectionManagerReconnectDelayMs, ConnectionManager, } from '../src/sdk/connection_manager.ts'; +import WebsocketTestAdapter from '../src/sdk/websocket_test_adapter.ts'; +import { DbConnection } from '../test-app/src/module_bindings/index.ts'; +import { anIdentity } from './utils.ts'; type ErrorContextInterface = { isActive: boolean; @@ -144,6 +148,10 @@ class MockBuilder { return this; } + hasTokenProvider(): boolean { + return false; + } + build(): MockConnection { const connection = new MockConnection(this.token); this.buildCount += 1; @@ -809,3 +817,83 @@ describe('ConnectionManager session continuity across rebuilds', () => { ConnectionManager.release(key); }); }); + +// These use a real `DbConnection` because the provider is resolved inside it, +// per connection attempt. +describe('ConnectionManager with a token provider', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + function providerBuilder(provider: TokenProvider) { + const sockets: WebsocketTestAdapter[] = []; + const authTokens: (string | undefined)[] = []; + const builder = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(provider) + .withWSFn(args => { + authTokens.push(args.authToken); + const socket = new WebsocketTestAdapter(); + sockets.push(socket); + return socket.openWebSocket(args); + }); + return { builder, sockets, authTokens }; + } + + test('asks the provider again on reconnect instead of resuming its last token', async () => { + const key = nextKey(); + let issued = 0; + const { builder, sockets, authTokens } = providerBuilder( + async () => `jwt-${++issued}` + ); + + ConnectionManager.retain(key, builder); + await vi.advanceTimersByTimeAsync(0); + sockets[0].acceptConnection(); + sockets[0].sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'jwt-1', + connectionId: ConnectionId.random(), + }) + ); + expect(ConnectionManager.getSnapshot(key)?.token).toBe('jwt-1'); + + // By now the short-lived token may have expired; the rebuild must not + // resume it. + sockets[0].close(); + await vi.advanceTimersByTimeAsync(connectionManagerReconnectDelayMs(0)); + + expect(authTokens).toEqual(['jwt-1', 'jwt-2']); + + ConnectionManager.release(key); + }); + + test('retries after the provider rejects', async () => { + const key = nextKey(); + const provider = vi + .fn() + .mockRejectedValueOnce(new Error('token fetch failed')) + .mockResolvedValue('jwt'); + const { builder, authTokens } = providerBuilder(provider); + + ConnectionManager.retain(key, builder); + await vi.advanceTimersByTimeAsync(0); + expect(ConnectionManager.getSnapshot(key)?.connectionError?.message).toBe( + 'token fetch failed' + ); + expect(authTokens).toEqual([]); + + await vi.advanceTimersByTimeAsync(connectionManagerReconnectDelayMs(0)); + expect(provider).toHaveBeenCalledTimes(2); + expect(authTokens).toEqual(['jwt']); + + ConnectionManager.release(key); + }); +}); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 9e340637eb8..d2cf6edb5fd 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -338,6 +338,52 @@ describe('DbConnection', () => { expect(called).toBeTruthy(); }); + test.each([ + ['sync', (token: string) => token], + ['async', async (token: string) => token], + ])( + 'asks a %s token provider for a fresh token on every build', + async (_kind, provide) => { + let issued = 0; + const authTokens: (string | undefined)[] = []; + const builder = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(() => provide(`token-${++issued}`)) + .withWSFn(args => { + authTokens.push(args.authToken); + return new WebsocketTestAdapter().openWebSocket(args); + }); + + const first = builder.build(); + await first['wsPromise']; + const second = builder.build(); + await second['wsPromise']; + + expect(authTokens).toEqual(['token-1', 'token-2']); + expect(first.token).toBe('token-1'); + expect(second.token).toBe('token-2'); + } + ); + + test('reports a throwing token provider through onConnectError', async () => { + let connectError: Error | undefined; + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withToken(() => { + throw new Error('token fetch failed'); + }) + .withWSFn(new WebsocketTestAdapter().openWebSocket) + .onConnectError((_ctx, error) => { + connectError = error; + }) + .build(); + + await client['wsPromise']; + expect(connectError?.message).toBe('token fetch failed'); + }); + test('batches same-tick reducer calls when v3 is negotiated', async () => { const wsAdapter = new WebsocketTestAdapter(); const client = DbConnection.builder() diff --git a/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md b/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md index 13df43bf929..f75228cedc3 100644 --- a/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md +++ b/docs/docs/00200-core-concepts/00500-authentication/00300-Clerk.md @@ -211,8 +211,10 @@ createRoot(document.getElementById('root')!).render( Update your `App.tsx` file to: -1. Read the Clerk token via `useClerkToken`. -2. Pass it to the `DbConnection` builder using `.withToken(...)`. +1. Get Clerk's `getToken` function via `useAuth`. +2. Pass the `DbConnection` builder a function that calls it, using `.withToken(...)`. + +Clerk session tokens expire after about a minute, so pass a function rather than a token string: the SDK calls it on every connection attempt, including automatic reconnects, and so always authenticates with a fresh token. This mirrors the Auth0 flow: SpacetimeDB receives a bearer token (JWT) and can validate it server-side. @@ -220,10 +222,10 @@ This mirrors the Auth0 flow: SpacetimeDB receives a bearer token (JWT) and can v ```tsx import { useMemo } from 'react'; +import { useAuth } from '@clerk/clerk-react'; import { Identity } from 'spacetimedb'; import { SpacetimeDBProvider } from 'spacetimedb/react'; import { DbConnection, ErrorContext } from './module_bindings'; -import { useClerkToken } from './ClerkTokenProvider'; const onConnect = (_conn: DbConnection, identity: Identity) => { console.log( @@ -241,17 +243,17 @@ const onConnectError = (_ctx: ErrorContext, err: Error) => { }; export default function App() { - const token = useClerkToken(); + const { getToken } = useAuth(); const connectionBuilder = useMemo(() => { return DbConnection.builder() .withUri('') .withDatabaseName('') - .withToken(token) + .withToken(async () => (await getToken()) ?? undefined) .onConnect(onConnect) .onDisconnect(onDisconnect) .onConnectError(onConnectError); - }, [token]); + }, [getToken]); return ( @@ -296,7 +298,7 @@ export function Header() { You are now set up to use **Clerk** authentication in your React application. When users access your app, they will be redirected to Clerk for sign-in, a session token (JWT) will be retrieved in the browser, and that token will be used to authenticate your SpacetimeDB connection. -If you are using **Clerk JWT templates** (recommended for controlling claims/audience/issuer), update the token retrieval line to: +If you are using **Clerk JWT templates** (recommended for controlling claims/audience/issuer), update both `getToken()` calls to: ```ts await getToken({ template: '' });