A complete Node.js/Express implementation of OIDC login flow with Keycloak using PKCE, server-side sessions, role-based authorization, and database persistence.
Server: 139.59.66.194 (DigitalOcean K3s) | Namespace: d2 | Service: backend-api:8000
# SSH to server
ssh root@139.59.66.194
# Check backend pod status
kubectl get pods -n d2 -l app=backend-api -o wide
# View application logs
kubectl logs -n d2 -l app=backend-api --tail=20
# Test API health (from server)
kubectl port-forward -n d2 svc/backend-api 8000:8000 &
curl http://localhost:8000/healthFor comprehensive verification commands and troubleshooting, see the Kubernetes Deployment section below.
- Image Registry: GHCR (GitHub Container Registry)
- Image Name:
ghcr.io/healthcare-monitoring-system/backend - Repository: https://github.com/Healthcare-Monitoring-System/backend
- Auto-build: GitHub Actions workflow triggers on
mainbranch push or tags
The backend runs in the same Kubernetes cluster as d2-data-intelligence in namespace d2.
Deployment Info:
- Namespace:
d2 - Service:
backend-api(ClusterIP) - Port: 8000
- Replicas: 1 (configurable)
- Image Pull Policy: Always
ssh root@139.59.66.194# View backend pod
kubectl get pods -n d2 -l app=backend-api
# Expected output:
# NAME READY STATUS RESTARTS AGE
# backend-api-6d7bf7b9bd-7mbgs 1/1 Running 0 2m# View deployment
kubectl get deployment backend-api -n d2
# View service
kubectl get service backend-api -n d2
# Expected output:
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# backend-api ClusterIP 10.43.198.19 <none> 8000/TCP 162m# View container logs
kubectl logs -n d2 -l app=backend-api --tail=30
# Expected output should show:
# Database pool initialized
# JWKS client initialized
# Server running on http://localhost:8000# Check pod is Ready
kubectl describe pod -n d2 -l app=backend-api | grep -A 5 "Conditions:"
# Expected: All conditions should be True (Ready, Initialized, etc.)# View current image
kubectl get deployment backend-api -n d2 -o jsonpath='{.spec.template.spec.containers[0].image}'
# Expected: ghcr.io/healthcare-monitoring-system/backend:sha-6303bb98f97269c31053a22bccec775ec25ece1d# Port-forward to test locally
kubectl port-forward -n d2 svc/backend-api 8000:8000 &
# Test health endpoint (from your machine or server)
curl http://localhost:8000/health
# Expected: {"status":"ok"}# View endpoints (pod IPs that service routes to)
kubectl get endpoints backend-api -n d2
# Expected:
# NAME ENDPOINTS AGE
# backend-api 10.42.0.54:8000 162m# View all backend resources
kubectl get all -n d2 | grep backend
# Should show: deployment, service, pod, replicaset# Watch pod status in real-time
kubectl get pods -n d2 -l app=backend-api -wThe deployment pulls configuration from:
- ConfigMap:
d2-config(shared with d2-data-intelligence) - Secret:
d2-secrets(shared with d2-data-intelligence)
Required env vars for backend:
DATABASE_URL- PostgreSQL connection stringKEYCLOAK_URL- Keycloak server URLKEYCLOAK_REALM- Keycloak realm nameKEYCLOAK_CLIENT_ID- OAuth client IDKEYCLOAK_CLIENT_SECRET- OAuth client secretNODE_ENV- Set toproductionfor secure cookies
See infra/k8s/base/secret.example.yaml for template.
If you need to redeploy or update the image:
# Trigger new build/push (push to main or create tag v*)
cd backend
git add .
git commit -m "chore: update backend"
git push origin main
# GitHub Actions builds and pushes to GHCR automatically
# Kubernetes will pull the new image (imagePullPolicy: Always)
# Or manually restart pods:
kubectl rollout restart deployment/backend-api -n d2
# Monitor rollout:
kubectl rollout status deployment/backend-api -n d2# List available tags (public package)
curl -s https://ghcr.io/v2/healthcare-monitoring-system/backend/tags/list | jq .
# Expected: {"name":"healthcare-monitoring-system/backend","tags":["latest","sha-abc123..."]}✅ OIDC/Keycloak Integration
- Authorization Code flow with PKCE for enhanced security
- Automatic JWT signature verification using JWKS
- Secure token exchange and claim extraction
✅ Server-Side Session Management
- All tokens stored server-side (never exposed to browser)
- Opaque session ID in httpOnly, secure cookie
- Configurable session expiration
- In-memory store (swappable with Redis)
✅ Role-Based Access Control
- Role extraction from JWT claims
- Middleware for route protection
- Support for multiple roles per user
- Easy per-route authorization
✅ Database Integration
- OIDC identity separation (auth_subjects table)
- Doctor and patient linking to authentication
- Automatic auth subject upsert on login
- Repository pattern for clean queries
✅ Security Features
- PKCE for public client protection
- Cryptographically secure random generation
- JWT signature verification
- Protection against CSRF, token interception
- Row-level security on auth_subjects table
src/
auth/
config.ts # Keycloak and app configuration
jwt.ts # JWT verification and claim extraction
middleware.ts # Authentication/authorization middleware
pkce.ts # PKCE utilities
sessionStore.ts # Session storage interface and in-memory impl
db/
authService.ts # Auth database operations using pg Pool
client.ts # PostgreSQL pool initialization
middleware/
errorHandler.ts # (For custom error handling if needed)
routes/
auth.ts # Auth endpoints (login, callback, logout)
alerts.ts # (Existing)
devices.ts # (Existing)
doctors.ts # (Existing)
patients.ts # (Existing)
utils/
errors.ts # (For shared error classes)
index.ts # Main Express app
supabase/
migrations/
003_auth_subjects.sql # Auth subject table
004_auth_subject_links.sql # Doctor/patient linking
npm installCopy .env.example to .env.local and fill in your values:
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=your-client-id
KEYCLOAK_CLIENT_SECRET=your-client-secret
DATABASE_URL=postgresql://postgres:password@localhost:5432/healthcare_dbRun migrations using psql:
psql -U postgres -d healthcare_db -f supabase/migrations/003_auth_subjects.sql
psql -U postgres -d healthcare_db -f supabase/migrations/004_auth_subject_links.sqlAlternatively, execute the SQL manually in your database client.
npm run devServer runs on http://localhost:3000
Initiates OIDC login flow.
Response:
{
"success": true,
"authUrl": "https://keycloak-url/auth?client_id=..."
}Handles Keycloak callback and exchanges authorization code for tokens.
Request:
{
"code": "authorization-code-from-keycloak",
"state": "state-parameter"
}Response:
{
"success": true,
"sessionId": "opaque-session-id",
"user": {
"sub": "user-subject-id",
"role": "doctor",
"preferred_username": "john_doe",
"name": "John Doe",
"email": "john@example.com"
}
}Clears server-side session and cookie.
Response:
{
"success": true,
"message": "Logged out"
}Returns current user session info. Requires authentication.
Response:
{
"success": true,
"user": {
"sub": "user-id",
"role": "doctor",
"preferred_username": "john_doe",
"name": "John Doe",
"email": "john@example.com",
"auth_subject_id": 123
}
}Doctor-only endpoint. Requires doctor or admin role.
Patient-only endpoint. Requires patient role.
app.get('/api/my-data', requireAuth, (req, res) => {
// req.user contains the session data
const userId = req.user.sub;
res.json({ userId });
});app.post('/api/patients', requireRole(['doctor', 'admin']), (req, res) => {
// Only doctors and admins
res.json({ message: 'Created patient' });
});
app.get('/api/prescriptions', requireRole(['doctor']), (req, res) => {
// Only doctors
res.json({ prescriptions: [] });
});import { express } from 'express';
import { createRoleAuthorizationMiddleware } from './auth/middleware';
const doctorRouter = express.Router();
doctorRouter.use(requireRole(['doctor', 'admin']));
doctorRouter.get('/patients', (req, res) => {
// All routes here require doctor role
res.json({ patients: [] });
});
app.use('/api/doctors', doctorRouter);1. Login Initiation
Frontend → GET /api/auth/login
← Return authUrl
Frontend redirects to authUrl
2. User Authentication (at Keycloak)
User enters credentials
Keycloak verifies and generates auth code
3. Callback & Token Exchange
Keycloak → POST /api/auth/callback?code=...&state=...
Backend validates state (CSRF protection)
Backend exchanges code + code_verifier for tokens
4. JWT Verification
Backend verifies JWT signature using JWKS
Backend extracts claims (sub, roles, email, etc)
5. Session Creation
Backend creates server-side session with tokens & claims
Backend stores session in InMemorySessionStore
Backend links auth subject to doctor/patient
Backend sets session_id in httpOnly cookie
6. Response to Frontend
Backend ← { success, user, sessionId }
Frontend stores user in state (not localStorage!)
Cookie automatically sent on subsequent requests
7. Subsequent Requests
Frontend → GET /api/protected/me
Browser sends Cookie: session_id=...
Middleware loads session from server-side store
Middleware attaches req.user
Route handler uses req.user
8. Logout
Frontend → POST /api/auth/logout
Backend clears session
Backend clears cookie
Frontend clears user state
Implemented per RFC 7636 to prevent authorization code interception:
- Code verifier: 128-character random string
- Code challenge: SHA-256 hash of verifier
- Only server knows code_verifier, protects against code theft
- Never in browser: Access/refresh tokens stored only on server
- Verified on arrival: JWT signatures checked against Keycloak JWKS
- Claims extracted safely: Role and user info parsed securely
- Opaque session ID: No sensitive data in cookie
- httpOnly flag: Prevents JavaScript access
- Secure flag: HTTPS only in production
- SameSite=strict: CSRF protection
- Server-side expiration: Automatic cleanup of old sessions
auth_subjects: Single source of truth for OIDC identitydoctors.auth_subject_id(unique FK): One doctor per auth subjectpatients.auth_subject_id(unique FK): One patient per auth subject- RLS policies: Enhanced row-level access control
In src/auth/jwt.ts, extend UserClaims:
export interface UserClaims {
sub: string;
roles: string[];
preferred_username: string;
name: string;
email: string;
phone?: string; // Add custom fields
organization?: string;
}
export function extractClaims(decodedToken: any): UserClaims {
// Add extraction logic
return {
// ... existing fields
phone: decodedToken.phone,
organization: decodedToken.org,
};
}Create src/auth/redisSessionStore.ts:
import { createClient } from 'redis';
import { SessionStore, SessionData } from './sessionStore';
export class RedisSessionStore implements SessionStore {
private client = createClient();
async setSession(sessionId: string, data: SessionData): Promise<void> {
await this.client.setEx(
`session:${sessionId}`,
data.expires_at - Date.now(),
JSON.stringify(data)
);
}
// ... implement other methods
}Then in src/index.ts:
import { RedisSessionStore } from './auth/redisSessionStore';
const sessionStore = new RedisSessionStore();Implement silent refresh in middleware:
export async function refreshTokenIfNeeded(
session: SessionData,
sessionStore: SessionStore
) {
if (session.expires_at - Date.now() < 5 * 60 * 1000) {
// Less than 5 min remaining
const newTokens = await exchangeRefreshToken(session.refresh_token);
session.access_token = newTokens.access_token;
session.expires_at = Date.now() + newTokens.expires_in * 1000;
await sessionStore.setSession(sessionId, session);
}
}Modify getPrimaryRole() in src/auth/jwt.ts:
export function getPrimaryRole(roles: string[]): string {
// Custom priority
if (roles.includes('super_admin')) return 'super_admin';
if (roles.includes('admin')) return 'admin';
if (roles.includes('radiologist')) return 'doctor';
if (roles.includes('nurse')) return 'medical';
// ... more roles
return 'user';
}- Set
NODE_ENV=production - Set
SECURE_COOKIE=true(requires HTTPS) - Use HTTPS for all URLs
- Replace
InMemorySessionStorewith Redis/Memcached - Set strong random
KEYCLOAK_CLIENT_SECRET - Configure Keycloak to use HTTPS
- Enable rate limiting on
/api/auth/callback - Implement request logging and monitoring
- Set up CORS properly for frontend domain
- Enable HTTPS in Keycloak OAuth client settings
- Verify email verification is enabled in Keycloak
- Backup and secure Supabase credentials
- Test failover and recovery scenarios
- Implement audit logging for auth events
The JWKS client caches public keys automatically. No additional action needed.
In-memory store cleans up expired sessions every 5 minutes. For Redis, set TTL on keys.
The pg library handles connection pooling automatically via Pool:
- Max connections: 10 (configurable via
DB_POOL_MAX) - Idle timeout: 30s (configurable via
DB_IDLE_TIMEOUT) - Automatic reconnection on pool errors
- Graceful cleanup on app shutdown
- Check
SESSION_COOKIE_NAMEmatches between setting and reading - Verify session_id cookie is being sent with requests
- Check session hasn't expired in server store
- Ensure
KEYCLOAK_URLandKEYCLOAK_REALMare correct - Verify Keycloak is running and accessible
- Check firewall rules
- Verify email in Keycloak matches email in doctors table exactly
- Check email_verified flag in Keycloak
- Ensure no typos or case mismatches
The deployable migration for users.keycloak_sub lives in postgres/deploy-migrations/010_keycloak_identity_sync.sql and is applied by the backend migration Job during deployment.
-- Columns and uniqueness constraints expected by the login sync.
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name IN ('email', 'first_name', 'last_name', 'keycloak_sub')
ORDER BY column_name;
SELECT conname
FROM pg_constraint
WHERE conrelid = 'users'::regclass
AND conname IN ('users_email_key', 'users_keycloak_sub_key');
-- Any placeholder rows should be reviewed and corrected from Keycloak/admin data.
SELECT id, email, first_name, last_name, role, keycloak_sub
FROM users
WHERE email LIKE 'local-user-%@placeholder.invalid'
OR first_name = 'Unknown'
OR last_name = 'User';
-- Role profile rows should exist only where required by the app role.
SELECT u.id, u.email, u.role, u.keycloak_sub, d.doctor_id, p.id AS patient_id
FROM users u
LEFT JOIN doctor d ON d.user_id = u.id
LEFT JOIN patients p ON p.user_id = u.id
WHERE u.role IN ('doctor', 'patient', 'admin')
ORDER BY u.id;Backend is the source of truth for MediPulse core app schema and auth logic. Put production schema migrations in postgres/deploy-migrations/ as incremental, non-destructive SQL files. Do not put demo/test seed data in this directory.
Deployment runs scripts/run-migrations.sh through the Kubernetes Job infra/k8s/base/backend-migrate-job.yaml. The Job is an Argo CD PreSync hook, uses d2-secrets.BACKEND_DATABASE_URL, applies SQL files in sorted order, and records completed files in schema_migrations using filename, checksum, and applied_at. If a migration fails, the Job fails visibly and the sync does not silently continue.
Manual verification:
kubectl exec -n d2 d2-postgres-0 -- psql -U d2 -d d2_vitals -c "\d users"
kubectl exec -n d2 d2-postgres-0 -- psql -U d2 -d d2_vitals -c "select * from schema_migrations order by applied_at desc;"Manual local migration run:
DATABASE_URL="postgresql://..." scripts/run-migrations.shDemo seed data is manual only. Use the safe idempotent D2 seed explicitly when needed:
DATABASE_URL="postgresql://..." scripts/run-demo-seed.shNever add destructive demo seeds to the backend deployment pipeline. Real Keycloak-synced users must not be deleted or overwritten by seed scripts.
- State expires after 1 hour
- User may have taken too long to authenticate
- Browser cookies may be disabled/cleared
When extending the auth system:
- Keep session store interface clean (for easy backend swapping)
- Add TypeScript types to all functions
- Document middleware usage with examples
- Update this README with new features
- Test with multiple roles and edge cases
ISC