Introduction
In Part 1, we discussed the architectural necessity of a Single Source of Truth (SSoT) and designed a robust PostgreSQL schema using Prisma. We established that managing users manually across fragmented services is a liability.
Today, we move from design to implementation. We will build the Identity Hub's engine using Next.js Route Handlers. Our goal is to create a secure, high-performance API that allows our "Spoke" applications (like Laravel services) to authenticate users and verify permissions without owning the data.
1. The Security Handshake: Service-to-Service Auth
Since our Identity Hub is a private internal service, we cannot leave the API endpoints open. We need a way to ensure that only authorized "Spoke" applications can talk to our Next.js Hub.
For this implementation, we will use a Secret Header-based Authentication (or an API Key).
import { headers } from 'next/headers';
export function validateServiceSecret() {
const headerList = headers();
const apiKey = headerList.get('x-identity-shared-secret');
if (!apiKey || apiKey !== process.env.INTERNAL_SERVICE_SECRET) {
throw new Error('Unauthorized: Service Secret Mismatch');
}
}
2. Implementing the Authentication Logic
The most critical endpoint is the login. It must verify credentials and return a scoped payload. Note how we use Prisma’s include to fetch roles and permissions in a single query—maintaining the performance we promised in Part 1.
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { validateServiceSecret } from '@/lib/auth-guard';
export async function POST(request: Request) {
try {
validateServiceSecret();
const { email, password } = await request.json();
const user = await prisma.user.findUnique({
where: { email },
include: {
roles: {
include: {
role: {
include: { permissions: { include: { permission: true } } }
}
}
}
}
});
if (!user || !user.isActive) {
return NextResponse.json({ error: 'User not found or inactive' }, { status: 401 });
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Flattening permissions for the Spoke application
const permissions = Array.from(new Set(
user.roles.flatMap(ur => ur.role.permissions.map(rp => rp.permission.slug))
));
return NextResponse.json({
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
permissions: permissions
}
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
3. Why This Logic Matters for Scalability
In a traditional setup, you’d be tempted to return the whole User object. By flattening the permissions into a simple string array (e.g., ['users.create', 'billing.view']), we:
- Reduce Payload Size: Important for high-traffic service-to-service calls.
Decouple Spoke Logic: The Laravel app doesn't need to know how the roles are mapped in the Hub; it only needs to know what the user is allowed to do.
Handling Atomicity with Prisma Transactions
When creating a user, you must ensure they are assigned a role immediately. If the user is created but the role assignment fails, your "Single Source of Truth" is now corrupted.
We use Prisma Transactions to ensure an "all-or-nothing" execution:
const newUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: data.email,
passwordHash: hashedPw,
}
});
await tx.userRole.create({
data: {
userId: user.id,
roleId: defaultRoleId,
}
});
return user;
});
5. Performance: To Cache or Not to Cache?
In Part 1, we mentioned Redis. Since the Identity Hub will be hit every time a user logs in or performs a sensitive action in any Spoke app, database latency can become a bottleneck.
The Strategy:
- Cache Permissions: Store the flattened permission array in Redis with a TTL (Time to Live).
- Invalidation: Use a Webhook or a simple API call to clear the Redis cache whenever a user's role is updated in the Hub.
What’s Next?
We have the Hub, the Security Guard, and the API logic. But how does a legacy service actually consume this?
In Part 3: Connecting Laravel to the Hub, we will dive into the "Spoke" side. We will build a custom Guard in Laravel that bypasses its local database and validates everything against our Next.js Identity Hub.
Question for the community:
How do you handle session invalidation across multiple apps when a user is banned in the Central Hub? Let's discuss in the comments.
Top comments (0)