Loading...
Azure-Ad-B2C
Author Cloudapp
E.G.

Integrating Azure AD B2C into Next.js 14 with ease

May 18, 2024
Table of Contents

In the previous post, I provided a step-by-step guide on creating an Azure AD B2C tenant at no cost. We will focus on integrating this tenant into a full-fledged Next.js 14 project. As a foundation, we will take the example which we created the past stories, which is based on the headless CMS Contentful, the on-site-search Algolia, etc.

Here is the GitHub repo with the full code

And here is a spoiler to the front end, with the integrated Azure AD B2C Auth Solution.

Screenshot-home-login-logout
Screenshot-home-login-logout

As a first step, we retrieve the key from Azure AD B2C and use it in our .env.local file.

Env Variables

NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME=xxxxx -> First Part of the Url xxx.onmicrosoft.com
NEXT_PUBLIC_AZURE_AD_B2C_PRIMARY_USER_FLOW=B2C_1_Webapp_signupsignin_1
NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI=http://localhost:3000
AZURE_AD_B2C_CLIENT_SECRET=xxxx -> we copied it after the secret creation
NEXT_PUBLIC_AZURE_AD_B2C_CLIENT_ID=xxxx -> Applicaton (client) ID. (point 2 screenshot above)

Then, we install a new package, “next-auth,” version 4.24.7, which is the base of the integration. Additionally, we add two new env-variables for next-auth

NEXTAUTH_SECRET=TBD
NEXTAUTH_URL=http://localhost:3000

You can use this website https://www.lastpass.com/features/password-generator to generate a new and safe secret (NEXTAUTH_SECRET).

Changed and added files

Here is a list of all files we will add or change.

Added Changed Files GIT
Added Changed Files GIT

Now we create a new file under src/app with the name providers.tsx

"use client";

import { SessionProvider } from "next-auth/react";

type Props = {
  children?: React.ReactNode;
};

export const NextAuthProvider = ({ children }: Props) => {
  return <SessionProvider>{children}</SessionProvider>;
};

which we then use as a wrapper in the layout.tsx file under src/app/[locale]. This allows us to have the next-auth protection available everywhere.

return (
    <html lang={htmlLang} suppressHydrationWarning>
      <head></head>
      <body>
        <main className={`${urbanist.variable} font-sans dark:bg-gray-900`}>
          <NextAuthProvider>
            <Providers>
              <Header showBar={true} menuItems={headerdata} logourl={logourl} />
              {draftMode().isEnabled && (
                <p className="bg-emerald-400 py-4 px-[6vw]">
                  Draft mode is on! <ExitDraftModeLink className="underline" />
                </p>
              )}
              {children}
              {/*PiwikPro */}
              <PiwikPro />
              <Footer footerItems={footerdata} />
            </Providers>
          </NextAuthProvider>
        </main>
      </body>
    </html>
  );

The main file handling the logic is located under src/lib, called auth.ts. Here, we define Azure AD as an Auth Provider, and we define the needed details for the integration, as well as a custom sign in page. Here, you can see the Env-Variables we get from Azure AD B2C.

We define the two scopes “offline_access” and “openid” and we read the “token.role” and assign it to “session.role”, so that we can use it throughout our project.

import type { NextAuthOptions } from "next-auth";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";

