CS After Dark

How to Implement LinkedIn Sign In with Next.JS (Typescript + 2024)

It's 2024 and I'm about to finish grad school. As I prepare myself to enter the job market, I am building some summer side-hustles to add to my portfolio.

One such side-hustle requires you to sign-in with LinkedIn to use some functionality. That brings me to the motivation for writing this blog:

  1. Most of the tutorials I found on LinkedIn Sign In were out-dated by a few years and the LinkedIn authorization endpoint contracts had significantly changed. Some tutorials were behind a paywall. I wanted a tutorial that is up to date + accessible + terse.
  2. I'm building a side-hustle in public and maintaining a dev-log to showcase my progress to whoever cares (recruiters * wink *).
I

First, go download the latest Node JS if you haven't already. Next, quickly create a next-js app:

npx create-next-app linkedinauth

The CLI tool will prompt you with a few configuration options. I follow this:

setup1

Let's quickly start the app:

cd linkedinauth
npm run dev

Aight, let's clean up the codebase and keep only the parts that we need for this tutorial.

Navigate to app/page.tsx and create a cool button:

'use client';

export default function Home() {

  const handleSignIn = () => {
    window.location.href = LINKEDIN_URL;
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <button onClick={handleSignIn} style={{ backgroundColor: "white", color: "black", padding: 10 }}>
        Login with LinkedIn
      </button>
    </main>
  );
}

The best part is that to perform sign in, you just need to go to a authorization URL and that's what handleSignIn method will do.

II

Next, we need to setup our LinkedIn app. Head over to LinkedIn Developer portal and create a new app. This is what I put in:

appsetup

Cool. Once we have created an app, head to the "Auth" tab where we should find a Client-ID and a Primary Client-Secret. This is going to be needed for authentication.

auth-secrets

Finally, in the same tab, scroll down to the "OAuth 2.0 settings" section and add a redirect URL. Make sure http://localhost:3000 is replaced with whatever port your Next.JS app is running locally on. It will look as follows:

redirecturl

Ok this should be sufficient! In the next sections we will dive into some code. If you are interested in deeper technical details, click on these dropdowns:

✨ OAuth 2.0 Flow Explained
  1. Client Registration

    The application registers with the authorization server, providing details such as the redirect URL, client name, and type. In return, it receives a client ID and possibly a client secret.

  2. Authorization Request

    The client redirects the user to the authorization server’s authorization endpoint. The request includes the client ID, requested scope, and the redirect URL where the user will be sent after authorization.

    • Example URL: https://auth-server.com/authorize?response_type=code&client_id=client_id&redirect_uri=https://your-app.com/callback&scope=read
  3. User Authentication and Authorization

    The user is presented with a login form on the authorization server, where they authenticate themselves and grant the requested permissions to the client application.

  4. Redirect Back to Client

    After the user grants permission, the authorization server redirects the user back to the client’s specified redirect URL with an authorization code included in the query parameters.

    • Example Redirect URL: https://your-app.com/callback?code=authorization_code
  5. Authorization Code Exchange

    The client sends a request to the authorization server’s token endpoint, exchanging the authorization code for an access token and, optionally, a refresh token.

    • Example Request: POST /token with parameters: grant_type=authorization_code&code=authorization_code&redirect_uri=https://your-app.com/callback&client_id=client_id&client_secret=client_secret
  6. Receive Access Token

    The authorization server returns an access token (and possibly a refresh token) to the client. This token allows the client to access the user’s resources on the resource server.

    • Example Response: { "access_token": "access_token_value", "expires_in": 3600, "token_type": "Bearer", "refresh_token": "refresh_token_value" }
  7. Access Resource

    The client uses the access token to request resources from the resource server. The token is included in the request headers.

    • Example Request: GET /resource with headers: Authorization: Bearer access_token_value
  8. Token Refresh (Optional)

    If the access token expires, the client can use the refresh token to obtain a new access token without requiring the user to re-authenticate.

    • Example Request: POST /token with parameters: grant_type=refresh_token&refresh_token=refresh_token_value&client_id=client_id&client_secret=client_secret
