Inhaltsverzeichnis
- Voraussetzungen:
- Prisma Anpassungen
- Neue Umgebungsvariablen
- Benuterdefinierte Attribute in AZURE AD B2C
- Neue Api Route für Access Token
- Neue NPM Pakete
- Anpassung Tailwind.config.js
- Utils.ts
- Konfiguration der Rollen in Azure AD B2C
- Access Token generieren
- UserID aus User Objekt holen
- User Objekt "Patchen" und neuen Wert für das benutzerdefinierte Attribute "Role" einfügen
- Hier das Endresultat
In den vorherigen Beiträgen habe ich einen Azure AD B2C-Tenant erstellt und eine Schritt-für-Schritt-Anleitung für die Integration in Ihr Next.js 14-Projekt gegeben. Darauf aufbauend habe ich zunächst das Styling der Anmelde-/Registrierseite geändert und schließlich drei Identitätsanbieter (Google, GitHub und Microsoft) zum AD B2C-Tenant hinzugefügt. Hier ist das GitHub-Repo mit dem vollständigen Code
Voraussetzungen:
GitHub Account (erleichtert die Erstellung der kommenden Konten über Social Login)
Neon.Tech Serverless PostgreSQL Konto (Free Plan) und Prisma Setup
Prisma Anpassungen
Als ersten Schritt werden wir zwei neue Tabellen zu unserem Prisma-Schema unter prisma/prisma.schema hinzufügen. Wir werden diese neuen Tabellen verwenden, um das Zugriffstoken zu speichern, das wir von Azure AD erhalten, sowie die neuen Rollen.
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())
}Nun führen wir
npx prisma db pushaus, damit die Änderungen an die PostgresDB übertragen werden.
Hier die vollständige prisma.schema Datei (Tabelle “logins” haben wir im letzten Post last post regarding custom login data tracking hinzugefügt).
// 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())
}Neue Umgebungsvariablen
Wir werden die fehlenden Umgebungsvariablen zu unserer .env.local-Datei hinzufügen.
# 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_RoleMelden Sie sich unter https://portal.azure.com/#view/Microsoft_AAD_B2CAdmin/TenantManagementMenuBlade/~/registeredApps an und holen sich die Schlüssel.
Klicken Sie auf die App-Registrierung, die Sie für die Produktionsumgebung erstellt haben. In meinem Fall „Testprod“. Auf der entsprechenden Übersichtsseite können Sie die „Anwendungs-ID (Client-ID)“ für die Umgebungsvariable „AZURE_CLIENT_ID“ abrufen, und Sie können den „AZURE_CLIENT_SECRET“ im Abschnitt „Zertifikate & Geheimnisse“ auf der linken Seite erhalten. Die „AZURE_TENANT_ID“ ist ebenfalls in der Übersicht der App-Registrierung unter „Verzeichnis-ID (Mandanten-ID)“ sichtbar.
Verwenden Sie die AZURE_TENANT_ID auch für die „AZURE_TOKEN_URL“ und ersetzen Sie das „xxxx“ in der URL durch die Mandanten-ID.
AZURE_TOKEN_URL=https://login.microsoftonline.com/xxx/oauth2/v2.0/token
Benuterdefinierte Attribute in AZURE AD B2C
Für die letzte Umgebungsvariable „AZURE_B2C_EXTENSION_USER“ klicken wir auf den entsprechenden Eintrag „b2c-extensions-app.xxxx“ und kopieren die „Anwendungs-ID (Client-ID)“. Dann entfernen wir alle Bindestriche in der ID und fügen „extension_(ID ohne Bindestriche)_custom_attribute“ zu einem String zusammen. Aber halt, was ist „Role“ in diesem Fall?
AZURE_B2C_EXTENSION_USER=extension_xxxx_Role„Role“ ist das benutzerdefinierte Attribut, das wir während der Mandantenerstellung hinzugefügt haben.
Wir benötigen es, um die „Custom Attribute“-Daten aus dem AD-Verzeichnis abzurufen. Aber ein wichtiges Detail fehlt. Wir müssen die Zugriffsrechte für den Benutzer „Testprod“ ändern, damit wir auf die AD-Verzeichnisdaten zugreifen können.


