Azure AD B2C sign in and profile editing with NextAuth.js/Auth.js

I am implementing Azure AD B2C in a small app where I need to handle the usual cases like profile editing, login, and reset password. I use NextAuth.js/Auth.js for this and because how Azure AD B2C handles initialization of user flows, it creates an interesting challenge.

The problem

To initiate a user flow, the flow needs their own issuer path: const issuer = `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${user_flow}/v2.0`. A consequence of this is that each flow has its own unique authorization code flow paths (that you get from the .well-known path from the issuer). This isn't inherently bad since you automatically get updated claims when updating the profile, so I prefer this method to having to other methods.

NextAuth.js uses id from the config to create the callback path, so if the id is azure-ad-b2c the callback path is auto generated: /api/auth/callback/azure-ad-b2c.

This means that I need to create a new NextAuth provider for each user flow with an unique id so NextAuth know which issuer to use for each flow. Say I use three flows, I need three providers, witch generates three callbacks:

  • B2C_LOGIN_FLOW: /api/auth/callback/B2C_LOGIN_FLOW
  • B2C_PROFILE_EDIT_FLOW: /api/auth/callback/B2C_PROFILE_EDIT_FLOW
  • B2C_RESET_PW_FLOW: /api/auth/callback/B2C_RESET_PW_FLOW

In turn this means that I need to add a different callback to my tenant for each environment. This does not scale well if you have a local, development and production environment.

What I want

  1. Use the same callback url for every flow possible.
  2. I want it to be arbitrary which user flow is executed at runtime.

To achieve this I will create a dynamic provider which chooses the flow at runtime with the help of a cookie. The ID of the provider will always be the same: azure-ad-b2c.

Solution

Creating the dynamic provider

NextAuth is flexible enough that I can intercept the handler on each request with the help of advanced initialization. I will use the AzureADB2C-provider and tweak it slightly.

// api/auth/[...nextauth].ts
import { NextApiRequest, NextApiResponse } from "next"
import NextAuth from "next-auth"
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c"

const tenantId = process.env.AZURE_AD_B2C_TENANT_NAME
const clientId = process.env.AZURE_AD_B2C_CLIENT_ID
const clientSecret = process.env.AZURE_AD_B2C_CLIENT_SECRET
const base_url = process.env.NEXTAUTH_URL

// This is the dynamic part that builds the issuer path
const create_issuer = (user_flow: string) => {
     return `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${user_flow}/v2.0`
}

export default async function handler(
     req: NextApiRequest,
     res: NextApiResponse
) {
     // This is the error that is returned when the user cancels a user flow,
     // not sure if best method
     const error = req.query.error
     if (error) {
          if (error === "access_denied") {
               res.redirect(base_url)
               res.end()
               return
          }
     }

     // I just redirect if I can't find any flows in the cookie
     const flow = req.cookies.flow
     if (!flow) {
          res.redirect(base_url)
          res.end()
          return
     }

     return await NextAuth(req, res, {
          providers: [
               // The id for this provider is: azure-ad-b2c
               // Callback will be: /api/auth/callback/azure-ad-b2c
               AzureADB2CProvider({
                    clientId,
                    clientSecret,
                    // Dynamic .well-known configuration
                    wellKnown: create_issuer(flow as string)/.well-known/openid-configuration`,
                    authorization: {
                         params: { scope: "offline_access openid" },
                    },
               }),
          ],
     })
}

I need to know which flow was initiated in both the initiation and in the callback. This creates two possibilities: My first iteration used a query string, but Azure AD B2C do not accept arbitrary query strings in the redirect URL, so I use cookies.

Initiate a flow

I want the user flows to be dynamic, so I create an API endpoint and a wrapper function for the regular signIn method from NextAuth.

// /api/flow/[flow].ts
import type { NextApiRequest, NextApiResponse } from "next"

/*
This sets a cookie that is used by the [...nextauth].ts endpoints to determine which user flow to use.
*/
export default function handler(req: NextApiRequest, res: NextApiResponse) {
     const { id } = req.query
     res.setHeader("Set-Cookie", `flow=${id}; Path=/; HttpOnly`)
     res.end()
}
// startUserFlow.ts
import { signIn as NextAuthSignin } from "next-auth/react"

// Sets what user flow to use when signing in
const startUserFlow = async (user_flow: string) => {
     await fetch(`/api/flow/${user_flow}`)
     return NextAuthSignin("azure-ad-b2c")
}

export { startUserFlow }

Usage

const UserFlowButtons = () => {
     return (
          <div>
               <button onClick={() => startUserFlow("B2C_1A_ProfileEdit")}>
                    Edit Profile
               </button>
               <button onClick={() => startUserFlow("B2C_1A_PasswordReset")}>
                    Reset Password
               </button>
          </div>
     )
}
export default UserFlowButtons

Conclusion

Well, this just works. Now I only need to specify one callback path for each environment. I can use the same method to send dynamic scopes or other relevant information to the provider dynamically as well.

  1. Use the same callback url for every flow possible. ✔️
  2. I want it to be arbitrary which user flow is executed at runtime. ✔️

For reference I used NextAuth 4.20.1.