MeshiTrack/docs/instructions/keycloak.md

347 lines
10 KiB
Markdown

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