Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bug: useSuspenseQuery will get "UNAUTHORIZED" tRPC error #1765

Closed
mengxi-ream opened this issue Feb 20, 2024 · 13 comments · Fixed by #1932
Closed

bug: useSuspenseQuery will get "UNAUTHORIZED" tRPC error #1765

mengxi-ream opened this issue Feb 20, 2024 · 13 comments · Fixed by #1932
Labels
🐞 upstream bug Blocked by a bug upstream

Comments

@mengxi-ream
Copy link

mengxi-ream commented Feb 20, 2024

Provide environment information

System:
OS: macOS 14.2.1
CPU: (8) arm64 Apple M1 Pro
Memory: 196.13 MB / 16.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 20.9.0 - ~/.nvm/versions/node/v20.9.0/bin/node
Yarn: 1.22.21 - ~/.nvm/versions/node/v20.9.0/bin/yarn
npm: 10.1.0 - ~/.nvm/versions/node/v20.9.0/bin/npm
pnpm: 8.14.0 - ~/Library/pnpm/pnpm

Describe the bug

If we have a protected api like:

  hello: protectedProcedure
    .input(z.object({ text: z.string() }))
    .query(async ({ input }) => {
      // wait for 1 second
      await new Promise((resolve) => setTimeout(resolve, 1000));

      return {
        greeting: `Hello ${input.text}`,
      };
    }),

Then we use useSuspenseQuery

"use client";

import { api } from "@/trpc/react";
import { Suspense } from "react";

export default function TestSuspense() {
 return (
   <div>
     <h1>Test Suspense</h1>
     <Suspense fallback={<div>Loading...</div>}>
       <SayHi />
     </Suspense>
   </div>
 );
}

function SayHi() {
 const [greet, getGreet] = api.post.hello.useSuspenseQuery({
   text: "Suspense user",
 });

 return <div>{greet.greeting}</div>;
}

We will get the error UNAUTHORIZED from

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `session` as non-nullable
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});
CleanShot.2024-02-19.at.17.54.35.mp4

You can check the detail of my code in this commit: mengxi-ream/t3-trpc-suspense-bug@b747bba

Reproduction repo

https://github.com/Crayon-ShinChan/t3-trpc-suspense-bug

To reproduce

  1. clone the repo
  2. Go to Discord Portal to create an oauth app for this repo and copy paste the DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET to .env file
  3. pnpm dev

Additional information

No response

@juliusmarminge
Copy link
Member

juliusmarminge commented Feb 20, 2024

Yup this is a limitation in Next since they don't provide any primitive to access headers from a client component during the SSR prepass phase, so queries made on the server wont be authed...

You can fix this by prefetching the data in an RSC and hydrating the query client or pass the initial data as props.

Unfortunately not much we can do here, there is a community package https://github.com/moshest/next-client-cookies that "hacks" around it although I've never tried it

@juliusmarminge juliusmarminge added 🐞 upstream bug Blocked by a bug upstream and removed 🐞❔ unconfirmed bug labels Feb 20, 2024
@mattiaseyram
Copy link

Any update or ways around this? Would be great to use useSuspenseQuery

@mengxi-ream
Copy link
Author

Any update or ways around this? Would be great to use useSuspenseQuery

I use isPending to determine the UI

@22JH
Copy link

22JH commented May 22, 2024

Are you solved this problem?

@mengxi-ream
Copy link
Author

mengxi-ream commented May 22, 2024

Are you solved this problem?

I didn't use useSuspenseQuery and used isPending to determine if I need render skeleton instead

@sbkl
Copy link

sbkl commented May 31, 2024

Using this package phryneas/ssr-only-secrets seems to be working great to pass the cookie to the headers on the SSR piece

Add a new env variable:

// .env.local

SECRET_CLIENT_COOKIE_VAR={"key_ops":["encrypt","decrypt"],"ext":true,"kty":"oct","k":"asdas....","alg":"A256CBC"}

I used the code provided to create the key above:

crypto.subtle
  .generateKey(
    {
      name: "AES-CBC",
      length: 256,
    },
    true,
    ["encrypt", "decrypt"]
  )
  .then((key) => crypto.subtle.exportKey("jwk", key))
  .then(JSON.stringify)
  .then(console.log);

Then in the layout file, access the cookie, encrypt it and pass it to the TRPCReactProvider

// app/layout.tsx

import { headers } from "next/headers";
import { TRPCReactProvider } from "@/trpc/react";

export default async function Layout(props: {
  children: React.ReactNode;
}) {
    const cookie = new Headers(headers()).get("cookie");
    const encryptedCookie = await cloakSSROnlySecret(cookie ?? "", "SECRET_CLIENT_COOKIE_VAR")
    
    return <html>
        <Head/>
        <body>
            ...
            <TRPCReactProvider ssrOnlySecret={encryptedCookie}>            
              {props.children}
            </TRPCReactProvider>
        </body>
    </.html>
}

Then decrypt the value and pass it to the headers on the client side. Reading the value on the browser always returns undefined so you won't be able to see it there.

// trcp/react.ts

"use client";

import { readSSROnlySecret } from "ssr-only-secrets";