export const authOptions: NextAuthOptions = {
  pages: {
    signIn: "/", // Custom sign in page
  },
  session: {
    strategy: "jwt",
  },
  providers: [
    AzureADB2CProvider({
      tenantId: process.env.NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME,
      clientId: process.env.NEXT_PUBLIC_AZURE_AD_B2C_CLIENT_ID || "",
      clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET || "",
      primaryUserFlow: process.env.NEXT_PUBLIC_AZURE_AD_B2C_PRIMARY_USER_FLOW,
      authorization: {
        params: {
          scope: "offline_access openid",
        },
      },
      checks: ["pkce"],
      client: {
        token_endpoint_auth_method: "none",
      },
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async signIn({ account, profile }) {
      if (account && profile) {
        return true;
      }
      return true;
    },
    async jwt({ token, account, user, profile }) {
      if (account) {
        token.accessToken = account.id_token;
      }
      if (user) {
        // token.role = user.role;
        // token.subscribed = user.subscribed;
      }
      if (profile) {
        token.role = profile?.extension_Rolle;
      }
      return token;
    },
    async session({ session, token, user }) {
      session.accessToken = token.accessToken;
      session.role = token.role;
      session.user.sub = token.sub;

      return session;
    },
  },
};

With the main config in place, we can create the needed API route under src/app/api/auth/[..nextauth]/route.ts

import { authOptions } from "@/lib/auth";
import NextAuth from "next-auth";

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Since the project is based on typescript, we have to provide a file for the used types as well. So let’s create one under src/types/next-auth.d.ts

import NextAuth, { DefaultSession } from "next-auth";

declare module "next-auth" {
  /**
   * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
   */
  interface Session {
    accessToken?: string;
    role?: string;
    user: {
      name: string;
      email: string;
      sub: string | undefined;
    };
  }

  interface Profile {
    extension_Rolle?: string;
    emails?: string;
    name?: string;
  }
}

declare module "next-auth/jwt" {
  /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
  interface JWT {
    /** OpenID ID Token */
    accessToken?: string;
    role?: string;
  }
}

The background logic is now ready, so we will adapt our header component because we need to show a new icon for log in/registration and logout. Therefore, we will adapt the navbar.component.tsx under src/components/header. Below is the complete file.

"use client";

import { Disclosure, Menu, Transition } from "@headlessui/react";
import { Bars3Icon, BellIcon, XMarkIcon } from "@heroicons/react/24/outline";
import DarkModeButton from "@/components/header/darkmode.component";
import Image from "next/image";
import Link from "next/link";
import { Fragment } from "react";
// Authentication
import { signIn, useSession } from "next-auth/react";
// Internationalization
import { useTranslation } from "@/app/i18n/client";
import type { LocaleTypes } from "@/app/i18n/settings";
import {
  useRouter,
  usePathname,
  useParams,
  useSelectedLayoutSegments,
  useSearchParams,
} from "next/navigation";
import {
  GlobeAmericasIcon,
  UserCircleIcon,
  UsersIcon,
} from "@heroicons/react/24/solid";

function classNames(...classes: any[]) {
  return classes.filter(Boolean).join(" ");
}

interface Linkitems {
  key: number;
  name: string;
  href: string;
}

export default function Navbar({ menuItems, logourl }: any) {
  // Internationalization
  const locale = useParams()?.locale as LocaleTypes;
  const pathname = usePathname();
  const currentRoute = pathname;
  const { t } = useTranslation(locale, "common");

  // Authentication
  const SIGN_OUT_URL = `https://${process.env.NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME}.b2clogin.com/${process.env.NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME}.onmicrosoft.com/${process.env.NEXT_PUBLIC_AZURE_AD_B2C_PRIMARY_USER_FLOW}/oauth2/v2.0/logout?post_logout_redirect_uri=${process.env.NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI}/${locale}`;

  const { data: session } = useSession();
  const user = session?.user;
  const role = session?.role;

  const { push } = useRouter();
  const router = useRouter();
  const urlSegments = useSelectedLayoutSegments();

  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get("callbackUrl") || `/${locale}/`;

  async function handleLocaleChange(event: any) {
    const newLocale = event;

    // This is used by the Header component which is used in `app/[locale]/layout.tsx` file,
    // urlSegments will contain the segments after the locale.
    // We replace the URL with the new locale and the rest of the segments.
    router.push(`/${newLocale}/${urlSegments.join("/")}`);
  }

  async function logoutredirect() {
    const logoutfeedback = await fetch(
      "/api/auth/signout?callbackUrl=/api/auth/session",
      {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: await fetch("/api/auth/csrf").then((rs) => rs.text()),
      }
    );

    // console.log(logoutfeedback);
    // console.log(SIGN_OUT_URL);
    push(SIGN_OUT_URL);
  }

  function NavigationLink({ href, name }: Linkitems) {
    return (
      <a
        href={href}
        className={
          currentRoute === href
            ? "border-indigo-500 text-gray-900 dark:text-indigo-500 inline-flex items-center px-1 pt-1 border-b-2 text-base font-medium"
            : "border-transparent text-gray-500 dark:text-gray-50 hover:border-gray-300 hover:text-gray-700 inline-flex items-center text-base px-1 pt-1 border-b-2 font-medium"
        }
      >
        {name}
      </a>
    );
  }

  function NavigationLinkDisclosure({ href, name }: Linkitems) {
    return (
      <Disclosure.Button
        as="a"
        href={href}
        className={
          currentRoute === href
            ? "bg-indigo-50 border-indigo-500 text-indigo-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
            : "border-transparent text-gray-600 dark:text-gray-100 hover:bg-gray-100 dark:hover:text-indigo-700 hover:border-gray-300 hover:text-gray-800 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
        }
      >
        {name}
      </Disclosure.Button>
    );
  }

  return (
    <Disclosure
      as="nav"
      className="px-2 py-2.5 dark:border-gray-700 dark:bg-gray-900 sm:px-4"
    >
      {({ open }) => (
        <>
          <div className="flex flex-wrap items-center justify-between mx-auto">
            <Link className="flex items-center" href={`/${locale}/`}>
              <Image
                className="block float-left w-auto h-12 lg:hidden dark:bg-blue-100"
                src={logourl ? logourl : "/images/svgrepo-com.svg"}
                alt="Testblog"
                width={48}
                height={48}
              />

              <Image
                className="hidden float-left w-auto h-12 lg:block dark:bg-blue-100"
                src={logourl ? logourl : "/images/svgrepo-com.svg"}
                alt="Testblog"
                width={48}
                height={48}
              />
            </Link>

            <div className="hidden lg:ml-6 lg:flex lg:space-x-8">
              {/* Desktop View */}
              {menuItems.map((menuItem: any, index: number) => (
                <NavigationLink
                  key={index}
                  // href={menuItem.href}
                  href={`/${locale}${menuItem.href}`}
                  name={menuItem.name}
                />
              ))}
            </div>

            <div className="flex items-center justify-center flex-1 px-2 lg:ml-6 lg:justify-end">
              <DarkModeButton />

              {/* Language dropdown */}
              <Menu as="div" className="relative flex-shrink-0 ml-4">
                <div>
                  <Menu.Button className="flex text-sm bg-white rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                    <span className="sr-only">
                      {" "}
                      {t("user.languageswitcher")}
                    </span>
                    <GlobeAmericasIcon
                      className="w-8 h-8 hover:text-gray-500"
                      aria-hidden="true"
                    />
                  </Menu.Button>
                </div>
                <Transition
                  as={Fragment}
                  enter="transition ease-out duration-100"
                  enterFrom="transform opacity-0 scale-95"
                  enterTo="transform opacity-100 scale-100"
                  leave="transition ease-in duration-75"
                  leaveFrom="transform opacity-100 scale-100"
                  leaveTo="transform opacity-0 scale-95"
                >
                  <Menu.Items className="absolute right-0 z-10 block w-48 py-1 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
                    <Menu.Item>
                      {({ active }) => (
                        <a
                          onClick={() => {
                            handleLocaleChange("en-US");
                          }}
                          className={classNames(
                            active ? "bg-gray-100" : "",
                            "block px-4 py-2 text-sm text-gray-700"
                          )}
                        >
                          🇺🇸 {t("languages.en")}
                        </a>
                      )}
                    </Menu.Item>
                    <Menu.Item>
                      {({ active }) => (
                        <a
                          onClick={() => {
                            handleLocaleChange("de-DE");
                          }}
                          className={classNames(
                            active ? "bg-gray-100" : "",
                            "block px-4 py-2 text-sm text-gray-700"
                          )}
                        >
                          🇩🇪 {t("languages.de")}
                        </a>
                      )}
                    </Menu.Item>
                  </Menu.Items>
                </Transition>
              </Menu>
              {/* Profile dropdown */}
              <Menu as="div" className="relative flex-shrink-0 ml-4">
                <div>
                  <Menu.Button className="flex text-sm bg-white rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                    <span className="sr-only">Open user menu</span>
                    {(!user && (
                      <UserCircleIcon
                        className="w-8 h-8 hover:text-gray-500"
                        aria-hidden="true"
                      />
                    )) ||
                      (user && (
                        <UsersIcon
                          className="w-8 h-8 p-1 hover:text-gray-500"
                          aria-hidden="true"
                        />
                      ))}
                  </Menu.Button>
                </div>
                <Transition
                  as={Fragment}
                  enter="transition ease-out duration-100"
                  enterFrom="transform opacity-0 scale-95"
                  enterTo="transform opacity-100 scale-100"
                  leave="transition ease-in duration-75"
                  leaveFrom="transform opacity-100 scale-100"
                  leaveTo="transform opacity-0 scale-95"
                >
                  <Menu.Items className="absolute right-0 z-10 block w-48 py-1 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
                    {user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            href={`/${locale}/profile`}
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.profile")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                    {user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            onClick={() => {
                              logoutredirect();
                            }}
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.logout")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                    {!user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            onClick={() =>
                              signIn("azure-ad-b2c", { callbackUrl })
                            }
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.login")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                  </Menu.Items>
                </Transition>
              </Menu>
            </div>

            <div className="flex items-center lg:hidden">
              {/* Mobile menu button */}
              <Disclosure.Button className="inline-flex items-center justify-center p-2 text-gray-400 rounded-md hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
                <span className="sr-only">Open main menu</span>
                {open ? (
                  <XMarkIcon className="block w-6 h-6" aria-hidden="true" />
                ) : (
                  <Bars3Icon className="block w-6 h-6" aria-hidden="true" />
                )}
              </Disclosure.Button>
            </div>
          </div>

          <Disclosure.Panel className="lg:hidden">
            <div className="pt-2 pb-3 space-y-1">
              {/* Only show up in Mobile view */}
              {menuItems.map((menuItem: any, index: number) => (
                <NavigationLinkDisclosure
                  key={index}
                  // href={menuItem.href}
                  href={`/${locale}${menuItem.href}`}
                  name={menuItem.name}
                />
              ))}
            </div>
          </Disclosure.Panel>
        </>
      )}
    </Disclosure>
  );
}

