How to sign in with LinkedIn in a Strapi and Next.js app with custom authentication
Published: 2024-06-11 07:52:27

## Intro
While working on a project for a client, one of the requirements was to allow users to sign up/sign in using their LinkedIn accounts besides registering with their email/password. In this post we will dive into this implementation using Strapi on the backend, Next.js on the frontend (Authentication handled by next-auth on the client side), issues I encountered and how I solved them, so it might help anyone working on similar features.

## LinkedIn Setup

There are many available providers supported out of the box inside Strapi: auth0, cas, cognito, discord, email, facebook, github, google, instagram, microsoft, patreon, reddit, twitch, twitter, vk, and finally linkedin.

When you open up Strapi admin and go to Settings — Providers you will see these options for LinkedIn. You will need Client ID and Client Secret, but you can completely ignore these settings and leave Linkedin Provider disabled inside the Strapi admin settings, we will not use them for this guide.



You can find LinkedIn Client ID and Client Secret by signing up on the [LinkedIn Developers website](https://developer.linkedin.com/). On the website you’ll have to create a new app and verify your ownership. That will allow you to get the Client ID and Client Secret and add authorized redirects. Additionally, you’ll have to enable the following product: Sign In with LinkedIn using OpenID Connect which will give you openid, profile and email scopes that will allow you to login.

## Strapi Setup and problem encountered

While working on implementing LinkedIn authentication I was following along with Strapi docs available on this url. If you look at the section “Setup the frontend” I got stuck at the final step:

> Create a frontend route like FRONTEND_URL/connect/${provider}/redirect that have to handle the access_token param and that have to request STRAPI_BACKEND_URL/api/auth/${provider}/callback with the access_token parameter.
> The JSON request response will be { "jwt": "...", "user": {...} }.

After I call the backend, I was constantly getting errors that pointed to a authorization problem. After hours of analyzing what could be the issue, I realized LinkedIn changed their scopes required for authentication so they don’t align with what Strapi uses. I opened an issue on Strapi forums and currently there is a Github issue connected with my question, but it’s still not merged and fixed.

```
  body: {
    serviceErrorCode: 100,
    message: 'Not enough permissions to access: GET /me',
    status: 403
  },
```


According to this error and scopes required for the /me endpoint — [Profile API — LinkedIn | Microsoft Learn](https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api) seems like Strapi is calling linkedin /v2/me endpoint which requires r_liteprofile, r_basicprofile, r_compliance scopes, while Linkedin is now using /v2/userinfo for authentication and these scopes: openid, profile, email, and that’s why I’m getting the error.

## Workaround Fix for LinkedIn provider

To make Strapi authentication work with LinkedIn I had to ignore the official authentication flow from Strapi and create a custom auth endpoint.

Like I pointed, I’m using Next.js on the frontend side and Next-Auth.js to handle authentication.

To handle authentication on the frontend inside authOptions, LinkedIn provider looks like this:

```
export const authOptions = {
  providers: [
    LinkedInProvider({
      clientId: process.env.LINKEDIN_CLIENT_ID || '',
      clientSecret: process.env.LINKEDIN_CLIENT_SECRET || '',
      client: { token_endpoint_auth_method: 'client_secret_post' },
      issuer: 'https://www.linkedin.com',
      profile: (profile: LinkedInProfile) => ({
        id: profile.sub,
        name: profile.name,
        email: profile.email,
        image: profile.picture
      }),
      wellKnown: 'https://www.linkedin.com/oauth/.well-known/openid-configuration',
      authorization: {
        params: {
          scope: 'openid profile email'
        }
      }
    }),
]
}
```


Inside the callback jwt function, if the user is authenticating with LinkedIn I am calling a custom endpoint in Strapi that is handling the authentication and issuing jwt token.

```
callbacks: {
    async jwt({ token, user, account }: { user: any; token: any; account: any }) {
      if (account?.provider === 'linkedin') {
        const res = await fetch(`${process.env.API_URL}/customLinkedinAuthEndpoint`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(user)
        });
        const data = await res.json();
        token.jwt = data.jwtToken;
      } else {
        if (user) {
          token.jwt = user.jwt;
        }
      }

      return token;
    },
  },
```

On the Strapi side create your custom endpoint. You will have to update the controller to handle authentication.

Inside your src/api/customLinkedinAuthEndpoint/controllers/ create a new file customLinkedinAuthEndpoint.js

Inside the file we define a function findOrCreateUser() that is handling all the logic. The function is checking if the user already exists and handling different use cases. If the user doesn’t exists it creates a new user. Based on this logic I am issuing the jwt token that the frontend can receive.

```
"use strict";

module.exports = {
  linkedinAuth: async (ctx) => {
    try {
      const linkedinData = ctx.request.body;
      const user = await findOrCreateUser(linkedinData);
      // Issue a JWT token
      const jwtToken = strapi.plugins["users-permissions"].services.jwt.issue({
        id: user.id,
      });
      ctx.send({ jwtToken, user });
    } catch (err) {
      ctx.body = err;
    }
  },
};

async function findOrCreateUser(linkedinData) {
  // Try to find the user by their LinkedIn ID
  let user = await strapi.db.query("plugin::users-permissions.user").findOne({
    where: { linkedinId: linkedinData.id },
  });

  if (!user) {
    // If the user doesn't exist, try to find the user by their email
    user = await strapi.db.query("plugin::users-permissions.user").findOne({
      where: { email: linkedinData.email },
    });

    if (user && !user.linkedinId) {
      // If the user exists but doesn't have a LinkedIn ID, update the user
      user = await strapi.db.query("plugin::users-permissions.user").update({
        where: { id: user.id },
        data: {
          confirmed: true,
          linkedinId: linkedinData.id,
          linkedinImage: linkedinData.image,
        },
      });
    } else if (!user) {
      // If the user doesn't exist, create new user
      user = await strapi.db.query("plugin::users-permissions.user").create({
        data: {
          linkedinId: linkedinData.id,
          username: linkedinData.name,
          email: linkedinData.email,
          linkedinImage: linkedinData.image,
          role: 1,
          confirmed: true,
          provider: "local",
        },
      });
    }
  }

  return user;
}
```

Finally inside the src/api/customLinkedinAuthEndpoint/routes/customLinkedinAuthEndpoint.js update the route

```
module.exports = {
  routes: [
    {
      method: "POST",
      path: "/customLinkedinAuthEndpoint",
      handler: "customLinkedinAuthEndpoint.linkedinAuth",
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};
```

You will also have to update permissions for this custom endpoint inside the Strapi admin to be able to call this endpoint on the client side.

And that’s it, you will be able to get the jwt on the client side with LinkedIn and make authenticated requests to the Strapi backend.

## Conclusion

Hope this guide can help anyone who encounters similar problems while working with Strapi and authentication. Hopefully issue with default auth flow for LinkedIn will be fixed soon but this guide can help anyone who wants to build custom authentication flow.