Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
347
docs/instructions/keycloak.md
Normal file
347
docs/instructions/keycloak.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue