diff --git a/Cargo.toml b/Cargo.toml index e679f9d8c4a..7db7d3a965d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -285,7 +285,7 @@ rand = "0.9" rayon = "1.8" rayon-core = "1.11.0" regex = "1" -reqwest = { version = "0.12", features = ["stream", "json", "gzip", "brotli"] } +reqwest = { version = "0.12", features = ["stream", "json", "gzip", "brotli", "native-tls-alpn"] } rolldown = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } rolldown_common = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } rolldown_error = { git = "https://github.com/rolldown/rolldown.git", tag = "v1.0.0-rc.3" } 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..65a0fd419c8 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; }); @@ -1160,6 +1176,16 @@ export class DbConnectionImpl this.#processMessage(data); } } + } catch (e) { + // A message that fails to apply (undecodable, or a callback threw) + // leaves the client cache inconsistent, and rethrowing from a WebSocket + // listener crashes Node hosts. Fail the connection instead: drop what is + // queued and close, so `onDisconnect` receives the error. + stdbLogger('error', 'Failed to process a server message', e); + this.#connectionError = toError(e); + this.#inboundQueueOffset = this.#inboundQueue.length; + this.isActive = false; + this.ws?.close(); } finally { if (this.#inboundQueueOffset >= this.#inboundQueue.length) { this.#inboundQueue.length = 0; 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..6971525f4b8 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -213,6 +213,40 @@ describe('DbConnection', () => { expect(client.isActive).toBe(false); }); + test('reports a server message it cannot apply through onDisconnect instead of throwing', async () => { + const onDisconnectPromise = new Deferred(); + const wsAdapter = new WebsocketTestAdapter(); + + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.openWebSocket) + .onConnect(() => { + throw new Error('callback failed'); + }) + .onDisconnect((_ctx, error) => onDisconnectPromise.resolve(error)) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + // Node rethrows errors from WebSocket listeners on the next tick, which + // crashes the host process. + expect(() => + wsAdapter.sendToClient( + ServerMessage.InitialConnection({ + identity: anIdentity, + token: 'a-token', + connectionId: ConnectionId.random(), + }) + ) + ).not.toThrow(); + + expect((await onDisconnectPromise.promise)?.message).toBe( + 'callback failed' + ); + expect(client.isActive).toBe(false); + }); + test('marks disconnect as requested when disconnect() is called', async () => { const onDisconnectPromise = new Deferred(); const wsAdapter = new WebsocketTestAdapter(); @@ -338,6 +372,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: '' });