# 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<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:

```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.