✨ What is a redirect-URL? A redirect URL is a specific endpoint or address within your application where the OAuth 2.0 authorization server sends the user back to after they have either granted or denied permission. This URL is crucial for completing the OAuth authorization process and obtaining the required tokens.

We only allow redirects to pre-approved URLs. This prevents attackers from tricking the authorization server into sending tokens to malicious URLs.

III

Now, we will setup our app-environment. First, let's go and create a .env file at the root of your project.

LINKEDIN_STATE="a_randomly_generated_string"
LINKEDIN_SCOPE="openid profile email"
LINKEDIN_REDIRECT_URI="http://localhost:3000/api/oauth"
LINKEDIN_CLIENT_ID="CLIENT_ID"
LINKEDIN_CLIENT_SECRET="PRIMARY_CLIENT_SECRET"
✨ Why do we need LINKEDIN_STATE? The state parameter in OAuth 2.0 is used primarily to prevent Cross-Site Request Forgery (CSRF) attacks by ensuring that the authorization response is genuinely related to the initial request made by the user. It achieves this by including a unique, client-generated value in the authorization request that must be returned by the authorization server. Upon receiving the response, the client verifies the state value to confirm that it matches the original, thus ensuring the legitimacy of the transaction and safeguarding the user's session from potential malicious interference.

Be sure to add .env in your .gitignore file so that you don't accidently commit your secrets to git:

.env*

Finally, just put this in your next.config.mjs file at the root (create this file if it doesn't exist):

/** @type {import('next').NextConfig} */
const nextConfig = {
    reactStrictMode: true,
  env: {
    LINKEDIN_CLIENT_ID: process.env.LINKEDIN_CLIENT_ID,
    LINKEDIN_CLIENT_SECRET: process.env.LINKEDIN_CLIENT_SECRET,
    LINKEDIN_REDIRECT_URI: process.env.LINKEDIN_REDIRECT_URI,
    LINKEDIN_STATE: process.env.LINKEDIN_STATE,
    LINKEDIN_SCOPE: process.env.LINKEDIN_SCOPE,
  },
};

export default nextConfig;
✨ What is `next.config.mjs`? The next.config.mjs file in Next.js is a configuration file that allows developers to customize the behavior of their Next.js application. It centralizes settings such as routing, image optimization, internationalization, and environment variables, enabling fine-tuned control over application performance and features. This file supports modifications to default settings, such as adjusting Webpack configurations and enabling experimental features, making it a critical tool for optimizing and tailoring Next.js applications to specific requirements.
IV

Finally, we are ready to write some code. Let's create a Javascript helper file to generate an authorization URL with proper encoding. Create app/helpers/auth.js and put this in:

export const LINKEDIN_STATE = process.env.LINKEDIN_STATE;
const LINKEDIN_SCOPE = process.env.LINKEDIN_SCOPE;
const LINKEDIN_REDIRECT_URI = process.env.LINKEDIN_REDIRECT_URI;
const LINKEDIN_CLIENT_ID = process.env.LINKEDIN_CLIENT_ID;

export const getURLWithQueryParams = (base, params) => {
  const query = Object.entries(params)
    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
    .join("&");

  return `${base}?${query}`;
};

export const LINKEDIN_URL = getURLWithQueryParams(
  "https://www.linkedin.com/oauth/v2/authorization",
  {
    response_type: "code",
    client_id: LINKEDIN_CLIENT_ID,
    redirect_uri: LINKEDIN_REDIRECT_URI,
    state: LINKEDIN_STATE,
    scope: LINKEDIN_SCOPE
  }
);

The auth.js was originally implemented by marieqg on GitHub. Please go show them some love: marieqg's auth.js

The LINKEDIN_URL that is returned from the necessary authorization URL needed to perform login. When the user clicks on the "Login with LinkedIn" button, they will be taken to the https://www.linkedin.com/oauth/v2/authorization URL. Once you authenticate, LinkedIn will take you to the redirect URL and in the query-params you will find the authorization-code. This authorization-code can then be exchanged for an access-token.

LinkedIn Authorization LinkedInAuth

Let's create a new Typescript file app/api/oauth/route.ts. This endpoint will handle the response from LinkedIn authentication. Add this code (explanation below):

import { NextRequest, NextResponse } from 'next/server';

import fetch from 'node-fetch'; // npm add node-fetch

interface TokenResponse {
  access_token: string;
  expires_in: number;
  error_description?: string;
}

interface ProfileResponse {
  sub: string;
  email_verified: boolean;
  name: string;
  locale: {
    country: string;
    language: string;
  };
  given_name: string;
  family_name: string;
  email: string;
}

export async function GET(req: NextRequest) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');

  if (!code || !state) {
    return NextResponse.json({ error: 'Authorization code or state missing' }, { status: 400 });
  }

  const params = new URLSearchParams({
    grant_type: 'authorization_code',
    code: code as string,
    redirect_uri: process.env.LINKEDIN_REDIRECT_URI as string,
    client_id: process.env.LINKEDIN_CLIENT_ID as string,
    client_secret: process.env.LINKEDIN_CLIENT_SECRET as string,
  });

  try {
    const tokenResponse = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: params.toString(),
    });

    const tokenData: TokenResponse = await tokenResponse.json() as TokenResponse;

    if (tokenResponse.ok) {
      const accessToken = tokenData.access_token;

      const profileResponse = await fetch('https://api.linkedin.com/v2/userinfo', {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });

      const profileData: ProfileResponse = await profileResponse.json() as ProfileResponse;

      const userProfile = {
        firstName: profileData.given_name,
        lastName: profileData.family_name,
        email: profileData.email,
        linkedInId: profileData.sub,
      };

      // Construct the redirect URL with query parameters
      const redirectUrl = new URL('/', req.url);
      redirectUrl.searchParams.append('accessToken', accessToken);
      redirectUrl.searchParams.append('firstName', userProfile.firstName);
      redirectUrl.searchParams.append('lastName', userProfile.lastName);
      redirectUrl.searchParams.append('email', userProfile.email);

      return NextResponse.redirect(redirectUrl.toString());
    } else {
      return NextResponse.json({ error: tokenData.error_description || 'Failed to get access token' }, { status: 400 });
    }
  } catch (error) {
    console.error('Error exchanging authorization code:', error);
    return NextResponse.json({ error: 'Error exchanging authorization code' }, { status: 500 });
  }
}

