DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

8/ Using NextAuth GoogleProvider to put a user into the Strapi database

The final code for this chapter is available on github (branch callbacksForGoogleProvider).

The callback we need to place our user in Strapi's database is the jwt callback. We also know when to do this. In the previous chapter we saw how the account argument of jwt only gets populated on a certain trigger: signin. So we will listen for these events:

// frontend/src/api/auth/[...nextAuth]/authOptions.ts

async jwt({ token, trigger, account, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // we now know we are doing a sign in using GoogleProvider
      // do some strapi stuff here
    }
  }
  return token;
},
Enter fullscreen mode Exit fullscreen mode

Strapi authentication and authorization

Strapi handles the entire auth process via the Users & Permissions plugin:

To authenticate users, Strapi provides two auth options: one social (Google, Twitter, Facebook, etc.) and another local auth (email and Password). Both of these options return a token (JWT token), which will be used to make further requests.
(Source: https://strapi.io/blog/strapi-authentication-with-react)

The endpoint for local auth is /api/auth/local (careful, this is Strapi, so backend) and we will be using this later on when we setup our app with credentials. The endpoint for social auth is /api/connect/[providername]. In our case it will be /api/connect/google.

In theory, you don't need NextAuth. You can call the Strapi google endpoint (/api/connect/google) directly from your frontend and Strapi will start the entire auth process. All of it, including redirecting to Google on the first signing to ask for permission. This is quite a complex flow that Strapi explains well in the docs.

Why don't we use this flow then and dump NextAuth ? (Oh, the temptation!) Couple of very good reasons:

  • You would expose your backend directly to the user.
  • UX confusion: the user would be redirected to a different url and then have to suffer a whole series of redirects.
  • Absence of error handling. Any error in this process would expose the user to a dry json error.
  • You would lose all the goodies from NextAuth: cookies, access to a session, security, updates, customization,...
  • ...

Integration between NextAuth and Strapi Users & Permissions plugin providers

As said, you can do an entire auth flow using Strapi. This is a multi step flow. What we're going to do is skip the first steps from this Strapi auth flow. Why? Because NextAuth handles these. We then hook into a later step in the Strapi auth flow from inside NextAuth.

In our NextAuth, we're at this point:

// frontend/src/api/auth/[...nextAuth]/authOptions.ts

async jwt({ token, trigger, account, profile, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // we now know we are doing a sign in using GoogleProvider
      // do some strapi stuff here
    }
  }
  return token;
},
Enter fullscreen mode Exit fullscreen mode

Google has approved our OAuth request and returned some data to NextAuth. This data corresponds to the account and profile arguments in our jwt callback. We covered this in the previous chapter. When we sign in with Google, account will be populated and account.provider will be 'google'.

Now, we move to the Strapi auth flow. We call this Strapi endpoint: /api/auth/google/callback?access_token=[>>google access token here<<] with a Google access token. Where does this access token come from? From the account argument in our jwt callback. Google sent it back and NextAuth makes it available for us.

When calling the Strapi endpoint with a valid token, Strapi will do some magic and will pop our google user into the Strapi database as a Strapi User. Strapi will then create a jwt token for this user and send it back to us in the response from the api call.

async jwt({ token, trigger, account, profile, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // on success, we will receive the Strapi JWT token from this
      const strapiResponse = await fetch(
        `${process.env.STRAPI_BACKEND_URL}/api/auth/google/callback?access_token=${account.access_token}`,
      );
    }
  }
  return token;
},
Enter fullscreen mode Exit fullscreen mode

We're back in NextAuth (frontend) and have a fresh Strapi token. What do we need to do with this Strapi token? We need to place it inside our NextAuth token. How do we customize the NextAuth token? By using the jwt callback. Where did we call this api endpoint from? From inside the jwt callback. And this will conclude our auth flow.

Tokens for all

There's a lot of token-ing going on so I will go over it again. Our main token is the NextAuth token that gets saved as a cookie in the browser. This token has a payload. By default NextAuth puts some things inside it like name and email. But, we also need it to hold the Strapi token.

The Strapi token authorizes our frontend user with the backend (=Strapi). When making an api request to the backend we need to pass the Strapi token. So, we need access to the token in the frontend. How? We save it inside the payload of the NextAuth token. What's inside the payload of the Strapi token? Don't know, don't care.

Finally, there is the Google OAuth token. Google sends back this token after a succesfull authentication. This authentication is handled by NextAuth in the frontend. To create a Strapi user using the Strapi social provider endpoint (api/auth/google) we need to send along the Google OAuth token to the backend via this api endpoint. What is on the payload of the Google token? Don't know, don't care. How does Strapi handle this endpoint? Don't know, don't care.

