apollographql / apollo-client-nextjs

Apollo Client support for the Next.js App Router
https://www.npmjs.com/package/@apollo/experimental-nextjs-app-support
MIT License
459 stars 36 forks source link

Authentification question #281

Open dChunikhin opened 7 months ago

dChunikhin commented 7 months ago

Hello! I have existing app react/apollo-graphql/next

I need to ensure login/logout functionality.

My server is on python/django/graphene stack.

I have mutations to login, and refresh the JWT token.

mutation emailSignIn($email: String!, $password: String!) {
    emailSignIn(email: $email, password: $password) {
        token
        refreshToken
    }
}
mutation refreshToken($refreshToken: String) {
    refreshToken(refreshToken: $refreshToken) {
        token
        refreshToken
    }
}

Previously, before SSR i stored got tokens in LocalStorage and then pass into headers of every request:

const ApiLink = new ApolloLink((operation, forward) => {
    operation.setContext(({ headers = {} }) => ({
        credentials: 'include',
        headers: {
            ...headers,
            authorization: TokenStorage.isAuthenticated()
                ? TokenStorage.getAuthenticationHeader()
                : '',
        },
    }))
    return forward(operation)
})

and refresh token in case of expiration error:

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    if (graphQLErrors) {
        const messages = graphQLErrors.map(({ message }) => message)
        if (
            messages.includes('Signature has expired') ||
            messages.includes('Error decoding signature')
        ) {
            return getNewTokenByRefreshToken(TokenStorage.getRefreshToken())
                .filter(value => Boolean(value))
                .flatMap(newToken => {
                    const oldHeaders = operation.getContext().headers
                    // eslint-disable-next-line @typescript-eslint/no-use-before-define
                    operation.setContext({
                        headers: {
                            ...oldHeaders,
                            authorization: `JWT ${newToken}`,
                        },
                    })
                    return forward(operation)
                })
        }
    }
})

But now I have no localStorage on my SSRed pages. Could you please provide me with a way to manage it in a new way? I use App Router mode and my root layout is with 'use client' directive

Thanks in advance!

phryneas commented 7 months ago

Hi Denis,

authentication using Next.js is unfortunately a bit more complex :/

As you noticed, your client components will be rendered on both the server and in the client - and of course, on the server you cannot access the browser's localStorage.

What I would recommend is using cookies.

Best would be if your Django server would be accessible from the same domain, because then you can share cookies between your Django server and your Next.js server.

In that scenario, the Django server would set authentication cookies (httpOnly, secure). Requests from your Browser to your Django server would automatically include those cookies, so no need to do any localStorage stuff in your client.

Then you would need to get access to those cookies during SSR. Unfortunately, the SSR run of Next.js has no direct access to cookies - you would need to pass them into your Client Components as props from a RSC component that can access cookies. To ensure that these secrets don't leak into the browser, you could use the https://github.com/phryneas/ssr-only-secrets package.

patrick91 commented 7 months ago

for reference, this is how I did something similar, not sure if it is the best way:

"use client";

import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  NextSSRApolloClient,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { readSSROnlySecret } from "ssr-only-secrets";

const getAuthLink = (cloakedSessionId: Promise<string>) =>
  setContext(async (_, { headers }) => {
    const sessionId = await readSSROnlySecret(
      // @ts-ignore
      await cloakedSessionId,
      "SECRET_KEY",
    );

    if (sessionId) {
      return {
        headers: {
          ...(headers || {}),
          Cookie: `sessionid=${sessionId}`,
        },
      };
    }

    return { headers };
  });

