Keycloak Authentication
Keycloak serves as the enterprise identity provider for iHospita, providing OAuth2/OIDC authentication, multi-factor authentication, and multi-tenant user management.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ KEYCLOAK ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KEYCLOAK SERVER │ │
│ │ │ │
│ │ Realms: │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ master │ │ ihospita │ │ samaki │ ... │ │
│ │ │ (admin) │ │(console) │ │(hospital)│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ Features: │ │
│ │ - OAuth2 / OpenID Connect │ │
│ │ - Two-Factor Authentication (Email OTP) │ │
│ │ - User Federation │ │
│ │ - Role-Based Access Control │ │
│ │ - Session Management │ │
│ │ - Impersonation │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KEYCLOAK DATABASE │ │
│ │ (PostgreSQL) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Multi-Tenant Realm Structure
Each hospital has its own isolated Keycloak realm:
Keycloak
├── master realm (Keycloak admin only)
├── ihospita realm (Console users)
│ └── Roles: SUPER_ADMIN, ADMIN, SUPPORT, BILLING
├── samaki realm (Hospital A)
│ └── Roles: OWNER, ADMIN, DOCTOR, NURSE, RECEPTIONIST, CASHIER
├── royal realm (Hospital B)
│ └── Roles: OWNER, ADMIN, DOCTOR, NURSE, RECEPTIONIST, CASHIER
└── ... (one realm per hospital)
Docker Compose Configuration
# docker-compose.keycloak.yml
version: '3.8'
services:
keycloak-db:
image: postgres:15-alpine
container_name: ihospita-keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
volumes:
- keycloak_data:/var/lib/postgresql/data
networks:
- ihospita-network
keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: ihospita-keycloak
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
KC_HOSTNAME: auth.ihospita.com
KC_HOSTNAME_STRICT: false
KC_HTTP_ENABLED: true
KC_PROXY: edge
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
ports:
- "8080:8080"
depends_on:
- keycloak-db
networks:
- ihospita-network
networks:
ihospita-network:
external: true
volumes:
keycloak_data:
Authentication Flow
JWT Token Structure
{
"exp": 1703862400,
"iat": 1703833600,
"iss": "https://auth.ihospita.com/realms/samaki",
"sub": "user-uuid-here",
"typ": "Bearer",
"azp": "ihospita-portal",
"session_state": "session-uuid",
"email": "doctor@samaki.com",
"email_verified": true,
"name": "Dr. John Doe",
"preferred_username": "dr.john",
"realm_access": {
"roles": ["DOCTOR", "default-roles-samaki"]
},
"resource_access": {
"ihospita-portal": {
"roles": ["user"]
}
},
"hospital_id": "hospital-uuid",
"hospital_prefix": "samaki",
"clinic_id": "clinic-uuid"
}
Realm Configuration
Hospital Realm Settings
{
"realm": "samaki",
"displayName": "Samaki Medical Center",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"accessTokenLifespan": 28800,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400
}
Role Definitions
{
"roles": {
"realm": [
{
"name": "OWNER",
"description": "Hospital owner with full access"
},
{
"name": "ADMIN",
"description": "Hospital administrator"
},
{
"name": "DOCTOR",
"description": "Medical doctor"
},
{
"name": "NURSE",
"description": "Nursing staff"
},
{
"name": "RECEPTIONIST",
"description": "Front desk staff"
},
{
"name": "CASHIER",
"description": "Billing staff"
},
{
"name": "LAB_TECHNICIAN",
"description": "Laboratory staff"
},
{
"name": "PHARMACIST",
"description": "Pharmacy staff"
}
]
}
}
Two-Factor Authentication
Email OTP Configuration
{
"requiredActions": ["CONFIGURE_TOTP"],
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA256",
"otpPolicyDigits": 6,
"otpPolicyPeriod": 300,
"otpPolicyLookAheadWindow": 1
}
Email Template
<!-- Email OTP Template -->
<html>
<body>
<h2>iHospita Login Verification</h2>
<p>Your verification code is:</p>
<h1 style="font-size: 32px; letter-spacing: 5px;">${code}</h1>
<p>This code expires in 5 minutes.</p>
<p>If you did not request this code, please ignore this email.</p>
</body>
</html>
NestJS Integration
Keycloak Strategy
// keycloak.strategy.ts
@Injectable()
export class KeycloakStrategy extends PassportStrategy(Strategy, 'keycloak') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKeyProvider: async (request, rawJwtToken, done) => {
const decoded = jwt.decode(rawJwtToken, { complete: true });
const issuer = decoded.payload['iss'];
const publicKey = await this.getRealmPublicKey(issuer);
done(null, publicKey);
},
});
}
async validate(payload: JwtPayload): Promise<User> {
return {
id: payload.sub,
email: payload.email,
roles: payload.realm_access?.roles || [],
hospitalId: payload.hospital_id,
hospitalPrefix: payload.hospital_prefix,
};
}
}
Auth Guard
// keycloak-auth.guard.ts
@Injectable()
export class KeycloakAuthGuard extends AuthGuard('keycloak') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
Roles Guard
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
Impersonation
Console support staff can impersonate hospital users for troubleshooting:
// impersonation.service.ts
@Injectable()
export class ImpersonationService {
async impersonate(
supportUserId: string,
targetUserId: string,
targetRealm: string,
): Promise<ImpersonationSession> {
// Log impersonation start
await this.auditLog.create({
action: 'IMPERSONATION_START',
actorId: supportUserId,
targetId: targetUserId,
targetRealm,
timestamp: new Date(),
});
// Get impersonation token from Keycloak
const token = await this.keycloakAdmin.users.impersonate({
realm: targetRealm,
id: targetUserId,
});
return {
token,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
};
}
}
Session Management
Session Timeout: 8 hours
{
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"accessTokenLifespan": 28800
}
Token Refresh
// Portal: Token refresh
async function refreshToken(refreshToken: string) {
const response = await fetch(
`${KEYCLOAK_URL}/realms/${realm}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
}
);
return response.json();
}
Security Best Practices
- SSL Required: All connections use HTTPS
- Brute Force Protection: Account lockout after 5 failed attempts
- Password Policy: Minimum 8 characters, complexity required
- Session Security: Secure cookies, CSRF protection
- Audit Logging: All authentication events logged