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.

1// api/auth/[...nextauth].ts 2import { NextApiRequest, NextApiResponse } from "next" 3import NextAuth from "next-auth" 4import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c" 5 6const tenantId = process.env.AZURE_AD_B2C_TENANT_NAME 7const clientId = process.env.AZURE_AD_B2C_CLIENT_ID 8const clientSecret = process.env.AZURE_AD_B2C_CLIENT_SECRET 9const base_url = process.env.NEXTAUTH_URL 10 11// This is the dynamic part that builds the issuer path 12const create_issuer = (user_flow: string) => { 13 return `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${user_flow}/v2.0` 14} 15 16export default async function handler( 17 req: NextApiRequest, 18 res: NextApiResponse 19) { 20 // This is the error that is returned when the user cancels a user flow, 21 // not sure if best method 22 const error = req.query.error 23 if (error) { 24 if (error === "access_denied") { 25 res.redirect(base_url) 26 res.end() 27 return 28 } 29 } 30 31 // I just redirect if I can't find any flows in the cookie 32 const flow = req.cookies.flow 33 if (!flow) { 34 res.redirect(base_url) 35 res.end() 36 return 37 } 38 39 return await NextAuth(req, res, { 40 providers: [ 41 // The id for this provider is: azure-ad-b2c 42 // Callback will be: /api/auth/callback/azure-ad-b2c 43 AzureADB2CProvider({ 44 clientId, 45 clientSecret, 46 // Dynamic .well-known configuration 47 wellKnown: create_issuer(flow as string)/.well-known/openid-configuration`, 48 authorization: { 49 params: { scope: "offline_access openid" }, 50 }, 51 }), 52 ], 53 }) 54}

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.

1// /api/flow/[flow].ts 2import type { NextApiRequest, NextApiResponse } from "next" 3 4/* 5This sets a cookie that is used by the [...nextauth].ts endpoints to determine which user flow to use. 6*/ 7export default function handler(req: NextApiRequest, res: NextApiResponse) { 8 const { id } = req.query 9 res.setHeader("Set-Cookie", `flow=${id}; Path=/; HttpOnly`) 10 res.end() 11}
1// startUserFlow.ts 2import { signIn as NextAuthSignin } from "next-auth/react" 3 4// Sets what user flow to use when signing in 5const startUserFlow = async (user_flow: string) => { 6 await fetch(`/api/flow/${user_flow}`) 7 return NextAuthSignin("azure-ad-b2c") 8} 9 10export { startUserFlow }

Usage

1const UserFlowButtons = () => { 2 return ( 3 <div> 4 <button onClick={() => startUserFlow("B2C_1A_ProfileEdit")}> 5 Edit Profile 6 </button> 7 <button onClick={() => startUserFlow("B2C_1A_PasswordReset")}> 8 Reset Password 9 </button> 10 </div> 11 ) 12} 13export 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.