Table of Contents
- Prerequisites:
- Prisma changes
- New Env-Variables
- Custom Attributes in AZURE AD B2C
- Access Token API Route
- New NPM Packages
- Modification Tailwind.config.js
- Utils.ts
- Roles Setup Azure AD B2C
- Getting Access Token
- Getting User Data (especially UserID)
- Patch User object with new value for custom attribute “Role”
- Here is the final result
In the previous stories, I created an Azure AD B2C tenant and provided a step-by-step guide regarding the integration into your Next.js 14 project.
Based on that, I first changed the styling of the sign-in / signup page and, lastly, added three Identity providers (Google, GitHub, and Microsoft) to the AD B2C tenant.
In this story, I will show you how easy it is to access your data in your Azure AD B2C Tenant via REST so that you can integrate it into your Next.js 14 project. We will extend our Prisma schema as well, so that we can save the obtained Access Token from Azure Ad B2C.
Here is the GitHub repo with the full code.
Prerequisites:
GitHub Account (makes it easier to create upcoming accounts via social login)
Neon.Tech Serverless PostgreSQL Account (Free Plan) and Prisma Setup (Step-by-Step Guide)
Prisma changes
As a first step, we will add two new tables to our Prisma Schema under prisma/prisma.schema. We will use these new tables to store the access token, which we will get from Azure AD and the new roles.
model ApiToken {
tokenId String @id @default(cuid())
accessToken String @db.VarChar
tokenType String @default("Azure")
expirationDate DateTime
addedOn DateTime @default(now())
}
model roles {
id Int @id @default(autoincrement())
name String
addedOn DateTime @default(now())
}Let's run
npx prisma db pushto push the changes to the connected DB.
Here is the complete prisma.schema file (table “logins”, was added in the last post regarding custom login data tracking).
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model logins {
id Int @id @default(autoincrement())
email String
name String
logins Int
objectId String?
lastLogin DateTime @default(now())
}
model ApiToken {
tokenId String @id @default(cuid())
accessToken String @db.VarChar
tokenType String @default("Azure")
expirationDate DateTime
addedOn DateTime @default(now())
}
model roles {
id Int @id @default(autoincrement())
name String
addedOn DateTime @default(now())
}New Env-Variables
we will add the missing env-variables to our .env.local file.
# Getting Data from Azure AD B2C
AZURE_CLIENT_ID=xxxx
AZURE_CLIENT_SECRET=xxxx
AZURE_GRANT_TYPE=client_credentials
AZURE_SCOPE=https://graph.microsoft.com/.default
AZURE_TENANT_ID=xxxx
AZURE_TOKEN_URL=https://login.microsoftonline.com/xxx/oauth2/v2.0/token
AZURE_B2C_EXTENSION_USER=extension_277fdddaa2e54130a3832d63736a808a_RoleLogin to https://portal.azure.com/#view/Microsoft_AAD_B2CAdmin/TenantManagementMenuBlade/~/registeredApps to get the keys.
Click on the App registration that you created for the production deployment. In my case, “Testprod.” On the corresponding overview page, you can get the “Application (client) id” for the “AZURE_CLIENT_ID” Env variable, and you can get the “AZURE_CLIENT_SECRET” in the “Certificate & secrets” section on the left side. The “AZURE_TENANT_ID” is also visible on the app registration overview “Directory (tenant) ID”.
Use the AZURE_TENANT_ID for the “AZURE_TOKEN_URL” as well, and replace the “xxxx” in the URL with the Tenant ID.
AZURE_TOKEN_URL=https://login.microsoftonline.com/xxx/oauth2/v2.0/token
Custom Attributes in AZURE AD B2C
For the last Env-Variable “AZURE_B2C_EXTENSION_USER” we click on the corresponding “b2c-extensions-app.xxxx” entry, and we copy the “Application (client) id”, then we remove all dashes in the id and we concat “extension_(id without dashes)_custom_attribute” to one string. But stop, what is “Role” in this case?
AZURE_B2C_EXTENSION_USER=extension_xxxx_Role“Role” is the custom attribute we added during the Tenant Creation.
We need it to get the “Custom Attribute” data from the AD directory.
But one important detail is missing. We need to change the access rights for the user “Testprod” so that we can access the AD directory data.