Neue Api Route für Access Token
Lassen Sie uns eine neue API-Route hinzufügen, um zuerst das Access Token und dann die Benutzer im AD-Verzeichnis abzurufen. Wir verwenden Prisma, um das Access Token in der PostgreSQL-Datenbank zu speichern, sodass wir nur ein neues Token abrufen, wenn das alte abgelaufen ist. Also erstellen wir eine neue Route unter 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,
});
}Wir benötigen zwei weitere Routen zum „Patchen“ oder Aktualisieren der Azure AD-Daten und zum Hinzufügen von „role“-Einträgen zu unseren Rollentabellen 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,
});
}Rollen hinzufügen
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,
});
}Neue NPM Pakete
Wir benötigen drei neue NPM-Pakete für die neuen Frontend-Komponenten.
npm i @tremor/react react-icons react-toastifyAnpassung Tailwind.config.js
Für Styling-Zwecke werden wir die tailwind.config.ts-Datei anpassen.
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;Wir haben eine neue Zeile nach Zeile 7 hinzugefügt, die notwendig ist, damit Tailwind auf die benötigten Tremor-Dateien zugreifen kann.
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx,mdx}",und wir haben einen neuen „Colorblock“ unter Zeile 13 hinzugefügt
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
},
},
},Dann erstellen wir drei neue page.tsx-Dateien unter src/app/[locale]/dashboard, src/app/[locale]/dashboard/activities und src/app/[locale]/dashboard/user, da wir den Backend-Teil für eingeloggte Benutzer mit Admin-Rechten erstellen werden. Die Änderungen in den common.json-Dateien sind notwendig, um die Beschriftungs-/Schaltflächentexte zu übersetzen. Einen vollständigen Überblick finden Sie im verbundenen GitHub-Repository, das Sie hier finden.

Wir fügen eine neue Typdatei (src/types/api.ts) für die Daten hinzu, die von der Azure Rest API kommen, und korrigieren die Typdatei für next-Auth (src/types/next-auth.ts). Als letzten Schritt fügen wir eine neue Datei unter src/lib für Base64-Codierung usw. hinzu.
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 };und wir passen den Name des neuen benutzerdefinierten Attributes in der Datei src/lib/auth.ts an.
Konfiguration der Rollen in Azure AD B2C
Da die Rollentabelle neu ist, haben wir noch keine Daten darin. Nur ein Benutzer mit dem Wert „Admin“ im Rollenattribut kann nach dem Einloggen den Dashboard-Bereich sehen und darauf zugreifen. Wie können wir das lösen?
// 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>
</>
);
}Wir müssen diese Informationen zu Azure AD B2C übertragen, da wir sie nicht über die Web-Oberfläche unter https://portal.azure.com hinzufügen können. Sie können Postman verwenden, aber ich bevorzuge die VSCode-Erweiterung „Thunder Client“ für diese Aufgabe, da sie bereits in meine IDE VSCode integriert ist.
Um es einfacher zu machen, werde ich drei verschiedene Anfragen erstellen:
Access Token generieren
Request type: POST Url: https://login.microsoftonline.com/xxx/oauth2/v2.0/token (replace xxx with tour tenant ID) Body: “Form” auswählen und 4 Felder hinzufügen (grant_type, client_id, client_secret, scope). Die entsprechenden Werte der Felder können der Datei .env.local entnommen werden.
UserID aus User Objekt holen
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: “Bearer” auswählen und den Access Token aus der ersten Abfrage einsetzen. id aus Json Antwort holen.
{
"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"
}
]
},User Objekt "Patchen" und neuen Wert für das benutzerdefinierte Attribute "Role" einfügen
Request type: PATCH Url: https://graph.microsoft.com/v1.0/users/xxx (xxx mit der UserID aus der vorherigen Abfrage ersetzen) Auth: “Bearer” auswählen und den Access Token aus der ersten Abfrage einsetzen. Body:
{
"extension_xxxx_Role": "Admin"
}Nach dieser Patch-Anfrage wird das Benutzerobjekt den neuen Wert anzeigen, und Sie können versuchen, sich mit diesem Benutzer anzumelden, um zu sehen, ob Sie das neue Dashboard sehen können.
Hier das Endresultat
Ein neues Dropdown-Menü mit dem Menüpunkt „Dashboard“, falls der Benutzer „Admin“-Rechte hat.

Hier die “Aktivitäten” Seite


Zuerst müssen Sie die benötigten Rollen wie „Admin“, „Editor“ oder was auch immer Sie bevorzugen, erstellen und dann den Benutzern zuweisen. Sobald Sie auf den CTA „Rolle ändern“ klicken, wird das Benutzerobjekt in Azure AD aktualisiert.
Cloudapp-dev und bevor Sie uns verlassen
Danke, dass Sie bis zum Ende gelesen haben. Noch eine Bitte bevor Sie gehen:





