apollo-client-nextjs icon indicating copy to clipboard operation
apollo-client-nextjs copied to clipboard

examples handling authenticated clients

Open masterkain opened this issue 2 years ago • 32 comments

this has been a torn in my side for a while, if you can provide an example with authenticated clients that would be super.

masterkain avatar May 11 '23 12:05 masterkain

@masterkain that's a good suggestion! I think I can update the polls demo to add a section that needs authentication :)

Do you have any preference regarding auth system? I'll try with a cookie based session if not 😊

patrick91 avatar May 11 '23 13:05 patrick91

cookies / auth token would be a blessing for me, need to better understand what to do when a client becomes unauthenticated, how to properly pass auth data to the client after login, etc. ❤️

masterkain avatar May 11 '23 13:05 masterkain

This library is amazing for firebase

https://github.com/awinogrodzki/next-firebase-auth-edge

easy examples

seanaguinaga avatar May 11 '23 18:05 seanaguinaga

I just have it doing this now

layout.tsx

import { getTokens } from 'next-firebase-auth-edge/lib/next/tokens';
import { cookies } from 'next/dist/client/components/headers';
import { ApolloWrapper } from '../components/apollo-wrapper';
import { AuthProvider } from '../components/auth-provider';
import { mapTokensToTenant } from '../lib/firebase/auth';
import { serverConfig } from '../lib/firebase/server-config';

import './global.css';

export const metadata = {
  title: 'Nx Next App',
  description: 'Generated by create-nx-workspace',
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const tokens = await getTokens(cookies(), {
    serviceAccount: serverConfig.serviceAccount,
    apiKey: serverConfig.firebaseApiKey,
    cookieName: 'AuthToken',
    cookieSignatureKeys: ['secret1', 'secret2'],
  });

  const tenant = tokens ? mapTokensToTenant(tokens) : null;

  return (
    <html lang="en">
      <body>
        <AuthProvider defaultTenant={tenant}>
          <ApolloWrapper token={tokens?.token}>{children}</ApolloWrapper>
        </AuthProvider>
      </body>
    </html>
  );
}

apollo-wrapper.tsx

'use client';

import {
  ApolloClient,
  ApolloLink,
  HttpLink,
  SuspenseCache,
} from '@apollo/client';
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  SSRMultipartLink,
} from '@apollo/experimental-nextjs-app-support/ssr';
import React from 'react';

const uri = process.env.NEXT_PUBLIC_HASURA_URL;

