MeshiTrack/docs/instructions/keycloak.md

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

  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.