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
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 usersview-users— Look up usersquery-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": "[email protected]",
"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_informationin 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": "[email protected]",
"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 |
|---|---|---|---|
email |
users.email |
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 |
email | 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_idis 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_hashcolumn kept for legacy login
Planned Cleanup#
After full migration is verified:
- Remove
dart_jsonwebtokendependency - Remove
bcryptdependency - Remove
TokenServiceand Hive token storage - Drop
password_hashcolumn fromusers - Drop
temporary_passwordcolumn fromusers - Remove
otp_servicepackage (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