jstack icon indicating copy to clipboard operation
jstack copied to clipboard

Cannot use ClerkMiddleware and input together

Open Shivam-002 opened this issue 11 months ago • 4 comments

I encountered following issue when using Clerk Middleware and jstack's input validation.

X [ERROR] [API Error] TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body.

at new ClerkRequest (file:///E:/intonix/application-backup-v3/node_modules/@clerk/backend/src/tokens/clerkRequest.ts:29:5)

The main cause to the problem is clerk reading the stream after it's already been read.

When I debug it I found that this code trying to read it.

node_modules/wrangler/templates/middleware/middleware-ensure-req-body-drained.ts
var drainBody = /* @__PURE__ */ __name(async (request, env4, _ctx, middlewareCtx) => {
  try {
    return await middlewareCtx.next(request, env4);
  } finally {
    try {
      if (request.body !== null && !request.bodyUsed && request) {
        const reader = request.body.getReader();
        while (!(await reader.read()).done) {
        }
      }
    } catch (e) {
      console.error("Failed to drain the unused request body.", e);
    }
  }
}, "drainBody");
var middleware_ensure_req_body_drained_default = drainBody;

Just reporting the bug. As I am now directly using the await c.req.json() and manually parsing it. :)

Shivam-002 avatar Feb 07 '25 14:02 Shivam-002

Please lmk these things to help:

  • jstack version
  • which clerk middleware exactly youve used
  • your middleware implementation you expect to work

thanks for raising the issue

joschan21 avatar Feb 10 '25 14:02 joschan21

Jstack Version : [email protected] Clerk Version : @hono/[email protected]

Here is the middleware implementation.

const authMiddleware = j.middleware(async ({ c, next }) => {
  const { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY } = env(c) as {
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
    CLERK_SECRET_KEY: string;
  };

  await honoClerkMiddleware({
    publishableKey: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
    secretKey: CLERK_SECRET_KEY,
  })(c, async () => {
    const auth = getAuth(c);

    if (!auth?.userId) {
      c.json({ message: "You are not logged in." }, 401);
      return;
    }

    await next({ auth });
  });
});

When in a procedure i have input with a schema it throws this error. But as I remove the input and directly use the procedure it works. The error stack trace says it's clerk fault.

Shivam-002 avatar Feb 10 '25 15:02 Shivam-002

#58 covers this in more detail as to why you are getting errors.

Its because zod is consuming the body before clerk middleware can, so its erroring as contaminated/consumed/read whatever you want to call it. This is most commonly and likely a security thing. There is a workaround for this, but its a tough setup and requires you to modify how you call anything on the client side that requires the ClerkMiddleware for securing authenticated procedures.

This is how I did the middlewares, given i don't need authMiddleware to run on some paths but need access to clerk data i made a separate procedure, but it works very well.

// other jstack imports and stuff for db
import { createClerkClient, verifyToken } from "@clerk/backend";
import { env } from "hono/adapter";
import { getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";

const clerkMiddleware = j.middleware(async ({ c, next }) => {
	const { CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, CLERK_JWT_KEY } = env(c);
	const cookies = getCookie(c);
	const sessionCookie = cookies["__session"];
	const authorization = c.req.header("Authorization")?.replace("Bearer ", "");

	const token = sessionCookie || authorization;
	const client = createClerkClient({
		secretKey: CLERK_SECRET_KEY,
		publishableKey: CLERK_PUBLISHABLE_KEY,
	});

	if (!token) {
		throw new HTTPException(401, {
			message: "Unauthorized, no session token. Please sign in.",
		});
	}

	try {
		const verifiedToken = await verifyToken(token, {
			jwtKey: CLERK_JWT_KEY,
			secretKey: CLERK_SECRET_KEY,
			clockSkewInMs: 120000,
			authorizedParties: [
				"http://localhost:3000",
				"https://jconetjobs.cloud",
				"http://localhost:8000",
				"https://api.jconetjobs.cloud",
			],
		});

		return await next({ client, verifiedToken });
	} catch (error) {
		console.log(error);
		throw new HTTPException(500, {
			message: "Something went wrong please try again.",
		});
	}
});

export const withClerk = publicProcedure.use(clerkMiddleware);

const authMiddleware = j.middleware(async ({ c, ctx, next }) => {
	const { db, client, verifiedToken } = ctx as InferMiddlewareOutput<
		typeof dbMiddleware
	> &
		InferMiddlewareOutput<typeof clerkMiddleware>;

	const session = await client.sessions.getSession(verifiedToken.sid);

	if (!session || !session.userId) {
		throw new HTTPException(401, {
			message: "Unauthorized, sign in to continue.",
		});
	}

	const user = await db.user.findUnique({
		where: {
			extId: session.userId,
		},
		select: {
			id: true,
			username: true,
			extId: true,
		},
	});

	if (!user) {
		throw new HTTPException(404, {
			message: "User not recognized, sign up to continue.",
		});
	}

	return await next({ user });
});

export const privateProcedure = withClerk.use(authMiddleware);

This is how its called from the frontend

import { client } from "@/lib/client";
import { useAuth } from "@clerk/nextjs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
// rest of imports

// rest of consts needed
const queryClient = useQueryClient();

const {
	data,
	isLoading: queryLoading,
	error,
	isError,
} = useQuery({
	queryKey: ["personal-info-query"],
	queryFn: async () => {
		const res = await client.auth.getPersonalInformation.$get(
			undefined,
			{
				headers: {
					Authorization: `Bearer ${await getToken()}`,
				},
			}
		);

		return await res.json();
	},
});

const { mutate, isPending: mutatePending } = useMutation({
	mutationKey: ["personal-info-upate"],
	mutationFn: async ({
		dob,
		sex,
		country,
	}: {
		dob: string;
		sex: string;
		country: string;
	}) => {
		await client.auth.updatePersonalInformation.$post(
			{
				country,
				sex,
				dob,
			},
			{
				headers: {
					Authorization: `Bearer ${await getToken()}`,
				},
			}
		);
	},
	onError: (error: HTTPException) => {
		toast.error(error.message);
	},
	onSuccess: async () => {
		await queryClient.invalidateQueries({
			queryKey: ["personal-info-query"],
		});
	},
});
// rest of file...

You can see a working version of this here and view the code here JCoNet Jobs Build Status

jcodog avatar Feb 21 '25 03:02 jcodog

Thank You for the solution. Now it is working. Cheers 👍

Shivam-002 avatar Mar 09 '25 17:03 Shivam-002