function createClient(token: string | undefined) {
  const httpLink = new HttpLink({
    uri,
    headers: token
      ? {
          Authorization: `Bearer ${token}`,
        }
      : {
          'x-hasura-admin-secret': 'myadminsecretkey',
        },
  });

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

function makeSuspenseCache() {
  return new SuspenseCache();
}

export function ApolloWrapper({
  children,
  token,
}: React.PropsWithChildren<{
  token: string | undefined;
}>) {
  const makeClient = React.useCallback(() => createClient(token), [token]);

  return (
    <ApolloNextAppProvider
      makeClient={makeClient}
      makeSuspenseCache={makeSuspenseCache}
    >
      {children}
    </ApolloNextAppProvider>
  );
}

seanaguinaga avatar May 11 '23 18:05 seanaguinaga

Yup, that's a very good approach!

Just to highlight the important parts from your code snippet to make it easier for other people following along:

  • use cookies() from 'next/dist/client/components/headers' in a React Server Component to get the current cookies, and extract your token from them
  • pass that token as a prop into your ApolloWrapper.
  • use that token in your makeClient function

phryneas avatar May 12 '23 08:05 phryneas

Thanks @seanaguinaga! How do you update the given token when it expired ?

louisthomaspro avatar May 28 '23 08:05 louisthomaspro

Any examples of doing this with Next-Auth?

Also, the example above is for client-side auth. Is there a way to use authentication with RSC?

Do we need to manually pass the context into every call?

We used to pass the context into the createIsomorphicLink function like so:

type ResolverContext = {
  req?: IncomingMessage
  res?: ServerResponse
}

function createIsomorphicLink(context?: ResolverContext) {
  if (typeof window === 'undefined') {
    const { SchemaLink } = require('@apollo/client/link/schema')

    const schema = makeExecutableSchema({
      typeDefs: typeDefs,
    })
    return new SchemaLink({ schema, context })
  } else {
    const { HttpLink } = require('@apollo/client')
    return new HttpLink({
      uri: '/api/graphql',
      credentials: 'same-origin',
    })
  }
}

Is there a way we can do this in the getClient function to have some context on the Server Side calls?

Can we use SchemaLink with getClient?

rafaelsmgomes avatar May 30 '23 16:05 rafaelsmgomes

@rafaelsmgomes You probably don't need context here, in Next.js you should be able to just call headers() or cookies() within your registerApolloClient callback.

Can we use SchemaLink with getClient?

I don't see a reason why not, but be aware that if you have any global variables like typeDefs here, they will be shared between all requests, so don't store any state in there.

phryneas avatar May 31 '23 10:05 phryneas

Thanks @seanaguinaga! How do you update the given token when it expired ?

The auth library does that for me, thankfully

seanaguinaga avatar Jun 03 '23 00:06 seanaguinaga

Hi, @phryneas!

I still don't understand how to crack this one.

This is how I used to authenticate the getServerSideProps function:

export async function getServerSideProps(ctx: GetServerSidePropsContext<{ symbol: string }>) {
  const { symbol } = ctx.params!
  const { req, res } = ctx

  const apolloClient = initializeApollo({})

  const context: DefaultContext = {
    headers: {
      cookie: req.headers?.cookie, // this is where I'm passing the cookies down to authenticate
    },
  }

  await apolloClient.query({
      query: GET_PROFILE_CHART,
      variables: { symbol, fromDate },
      context,
    }),

  return addApolloState(apolloClient, {
    props: { symbol },
  })
}

I'm trying to authenticate my user on the register client function calls via:

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
import { cookies } from 'next/dist/client/components/headers'

export const { getClient } = registerApolloClient(() => {

  let nextCookies = cookies()
    .getAll()
    .reduce((acc, cur) => {
      const { name, value } = cur
      acc += `${name}:${value}`
      return acc
    }, '')
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      // https://studio.apollographql.com/public/spacex-l4uc6p/
      // uri: '/api/graphql',
      uri: 'http://localhost:3000/api/graphql',
      headers: {
        cookie: nextCookies,
      },
      credentials: 'same-origin',

      // you can disable result caching here if you want to
      // (this does not work if you are rendering your page with `export const dynamic = "force-static"`)
      // fetchOptions: { cache: "no-store" },
    }),
  })
})

That did not work, but neither did passing the context in the getClient function:

  let nextCookies = cookies()
    .getAll()
    .reduce((acc, cur) => {
      const { name, value } = cur
      acc += `${name}:${value}`
      return acc
    }, '')

  const { data } = await getClient().query({
    query: TEST_QUERY,
    variables: { symbol },
    context: { headers: { cookie: nextCookies } },
  })

I thought this would work, and I don't see another way of doing it.

Maybe I added the cookies in a wrongful way? But that would mean the headers need to be passed differently now?

Maybe I have to call cookies in a different way. Or not pass it in the same manner as it works in the frontend. I don't know.

rafaelsmgomes avatar Jun 09 '23 02:06 rafaelsmgomes

@rafaelsmgomes It feels to me that both of these should be working - have you tried to console.log that nextCookies variable?

phryneas avatar Jun 09 '23 08:06 phryneas

Hi @phryneas! Thanks for letting me know as I pursuing the right solution!

The issue was within the keyboard and the chair on my side of the screen. The reduce function had a couple of mistakes!

I'll put it here in case anyone is wondering:

  let nextCookies = cookies()
    .getAll()
    .reduce((acc, cur, i, arr) => {
      const { name, value } = cur
      acc += `${name}=${value}${arr.length - 1 !== i ? '; ' : ''}` // forgot to give it a space after each cookie. Also, was using ":" instead of "="
      return acc
    }, '')

rafaelsmgomes avatar Jun 09 '23 14:06 rafaelsmgomes

Is there an example of how to use this with Next Auth? I'm having a lot of issues with getting this to work in both client and server side components.

jnovak-SM2Dev avatar Jun 26 '23 01:06 jnovak-SM2Dev

Yup, that's a very good approach!

