347 lines
10 KiB
Markdown
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.
|