10 KiB
Keycloak Integration Best Practices — MeshiTrack
Instruction file for Keycloak setup, configuration, and Fastify/Next.js integration.
Realm Configuration
Realm: meshitrack
Export a realm JSON for reproducible setup across environments. Store in docker/keycloak/realm-export.json.
Clients
| Client ID | Type | Access | Purpose |
|---|---|---|---|
meshitrack-web |
Public | PKCE (no secret) | Frontend (Next.js) login |
meshitrack-api |
Bearer-only | Confidential | Backend token validation |
Client Configuration: meshitrack-web
{
"clientId": "meshitrack-web",
"publicClient": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"redirectUris": ["http://localhost:3000/*", "https://meshitrack.example.com/*"],
"webOrigins": ["http://localhost:3000", "https://meshitrack.example.com"],
"attributes": {
"pkce.code.challenge.method": "S256"
}
}
Client Configuration: meshitrack-api
{
"clientId": "meshitrack-api",
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false
}
Realm Roles
| Role | Description |
|---|---|
admin |
Can manage household settings, delete items |
member |
Standard access: CRUD on own data |
Assign default role member to all new users.
Custom Token Claims (Household Mapping)
User Attributes
Each Keycloak user gets custom attributes:
householdIds: JSON array string, e.g.["household-uuid-1", "household-uuid-2"]defaultHouseholdId: single UUID string
Protocol Mapper: Household Claims
Create a protocol mapper on the meshitrack-web client (or realm level):
{
"name": "household-ids-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"claim.name": "householdIds",
"user.attribute": "householdIds",
"jsonType.label": "JSON",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"multivalued": "false"
}
}
This injects householdIds directly into the JWT access token, so the API can read it without a separate database call.
Fastify Integration
JWT Validation with jose
Use the jose library for JWKS-based JWT verification — lightweight, ESM-native, no Passport overhead.
npm install jose --workspace=packages/api
// plugins/auth.plugin.ts
import fp from 'fastify-plugin';
import * as jose from 'jose';
import config from '../config/configuration.js';
import type { AuthUser } from '../common/types.js';
import { UnauthorizedError } from '../common/errors.js';
let jwks: jose.JWTVerifyGetKey | undefined;
function getJwks(): jose.JWTVerifyGetKey {
if (!jwks) {
const issuerUrl = `${config.keycloak.url}/realms/${config.keycloak.realm}`;
jwks = jose.createRemoteJWKSet(new URL(`${issuerUrl}/protocol/openid-connect/certs`));
}
return jwks;
}
export default fp(
async (fastify) => {
fastify.decorateRequest('user', null as unknown as AuthUser);
fastify.addHook('onRequest', async (request) => {
// Skip auth for routes marked as public via route config
const routeConfig = request.routeOptions.config as Record<string, unknown> | undefined;
if (routeConfig?.['public'] === true) return;
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
const issuerUrl = `${config.keycloak.url}/realms/${config.keycloak.realm}`;
const { payload } = await jose.jwtVerify(token, getJwks(), {
issuer: issuerUrl,
audience: config.keycloak.clientId,
});
request.user = {
keycloakId: payload.sub ?? '',
email: (payload['email'] as string) ?? '',
displayName: (payload['preferred_username'] as string) ?? '',
roles: (payload['realm_access'] as Record<string, string[]>)?.['roles'] ?? [],
householdIds: (payload['householdIds'] as string[]) ?? [],
};
});
},
{ name: 'auth-plugin' },
);
Route Configuration for Public/Protected
Use Fastify route config to mark endpoints as public:
// Public endpoint — no auth required
app.route({
method: 'GET',
url: '/api/v1/health',
config: { public: true },
handler: async () => ({ status: 'ok' }),
});
// Protected endpoint (default — auth hook enforces JWT)
app.route({
method: 'GET',
url: '/api/v1/users/me',
config: { skipHousehold: true }, // auth required, household check skipped
handler: async (request) => {
/* request.user is populated */
},
});
User Sync on First Login
When a user first authenticates, sync their Keycloak profile to the local MongoDB User document via the route handler:
// modules/users/users.routes.ts
app.route({
method: 'GET',
url: '/api/v1/users/me',
config: { skipHousehold: true },
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('usersService');
const user = await service.syncFromToken(request.user);
return reply.send(user);
},
});
// modules/users/users.service.ts
export class UsersService {
constructor({ usersRepository }: { usersRepository: UsersRepository }) {
this.usersRepository = usersRepository;
}
async syncFromToken(user: AuthUser) {
return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName);
}
}
Next.js Integration
Using next-auth v5 with Keycloak provider
// lib/auth.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
token.householdIds = (profile as any)?.householdIds;
}
// Handle token refresh
if (Date.now() < (token.expiresAt as number) * 1000) {
return token;
}
return await refreshAccessToken(token);
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.householdIds = token.householdIds as string[];
return session;
},
},
});
async function refreshAccessToken(token: any) {
try {
const response = await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
},
);
const refreshed = await response.json();
return {
...token,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
};
} catch {
return { ...token, error: 'RefreshAccessTokenError' };
}
}
Proxy for route protection
Next.js 16 uses proxy.ts instead of middleware.ts:
// proxy.ts
export { auth as proxy } from '@/lib/auth';
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
Test Users
Create in realm export for development:
| Username | Password | Roles | Households |
|---|---|---|---|
testuser1 |
test1234 |
member, admin | ["household-test-1"] |
testuser2 |
test1234 |
member | ["household-test-1"] |
testuser3 |
test1234 |
member | ["household-test-2"] |
Token Lifetime Configuration
| Setting | Dev Value | Prod Recommendation |
|---|---|---|
| Access Token Lifespan | 30 min | 5 min |
| Refresh Token Lifespan | 1 day | 30 min |
| SSO Session Idle | 1 day | 30 min |
| SSO Session Max | 7 days | 8 hours |
Configure in Keycloak Admin → Realm Settings → Tokens.
Keycloak Admin API (for household management)
When a user creates a household or invites members, you may need to update Keycloak user attributes via the Admin API:
// services/keycloak-admin.service.ts
import KcAdminClient from '@keycloak/keycloak-admin-client';
export class KeycloakAdminService {
private kcAdmin: KcAdminClient;
constructor() {
this.kcAdmin = new KcAdminClient({
baseUrl: process.env['KEYCLOAK_URL'],
realmName: process.env['KEYCLOAK_REALM'],
});
}
async authenticate() {
await this.kcAdmin.auth({
grantType: 'client_credentials',
clientId: 'meshitrack-api',
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET']!,
});
}
async updateUserHouseholds(keycloakId: string, householdIds: string[]) {
await this.kcAdmin.users.update(
{ id: keycloakId },
{ attributes: { householdIds: [JSON.stringify(householdIds)] } },
);
}
}
Common Pitfalls
-
CORS issues: Keycloak's public URL must be accessible from the browser. In Docker, the browser connects to
localhost:8080, but the API connects tokeycloak:8080. UseKC_HOSTNAME_URLin production. -
Token clock skew: Ensure system clocks are synced between API server and Keycloak. Use NTP.
-
Realm export not importing: The import only works on first startup. To re-import, delete the Keycloak data volume.
-
HTTPS in production: Always use HTTPS for Keycloak in production. Use
KC_PROXY=edgewith a reverse proxy.