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
358 stars 25 forks source link

examples handling authenticated clients #21

Open masterkain opened 12 months ago

masterkain commented 12 months ago

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

patrick91 commented 12 months ago

@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 😊

masterkain commented 12 months ago

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. ❤️

seanaguinaga commented 12 months ago

This library is amazing for firebase

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

easy examples

seanaguinaga commented 12 months ago

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>
  );
}
phryneas commented 12 months ago

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:

louisthomaspro commented 11 months ago

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

rafaelsmgomes commented 11 months ago

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?

phryneas commented 11 months ago

@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.

seanaguinaga commented 11 months ago

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

The auth library does that for me, thankfully

rafaelsmgomes commented 11 months ago

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.

phryneas commented 11 months ago

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

rafaelsmgomes commented 11 months ago

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
    }, '')
jnovak-SM2Dev commented 10 months ago

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.

yasharzolmajdi commented 10 months ago

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?

ben-hapip commented 8 months ago

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>
  )
}
chvllad commented 8 months ago

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

phryneas commented 8 months ago

@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.

ben-hapip commented 8 months ago

Ayyy thanks fellas for the suggestions!! 🤝

romain-hasseveldt commented 8 months ago

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 :(

phryneas commented 8 months ago

@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.

romain-hasseveldt commented 7 months ago

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>
  );
}
phryneas commented 7 months ago

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>
  );
}
romain-hasseveldt commented 7 months ago

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!

phryneas commented 7 months ago

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}` : "",
romain-hasseveldt commented 7 months ago

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>
  );
}
phryneas commented 7 months ago

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?

romain-hasseveldt commented 7 months ago

I have the following:

  1. updating sessionRef to session object
  2. working with null
jerelmiller commented 7 months ago

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 ```ts 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
romain-hasseveldt commented 7 months ago

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

phryneas commented 7 months ago

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

wchorski commented 7 months ago

So I believe I got token auth working for use client components via

app/ApolloWrapper.tsx

"use client";
// ^ this file needs the "use client" pragma
// https://github.com/apollographql/apollo-client-nextjs

import { ApolloLink, HttpLink } from "@apollo/client";
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  NextSSRApolloClient,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { envvars } from "@lib/envvars";

// have a function to create a client for you
function makeClient(token:string|undefined) {
  const httpLink = new HttpLink({
    // this needs to be an absolute url, as relative urls cannot be used in SSR
    uri: envvars.API_URI,
    headers: {
      Authorization: token ? `Bearer ${token}` : '',
    },

    // 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" },
    // you can override the default `fetchOptions` on a per query basis
    // via the `context` property on the options passed as a second argument
    // to an Apollo Client data fetching hook, e.g.:
    // const { data } = useSuspenseQuery(MY_QUERY, { context: { fetchOptions: { cache: "force-cache" }}});
  });

  return new NextSSRApolloClient({
    // use the `NextSSRInMemoryCache`, not the normal `InMemoryCache`
    cache: new NextSSRInMemoryCache(),
    link:
      typeof window === "undefined"
        ? ApolloLink.from([
            // in a SSR environment, if you use multipart features like
            // @defer, you need to decide how to handle these.
            // This strips all interfaces with a `@defer` directive from your queries.
            new SSRMultipartLink({
              stripDefer: true,
            }),
            httpLink,
          ])
        : httpLink,
  });
}

// you need to create a component to wrap your app in
export function ApolloWrapper({ token, children }: React.PropsWithChildren<{
  token: string | undefined;
}>) {
  return (
    <ApolloNextAppProvider makeClient={() => makeClient(token)}>
      {children}
    </ApolloNextAppProvider>
  );
}

I'm also trying to figure out auth via Server side client.ts script

client.ts

import { HttpLink } from "@apollo/client";
import {
  NextSSRInMemoryCache,
  NextSSRApolloClient,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc";
import { envs } from "@/envs";
import { cookies } from "next/headers";

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

  const cookieStore = cookies()
  const cookieSession = cookieStore.get('keystonejs-session')
  console.log('cookieSession::::: ');
 const token = cookieSession?.value
 console.log(`==== Bearer ${token}`);

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link: new HttpLink({
      uri: envs.API_URI,
    }),
    headers: {
      'Authorization': (token) ? `Bearer ${token}`: "",
      'Content-Type': 'application/json'
    },
  })
})

I know I'm getting the right session token because It works when I manually set the token in Apollo Studio Sandbox it works, but If I manually set the token inside this client.ts it still doesn't work.

a linke to the full source code git repo