Just to highlight the important parts from your code snippet to make it easier for other people following along:

  • use cookies() from 'next/dist/client/components/headers' in a React Server Component to get the current cookies, and extract your token from them
  • pass that token as a prop into your ApolloWrapper.
  • use that token in your makeClient function

One issue I'm having is that, my login page is part of the same app. when makeClient is called with user A token and then during the same session you log out and login with user B. makeClient will not get called again with the new token.

my flow is

  1. User visits "/login"
  2. Login with Credentials A and cookie gets set by external API
  3. Navigate user to dashboard using next/navigation
  4. Log out and navigate to login using next/navigation
  5. Login with Credentials B and cookie gets set by external API
  6. Navigate user to dashboard using next/navigation

The dashboard information is still showing information from Credentials A. The components are mix of "use client" and "use server". When console logging the token it only ever gets set on when makeClient is called, which only happens once.

as a work around for now I do window.location.href = "/dashbaord"; and window.location.href = "/login"; when navigating between private and public pages so that makeClient gets called with the correct token.

any ideas what i might be doing wrong or solution for this issue?

yasharzolmajdi avatar Jul 12 '23 04:07 yasharzolmajdi

Looking for some help on this myself, I am just trying to send in a new token/create a new authorization header when a user logins. My setToken function gets called when a successful login comes back, I noticed my makeClient was never being called again...Any help would be greatly appreciated! :D

export const Wrapper = ({children}: {children: React.ReactNode}) => {
  const [token, setToken] = React.useState<string>()
  const makeClient = React.useCallback(() => createClient(token), [token])
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      <ToastProvider>
        <UserProvider setToken={setToken}>{children}</UserProvider>
      </ToastProvider>
    </ApolloNextAppProvider>
  )
}

p1pah avatar Aug 25 '23 21:08 p1pah

Seems like currently the only way is to save token in local storage/cookies and reload page. ApolloNextAppProvider specifically creates singleton and calls makeClient once in server context and once in client one. https://github.com/apollographql/apollo-client-nextjs/blob/main/package/src/ssr/ApolloNextAppProvider.tsx

chvllad avatar Aug 25 '23 23:08 chvllad

@ben-hapip @chvllad You should never recreate your whole ApolloClient instance when an authentication token changes.

The best way to solve this would storing the auth token in a way that is transparent to Apollo Client (at least in the browser) - in a httpOnly secure cookie. If that is not possible, you could e.g. use a ref to hold your token, inline your makeClient function and access that ref from your setContext link to add the authentication header.

phryneas avatar Aug 28 '23 09:08 phryneas

Ayyy thanks fellas for the suggestions!! 🤝

p1pah avatar Aug 28 '23 15:08 p1pah

Looking for some help on this myself, I am just trying to send in a new token/create a new authorization header when a user logins. My setToken function gets called when a successful login comes back, I noticed my makeClient was never being called again...Any help would be greatly appreciated! :D

Has this been solved @ben-hapip ? I tried your solution @phryneas but it does not work for me :(

rmn-hssvldt avatar Sep 12 '23 19:09 rmn-hssvldt

@romain-hasseveldt As I said before, makeClient will not be called again, and you should also never do that in the browser. You should keep one Apollo Client instance for the full lifetime of your application, or you will throw your full cache away.

If you show a code example here, I can show you how to leverage a ref here to change a token without recreating the client.

phryneas avatar Sep 13 '23 08:09 phryneas

Hello @phryneas , thank you for the promptness of your response. Here is what my current implementation looks like:

'use client';

import { ApolloLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  NextSSRApolloClient,
  SSRMultipartLink,
} from '@apollo/experimental-nextjs-app-support/ssr';
import { createUploadLink } from 'apollo-upload-client';
import { User } from 'next-auth';
import { useSession } from 'next-auth/react';

function makeClient(user?: User) {
  const httpLink = createUploadLink({
    uri: process.env.NEXT_PUBLIC_BACK_GRAPHQL_URL,
  }) as unknown as ApolloLink;

  const authLink = setContext((_, { headers }) => {
    return {
      headers: {
        ...headers,
        authorization: user ? `Bearer ${user.jwt}` : '',
      },
    };
  });

  const links = [authLink, httpLink];

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link:
      typeof window === 'undefined'
        ? from(
            [
              new SSRMultipartLink({
                stripDefer: true,
              }),
              links,
            ].flat(),
          )
        : from(links),
  });
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  const { data: session } = useSession();

  return (
    <ApolloNextAppProvider makeClient={() => makeClient(session?.user)}>
      {children}
    </ApolloNextAppProvider>
  );
}

