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

Diagram
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     (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:

json
{
  "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

dart
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:

json
{
  "email": "user@example.com",
  "password": "securePassword123",
  "first_name": "John",
  "last_name": "Doe"
}

Response:

json
{
  "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
yaml
// 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:

json
{
  "email": "user@example.com",
  "password": "securePassword123"
}

Response:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 300,
  "token_type": "Bearer"
}

Login Flow

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"
dart
// 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

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
dart
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:

json
{
  "refresh_token": "eyJhbGci..."
}

Response:

json
{
  "access_token": "eyJhbGci...",
  "refresh_token": "eyJhbGci...",
  "expires_in": 300,
  "token_type": "Bearer"
}
dart
final tokens = await keycloakClient.refreshToken(refreshToken);

Logout

POST /api/auth/logout

Invalidates the session via Keycloak.

Request:

json
{
  "refresh_token": "eyJhbGci..."
}

Response:

json
{
  "message": "Logout successful"
}
dart
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

DataPostgreSQLKeycloakDirection
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

OperationPostgreSQLKeycloak
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 ProfileUpdate user_infoSync attributes
Password ResetN/AKeycloak 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:

dart
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:

sql
-- 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

ColumnTypeNotes
uuidUUIDPublic 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_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:

dart
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:

dart
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

ErrorStatusCause
Missing authorization header 403 No Authorization header
Invalid/expired token 403 Keycloak introspection returned inactive + legacy JWT failed
Blocked account403User status is blocked
Deleted account403User status is deleted
Invalid credentials403Keycloak login failed + BCrypt fallback failed
Duplicate email409Email already exists in Keycloak or PostgreSQL

Configuration

dart
# 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

yaml
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