What changed from the last post, in which we added the Contentful tags to our project?

Since we added “useSearchParams” as an import, Next.js 14 will complain during the build process with the error

useSearchParams() should be wrapped in a suspense boundary

so we have to wrap the navbar component into a suspense boundary in the header component

// Header Component
import Navbar from "@/components/header/navbar.component";
import SearchBar from "./search.component";
import { Suspense } from "react";

interface HeaderProps {
  showBar: boolean;
  menuItems: any;
  logourl: string;
}

export default function Header({ showBar, menuItems, logourl }: HeaderProps) {
  return (
    <header>
      {/* <DarkModeButton /> */}
      {/* <NavbarBanner /> */}
      <Suspense fallback={<div>Loading...</div>}>
        <Navbar menuItems={menuItems} logourl={logourl} />
      </Suspense>
      {showBar && (
        <SearchBar
          searchCta="Search"
          searchPlaceholder="Search example.dev..."
        />
      )}
    </header>
  );
}
// New Imports
import { signIn, useSession } from "next-auth/react";
import { useSearchParams} from "next/navigation";

// New Icons
import {
  GlobeAmericasIcon,
  UserCircleIcon,
  UsersIcon,
} from "@heroicons/react/24/solid";

// New variables
  const SIGN_OUT_URL = `https://${process.env.NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME}.b2clogin.com/${process.env.NEXT_PUBLIC_AZURE_AD_B2C_TENANT_NAME}.onmicrosoft.com/${process.env.NEXT_PUBLIC_AZURE_AD_B2C_PRIMARY_USER_FLOW}/oauth2/v2.0/logout?post_logout_redirect_uri=${process.env.NEXT_PUBLIC_POST_LOGOUT_REDIRECT_URI}/${locale}`;

  const { data: session } = useSession();
  const user = session?.user;
  const role = session?.role;

  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get("callbackUrl") || `/${locale}/`;

