LogoContainerPub

Authentication System

Keycloak OIDC authentication with legacy migration fallback for ContainerPub

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)](./admin-auth.md). 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   │ │
                                                │  └─────────────┘ │
                                                └──────────────────┘
  1. Client sends credentials to the platform backend
  2. Backend calls keycloakClient.login() (Direct Grant flow)
  3. Keycloak returns access_token + refresh_token
  4. Backend returns tokens to client
  5. Client includes access token in Authorization: Bearer <token> header
  6. Backend calls keycloakClient.introspectToken() to validate on every request
  7. 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": "[email protected]",
  "password": "securePassword123",
  "first_name": "John",
  "last_name": "Doe"
}

Response:

{
  "message": "Account created successfully"
}

Flow:

  1. Validate email format and password strength
  2. Check registration feature flag (early access gating)
  3. Call keycloakClient.createUserWithCredentials() — creates user in Keycloak
  4. Store user in PostgreSQL with keycloak_user_id
  5. Store user_information in PostgreSQL
  6. Sync user attributes to Keycloak (first name, last name, phone, country, etc.)
  7. 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)
    ↓
├─ SuccessGet 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)
    ↓
├─ SuccessCreate user in Keycloak with credentials
│          → Update PostgreSQL with keycloak_user_id
│          → Sync user_information to Keycloak attributes
│          → Login via Keycloak and return tokens
│
└─ FailReturn "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
    ↓
├─ SuccessExtract 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
│
└─ FailLegacy JWT verification
         → Verify JWT signature with JWT_SECRETExtract 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:

  1. Introspection endpointkeycloakClient.introspectToken(token) — primary method, validates token is active and returns claims
  2. Decode sub claim — Fallback if introspection fails but token is still valid
  3. UserInfo endpointkeycloakClient.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:

  1. User requests password reset via /api/auth/reset-password
  2. Backend triggers Keycloak's forgot password flow
  3. Keycloak sends reset email with link
  4. 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:

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#

ColumnTypeNotes
uuidUUIDPublic identifier
emailVARCHAR(255)Unique, mirrored in Keycloak
keycloak_user_idVARCHAR(255)Links to Keycloak user (sub)
password_hashVARCHAR(255)Nullable, kept for legacy migration
statusVARCHAR(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:

  1. Keycloak login fails (user doesn't exist in Keycloak yet)
  2. Fallback: BCrypt password verification succeeds
  3. User is created in Keycloak with the same credentials
  4. PostgreSQL keycloak_user_id is updated
  5. User information attributes are synced to Keycloak
  6. A fresh Keycloak login is performed and tokens returned
  7. 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 passwordspassword_hash column kept for legacy login

Planned Cleanup#

After full migration is verified:

  1. Remove dart_jsonwebtoken dependency
  2. Remove bcrypt dependency
  3. Remove TokenService and Hive token storage
  4. Drop password_hash column from users
  5. Drop temporary_password column from users
  6. Remove otp_service package (email verification handled by Keycloak)

Error Handling#

ErrorStatusCause
Missing authorization header403No 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#

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