Secure Your Sitecore XM Cloud App with NextAuth.js: Authentication & Authorization
Modern digital experiences demand secure, personalized, and seamless user access. In projects built with Sitecore XM Cloud and Next.js, combining NextAuth.js with Azure AD B2C offers a robust solution for authentication, while custom middleware provides fine-grained authorization. In this blog post, I’ll walk you through how we implemented both in a Sitecore XM Cloud project.
Authentication with Azure AD B2C and NextAuth.js
We’re using NextAuth.js for handling authentication via Azure AD B2C. This provider allows us to manage sign-in, profile editing, and password reset flows while integrating additional user metadata from Salesforce.
Key Features
-
Azure AD B2C handles user identity and federation.
-
NextAuth.js supports multiple user flows as separate providers.
-
JWT-based sessions allow for stateless authentication across SSR and API routes.
Here’s the core setup in /pages/api/auth/[...nextauth].ts
:
interface AzureADB2CProfile { sub: string; given_name: string; family_name: string; email: string; country?: string; companyName?: string; postalCode?: string; phone_number?: string; roles?: string[]; salesforceUserId?: string;
contactId?: string; accountId?: string; auth_time?: number;}
declare module 'next-auth' { interface Session extends DefaultSession { user?: { phoneNumber?: string; givenName?: string; familyName?: string; country?: string; companyName?: string; zipCode?: string; roles?: string[]; salesforceUserId?: string; contactId?: string; accountId?: string; authTime?: Date | null; } & DefaultSession['user']; }}
declare module 'next-auth/jwt' { interface JWT { phoneNumber?: string; accessToken?: string; roles?: string[]; salesforceUserId?: string; contactId?: string; accountId?: string; auth_time?: number; }}
export const authOptions: AuthOptions = { providers: [ AzureADB2CProvider({ clientId: process.env.AZURE_AD_B2C_CLIENT_ID!, clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!, tenantId: process.env.AZURE_AD_B2C_TENANT!, primaryUserFlow: process.env.AZURE_AD_B2C_USER_FLOW!, ...(process.env.AZURE_AD_B2C_ISSUER && { issuer: (() => { const issuerValue = process.env.AZURE_AD_B2C_ISSUER!; return `https://${issuerValue}/${issuerValue}/${process.env.AZURE_AD_B2C_USER_FLOW}/v2.0/`; })(), }), authorization: { params: { scope: 'openid profile offline_access' } }, profile(profile) { return { id: profile.sub, name: profile.given_name + ' ' + profile.family_name, email: profile.email, }; }, }), AzureADB2CProvider({ id: 'azure-ad-b2c-edit-profile', clientId: process.env.AZURE_AD_B2C_CLIENT_ID!, clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!, tenantId: process.env.AZURE_AD_B2C_TENANT!, primaryUserFlow: 'B2C_1A_PROFILEEDIT', ...(process.env.AZURE_AD_B2C_ISSUER && { issuer: (() => { const issuerValue = process.env.AZURE_AD_B2C_ISSUER!; return `https://${issuerValue}/${issuerValue}/B2C_1A_PROFILEEDIT/v2.0/`; })(), }), authorization: { params: { scope: 'openid profile offline_access' } }, profile(profile) { return { id: profile.sub, name: profile.given_name + ' ' + profile.family_name, email: profile.email, }; }, }), AzureADB2CProvider({ id: 'azure-ad-b2c-reset-password', clientId: process.env.AZURE_AD_B2C_CLIENT_ID!, clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!, tenantId: process.env.AZURE_AD_B2C_TENANT!, primaryUserFlow: 'B2C_1A_PASSWORDCHANGE', ...(process.env.AZURE_AD_B2C_ISSUER && { issuer: (() => { const issuerValue = process.env.AZURE_AD_B2C_ISSUER!; return `https://${issuerValue}/${issuerValue}/B2C_1A_PASSWORDCHANGE/v2.0/`; })(), }), authorization: { params: { scope: 'openid profile offline_access' } }, profile(profile) { return { id: profile.sub, name: profile.given_name + ' ' + profile.family_name, email: profile.email, }; }, }), ], secret: process.env.NEXTAUTH_SECRET, session: { strategy: 'jwt' }, cookies: process.env.NEXTAUTH_COOKIE_DOMAIN ? { sessionToken: { name: `__Secure-next-auth.session-token`, options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true, domain: process.env.NEXTAUTH_COOKIE_DOMAIN, }, }, } : undefined, callbacks: { async signIn({ profile }) { if (profile) { const azureProfile = profile as AzureADB2CProfile;
// Check if we have the required user data if (!azureProfile.salesforceUserId || !azureProfile.contactId) { console.error('Authentication failed: Required user data is missing', { salesforceUserId: azureProfile.salesforceUserId, contactId: azureProfile.contactId, }); return '/login-error'; }
// Validate user if we have both salesforceUserId and contactId const userValidationService = UserValidationService.getInstance(); const validation = await userValidationService.validateUser( azureProfile.salesforceUserId, azureProfile.contactId );
if (!validation.isValid || !validation.accountId) { return '/login-error'; }
// Store the validated accountId in the profile azureProfile.accountId = validation.accountId; } return true; // Allow sign-in }, async session({ session, token }) { if (session.user) { session.user.phoneNumber = token.phoneNumber as string; session.user.givenName = token.givenName as string; session.user.familyName = token.familyName as string; session.user.country = token.country as string; session.user.companyName = token.companyName as string; session.user.zipCode = token.postalCode as string; session.user.roles = token.roles as string[]; session.user.salesforceUserId = token.salesforceUserId as string; session.user.contactId = token.contactId as string; session.user.accountId = token.accountId as string; session.user.authTime = token.auth_time ? new Date(token.auth_time * 1000) : null; } return session; }, async jwt({ token, account, profile }) { if (profile) { const azureProfile = profile as AzureADB2CProfile; token.phoneNumber = azureProfile.phone_number; token.givenName = azureProfile.given_name; token.familyName = azureProfile.family_name; token.country = azureProfile.country; token.companyName = azureProfile.companyName; token.postalCode = azureProfile.postalCode; token.roles = azureProfile.roles; token.salesforceUserId = azureProfile.salesforceUserId; token.contactId = azureProfile.contactId; token.accountId = azureProfile.accountId; token.auth_time = azureProfile.auth_time; } if (account) { token.accessToken = account.access_token; } return token; }, },};
export default NextAuth(authOptions);
We are enriching the session with custom user claims like:
-
salesforceUserId
-
contactId
-
accountId
-
roles
This happens through the jwt
and session
callbacks, where we map Azure AD B2C claims to our application-specific session structure.
User Validation
Before allowing sign-in, we validate the Salesforce IDs using a custom service. If validation fails, the user is redirected to a custom error page (/login-error
). This adds an extra layer of business rule enforcement at login.
Authorization Middleware:
Once the user is authenticated, we use middleware to control access to protected pages under a specific path. This is required because we need to be mindful of the performance impact checking security might have on each request.
How It Works:
-
We fetch metadata for the requested page via Sitecore’s
PageService
. -
If roles are defined in Sitecore, we check whether the user has any of the required roles stored in the session.
-
If the user is:
-
Unauthenticated → redirect to
/not-authenticated
. -
Authenticated but unauthorized → redirect to
/not-authorized
.
-
Here’s an overview of the logic:
Pages with Role-Based Security in Sitecore
In Sitecore XM Cloud, editors define which roles can access a page using a custom securityRole
field. This integrates beautifully with our middleware, giving content authors the power to gate content without requiring developer intervention.
Best of Both Worlds
By combining Azure AD B2C + NextAuth.js for identity and custom middleware + Sitecore content structure for access control, we get:
-
Enterprise-grade authentication with minimal complexity
-
Fully decoupled authorization from code to content
-
Maintainable and scalable security across multi-tenant environments
Comments
Post a Comment