Setting up Strapi Users & Permissions plugin

Enough theory. Let's code. We start with setting up Strapi. We need to activate and configure the Google provider inside Strapi. So, run Strapi (strapi develop) and open the admin panel at http://localhost:1337/admin:

Setting > Users & Permissions plugin > providers > google
Enter fullscreen mode Exit fullscreen mode
  • Toggle to enable
  • Fill in the client id and secret, they should be in your frontend env file
  • Ignore the redirect url
  • save

Verify that the correct roles are set for the Public and Authenticated roles:

Setting > Users & Permissions plugin > Roles > public > permissions > User-permissions > auth
Setting > Users & Permissions plugin > Roles > authenticated > permissions > User-permissions > auth
Enter fullscreen mode Exit fullscreen mode

All the auth options should be allowed:

Strapi authentication permissions

Finally, in the frontend env file, add STRAPI_BACKEND_URL=http://localhost:1337.

Add the api call to Strapi

Next, we write our api call. Since it's a fetch, we wrap it inside a try catch block:

try {
  const strapiResponse = await fetch(
    `${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,
    { cache: 'no-cache' }
  );
  const data = await strapiResponse.json();
} catch (error) {
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Notice how we added the cache: 'no-cache' option to the fetch. Next uses an aggressive caching policy so we avoid that, just in case. In the catch block, we rethrow the error. We will handle this later.

In the actual api call, the url should make sense. But what will Strapi return? On success: a Strapi user, on error: not an Error but an object with an error property. Let's make Types for these:

We create some Type files:

// frontend/src/types/strapi/User.d.ts

export type StrapiUserT = {
  id: number;
  username: string;
  email: string;
  blocked: boolean;
  provider: 'local' | 'google';
};

export type StrapiLoginResponseT = {
  jwt: string;
  user: StrapiUserT;
};
Enter fullscreen mode Exit fullscreen mode

We also create a Strapi error type. Strapi errors for all api routes are the same so we will create a generic error type here:

// frontend/src/types/strapi/StrapiError.d.ts

export type StrapiErrorT = {
  data: null;
  error: {
    status: number;
    name: string;
    message: string;
  };
};
Enter fullscreen mode Exit fullscreen mode

We can update our fetch with these types:

const strapiResponse = await fetch(
  `${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,
  { cache: 'no-cache' }
);
if (!strapiResponse.ok) {
  const strapiError: StrapiErrorT = await strapiResponse.json();
  throw new Error(strapiError.error.message);
}
const strapiLoginResponse: StrapiLoginResponseT = await strapiResponse.json();
// customize token
Enter fullscreen mode Exit fullscreen mode

So, if strapiResponse is not ok (not code 200) then we know something went wrong and we throw an error. (We will handle these later). Else, we have data of type StrapiLoginResponseT. So a user and a jwt. We will now put these on our NextAuth token: token.strapiToken = strapiLoginResponse.jwt; and we're done. Remember, by default NextAuth already added the name and email on our token. We just added strapiToken to it. Here is the entire jwt callback:

// frontend/src/api/auth/[...nextAuth]/authOptions.ts

async jwt({ token, trigger, account, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // we now know we are doing a sign in using GoogleProvider
      try {
        const strapiResponse = await fetch(
          `${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,
          { cache: 'no-cache' }
        );
        if (!strapiResponse.ok) {
          const strapiError: StrapiErrorT = await strapiResponse.json();
          throw new Error(strapiError.error.message);
        }
        const strapiLoginResponse: StrapiLoginResponseT =
          await strapiResponse.json();
        // customize token
        // name and email will already be on here
        token.strapiToken = strapiLoginResponse.jwt;

      } catch (error) {
        throw error;
      }
    }
  }

  return token;
},
Enter fullscreen mode Exit fullscreen mode

And let's run this! Start up the front- and backend in dev mode and do a sign in flow. Everything ran fine but the real prove is in the Strapi admin panel:

Content manager > collection types > user
Enter fullscreen mode Exit fullscreen mode

And we see our user:

Added user to Strapi with NextAuth

Great! We're mostly done. We finished our jwt callback but there is something missing. We haven't updated our session.

Writing the NextAuth session callback

We know that the jwt callback runs before the session callback so, we can simply to this:

// frontend/src/api/auth/[...nextAuth]/authOptions.ts

async session({ token, session }) {
  session.strapiToken = token.strapiToken;
  return session;
},
Enter fullscreen mode Exit fullscreen mode

This give us a Typescript error but we will solve this in a bit. Let's first test this out using our <LoggedInClient /> and <LoggedInServer /> components. We uncomment the session log and check our logs after signing out and in again. As expected, session now show an extra property: strapiToken.

{
  user: {
    name: 'Peter Jacxsens',
    email: string,
    image: string,
  },
  strapiToken: string,
  expires: Date,
}
Enter fullscreen mode Exit fullscreen mode

More data

While we are at it. Let's put some more info on our token. We will add provider (from account), Strapi user id and user.blocked (from StrapiLoginResponse). We update our jwt callback:

token.provider = account.provider;
token.strapiUserId = strapiLoginResponse.user.id;
token.blocked = strapiLoginResponse.user.blocked;
Enter fullscreen mode Exit fullscreen mode

and the session callbacks:

session.provider = token.provider;
session.user.strapiUserId = token.strapiUserId;
session.user.blocked = token.blocked;
Enter fullscreen mode Exit fullscreen mode

Testing this, our session from useSession or getServerSession() now looks as expected:

{
  user: {
    name: 'Peter Jacxsens',
    email: string,
    image: string,
    blocked: boolean,
    strapiUserId: number,
  },
  strapiToken: string,
  provider: 'google',
  expires: Date,
}
Enter fullscreen mode Exit fullscreen mode

Setting types on the NextAuth session and jwt callback arguments

The customizations we just did as well as the strapiToken we added earlier all trigger TypeScript errors both in the jwt as the session callbacks. The long list of callback arguments receive their props somewhere inside NextAuth. But NextAuth provides the opportunity to extend them.

Note: I'm not sure the following TypeScript definitions are correct. All the TypeScript errors resolve but be careful here.

We create a new TypeScript definitions file:

// frontend/src/types/nextauth/next-auth.d.ts

// https://next-auth.js.org/getting-started/typescript
// not sure about any of this but it throws no TS errors (anymore)

import NextAuth, { DefaultSession } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import { StrapiUserT } from './strapi/StrapiLogin';

declare module 'next-auth' {
  // Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context

  interface Session {
    strapiToken?: string;
    provider?: 'google' | 'local';
    user: User;
  }

  /**
   * The shape of the user object returned in the OAuth providers' `profile` callback,
   * or the second parameter of the `session` callback, when using a database.
   */
  interface User extends DefaultSession['user'] {
    // not setting this will throw ts error in authorize function
    strapiUserId?: number;
    blocked?: boolean;
  }
}

declare module 'next-auth/jwt' {
  // Returned by the `jwt` callback and `getToken`, when using JWT sessions
  interface JWT {
    strapiUserId?: number;
    blocked?: boolean;
    strapiToken?: string;
    provider?: 'local' | 'google';
  }
}
Enter fullscreen mode Exit fullscreen mode

You can read all about this in the NextAuth docs but I will explain if briefly. The syntax we are using is called Module Augmentation. This is a TypeScript thing that allows to extend (and merge?) interfaces. That's pretty much all I know about it.

As for content. We extend 3 of the callback arguments: token, user and session. We know that session returns something like this: { strapiToken, provider, user: { ... }, ... }. It seems that internally, NextAuth uses the typeof the jwt callback user argument to type session.user. Because we put extra data on there, we have to extend the DefaultUser (name?:, email?:, ...) with strapiUserId and blocked, also both optional.

We then update the Session interface with this extended user and with the 2 other optional properties strapiToken and provider. Finally, we also extend the token interface with all the properties we put on it: strapiToken, strapiUserId, provider and blocked, all optional.

Here is a tip: inside the callbacks, type for example token. and then TypeScript will suggest all possible properties. If you can't see the one you need or see an extra one, you did something wrong.

Conclusion

We just learned how to put a user into the Strapi database using GoogleProvider. All and all, this is not very difficult. The tricky part is to know what, where and how NextAuth handles things.

The flow goes like this:

  1. NextAuth makes a request to Google OAuth.
  2. Google OAuth returns data.
  3. NextAuth makes this data available in its callback functions.
  4. Within the jwt callback we check if account is defined. (only on sign in will account be defined)
  5. We call a Strapi Google provider endpoint with a Google token.
  6. Strapi verifies the token and adds a user to the database.
  7. Strapi sends this user data + a Strapi JWT token back.
  8. Inside the jwt callback in NextAuth, we receive this data + Strapi JWT token from Strapi.
  9. We use the jwt callback to put the Strapi JWT token inside our NextAuth jwt token.
  10. We use the session callback to read out this token from the frontend using useSession hook or getServerSession function.
  11. The frontend can now make an api to the backend (Strapi), adding the Strapi token.

A final note, this gist helped me a lot with figuring all of this out.


If you want to support my writing, you can donate with paypal.

Top comments (0)