-
Notifications
You must be signed in to change notification settings - Fork 405
Description
Preliminary Checks
-
I have reviewed the documentation: https://clerk.com/docs
-
I have searched for existing issues: https://github.com/clerk/javascript/issues
-
I have not already reached out to Clerk support via email or Discord (if you have, no need to open an issue here)
-
This issue is not a question, general help request, or anything other than a bug report directly related to Clerk. Please ask questions in our Discord community: https://clerk.com/discord.
Reproduction
https://github.com/wuzzeb/clerk-auth-org
Publishable key
pk_test_ZmFzdC1iZWV0bGUtMjQuY2xlcmsuYWNjb3VudHMuZGV2JA
Description
High Level Picture: I have users in orgs and a user might be in more than one org. While most users will be in a single org, a consultant user will be a member of multiple orgs and will wish to have multiple orgs open in different tabs (so not a single active org, but different orgs they are consulting with).
I want to provide pages for each org like explained in https://clerk.com/docs/guides/organizations/org-slugs-in-urls . So I have a react single page application that on the client has a url such as https://domain/{orgId}/projects for example. The client extracts the {orgId} from the route and then makes a websocket connection to wss://domain/api/websocket/{orgId}. I now want to use authenticateRequest in the websocket handler on the server (cloudflare worker) to check if the user can access the org. Because it is websockets, the auth has to go through the cookies since can't set a bearer token.
Full Example Repository
I made an example github repository https://github.com/wuzzeb/clerk-auth-org I am using to test by starting with a fresh repository and adding only a little bit of code. See wuzzeb/clerk-auth-org@69e1582 for the code above the default scaffold. But below I post some snippets and my investigation of what the backend code is doing:
Server Code
On the server, the code looks like
const client = createClerkClient(...);
export default {
async fetch(req) {
if (req.method !== "GET") {
return new Response("Method Not Allowed", { status: 405 });
}
var url = new URL(req.url);
// path must be /api/websocket/{clerkOrgId}
if (!url.pathname.startsWith("/api/websocket/")) {
return new Response("Not Found", { status: 404 });
}
if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("Expected WebSocket Upgrade", { status: 400 });
}
const parts = url.pathname.split("/");
if (parts.length !== 4 || !parts[3]) {
return new Response("Not Found", { status: 404 });
}
const clerkOrgId = parts[3];
const token = await client.authenticateRequest(req, {
organizationSyncOptions: {
organizationPatterns: ["/api/websocket/:id"],
},
});
if (!token.isAuthenticated) {
return new Response("Unauthorized", { status: 401 });
}
const auth = await token.toAuth();
if (auth.orgId !== clerkOrgId) {
return new Response("Forbidden", { status: 403 });
}
// more auth.has checks, etc.
},
}Results
- If the currently active org matches the org id in the websocket URL, the call to
authenticateRequestworks, everything matches. - If the currently active org is a different org, I put a breakpoint there and single stepped through
- We go to
authenticateReqeustWithTokenInCookieasync function authenticateRequestWithTokenInCookie() { - It gets down to calling
handleMaybeOrganizationSyncHandshakeatconst handshakeRequestState = handleMaybeOrganizationSyncHandshake(authenticateContext, authObject); handleMaybeOrganizationSyncHandshakecorrectly detects that a org switch is needed at line 378if (organizationSyncTarget.organizationId && organizationSyncTarget.organizationId !== auth.orgId) { handleMaybeHandshakeStatusis calledconst handshakeState = handleMaybeHandshakeStatus( - The first thing
handleMaybeHandshakeStatusdoes is callisRequestEligibleForHandshakeif (!handshakeService.isRequestEligibleForHandshake()) { isRequestEligibleForHandshakechecks for the Sec-Fetch-Mode headerisRequestEligibleForHandshake(): boolean { Sec-Fetch-Modeis empty because this is a websocket requestisRequestEligibleForHandshakereturns false, causinghandleMaybeHandshakeStatusto returnsignedOuthandleMaybeOrganizationSyncHandshakereturns null,// Currently, this is only possible if we're in a redirect loop, but the above check should guard against that. - I think that comment is wrong, the comment says that returning null is only possible if we are in a redirect loop, but there have been no redirect loops yet.
- We go to
Why is that sec-fetch-req check there in the first place? I don't understand isRequestEligibleForHandshake.
Workaround?
On the client, right before making the websocket connection, I can call
await clerk.setActive({organization: orgIdFromURL });
await clerk.session?.getToken({ skipCache: true });
const ws = new WebSocket(`wss://${window.location.host}/api/websocket/`${orgIdFromURL}`);This works except if you have multiple tabs open there is a small timing window where things go wrong. I use ReconnectingWebsocket in that case and it seems to work. If two tabs are trying to connect at the same time, one will fail because it gets the wrong org, but then it will attempt a reconnect a second later. The constant switching of active orgs combined with reconnect seems to work.
Environment
System:
OS: Linux 6.17 Arch Linux
CPU: (16) x64 AMD Ryzen 7 7700X 8-Core Processor
Memory: 20.08 GB / 30.47 GB
Container: Yes
Shell: 4.1.2 - /usr/bin/fish
Binaries:
Node: 25.1.0 - /usr/bin/node
Yarn: 1.22.22 - /usr/bin/yarn
npm: 11.6.2 - /usr/bin/npm
pnpm: 10.20.0 - /usr/bin/pnpm
Browsers:
Chromium: 142.0.7444.134
Firefox: 144.0.2
Firefox Developer Edition: 144.0.2
npmPackages:
@clerk/backend: ^2.20.0 => 2.20.0
@clerk/clerk-react: ^5.53.8 => 5.53.8
@cloudflare/vite-plugin: ^1.14.0 => 1.14.0
@eslint/js: ^9.33.0 => 9.39.1
@types/react: ^19.1.10 => 19.2.2
@types/react-dom: ^19.1.7 => 19.2.2
@vitejs/plugin-react: ^5.0.0 => 5.1.0
eslint: ^9.33.0 => 9.39.1
eslint-plugin-react-hooks: ^5.2.0 => 5.2.0
eslint-plugin-react-refresh: ^0.4.20 => 0.4.24
globals: ^16.3.0 => 16.5.0
react: ^19.1.1 => 19.2.0
react-dom: ^19.1.1 => 19.2.0
typescript: ~5.8.3 => 5.8.3
typescript-eslint: ^8.39.1 => 8.46.3
vite: ^7.1.2 => 7.2.2
wrangler: ^4.46.0 => 4.46.0