// New Logoutredirect Async Function
  async function logoutredirect() {
    const logoutfeedback = await fetch(
      "/api/auth/signout?callbackUrl=/api/auth/session",
      {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: await fetch("/api/auth/csrf").then((rs) => rs.text()),
      }
    );

    // console.log(logoutfeedback);
    // console.log(SIGN_OUT_URL);
    push(SIGN_OUT_URL);
  }

// New Menuitem
 {/* Profile dropdown */}
              <Menu as="div" className="relative flex-shrink-0 ml-4">
                <div>
                  <Menu.Button className="flex text-sm bg-white rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                    <span className="sr-only">Open user menu</span>
                    {(!user && (
                      <UserCircleIcon
                        className="w-8 h-8 hover:text-gray-500"
                        aria-hidden="true"
                      />
                    )) ||
                      (user && (
                        <UsersIcon
                          className="w-8 h-8 p-1 hover:text-gray-500"
                          aria-hidden="true"
                        />
                      ))}
                  </Menu.Button>
                </div>
                <Transition
                  as={Fragment}
                  enter="transition ease-out duration-100"
                  enterFrom="transform opacity-0 scale-95"
                  enterTo="transform opacity-100 scale-100"
                  leave="transition ease-in duration-75"
                  leaveFrom="transform opacity-100 scale-100"
                  leaveTo="transform opacity-0 scale-95"
                >
                  <Menu.Items className="absolute right-0 z-10 block w-48 py-1 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
                    {user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            href={`/${locale}/profile`}
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.profile")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                    {user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            onClick={() => {
                              logoutredirect();
                            }}
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.logout")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                    {!user && (
                      <Menu.Item>
                        {({ active }) => (
                          <a
                            onClick={() =>
                              signIn("azure-ad-b2c", { callbackUrl })
                            }
                            className={classNames(
                              active ? "bg-gray-100" : "",
                              "block px-4 py-2 text-sm text-gray-700"
                            )}
                          >
                            {t("user.login")}
                          </a>
                        )}
                      </Menu.Item>
                    )}
                  </Menu.Items>
                </Transition>
              </Menu>