The above code does three things:

  1. Collects the authorization-code from the query params post authentication (you have been taken to the redirect URL)
  2. Exchange the authorization-code for an exchange token.
  3. Use the access-token to get the user profile info (or hit any LinkedIn API allowed by the scope). You are free to use this info however you wish (i.e. store it in a database or cache or just display it on the UI). Note that google search will point you to these LinkedIn Profile API docs. However, you should be following the OpenID Connect Standard
✨ Why do I need to care about the OpenID Connect Standard? Well, I honestly did not have an answer to this, so I just asked. This is what ChatGPT has to say: OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0 that standardizes the process of user authentication and authorization, allowing developers to securely verify user identities and access basic profile information. It simplifies the integration of authentication across different applications and platforms, enabling seamless single sign-on (SSO) experiences and reducing the need for custom authentication solutions. By adopting OIDC, developers benefit from enhanced security, interoperability, and ease of obtaining user data, making it a crucial standard for modern web and mobile applications.
✨ What is this `scope` thing? In OAuth 2.0 authorization request shown in the image, the scope parameter defines the specific permissions or access levels your application is requesting on behalf of the user. It is a space-delimited list of permissions that determine what kind of data and actions your application can perform.

In the most out-dated tutorials you will find that the LinkedIn scope might include r_liteprofile and r_emailaddress. These scopes tell the authorization server exactly what data or capabilities your application needs. However, in the recent days the scope is "openid", "profile" and "email"

V

The side hustle that I'm building is: CS Careers. It's a ranking of the hottest CS careers that I could find in 2024. This is a work in progress and so far, I've only implemented the Login button. There is a long way to go but the idea is that with every blog post, I show incremental progress in my web-app. Stay tuned for more. #BuildInPublic

#authentication #build in public #javascript #linkedin #nextjs #oauth #signin #typescript