Auth handling in synchronizer-ws-server-durable-object #196
Unanswered
legowhales
asked this question in
Q&A
Replies: 2 comments 2 replies
-
Agreed. The Cloudflare durable object synchronizer is AWESOME! I also have the same question on the best way to implement authentication. |
Beta Was this translation helpful? Give feedback.
0 replies
-
I got a working solution. Not sure if it is production caliber yet.
Apologies for the excess code but I don't have time to edit it to simplify it: WebSocketClient: import ReconnectingWebSocket from 'reconnecting-websocket';
import { useCurrentUser } from '@boundbybetter/auth';
import { logCall, logError, logSetup } from '@boundbybetter/shared';
import { createContext, useContext, useEffect, useState } from 'react';
const SERVER = process.env.EXPO_PUBLIC_CLOUDFLARE_WORKERS_URL; // "wss://my-durable-object.workers.dev"
export interface WebSocketContextType {
webSocket: ReconnectingWebSocket | undefined;
status: WebSocketStatus;
}
export enum WebSocketStatus {
Undefined = 'undefined',
Initializing = 'initializing',
Creating = 'creating',
Created = 'created',
Connecting = 'connecting',
Connected = 'connected',
Disconnected = 'disconnected',
Stopped = 'stopped',
Error = 'error',
}
export const WebSocketContext = createContext<WebSocketContextType>({
webSocket: undefined,
status: WebSocketStatus.Undefined,
});
export const useWebSocketClient = (): ReconnectingWebSocket | undefined => {
const context = useContext(WebSocketContext);
return context?.webSocket as ReconnectingWebSocket | undefined;
};
export const useWebSocketStatus = (): WebSocketStatus => {
const context = useContext(WebSocketContext);
return context?.status;
};
export const WebSocketProvider = (props: { children: React.ReactNode }) => {
const user = useCurrentUser();
const [webSocket, setWebSocket] = useState<ReconnectingWebSocket | undefined>(
undefined,
);
const [status, setStatus] = useState<WebSocketStatus>(
WebSocketStatus.Undefined,
);
logSetup('WebSocketProvider');
useEffect(() => {
setStatus(WebSocketStatus.Initializing);
const initializeWebSocket = async () => {
try {
setStatus(WebSocketStatus.Creating);
// Add authorization token to WebSocket URL
// I'm setting the sync level at the individual user for cross device sync in an expo app.
const wsUrl = new URL(SERVER + '/' + user.userId);
// Add the idToken returned by Microsoft Entra External Customer tenant.
wsUrl.searchParams.append('token', `${user.idToken}`);
// Initialize the client with the auth token
const client = new ReconnectingWebSocket(wsUrl.toString());
setWebSocket(client);
setStatus(WebSocketStatus.Created);
client.onopen = (event) => {
setStatus(WebSocketStatus.Connected);
logCall('WebSocketProvider', 'Connected to WebPubSub', event);
};
client.onclose = (event) => {
// Check if closure was due to auth failure
if (event.code === 1008) {
setStatus(WebSocketStatus.Error);
logError('WebSocketProvider', 'Authentication failed', event);
} else {
setStatus(WebSocketStatus.Disconnected);
logCall('WebSocketProvider', 'Disconnected from WebPubSub', event);
}
};
client.onmessage = (event) => {
logCall('WebSocketProvider', 'Received message', event);
};
client.onerror = (event) => {
setStatus(WebSocketStatus.Error);
logCall('WebSocketProvider', 'WebSocket error', event);
};
} catch (error) {
setStatus(WebSocketStatus.Error);
logError('WebSocketProvider', 'initializeWebSocket', error);
}
};
if (user?.idToken) {
initializeWebSocket();
}
return () => {
if (webSocket) {
webSocket.close();
}
};
}, [user]); // Re-run if user changes
return (
<WebSocketContext.Provider value={{ webSocket, status }}>
{props.children}
</WebSocketContext.Provider>
);
}; Durable Object: import { createMergeableStore, Id, IdAddedOrRemoved } from 'tinybase';
import { createDurableObjectStoragePersister } from 'tinybase/persisters/persister-durable-object-storage';
import {
getWsServerDurableObjectFetch,
WsServerDurableObject,
} from 'tinybase/synchronizers/synchronizer-ws-server-durable-object';
import * as jose from 'jose';
// Whether to persist data in the Durable Object between client sessions.
//
// If false, the Durable Object only provides synchronization between clients
// (which are assumed to persist their own data).
const PERSIST_TO_DURABLE_OBJECT = true;
export class GroupSyncServer extends WsServerDurableObject {
onPathId(pathId: Id, addedOrRemoved: IdAddedOrRemoved) {
console.info((addedOrRemoved ? 'Added' : 'Removed') + ` path ${pathId}`);
}
onClientId(pathId: Id, clientId: Id, addedOrRemoved: IdAddedOrRemoved) {
console.info(
(addedOrRemoved ? 'Added' : 'Removed') +
` client ${clientId} on path ${pathId}`,
);
}
createPersister() {
if (PERSIST_TO_DURABLE_OBJECT) {
return createDurableObjectStoragePersister(
createMergeableStore(),
this.ctx.storage,
);
}
}
}
// Custom fetch handler that validates the token before upgrading to WebSocket
const customFetch = async (request: Request, env: any) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
// In my local dev environment I bypass the login flow for speed.
const isTest = token === 'test-user-1' && env.IS_DEV === 'true';
if (!token) {
return new Response('Unauthorized - Missing token', { status: 401 });
}
if (isTest) {
return getWsServerDurableObjectFetch('GroupSyncServers')(request, env);
}
try {
// Get JWKS from Microsoft Entra ID - Update the URL to include the .well-known/openid-configuration/jwks endpoint
// https://boundbybettercustomers.ciamlogin.com/0009cc7a-b831-4911-be61-58865b14fccb
const jwksUrl = new URL(`${env.AUTH_PROVIDER_URL}/discovery/v2.0/keys`);
console.log('JWKS URL:', jwksUrl.toString());
const JWKS = jose.createRemoteJWKSet(jwksUrl);
// Verify token using JWKS client
const decoded = await jose.jwtVerify(token, JWKS);
console.log('decoded', decoded);
const userId = request.url.split('/').pop()?.split('?')[0];
console.log('userId', userId);
console.log('decoded.payload.oid', decoded.payload.oid);
if (!userId || decoded.payload.oid !== userId) {
return new Response('Unauthorized - Invalid token', { status: 401 });
}
// If token is valid, proceed with WebSocket upgrade
return getWsServerDurableObjectFetch('GroupSyncServers')(request, env);
} catch (error) {
console.error('error', error);
return new Response('Unauthorized - Invalid token', { status: 401 });
}
};
export default {
fetch: customFetch,
}; |
Beta Was this translation helpful? Give feedback.
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
The new synchronizer for cloudflare is awesome!
Quick question: how would you handle authentication?
Is there an easy way to send a token from the client to the server, and in the server throw an expired or unauthorized response based on the token and other params?
Thank's!
Beta Was this translation helpful? Give feedback.
All reactions