export function ApolloWrapper({
  children,
  sessionId,
}: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  function makeClient() {
    const httpLink = new HttpLink({
      uri:
        typeof window === "undefined"
          ? "http://localhost:8000/graphql"
          : "/graphql",

      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ApolloWrapper } from "@/components/apollo-wrapper";
import { cookies } from "next/headers";
import { cloakSSROnlySecret } from "ssr-only-secrets";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const sessionIdPlain = cookies().get("sessionid")?.value;

  const sessionId = cloakSSROnlySecret(sessionIdPlain ?? "", "SECRET_KEY");

  return (
    <ApolloWrapper sessionId={sessionId}>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </ApolloWrapper>
  );
}
sayandedotcom commented 7 months ago

I am not getting authorization in headers in the apollo server context!

The Error in next.js console Screenshot from 2024-04-19 19-52-19 My ENV.. Note: I have not generated any envs like image

In layout.tsx

  const token = cookies().get("next-auth.session-token")?.value;

  const sessionId = cloakSSROnlySecret(token ?? "", "SECRET_KEY");

 <ApolloWrapper sessionId={sessionId}> ...

in apollo-wrapper.tsx

"use client";

import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
  ApolloNextAppProvider,
  NextSSRApolloClient,
  NextSSRInMemoryCache,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { readSSROnlySecret } from "ssr-only-secrets";

const getAuthLink = (cloakedSessionId: Promise<string>) =>
  setContext(async (_, { headers }) => {
    const sessionId = await readSSROnlySecret(
      // @ts-ignore
      await cloakedSessionId,
      "SECRET_KEY"
    );

    if (sessionId) {
      return {
        headers: {
          ...headers,
          authorization: sessionId ? `Bearer ${sessionId}` : "",
        },
      };
    }

    return { headers };
  });

export function ApolloWrapper({
  children,
  sessionId,
}: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  function makeClient() {
    const httpLink = new HttpLink({
      uri: typeof window === "undefined" ? "http://localhost:8000/graphql" : "http://localhost:8000/graphql",

      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
}
phryneas commented 7 months ago

@sayandedotcom if you log it, do you have the cookies available? Are they sent from the browser if you look into the network tab?

sayandedotcom commented 7 months ago

yes , I logged it and it was available and it is working fine with the server component

Server Component

import { cookies } from "next/headers";

import { ApolloClient, InMemoryCache, createHttpLink, from } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { onError } from "@apollo/client/link/error";
import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc";

const httpLink = createHttpLink({
  uri: "http://localhost:8000/graphql",
  credentials: "include",
  fetchOptions: { cache: "no-store" },
});

const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    console.log(graphQLErrors);
  }

  if (networkError) {
    // handle network error
    console.log(networkError);
  }
});

const authLink = setContext((_, { headers }) => {
  const cookieStore = cookies();
  const token =
    cookieStore.get("__Secure-next-auth.session-token") ?? cookieStore.get("next-auth.session-token");

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token.value}` : "",
    },
  };
});

const appLink = from([errorLink, httpLink]);

export const { getClient } = registerApolloClient(() => {
  return new ApolloClient({
    link: authLink.concat(appLink),
    cache: new InMemoryCache(),
    defaultOptions: {
      query: {
        errorPolicy: "all",
      },
      mutate: {
        errorPolicy: "all",
      },
    },
  });
});
AlSirang commented 7 months ago

for reference, this is how I did something similar, not sure if it is the best way:

"use client";

import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  NextSSRApolloClient,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { readSSROnlySecret } from "ssr-only-secrets";

const getAuthLink = (cloakedSessionId: Promise<string>) =>
  setContext(async (_, { headers }) => {
    const sessionId = await readSSROnlySecret(
      // @ts-ignore
      await cloakedSessionId,
      "SECRET_KEY",
    );

    if (sessionId) {
      return {
        headers: {
          ...(headers || {}),
          Cookie: `sessionid=${sessionId}`,
        },
      };
    }

    return { headers };
  });

export function ApolloWrapper({
  children,
  sessionId,
}: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  function makeClient() {
    const httpLink = new HttpLink({
      uri:
        typeof window === "undefined"
          ? "http://localhost:8000/graphql"
          : "/graphql",

      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ApolloWrapper } from "@/components/apollo-wrapper";
import { cookies } from "next/headers";
import { cloakSSROnlySecret } from "ssr-only-secrets";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const sessionIdPlain = cookies().get("sessionid")?.value;

  const sessionId = cloakSSROnlySecret(sessionIdPlain ?? "", "SECRET_KEY");

  return (
    <ApolloWrapper sessionId={sessionId}>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </ApolloWrapper>
  );
}

As explained in the documentation for ssr-only-secrets, calling the function readSSROnlySecret in a browser environment will always return undefined. I have modified the approach used by @patrick91 a bit, and I am not sure if this is the best practice, but it seems to work fine for now.

The issue I was having with @patrick91's approach was that when I tried to use the useSuspenseQuery hook in a client component, it was not attaching the access token in the headers. This was because client components are rendered on both the server and the client, and readSSROnlySecret will return undefined in browser environments.

To handle this issue, I updated the code as follows to make it work on both the server and the client side:

On signIn, I am storing the access token in cookies as follows:


type TSignIn = { email: string; password: string }
export const signIn = async (params: TSignIn) => {
  const result = await getClient().mutate<{ signIn: AuthTokenType }>({
    mutation: SIGN_IN_MUTATION,
    variables: {
      input: {
        ...params
      }
    }
  })

  const accessToken = result.data?.signIn.access_token
  cookies().set(authConfig.storageTokenKeyName, accessToken)

}

ApolloWrapper.tsx

'use client'

import { ApolloLink, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  NextSSRApolloClient,
  SSRMultipartLink
} from '@apollo/experimental-nextjs-app-support/ssr'
import { readSSROnlySecret } from 'ssr-only-secrets'
import authConfig from './authConfig'
import { getCookie } from '../utils/getCookie'

export function ApolloWrapper({ children, sessionId }: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  function makeClient() {
    const authLink = setContext(async (_, { headers }) => {
      const cloakedSessionId = await sessionId

      const accessToken =
        typeof window === 'undefined'
          ? await readSSROnlySecret(cloakedSessionId, 'SECRET_KEY')
          : getCookie(authConfig.storageTokenKeyName)

      return {
        headers: {
          ...headers,
          authorization: accessToken ? `Bearer ${accessToken}` : ''
        }
      }
    })
    const httpLink = new HttpLink({
      uri:
        typeof window === 'undefined'
          ? 'https://example.com/graphql'
          : `${process.env.NEXT_PUBLIC_SERVER_URL}/graphql`
    })

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === 'undefined'
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true
              }),
              authLink.concat(httpLink)
            ])
          : authLink.concat(httpLink)
    })
  }

  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>
}
phryneas commented 7 months ago

The issue I was having with @patrick91's approach was that when I tried to use the useSuspenseQuery hook in a client component, it was not attaching the access token in the headers. This was because client components are rendered on both the server and the client, and readSSROnlySecret will return undefined in browser environments.

That is pretty much intentional - note that Patrick is working with cookies here. In the browser, cookies would automatically be set by the browser. There's just nothing to do in code here.

But yes, if you're working with manually set headers, you'd have to do it this way. If possible, I'd recommend going cookies only though.

AlSirang commented 7 months ago

The issue I was having with @patrick91's approach was that when I tried to use the useSuspenseQuery hook in a client component, it was not attaching the access token in the headers. This was because client components are rendered on both the server and the client, and readSSROnlySecret will return undefined in browser environments.

That is pretty much intentional - note that Patrick is working with cookies here. In the browser, cookies would automatically be set by the browser. There's just nothing to do in code here.

But yes, if you're working with manually set headers, you'd have to do it this way. If possible, I'd recommend going cookies only though.

Yes, in my case, I had to set headers manually because the backend is not configured with cookies. It is configured with Token authentication, so I had to use this approach.

AlSirang commented 7 months ago

Still stuck with this By logging the sessionid in ApolloWrapper functions

"use client";

import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
  ApolloNextAppProvider,
  NextSSRApolloClient,
  NextSSRInMemoryCache,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { readSSROnlySecret } from "ssr-only-secrets";

const getAuthLink = (cloakedSessionId: Promise<string>) =>
  setContext(async (_, { headers }) => {
    const sessionId = await readSSROnlySecret(
      // @ts-ignore
      await cloakedSessionId,
      "SECRET_KEY"
    );

    if (sessionId) {
      return {
        headers: {
          // ...(headers || {}),
          // Cookie: `sessionid=${sessionId}`,
          ...headers,
          authorization: sessionId ? `Bearer from wrapper ${sessionId}` : "",
        },
      };
    }

    return { headers };
  });

export function ApolloWrapper({
  children,
  sessionId,
}: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  console.log("🔥🔥🔥🔥🔥🔥🔥🔥", sessionId);

  function makeClient() {
    const httpLink = new HttpLink({
      uri: typeof window === "undefined" ? "http://localhost:8000/graphql" : "http://localhost:8000/graphql",

      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
}

I get this image

network tab image

Apollo Server image

Log from apollo server image

My env SECRET_KEY={"alg":"A256CBC","ext":true,"k":"...","key_ops":["encrypt","decrypt"],"kty":"oct"}

From error it seems like your SECRET_KEY is not valid. Use the function provided in docs to create it. You have used example provided in docs which is not complete.

sayandedotcom commented 7 months ago

I generated the secret key and still getting DataError: Invalid key length

The only thing different from my code and docs is

    const httpLink = new HttpLink({
      uri: typeof window === "undefined" ? "http://localhost:8000/graphql" : "http://localhost:8000/graphql",

      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });
phryneas commented 6 months ago

The key cannot be read or is incorrect, this has nothing to do with your HttpLink.

Can you try to console.log(process.env.SECRET_KEY) ?

Did you copy-paste the example key? That one is deliberately incomplete "..."

sayandedotcom commented 6 months ago

I kept my own generated key in SECRET_KEY as well as in SECRET_KEY_VAR Strangely while using SECRET_KEY as env, it shows the example "..." environment variable, but while using SECRET_KEY_VAR it shows my generate the key. Now I am using SECRET_KEY_VAR which is not throwing the error DataError: Invalid key length

sayandedotcom commented 6 months ago

Everything working well except the context. The function getAuthLink is not running either on client or on the server side.. I checked it by doing console.log

const getAuthLink = (cloakedSessionId: Promise<string>) =>
  setContext(async (_, { headers }) => {
    const sessionId = await readSSROnlySecret(
      // @ts-ignore
      await cloakedSessionId,
      "SECRET_KEY_VAR"
    );

    console.log("2st Console run");

    if (sessionId) {
      return {
        headers: {
          ...(headers || {}),
          Cookie: `sessionid=${sessionId}`,
        },
      };
    }

    return { headers };
  });

export function ApolloWrapper({
  children,
  sessionId,
}: React.PropsWithChildren<{ sessionId: Promise<string> }>) {
  function makeClient() {
    const httpLink = new HttpLink({
      uri: typeof window === "undefined" ? "http://localhost:8000/graphql" : "http://localhost:8000/graphql",
      fetchOptions: {
        cache: "no-store",
        crendentials: "include",
      },
    });

    console.log("1st Console run");

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
}

It is also not sending any headers

WhatsApp Image 2024-05-07 at 12 08 31 AM

in apollo server console image

phryneas commented 6 months ago

In your code snippet

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === "undefined"
          ? ApolloLink.from([
              new SSRMultipartLink({
                stripDefer: true,
              }),
              getAuthLink(sessionId),
              httpLink,
            ])
          : httpLink,
    });
  }

you run getAuthLink if you are in SSR (when typeof window === "undefined"). You won't see that happening in the browser devtools - but in your NextJs server console (which is probably different from your Apollo Server console)

sayandedotcom commented 6 months ago

Now everything seems good, I am following the same code as this https://github.com/apollographql/apollo-client-nextjs/issues/281#issuecomment-2057927433. why the headers are not sent to the Apollo server?

My apollo server

  app.use(
    "/graphql",
    cors({ credentials: true }),
    expressMiddleware(server as any, {
      context: async ({ req, res }) => {
        console.log("req-------------", req.headers);

        return {

        };
      },
    })
  );

This is what I am getting in the headers of apollo server image

naimulemon commented 1 month ago

I am using JWT http only token. I can't set manually the token. How can I set HTTP only token on apollo client ?

phryneas commented 1 month ago

@naimulemon as long as it's the same server, that's fine and it should be sent to your Next server, where you can access it and pass it around with the methods shown in this issue.

If that cookie points to a different server, you have a structural problem that makes SSR of private data impossible - but that doesn't have anything to do with this package or Next.js, more with the general concept of how cookies and the web works.

At that point, you'd have to revisit you authentication architecture, or just stick to in-browser rendering and disable SSR altogether.