Since we link to a profile page in the menu dropdown, we have to create it under src/app/[locale]/profile/page.tsx

export const dynamic = "force-dynamic";

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { createTranslation } from "@/app/i18n/server";
import { LocaleTypes } from "@/app/i18n/settings";

interface PageParams {
  slug: string;
  locale: string;
}

interface PageProps {
  params: PageParams;
}

export default async function Profile({ params }: PageProps) {
  const session = await getServerSession(authOptions);
  const user = session?.user;
  const { t } = await createTranslation(params.locale as LocaleTypes, "common");

  return (
    <>
      <section className="min-h-screen pt-20 bg-ct-blue-600">
        <div className="max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-[20rem] flex justify-center items-center">
          <div>
            <h1 className="mb-3 text-5xl font-semibold text-center">
              {t("pages.profile")}
            </h1>
            {!user ? (
              <p>Loading...</p>
            ) : (
              <div className="flex items-center gap-8">
                <div></div>
                <div className="mt-8">
                  <p className="mb-3">Name: {user.name}</p>
                  <p className="mb-3">Email: {user.email}</p>
                  <p className="mb-3">Rolle: {session?.role}</p>
                </div>
              </div>
            )}
          </div>
        </div>
      </section>
    </>
  );
}

Since introducing new labels, we must also adapt the translation file (common.json) for every language under src/app/i18n/locales/…..

That’s it. Now we have the base integration, and you can already test it in your local environment with

npm run dev

In the previous post, we created the Azure AD B2C tenant by creating only one app registration, “Localhost.” For the live deployment, we have to create a second app registration that will be used for the Vercel deployment.

Name of App Registration (in this case, “TestProd”)

TestProd-AD-Overview
TestProd-AD-Overview

Select “Authentication” -> Add a platform -> Single Page Application

TestProd-AD-Auth
TestProd-AD-Auth

Certificates & Secrets -> Add a new Secret (Client Secret)

TestProd-AD-Certificate
TestProd-AD-Certificate

API Permissions (offline_access and openid)

Will be assigned during creation of app registration

TestProd-AD-Rights
TestProd-AD-Rights

Use these Keys/IDs as Env-Variables for your Vercel Project.

After the deployment, you will have a fully functional Auth system based on Azure AD B2C—and that’s for free. Click on the User Icon on the top right, so that the dropdown menu will be opened. Now click on “Login/Register”

Homepage Login Dropdown
Homepage Login Dropdown

Now click on “Login/Register” to open the page provided by Azure.

Sign-up-or-sign-in
Sign-up-or-sign-in

As soon as you are logged in there will be a second menu item in the dropdown (top right) with the name profile. Click on it to see the details of your registration.

Enjoy it, use it for your production system, or play around to better understand it.

If you need help or you face trouble during deployment, please let me know in the comments, so that I can help you.

With this full example we implemented the basics, and in the upcoming stories, we will use it to integrate auth flows where we need it. Stay tuned.

Cloudapp-dev, and before you leave us

Thank you for reading until the end. Before you go:

Please consider clapping and following the writer! 👏 on our Medium Account

Related articles