rmn-hssvldt avatar Sep 13 '23 11:09 rmn-hssvldt

In that case, you need to move makeClient into the scope of your ApolloWrapper and use a ref to keep updating your scope-accessible session:

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  const { data: session } = useSession();
  const sessionRef = useRef(session);
  useEffect(() => {
    sessionRef.current = session;
  }, [session])

  function makeClient() {
    const httpLink = createUploadLink({
      uri: process.env.NEXT_PUBLIC_BACK_GRAPHQL_URL,
    }) as unknown as ApolloLink;

    const authLink = setContext((_, { headers }) => {
      return {
        headers: {
          ...headers,
          authorization: sessionRef.current.user ? `Bearer ${sessionRef.current.user.jwt}` : "",
        },
      };
    });

    const links = [authLink, httpLink];

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

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

phryneas avatar Sep 13 '23 12:09 phryneas

It does not seem to work (at least in my case). The value of sessionRef as I retrieve it in setContext is always null, which corresponds to the initial value passed to useRef, even though it later gets updated in the useEffect. Do you have any idea what the issue might be? Thanks again for your help!

rmn-hssvldt avatar Sep 13 '23 14:09 rmn-hssvldt

And you're actually accessing sessionRef.current and not destructuring something somewhere?

One correction though:

-          authorization: user ? `Bearer ${sessionRef.current.user.jwt}` : "",
+          authorization: sessionRef.current.user ? `Bearer ${sessionRef.current.user.jwt}` : "",

phryneas avatar Sep 13 '23 15:09 phryneas

This is the current state of my implementation:

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  const { data: session } = useSession();
  const sessionRef = useRef(session);
  useEffect(() => {
    sessionRef.current = session;
  }, [session]);

  function makeClient() {
    const httpLink = createUploadLink({
      uri: process.env.NEXT_PUBLIC_BACK_GRAPHQL_URL,
    }) as unknown as ApolloLink;

    const authLink = setContext((_, { headers }) => {
      return {
        headers: {
          ...headers,
          authorization: sessionRef.current?.user
            ? `Bearer ${sessionRef.current?.user.jwt}`
            : '',
        },
      };
    });

    const links = [authLink, httpLink];

    return new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === 'undefined'
          ? from(
              [
                new SSRMultipartLink({
                  stripDefer: true,
                }),
                links,
              ].flat(),
            )
          : from(links),
    });
  }

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

rmn-hssvldt avatar Sep 13 '23 15:09 rmn-hssvldt

And if you add some console.log calls here:

  useEffect(() => {
    console.log("updating sessionRef to", session
    sessionRef.current = session;

and here

    const authLink = setContext((_, { headers }) => {
      console.log("working with", sessionRef.current)
      return {

what log(and which order) do you get?

phryneas avatar Sep 13 '23 15:09 phryneas

I have the following:

  1. updating sessionRef to session object
  2. working with null

rmn-hssvldt avatar Sep 13 '23 16:09 rmn-hssvldt

~~Hey @romain-hasseveldt 👋~~

~~Could you try assigning the sessionRef on every render instead of inside a useEffect? This should keep it in sync with the latest value since effects fire after render (which means that session will always "lag behind" a bit)~~

~~Try this:~~

Old code suggestion
const sessionRef = useRef(session);

// assign on every render to keep it up-to-date with the latest value
sessionRef.current = session;

Edit: Apparently React deems this as unsafe, which is something I did not know about until now. Please ignore my suggestion 🙂

This is also mentioned in the useRef docs Screenshot 2023-09-13 at 11 24 40 AM

jerelmiller avatar Sep 13 '23 16:09 jerelmiller

Thank you for trying @jerelmiller :) I tested out of curiosity, and... it doesn't work either.

rmn-hssvldt avatar Sep 14 '23 14:09 rmn-hssvldt

This is honestly weird - could you try to create a small reproduction of that?

phryneas avatar Sep 14 '23 17:09 phryneas