This commit is contained in:
phaichayon
2026-04-26 00:15:22 +07:00
parent a330abf9b6
commit 043edff93a
64 changed files with 25076 additions and 461 deletions

422
docs/KEYCLOAK_AUTH.md Normal file
View File

@@ -0,0 +1,422 @@
# Keycloak Authentication Implementation
## Overview
This document describes the Keycloak (OIDC) authentication implementation integrated into the Next.js + ElysiaJS application.
## Architecture
### Authentication Flow
```
┌─────────────┐ 1. Init ┌──────────────┐ 2. Login ┌──────────┐
│ Browser │ ──────────────> │ Keycloak │ ──────────────> │ Browser │
│ │ │ Server │ │ │
└─────────────┘ └──────────────┘ └──────────┘
│ │
│ 3. Token (JWT) │
├──────────────────────────────────────────────────────────────>│
│ │
│ 4. API Call with Bearer Token │
├─────────────────────────────────────────────┐ │
│ │ │
▼ ▼ │
┌─────────────┐ 5. Verify Token ┌──────────────┐ │
│ Next.js API │ ───────────────────> │ Database │ │
│ (Elysia) │ │ (PostgreSQL) │ │
└─────────────┘ └──────────────┘ │
│ │
│ 6. User Context │
├──────────────────────────────────────────────────────────────────>│
```
## Components
### Backend (ElysiaJS)
#### 1. Database Layer (`src/database/`)
**Files:**
- `src/database/schema/users.ts` - User table schema
- `src/database/db.ts` - Database connection using Drizzle ORM
- `drizzle.config.ts` - Drizzle configuration
**User Schema:**
```typescript
{
id: uuid (primary key)
keycloakId: text (unique, from Keycloak sub)
email: text
name: text
createdAt: timestamp
updatedAt: timestamp
}
```
#### 2. Keycloak Verification (`src/lib/keycloak.ts`)
**Functions:**
- `verifyToken(token)` - Verifies JWT using JWKS from Keycloak
- `extractToken(authHeader)` - Extracts Bearer token from Authorization header
**Features:**
- Automatic JWKS caching
- Token validation (issuer, audience, expiration)
- Type-safe token payload
#### 3. Auth Middleware (`src/middleware/auth.ts`)
**Exports:**
- `authPlugin` - Elysia plugin that validates tokens and attaches user to context
- `requireAuth` - Helper function to require authentication
**Usage:**
```typescript
import { authPlugin } from "@/middleware/auth";
// Apply to all routes
const app = new Elysia().use(authPlugin).get("/protected", ({ user }) => {
return { message: "Hello!", user };
});
```
#### 4. User Service (`src/modules/auth/service.ts`)
**Functions:**
- `findOrCreateUser(payload)` - Finds existing user or creates new one from Keycloak payload
- `getUserByKeycloakId(keycloakId)` - Retrieves user by Keycloak ID
### Frontend (Next.js)
#### 1. Keycloak Client (`src/lib/keycloak-client.ts`)
**Functions:**
- `initKeycloak()` - Initializes Keycloak with `login-required` mode
- `logout()` - Logs out user and clears tokens
- `getUserInfo()` - Returns parsed token payload
- `getToken()` - Returns current access token
- `isAuthenticated()` - Check if user is authenticated
**Features:**
- Memory-only token storage (no localStorage)
- Automatic token refresh (30 seconds before expiry)
- Token refresh on 401 errors
- PKCE flow for security
#### 2. Auth Provider (`src/providers/AuthProvider.tsx`)
**Context:**
```typescript
{
isAuthenticated: boolean;
isLoading: boolean;
userInfo: any;
logout: () => Promise<void>;
}
```
**Hook:**
- `useAuth()` - Access auth context in components
#### 3. API Client (`src/lib/api-client.ts`)
**Enhanced Features:**
- Automatically adds `Authorization: Bearer <token>` header
- Handles 401 errors by triggering token refresh
- Reads token from `window.__KEYCLOAK_TOKEN__`
## Setup Instructions
### 1. Database Setup
```bash
# Copy environment template
cp .env.example .env
# Edit .env with your database credentials
# DATABASE_URL=postgresql://user:password@localhost:5432/allaos
# Generate and run migration
npx drizzle-kit generate
npx drizzle-kit migrate
```
### 2. Keycloak Setup
#### Create a Realm and Client:
1. Log in to Keycloak Admin Console
2. Create a new realm (e.g., `allaos`)
3. Create a new OpenID Connect client:
- Client ID: `allaos-frontend`
- Client Authentication: `ON` (for backend)
- Valid Redirect URIs: `http://localhost:3000/*`
- Web Origins: `http://localhost:3000`
- Access Type: `confidential`
#### Configure Environment Variables:
```env
# Backend (.env)
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=allaos
KEYCLOAK_CLIENT_ID=allaos-frontend
KEYCLOAK_CLIENT_SECRET=your-client-secret
# Frontend (.env.local or NEXT_PUBLIC_ in .env)
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM=allaos
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=allaos-frontend
```
### 3. Install Dependencies
```bash
npm install keycloak jose
npm install -D @types/keycloak-js
```
### 4. Run Application
```bash
npm run dev
```
## Usage Examples
### Protecting API Routes
```typescript
import { Elysia } from "elysia";
import { authPlugin } from "@/middleware/auth";
const app = new Elysia({ prefix: "/api" })
.use(authPlugin)
.get("/protected", ({ user, tokenPayload }) => {
// user is now available from database
// tokenPayload contains Keycloak claims
return {
message: "Protected data",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
```
### Accessing User Info in Components
```typescript
"use client";
import { useAuth } from "@/providers/AuthProvider";
export default function UserProfile() {
const { isAuthenticated, isLoading, userInfo, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <div>Not authenticated</div>;
return (
<div>
<h1>Welcome, {userInfo?.name}</h1>
<p>Email: {userInfo?.email}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
```
### Making Authenticated API Calls
```typescript
import { apiClient } from "@/lib/api-client";
// Automatically includes Bearer token
const data = await apiClient("/api/protected-endpoint");
```
## Token Flow
### 1. Initialization
1. User visits application
2. `AuthProvider` initializes Keycloak
3. Keycloak redirects to login page (if not authenticated)
4. User logs in
5. Keycloak redirects back with code
6. Keycloak exchanges code for tokens
7. Access token stored in memory (`window.__KEYCLOAK_TOKEN__`)
### 2. API Calls
1. Component calls `apiClient()`
2. API client reads token from `window.__KEYCLOAK_TOKEN__`
3. Adds `Authorization: Bearer <token>` header
4. Backend receives request, extracts token
5. Verifies token using JWKS
6. Finds/creates user in database
7. Attaches user to request context
8. Route handler processes request
### 3. Token Refresh
1. Background interval checks token every second
2. If token expires in < 30 seconds, refresh automatically
3. If refresh fails, redirect to login
4. On 401 error, trigger immediate refresh attempt
## Security Considerations
### ✅ Implemented
- **Memory-only token storage** - No localStorage/sessionStorage
- **PKCE flow** - Prevents authorization code interception
- **JWT verification** - Using JWKS from Keycloak
- **Token expiration** - Automatic refresh before expiry
- **HTTPS ready** - Works with secure cookies and headers
- **CORS configured** - Only allowed origins
### ⚠️ Additional Recommendations
1. **Enable HTTPS in production**
2. **Set up Keycloak SSL**
3. **Implement rate limiting** on auth endpoints
4. **Add session timeout** on client side
5. **Implement CSRF protection** for state-changing operations
6. **Add audit logging** for authentication events
7. **Enable Keycloak events** for security monitoring
## Testing
### Manual Testing
1. Start Keycloak and your application
2. Visit `http://localhost:3000`
3. You should be redirected to Keycloak login
4. Login with test credentials
5. After login, you should see the application
6. Open browser DevTools → Network
7. Check that API calls have `Authorization: Bearer <token>` header
### Testing Token Expiry
1. Set Keycloak token expiry to 1 minute (for testing)
2. Login to application
3. Wait for token to expire
4. Try making an API call
5. Token should refresh automatically
6. If refresh fails, should redirect to login
### Testing Invalid Token
1. Manually modify `window.__KEYCLOAK_TOKEN__` in DevTools
2. Make an API call
3. Should receive 401 error
4. Should trigger token refresh
## Troubleshooting
### Issue: "Unauthorized: Invalid or expired token"
**Possible causes:**
- Token expired and refresh failed
- Keycloak URL/realm/client ID mismatch
- JWKS endpoint unreachable
**Solutions:**
- Check environment variables
- Verify Keycloak is running
- Check browser console for errors
- Verify JWKS endpoint is accessible
### Issue: User not created in database
**Possible causes:**
- Database connection failed
- Migration not run
- Database permissions issue
**Solutions:**
- Run `npx drizzle-kit migrate`
- Check `DATABASE_URL` in .env
- Verify database is accessible
### Issue: Redirect loop
**Possible causes:**
- Keycloak callback URL not configured
- Client not created or disabled
- Invalid redirect URI
**Solutions:**
- Check Keycloak client settings
- Verify Valid Redirect URIs
- Check client is enabled
## File Structure
```
src/
├── database/
│ ├── db.ts # Database connection
│ └── schema/
│ ├── users.ts # User schema
│ └── index.ts # Schema exports
├── lib/
│ ├── keycloak.ts # JWT verification
│ ├── keycloak-client.ts # Keycloak JS client
│ └── api-client.ts # API client with auth
├── middleware/
│ └── auth.ts # Elysia auth plugin
├── modules/
│ └── auth/
│ └── service.ts # User sync logic
├── providers/
│ └── AuthProvider.tsx # React auth context
└── app/
└── layout.tsx # Root layout with AuthProvider
```
## Next Steps (Phase 2 & 3)
### Phase 2: Role-Based Access Control (RBAC)
- Store user roles in database
- Add role claims to token verification
- Create role-based route protection
- Add admin/role management UI
### Phase 3: Multi-Tenant Support
- Add tenant_id to user schema
- Filter data by tenant
- Add tenant context to requests
- Implement tenant isolation
## References
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [OpenID Connect Core](https://openid.net/connect/)
- [ElysiaJS Documentation](https://elysiajs.com/)
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
- [Next.js Documentation](https://nextjs.org/docs)