import type { AppRouter } from "@beebook/api";
import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import SuperJSON from "superjson";
import { getBaseUrl, getQueryClient } from "./utils";

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { ssrOnlySecret:  string, children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const [trpcClient] = React.useState(() =>
    api.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        unstable_httpBatchStreamLink({
          transformer: SuperJSON,
          url: getBaseUrl() + "/api/trpc",
          async headers() {
            const headers = new Headers();
            const secret = props.ssrOnlySecret;
            const value = await readSSROnlySecret(secret,"SECRET_CLIENT_COOKIE_VAR")
            headers.set("x-trpc-source", "nextjs-react");
            if(value) {
              headers.set("cookie", value);
            }
            return headers;
          },
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

@jchao01
Copy link

jchao01 commented Jul 4, 2024

Using this package phryneas/ssr-only-secrets seems to be working great to pass the cookie to the headers on the SSR piece

Add a new env variable:

// .env.local

SECRET_CLIENT_COOKIE_VAR={"key_ops":["encrypt","decrypt"],"ext":true,"kty":"oct","k":"asdas....","alg":"A256CBC"}

I used the code provided to create the key above:

crypto.subtle
  .generateKey(
    {
      name: "AES-CBC",
      length: 256,
    },
    true,
    ["encrypt", "decrypt"]
  )
  .then((key) => crypto.subtle.exportKey("jwk", key))
  .then(JSON.stringify)
  .then(console.log);

Then in the layout file, access the cookie, encrypt it and pass it to the TRPCReactProvider

// app/layout.tsx

import { headers } from "next/headers";
import { TRPCReactProvider } from "@/trpc/react";

export default async function Layout(props: {
  children: React.ReactNode;
}) {
    const cookie = new Headers(headers()).get("cookie");
    const encryptedCookie = await cloakSSROnlySecret(cookie ?? "", "SECRET_CLIENT_COOKIE_VAR")
    
    return <html>
        <Head/>
        <body>
            ...
            <TRPCReactProvider ssrOnlySecret={encryptedCookie}>            
              {props.children}
            </TRPCReactProvider>
        </body>
    </.html>
}

Then decrypt the value and pass it to the headers on the client side. Reading the value on the browser always returns undefined so you won't be able to see it there.

// trcp/react.ts

"use client";

import { readSSROnlySecret } from "ssr-only-secrets";

import type { AppRouter } from "@beebook/api";
import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import SuperJSON from "superjson";
import { getBaseUrl, getQueryClient } from "./utils";

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { ssrOnlySecret:  string, children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const [trpcClient] = React.useState(() =>
    api.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        unstable_httpBatchStreamLink({
          transformer: SuperJSON,
          url: getBaseUrl() + "/api/trpc",
          async headers() {
            const headers = new Headers();
            const secret = props.ssrOnlySecret;
            const value = await readSSROnlySecret(secret,"SECRET_CLIENT_COOKIE_VAR")
            headers.set("x-trpc-source", "nextjs-react");
            if(value) {
              headers.set("cookie", value);
            }
            return headers;
          },
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

you're a legend for this — thanks

@Vulxan
Copy link

Vulxan commented Jul 22, 2024

I ended up using useQuery instead of useSuspenseQuery and setting throwOnError to true in the defaultOptions of QueryClient. It was too much work for me to prefetch every suspense query.

@musjj
Copy link

musjj commented Jul 23, 2024

Is this issue tracked upstream somewhere (either tRPC or Next.js)? It's a critical issue, but this is basically the only thread I could find about it.

@Tatamo
Copy link

Tatamo commented Dec 10, 2024

I disabled SSR around useSuspenseQuery by using next/dynamic.

import dynamic from "next/dynamic";
import { Suspense, SuspenseProps } from "react";

export const SuspenseWithoutSsr = dynamic(
  () => Promise.resolve((({ children, ...props }) => <Suspense {...props}>{children}</Suspense>) as React.FC<SuspenseProps>),
  { ssr: false }
);

If you are using useSuspenseQuery, there should be a Suspense component wrapping it.
By replacing it with <SuspenseWithoutSsr>, everything seems to work as expected.
While disabling SSR for the entire Suspense might be too wide in some use cases and could impact performance, this approach is more convenient than finding where to use next/dynamic with useSuspnseQuery every time.

@nicofishman
Copy link

Is there any update on this issue?

@Nubebuster
Copy link

Is there any update on this issue?

I am coming back here and I realise I've had this exact issue in july. I thought I'll just use useQuery. But not this time.

Let's understand the problem first: SSR wants to use the TRPC react client to prefetch data. But it has no authorization, it only has authorization for the server client of TRPC. The solution is to install the ssr-only-secrets package which solves this issue.

The code from @sbkl is the solution and I just made it work perfectly.

So it is solvable. I am not sure if it's implemented into create-t3-app, but this repo is not really plug and play. You have to do some work here and there to unlock it's potential. Which is fine because we're web devs 👍

@Nubebuster
Copy link

Is there any update on this issue?

Okay, so the ssr only secrets thing had some issues. I stumbled upon this solution which is much better:

import "server-only";

import { headers, type UnsafeUnwrappedHeaders } from "next/headers";
import { cache } from "react";
import { createHydrationHelpers } from "@trpc/react-query/rsc";

import { createQueryClient } from "./query-client";
import { type AppRouter, createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";

/**
 * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
 * handling a tRPC call from a React Server Component.
 */
const createContext = cache(() => {
  const heads = new Headers(headers() as unknown as UnsafeUnwrappedHeaders);
  heads.set("x-trpc-source", "rsc");

  return createTRPCContext({
    headers: heads,
  });
});

const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);

export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
  caller,
  getQueryClient,
);

query-client.ts

import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";

export const createQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 30 * 1000,
      },
      dehydrate: {
        serializeData: SuperJSON.serialize,
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
      hydrate: {
        deserializeData: SuperJSON.deserialize,
      },
    },
  });

Note you should also import this createQueryClient in your react.ts for trpc.

Then finally anything that needs hydration needs to be wrapped in

import { HydrateClient } from "~/trpc/server";

you could put it in the layout.tsx for example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🐞 upstream bug Blocked by a bug upstream
Projects
None yet
Development

Successfully merging a pull request may close this issue.