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
- Use the same callback url for every flow possible.
- 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.
- Use the same callback url for every flow possible. ✔️
- I want it to be arbitrary which user flow is executed at runtime. ✔️
For reference I used NextAuth 4.20.1.