Access Token API Route
Let’s add a new API Route to get first the Access Token and then the Users in the AD directory.
We use Prisma to save the Access Token in the PostgreSQL DB to only get a new token when the old one has expired. So let’s create a new route under src/app/api/azure/user
import type { AuthTokenResp } from "@/types/api";
import prisma from "@/lib/prisma";
import { unixTimestampToDate, urlBase64Decode } from "@/lib/utils";
import { NextResponse, NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
async function AzureToken() {
const formdata = new FormData();
formdata.append("grant_type", "client_credentials");
formdata.append("client_id", process.env.AZURE_CLIENT_ID || "");
formdata.append("client_secret", process.env.AZURE_CLIENT_SECRET || "");
formdata.append("scope", process.env.AZURE_SCOPE || "");
if (!process.env.AZURE_TOKEN_URL) {
throw new Error("AZURE_TOKEN_URL is not set");
}
const res = await fetch(process.env.AZURE_TOKEN_URL, {
method: "POST",
body: formdata,
});
if (!res.ok) {
const text = await res.text(); // get the response body for more information
throw new Error(`
Failed to fetch data
Status: ${res.status}
Response: ${text}
`);
}
const newTokens: AuthTokenResp = await res.json();
return newTokens;
}
async function AzureData() {
const mostRecentApiToken = await prisma.apiToken.findFirst({
where: { tokenType: "Azure" },
orderBy: {
addedOn: "desc",
},
});
let token: string = "";
if (mostRecentApiToken) {
const { accessToken, expirationDate } = mostRecentApiToken;
const tokenExpirationDate = new Date(expirationDate);
const currentDate = new Date();
token = accessToken;
// compare the current date to the expiration date
if (currentDate > tokenExpirationDate) {
//Get Access Token
const tokenazure: any = await AzureToken();
const newTokens = tokenazure;
const accessToken = newTokens.access_token;
const { exp, nbf } = urlBase64Decode(accessToken.split(".")[1]);
const newExpirationDate = unixTimestampToDate(exp);
await prisma.apiToken.create({
data: {
accessToken: accessToken,
expirationDate: newExpirationDate,
tokenType: "Azure",
addedOn: new Date(),
},
});
//Accesstoken der Variablen zuweisen
token = accessToken;
}
} else {
//Get Access Token
const tokenazure: any = await AzureToken();
const newTokens = tokenazure;
const accessToken = newTokens.access_token;
const { exp, nbf } = urlBase64Decode(accessToken.split(".")[1]);
const newExpirationDate = unixTimestampToDate(exp);
await prisma.apiToken.create({
data: {
accessToken: accessToken,
expirationDate: newExpirationDate,
tokenType: "Azure",
addedOn: new Date(),
},
});
//Accesstoken der Variablen zuweisen
token = accessToken;
}
return token;
}
export async function GET(req: NextRequest) {
const authtoken = await getToken({ req });
const isAdmin = authtoken?.role === "Admin";
if (!authtoken || !isAdmin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let data = null;
//Get Access Token
const token = await AzureData();
const res = await fetch(
`https://graph.microsoft.com/beta/users?$select=displayName,id,identities,createdDateTime,otherMails,${process.env.AZURE_B2C_EXTENSION_USER}`,
{
method: "GET",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
},
}
);
data = await res.json();
return NextResponse.json({
data,
});
}We need two other routes for “Patching” or Updating the Azure AD Data and adding “role” records to our roles tables in PostgreSQL.
import type { AuthTokenResp } from "@/types/api";
import prisma from "@/lib/prisma";
import { unixTimestampToDate, urlBase64Decode } from "@/lib/utils";
import { NextResponse, NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
async function AzureToken() {
const formdata = new FormData();
formdata.append("grant_type", "client_credentials");
formdata.append("client_id", process.env.AZURE_CLIENT_ID || "");
formdata.append("client_secret", process.env.AZURE_CLIENT_SECRET || "");
formdata.append("scope", process.env.AZURE_SCOPE || "");
if (!process.env.AZURE_TOKEN_URL) {
throw new Error("AZURE_TOKEN_URL is not set");
}
const res = await fetch(process.env.AZURE_TOKEN_URL, {
method: "POST",
body: formdata,
});
if (!res.ok) {
const text = await res.text(); // get the response body for more information
throw new Error(`
Failed to fetch data
Status: ${res.status}
Response: ${text}
`);
}
const newTokens: AuthTokenResp = await res.json();
return newTokens;
}
async function AzureData() {
const mostRecentApiToken = await prisma.apiToken.findFirst({
where: { tokenType: "Azure" },
orderBy: {
addedOn: "desc",
},
});
let token: string = "";
if (mostRecentApiToken) {
const { accessToken, expirationDate } = mostRecentApiToken;
const tokenExpirationDate = new Date(expirationDate);
const currentDate = new Date();
token = accessToken;
// compare the current date to the expiration date
if (currentDate > tokenExpirationDate) {
//Get Access Token
const tokenazure: any = await AzureToken();
const newTokens = tokenazure;
const accessToken = newTokens.access_token;
const { exp, nbf } = urlBase64Decode(accessToken.split(".")[1]);
const newExpirationDate = unixTimestampToDate(exp);
await prisma.apiToken.create({
data: {
accessToken: accessToken,
expirationDate: newExpirationDate,
tokenType: "Azure",
addedOn: new Date(),
},
});
//Accesstoken
token = accessToken;
}
} else {
//Get Access Token
const tokenazure: any = await AzureToken();
const newTokens = tokenazure;
const accessToken = newTokens.access_token;
const { exp, nbf } = urlBase64Decode(accessToken.split(".")[1]);
const newExpirationDate = unixTimestampToDate(exp);
await prisma.apiToken.create({
data: {
accessToken: accessToken,
expirationDate: newExpirationDate,
tokenType: "Azure",
addedOn: new Date(),
},
});
//Accesstoken
token = accessToken;
}
return token;
}
export async function POST(req: NextRequest) {
// Extract the `messages` from the body of the request
const { id, role } = await req.json();
const AZURE_B2C_EXTENSION = `{${process.env.AZURE_B2C_EXTENSION_USER}: '${role}'}`;
const azurerole = JSON.stringify(AZURE_B2C_EXTENSION);
const authtoken = await getToken({ req });
const isAdmin = authtoken?.role === "Admin";
if (!authtoken || !isAdmin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
//Get Access Token
const token = await AzureData();
const patchUrl = "https://graph.microsoft.com/v1.0/users/" + id;
const res = await fetch(patchUrl, {
method: "PATCH",
body: AZURE_B2C_EXTENSION,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
},
});
return new NextResponse(undefined, {
status: res.status,
statusText: res.statusText,
});
}Adding roles
import prisma from "@/lib/prisma";
import { getToken } from "next-auth/jwt";
import { NextResponse, NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const authtoken = await getToken({ req });
const isAdmin = authtoken?.role === "Admin";
if (!authtoken || !isAdmin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Extract the `messages` from the body of the request
const { name } = await req.json();
let data: any = await prisma.roles.findFirst({
where: { name: name },
});
if (data) {
data["status"] = "present";
}
if (!data) {
data = await prisma.roles.create({
data: {
name: name,
},
});
data["status"] = "new";
}
return NextResponse.json({
data,
});
}New NPM Packages
We need three new NPM Packages for the new frontend components.
npm i @tremor/react react-icons react-toastifyModification Tailwind.config.js
For styling purposes, we are going to adapt the tailwind.config.ts file.
import type { Config } from "tailwindcss";
const { fontFamily } = require("tailwindcss/defaultTheme");
const config: Config = {
darkMode: "class",
content: [
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
// extraColors,
extend: {
colors: {
// ...extraColors,
// light mode
tremor: {
brand: {
faint: "#eff6ff", // blue-50
muted: "#bfdbfe", // blue-200
subtle: "#60a5fa", // blue-400
DEFAULT: "#3b82f6", // blue-500
emphasis: "#1d4ed8", // blue-700
inverted: "#ffffff", // white
},
background: {
muted: "#f9fafb", // gray-50
subtle: "#f3f4f6", // gray-100
DEFAULT: "#ffffff", // white
emphasis: "#374151", // gray-700
},
border: {
DEFAULT: "#e5e7eb", // gray-200
},
ring: {
DEFAULT: "#e5e7eb", // gray-200
},
content: {
subtle: "#9ca3af", // gray-400
DEFAULT: "#6b7280", // gray-500
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
},
},
// dark mode
"dark-tremor": {
brand: {
faint: "#0B1229", // custom
muted: "#172554", // blue-950
subtle: "#1e40af", // blue-800
DEFAULT: "#3b82f6", // blue-500
emphasis: "#60a5fa", // blue-400
inverted: "#030712", // gray-950
},
background: {
muted: "#131A2B", // custom
subtle: "#1f2937", // gray-800
DEFAULT: "#111827", // gray-900
emphasis: "#d1d5db", // gray-300
},
border: {
DEFAULT: "#374151", // gray-700
},
ring: {
DEFAULT: "#1f2937", // gray-800
},
content: {
subtle: "#4b5563", // gray-600
DEFAULT: "#6b7280", // gray-600
emphasis: "#e5e7eb", // gray-200
strong: "#f9fafb", // gray-50
inverted: "#000000", // black
},
},
},
maxWidth: {
"8xl": "90rem",
},
letterSpacing: {
snug: "-0.011em",
},
boxShadow: {
// light
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontSize: {
"2xs": "0.625rem",
"3xl": "1.75rem",
"4xl": "2.5rem",
"tremor-label": "0.75rem",
"tremor-default": ["1.0rem", { lineHeight: "1.25rem" }],
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
},
lineHeight: {
tighter: "1.1",
},
fontFamily: {
sans: ["var(--font-urbanist)", ...fontFamily.sans],
},
},
},
plugins: [require("@tailwindcss/typography")],
};
export default config;We added one new line after line 7, which is needed so that Tailwind can access the needed tremor files.
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx,mdx}",and we added a new “Colorblock” under line 13
colors: {
// light mode
tremor: {
brand: {
faint: "#eff6ff", // blue-50
muted: "#bfdbfe", // blue-200
subtle: "#60a5fa", // blue-400
DEFAULT: "#3b82f6", // blue-500
emphasis: "#1d4ed8", // blue-700
inverted: "#ffffff", // white
},
background: {
muted: "#f9fafb", // gray-50
subtle: "#f3f4f6", // gray-100
DEFAULT: "#ffffff", // white
emphasis: "#374151", // gray-700
},
border: {
DEFAULT: "#e5e7eb", // gray-200
},
ring: {
DEFAULT: "#e5e7eb", // gray-200
},
content: {
subtle: "#9ca3af", // gray-400
DEFAULT: "#6b7280", // gray-500
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
},
},
// dark mode
"dark-tremor": {
brand: {
faint: "#0B1229", // custom
muted: "#172554", // blue-950
subtle: "#1e40af", // blue-800
DEFAULT: "#3b82f6", // blue-500
emphasis: "#60a5fa", // blue-400
inverted: "#030712", // gray-950
},
background: {
muted: "#131A2B", // custom
subtle: "#1f2937", // gray-800
DEFAULT: "#111827", // gray-900
emphasis: "#d1d5db", // gray-300
},
border: {
DEFAULT: "#374151", // gray-700
},
ring: {
DEFAULT: "#1f2937", // gray-800
},
content: {
subtle: "#4b5563", // gray-600
DEFAULT: "#6b7280", // gray-600
emphasis: "#e5e7eb", // gray-200
strong: "#f9fafb", // gray-50
inverted: "#000000", // black
},
},
},Then, we create three new page.tsx files under src/app/[locale]/dashboard, src/app/[locale]/dashboard/activities, and src/app/[locale]/dashboard/user because we are going to create the backend part for logged-in users with Admin rights. The changes in the common.json files are needed to translate the label/button text. Get a complete overview in the connected GitHub repo, which you will find here.

We add a new type file (src/types/api.ts) for the data coming from the Azure Rest API, and we fix the type file for next-Auth (src/types/next-auth.ts). As a last step, we add a new file under src/lib for Base64 encoding, etc.
Utils.ts
import { DecodedPayload } from "@/types/api";
const unixTimestampToDate = (unixTimestamp: number): Date => {
return new Date(unixTimestamp * 1000);
};
const urlBase64Decode = (payload: string): DecodedPayload => {
// decode the base64 string
const base64Decoded = atob(payload);
// URL-decode the data
const urlDecoded = decodeURIComponent(base64Decoded);
// parse the JSON data
return JSON.parse(urlDecoded);
};
export { unixTimestampToDate, urlBase64Decode };and we adapt the name of our custom Azure AD attribute in src/lib/auth.ts.
Roles Setup Azure AD B2C
Since the roles table is new we don’t have any data in it, but only a User with the value “Admin” in the role attribute can see and access the dashboard section after logging, so how can we solve this?
// src/app/[locale]/dashboard
const session = await getServerSession(authOptions);
const user = session?.user;
const role = session?.role;
if (role !== "Admin") {
return (
<>
<div>Not authenticated...</div>
</>
);
}We must push this information to Azure AD B2C because we can’t add it via the web interface at https://portal.azure.com. You can use Postman, but I prefer the VSCode extension “Thunder Client” for that task because it is already integrated into my IDE Vscode.
To make things easier, I will create three different requests:
Getting Access Token
Request type: POST Url: https://login.microsoftonline.com/xxx/oauth2/v2.0/token (replace xxx with tour tenant ID) Body: Select “Form” and add 4 Form Fields (grant_type, client_id, client_secret, scope). These values should be present in your .env.local file.
Getting User Data (especially UserID)
Request type: GET Url: https://graph.microsoft.com/beta/users?$select=displayName,id,identities,createdDateTime,otherMails,extension_xxxx_Role (replace xxx with the ClientID of the corresponding b2c_extension_user of your AD) Auth: Select “Bearer” and add the Access Token obtained on the first call. Get the id from the Json result.
{
"displayName": "Test Test",
"id": "xxx-xxx-xxx-xxx-xxx",
"createdDateTime": "2024-05-17T15:40:50Z",
"otherMails": [],
"extension_xxx_Role": "Admin",
"identities": [
{
"signInType": "emailAddress",
"issuer": "xxx.onmicrosoft.com",
"issuerAssignedId": "xx.xxx@example.com"
},
{
"signInType": "userPrincipalName",
"issuer": "xxx.onmicrosoft.com",
"issuerAssignedId": "xxxx"
}
]
},Patch User object with new value for custom attribute “Role”
Request type: PATCH Url: https://graph.microsoft.com/v1.0/users/xxx (replace xxx with the UserID from the previous request) Auth: Select “Bearer” and add the Access Token obtained on the first call. Body:
{
"extension_xxxx_Role": "Admin"
}After that Patch request, the User object will show the new value, and you can try to log in with that user to see if you can see the new Dashboard.
Here is the final result
A new dropdown with the menu item “Dashboard” if the user has “Admin” rights.

Here the “Activities” page


First, you have to create the needed roles “Admin”, “Editor,” or whatever you prefer, and then you can assign them to the users. As soon as you click on the CTA “Change Role,” the user object in Azure AD will be updated.
Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:





