# 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` ```json { "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` ```json { "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): ```json { "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. ```bash npm install jose --workspace=packages/api ``` ```typescript // 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 | 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)?.['roles'] ?? [], householdIds: (payload['householdIds'] as string[]) ?? [], }; }); }, { name: 'auth-plugin' }, ); ``` ### Route Configuration for Public/Protected Use Fastify route config to mark endpoints as public: ```typescript // 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: ```typescript // 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 ```typescript // 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`: ```typescript // 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: ```typescript // 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 1. **CORS issues**: Keycloak's public URL must be accessible from the browser. In Docker, the browser connects to `localhost:8080`, but the API connects to `keycloak:8080`. Use `KC_HOSTNAME_URL` in production. 2. **Token clock skew**: Ensure system clocks are synced between API server and Keycloak. Use NTP. 3. **Realm export not importing**: The import only works on first startup. To re-import, delete the Keycloak data volume. 4. **HTTPS in production**: Always use HTTPS for Keycloak in production. Use `KC_PROXY=edge` with a reverse proxy.