Authentication System
ContainerPub's platform backend (dart_cloud_backend) uses Keycloak OIDC as its primary authentication provider, replacing the custom JWT/BCrypt system. A legacy JWT fallback is maintained during the migration period.
Both backend servers now use Keycloak:
- Platform Backend (dart_cloud_backend) β Keycloak OIDC with legacy JWT fallback
- Admin Backend (admin_backend_api) β Keycloak OIDC with role-based access control
For admin backend authentication specifics, see Admin Authentication (Keycloak OIDC). This page covers the platform backend's Keycloak integration and migration from legacy JWT.
Architecture
Authentication Provider: Keycloak
ββββββββββββ (1) POST /auth/login ββββββββββββββββββββ
β Client β ββββββββββββββββββββββββββββββββββ β Platform Backendβ
β (CLI / β β (Shelf Server) β
β App) β (2) Keycloak credentials β β β
β β β β (3) β
β β β βΌ β
β β β βββββββββββββββ β
β β (4) TokenResponse β β Keycloak β β
β β ββββββββββββββββββββββββββββββββββ β β Server β β
β β β βββββββββββββββ β
β β (5) Bearer <access_token> β β β
β β ββββββββββββββββββββββββββββββββββ β β (6) β
β β β β Intro- β
β β β β spect β
β β (7) Resource β βΌ β
β β ββββββββββββββββββββββββββββββββββ β βββββββββββββββ β
ββββββββββββ β β Keycloak β β
β β Token API β β
β βββββββββββββββ β
ββββββββββββββββββββ
- Client sends credentials to the platform backend
- Backend calls keycloakClient.login() (Direct Grant flow)
- Keycloak returns access_token + refresh_token
- Backend returns tokens to client
- Client includes access token in Authorization: Bearer <token> header
- Backend calls keycloakClient.introspectToken() to validate on every request
- If valid, the request proceeds
Keycloak Client Configuration
The platform backend uses a dedicated Keycloak client:
{
"clientId": "dart-cloud-backend",
"clientAuthenticatorType": "client-secret",
"serviceAccountsEnabled": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"protocol": "openid-connect"
}
Service account requires these realm-management roles:
- manage-users β Create and update users
- view-users β Look up users
- query-users β Search users
Environment Configuration
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=containerpub
KEYCLOAK_CLIENT_ID=dart-cloud-backend
KEYCLOAK_CLIENT_SECRET=<your-client-secret>
USE_KEYCLOAK=true
Registration
POST /api/auth/register
Creates a user in both Keycloak and PostgreSQL with bi-directional data sync.
Request:
{
"email": "user@example.com",
"password": "securePassword123",
"first_name": "John",
"last_name": "Doe"
}
Response:
{
"message": "Account created successfully"
}
Flow:
- Validate email format and password strength
- Check registration feature flag (early access gating)
- Call keycloakClient.createUserWithCredentials() β creates user in Keycloak
- Store user in PostgreSQL with keycloak_user_id
- Store user_information in PostgreSQL
- Sync user attributes to Keycloak (first name, last name, phone, country, etc.)
- Return success
// Keycloak + PostgreSQL dual creation
final keycloakUserId = await keycloakClient.createUserWithCredentials(
username: email,
email: email,
password: password,
firstName: userInfo.firstName,
lastName: userInfo.lastName,
emailVerified: false,
attributes: userInfo.toKeycloakAttributes(),
);
final user = UserEntity(
email: email,
keycloakUserId: keycloakUserId,
isEmailVerified: false,
);
await db.insert(user.toDBMap());
Login (with Auto-Migration)
POST /api/auth/login
The login flow handles both new Keycloak users and legacy BCrypt users via auto-migration.
Request:
{
"email": "user@example.com",
"password": "securePassword123"
}
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 300,
"token_type": "Bearer"
}
Login Flow
User attempts login
β
Try Keycloak login (Direct Grant)
β
ββ Success β Get keycloak_user_id
β β Look up user in PostgreSQL by keycloak_user_id
β β Check user status (rejects blocked/deleted)
β β Return tokens
β
ββ Fail (user not in Keycloak yet)
β
Try BCrypt fallback (legacy users with password_hash)
β
ββ Success β Create user in Keycloak with credentials
β β Update PostgreSQL with keycloak_user_id
β β Sync user_information to Keycloak attributes
β β Login via Keycloak and return tokens
β
ββ Fail β Return "Invalid credentials"
// Login with auto-migration
Future<LoginResult> login(String email, String password) async {
// 1. Try Keycloak first
try {
final tokens = await keycloakClient.login(email, password);
if (!tokens.hasError) {
final introspect = await keycloakClient.introspectToken(tokens.accessToken!);
final user = await findUserByKeycloakId(introspect.sub!);
if (user != null) {
_checkUserStatus(user); // rejects blocked/deleted
return LoginResult(user: user, tokens: tokens);
}
}
} catch (_) { /* fall through to legacy */ }
// 2. Legacy BCrypt fallback
final user = await findUserByEmail(email);
if (user?.passwordHash != null && BCrypt.checkpw(password, user!.passwordHash!)) {
// Auto-migrate to Keycloak
final kcId = await keycloakClient.createUserWithCredentials(...);
await updateUserKeycloakId(user.id!, kcId);
// Sync attributes
final userInfo = await getUserInfo(user.uuid!);
await keycloakClient.updateUserAttributes(
keycloakUserId: kcId,
attributes: userInfo.toKeycloakAttributes(),
);
// Now login via Keycloak
final tokens = await keycloakClient.login(email, password);
return LoginResult(user: user, tokens: tokens);
}
throw AuthException('Invalid credentials');
}
Token Validation (Middleware)
The auth middleware validates Bearer tokens using Keycloak token introspection, with a legacy JWT fallback during migration.
Flow
Request arrives with Authorization: Bearer <token>
β
Extract token from header
β
Try Keycloak introspection
β
ββ Success β Extract sub (keycloak_user_id)
β β Look up user in PostgreSQL by keycloak_user_id
β β If not found: resolve email from token, look up by email,
β backfill keycloak_user_id (migration safety net)
β β Add user_uuid + user_id to request context
β β Continue to handler
β
ββ Fail β Legacy JWT verification
β Verify JWT signature with JWT_SECRET
β Extract userId from payload
β Add to request context
β Continue to handler
Middleware createAuthMiddleware(KeycloakClient keycloakClient) {
return (Handler handler) {
return (Request request) async {
final token = authHeader.substring(7);
// Try Keycloak first
try {
final keycloakUserId = await _resolveKeycloakUserId(keycloakClient, token);
if (keycloakUserId != null) {
var user = await DatabaseManagers.users.findOne(
where: {'keycloak_user_id': keycloakUserId},
);
// Migration safety net: if mapping missing, resolve by email
if (user == null) {
final email = await _resolveKeycloakEmail(keycloakClient, token);
if (email != null) {
user = await DatabaseManagers.users.findOne(
where: {'email': email},
);
if (user != null) {
// Backfill keycloak_user_id
await DatabaseManagers.users.update(
{'keycloak_user_id': keycloakUserId},
where: {'id': user.id},
);
}
}
}
if (user != null) {
return handler(request.change(context: {
'userUUID': user.uuid,
'userId': user.id,
}));
}
}
} catch (_) { /* fall through to legacy */ }
// Legacy JWT fallback
final userId = await _verifyLegacyJWT(token);
if (userId != null) {
return handler(request.change(context: {'userUUID': userId}));
}
return Response.forbidden(...);
};
};
}
Token Introspection Resolution
The middleware tries three methods to resolve the Keycloak user ID:
- Introspection endpoint β keycloakClient.introspectToken(token) β primary method, validates token is active and returns claims
- Decode sub claim β Fallback if introspection fails but token is still valid
- UserInfo endpoint β keycloakClient.getUserInfo(token) β last resort
Token Refresh
POST /api/auth/refresh
Uses Keycloak's refresh_token grant.
Request:
{
"refresh_token": "eyJhbGci..."
}
Response:
{
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci...",
"expires_in": 300,
"token_type": "Bearer"
}
final tokens = await keycloakClient.refreshToken(refreshToken);
Logout
POST /api/auth/logout
Invalidates the session via Keycloak.
Request:
{
"refresh_token": "eyJhbGci..."
}
Response:
{
"message": "Logout successful"
}
await keycloakClient.logout(refreshToken);Password Reset
Password resets are handled natively by Keycloak:
- User requests password reset via /api/auth/reset-password
- Backend triggers Keycloak's forgot password flow
- Keycloak sends reset email with link
- User sets new password via Keycloak UI
Bi-Directional User Data Sync
User profile data is synchronized between PostgreSQL and Keycloak to maintain a single source of truth.
Sync Mapping
| Data | PostgreSQL | Keycloak | Direction |
|---|---|---|---|
| users.email | Both ways | ||
| firstName | user_information.first_name | firstName | Both ways |
| lastName | user_information.last_name | lastName | Both ways |
| phoneNumber | user_information.phone_number | attributes.phone_number | Both ways |
| country | user_information.country | attributes.country | Both ways |
| city | user_information.city | attributes.city | Both ways |
| address | user_information.address | attributes.address | Both ways |
| zipCode | user_information.zip_code | attributes.zip_code | Both ways |
| role | user_information.role | attributes.role | Both ways |
| avatar | user_information.avatar | attributes.avatar | PostgreSQL β Keycloak |
Sync Triggers
| Operation | PostgreSQL | Keycloak |
|---|---|---|
| Register | Insert user + user_info | Create user with attributes |
| Login | Look up by keycloak_user_id | Authenticate + introspect |
| Login (legacy) | BCrypt verify β backfill kc_id | Auto-create user |
| Update Profile | Update user_info | Sync attributes |
| Password Reset | N/A | Keycloak native flow |
| Middleware Validation | Look up by kc_id, backfill if missing | Introspect token |
User Status Validation
The platform backend checks user status during authentication:
void _checkUserStatus(UserEntity user) {
if (user.status == UserStatus.blocked) {
throw AuthException('Account has been blocked');
}
if (user.status == UserStatus.deleted) {
throw AuthException('Account has been deleted');
}
}
User status is managed via the admin backend. See User Management.
Database Schema Changes
The migration from JWT/BCrypt to Keycloak required schema changes:
-- New column to link PostgreSQL users to Keycloak
ALTER TABLE users ADD COLUMN keycloak_user_id VARCHAR(255);
CREATE INDEX idx_users_keycloak_user_id ON users(keycloak_user_id);
-- Password hash becomes nullable (legacy users only)
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
Updated Users Table
| Column | Type | Notes |
|---|---|---|
| uuid | UUID | Public identifier |
| VARCHAR(255) | Unique, mirrored in Keycloak | |
| keycloak_user_id | VARCHAR(255) | Links to Keycloak user (sub) |
| password_hash | VARCHAR(255) | Nullable, kept for legacy migration |
| status | VARCHAR(20) | active / blocked / deleted (app-managed) |
| temporary_password | BOOLEAN | Deprecated, to be removed post-migration |
| is_email_verified | BOOLEAN | Synced with Keycloak email verification |
Migration: Legacy Users
Auto-Migration on Login
Legacy users who registered before the Keycloak migration are automatically migrated on their first login:
- Keycloak login fails (user doesn't exist in Keycloak yet)
- Fallback: BCrypt password verification succeeds
- User is created in Keycloak with the same credentials
- PostgreSQL keycloak_user_id is updated
- User information attributes are synced to Keycloak
- A fresh Keycloak login is performed and tokens returned
- On subsequent logins, the user authenticates directly via Keycloak
Batch Migration
A migration script is available for bulk migration:
cd dart_cloud_backend
dart run bin/migrate_users_to_keycloak.dart
This creates Keycloak users for all existing PostgreSQL users, assigns temporary passwords, and syncs attributes.
Migration Safety Net (Middleware)
If a user has a Keycloak account but the keycloak_user_id mapping is missing from PostgreSQL, the middleware automatically backfills it by resolving the email from the token:
final email = await _resolveKeycloakEmail(keycloakClient, token);
final user = await DatabaseManagers.users.findOne(where: {'email': email});
if (user != null && user.keycloakUserId != keycloakUserId) {
await DatabaseManagers.users.update(
{'keycloak_user_id': keycloakUserId},
where: {'id': user.id},
);
}
Legacy JWT System (Deprecated)
The original JWT system is retained as a fallback during migration and will be removed post-migration:
- JWT verification β Still available if Keycloak introspection fails
- TokenService (Hive) β Encrypted Hive storage for token whitelists/blacklists
- BCrypt passwords β password_hash column kept for legacy login
Planned Cleanup
After full migration is verified:
- Remove dart_jsonwebtoken dependency
- Remove bcrypt dependency
- Remove TokenService and Hive token storage
- Drop password_hash column from users
- Drop temporary_password column from users
- Remove otp_service package (email verification handled by Keycloak)
Error Handling
| Error | Status | Cause |
|---|---|---|
| Missing authorization header | 403 | No Authorization header |
| Invalid/expired token | 403 | Keycloak introspection returned inactive + legacy JWT failed |
| Blocked account | 403 | User status is blocked |
| Deleted account | 403 | User status is deleted |
| Invalid credentials | 403 | Keycloak login failed + BCrypt fallback failed |
| Duplicate email | 409 | Email already exists in Keycloak or PostgreSQL |
Configuration
# Keycloak connection (required)
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=containerpub
KEYCLOAK_CLIENT_ID=dart-cloud-backend
KEYCLOAK_CLIENT_SECRET=<client-secret>
# Migration flag (set to false to revert to JWT-only)
USE_KEYCLOAK=true
# Legacy JWT secret (kept for fallback during migration)
JWT_SECRET=<legacy-secret>
Dependencies
dependencies:
auth_keycloak_client: # Keycloak OIDC client (primary auth)
dart_jsonwebtoken: # Legacy JWT verification (migration fallback)
bcrypt: # Legacy password hash (migration fallback)
hive_ce: # Legacy token storage (to be removed)
crypto: # SHA-256 hashing (legacy)
Next Steps
- API Reference β Complete endpoint documentation
- Admin Authentication β Keycloak OIDC for admin backend
- Architecture Overview β System design details
- CLI Authentication β Client-side token handling
- User Management β User status, orgs, tiers