LogoContainerPub

Admin Authentication (Keycloak OIDC)

Keycloak-based authentication and authorization for the admin backend

Admin Authentication (Keycloak OIDC)#

The admin backend uses Keycloak as its OpenID Connect (OIDC) provider for authentication, using the same Keycloak realm as the platform backend (dart_cloud_backend) with a different client ID and role structure.

Both backend servers now use Keycloak. For platform backend authentication details (including legacy JWT migration), see [Authentication System](./authentication.md).

Overview#

Why Keycloak?#

  • Standard Protocol — OIDC-compliant authentication and token management
  • Built-in Roles — Realm and client-level role management
  • Token Introspection — Server-side token validation without shared secrets
  • Centralized Management — Single point of admin user and role administration
  • Secure Client — Dedicated admin client with its own client ID and secret

Keycloak Client Usage#

Both backends share the same Keycloak realm but use different clients:

FeaturePlatform BackendAdmin Backend
Keycloak Clientdart-cloud-backendadmin-portal
Auth FlowDirect Grant + introspectionDirect Grant + introspection
Role SystemNone (per-user features via client roles)full_admin / viewer
User ManagementRegistration + auto-migrationView-only (uses admin APIs)
Token StorageKeycloak serverKeycloak server
Token ValidationIntrospection + legacy JWT fallbackIntrospection only
Feature RolesClient-level roles per userManages client-level roles

Architecture#

Auth Flow#

┌──────────┐     (1) POST /auth/login      ┌──────────────────┐
│  Admin   │ ────────────────────────────── │  Admin Backend    │
│  Client  │                                │  (Shelf Server)   │
│          │     (2) TokenResponse           │                   │
│          │ ◄────────────────────────────── │                   │
│          │                                │         │         │
│          │     (3) Bearer Token            │         │ (4) Introspect
│          │ ────────────────────────────── │         ▼         │
│          │                                │  ┌─────────────┐ │
│          │     (5) Resource               │  │  Keycloak    │ │
│          │ ◄────────────────────────────── │  │  Server      │ │
└──────────┘                                │  └─────────────┘ │
                                            └──────────────────┘
  1. Admin client sends credentials to /api/admin/auth/login
  2. Backend calls keycloakClient.login() and returns tokens
  3. Admin client includes access token in Authorization: Bearer header
  4. Backend calls keycloakClient.introspectToken() on every request
  5. If token is valid and has admin role, the request proceeds

Auth Endpoints#

POST /api/admin/auth/login#

Authenticate admin user via Keycloak.

Request:

{
  "email": "[email protected]",
  "password": "securepassword"
}

Response:

{
  "access_token": "eyJhbGci...",
  "refresh_token": "eyJhbGci...",
  "expires_in": 300,
  "token_type": "Bearer"
}

POST /api/admin/auth/refresh#

Refresh an expired access token.

Request:

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

Response:

{
  "access_token": "eyJhbGci...",
  "refresh_token": "eyJhbGci...",
  "expires_in": 300,
  "token_type": "Bearer"
}

POST /api/admin/auth/logout#

Invalidate the current session.

Request:

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

Response:

{
  "message": "Logout successful"
}

POST /api/admin/auth/verify-session#

Verify the current access token is still valid.

Headers:

Authorization: Bearer <access-token>

Response:

{
  "valid": true,
  "subject": "keycloak-user-id",
  "username": "[email protected]"
}

GET /api/admin/auth/me#

Get the currently authenticated admin user's profile.

Headers:

Authorization: Bearer <access-token>

Response:

{
  "subject": "keycloak-user-id",
  "username": "[email protected]",
  "role": "full_admin"
}

AdminPrincipal#

After successful authentication, the middleware constructs an AdminPrincipal:

class AdminPrincipal {
  final String subject;    // Keycloak user ID
  final String username;   // Email address
  final AdminRole role;    // full_admin or viewer

  bool get canWrite => role.canWrite;
  bool get canRead => true;
}

The principal is stored in the request context and accessible to all downstream handlers via request.adminPrincipal.

AdminRole#

enum AdminRole {
  viewer,
  fullAdmin;

  bool get canWrite => this == fullAdmin;

  static AdminRole fromRoleNames(List<String> roleNames) {
    final normalized = roleNames
        .map((r) => r.trim().toLowerCase().replaceAll(RegExp(r'[- ]'), '_'))
        .toSet();

    if (normalized.contains('full_admin')) return AdminRole.fullAdmin;
    if (normalized.contains('viewer')) return AdminRole.viewer;

    throw UnauthorizedException('No valid admin role found');
  }
}

Role Name Normalization#

Role names are normalized to handle variations:

  • full-adminfull_admin
  • Full Adminfull_admin
  • FULL_ADMINfull_admin

This normalization allows flexibility in how roles are named in Keycloak while maintaining consistent matching.

Role Extraction from JWT#

The extractRoleNamesFromJwt() function parses the decoded JWT payload and extracts roles from multiple locations:

List<String> extractRoleNamesFromJwt(Map<String, dynamic> payload) {
  final roles = <String>{};

  // 1. Realm-level roles
  final realmAccess = payload['realm_access'] as Map<String, dynamic>?;
  if (realmAccess != null) {
    for (final role in realmAccess['roles'] as List? ?? []) {
      roles.add(role.toString());
    }
  }

  // 2. Client-level roles (all clients)
  final resourceAccess = payload['resource_access'] as Map<String, dynamic>?;
  if (resourceAccess != null) {
    for (final entry in resourceAccess.entries) {
      final clientRoles = entry.value['roles'] as List?;
      if (clientRoles != null) {
        for (final role in clientRoles) {
          roles.add(role.toString());
        }
      }
    }
  }

  // 3. Scope claim (space-separated)
  final scope = payload['scope'] as String?;
  if (scope != null) {
    roles.addAll(scope.split(' '));
  }

  return roles.toList();
}

Two-Layer Middleware#

Layer 1: adminAuthMiddleware#

Validates every request to protected routes:

Middleware get adminAuthMiddleware {
  return (Handler handler) {
    return (Request request) async {
      final authHeader = request.headers['authorization'];
      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response(HttpStatus.unauthorized, ...);
      }

      // Dev bypass (only when ALLOW_DEV_AUTH_BYPASS=true)
      if (devAuthBypass) {
        final adminRole = request.headers['x-admin-role'];
        final subject = request.headers['x-admin-subject'];
        if (adminRole != null) {
          return handler(request.change(context: {
            adminPrincipalContextKey: AdminPrincipal(
              subject: subject ?? 'dev-user',
              username: 'dev@localhost',
              role: AdminRole.fromRoleNames([adminRole]),
            ),
          }));
        }
      }

      // Production: introspect token with Keycloak
      final token = authHeader.substring(7);
      final result = await keycloakClient.introspectToken(token);

      if (!result.active) {
        return Response(HttpStatus.unauthorized, ...);
      }

      // Extract roles and determine admin level
      final roles = extractRoleNamesFromJwt(result.payload);
      final adminRole = AdminRole.fromRoleNames(roles);

      return handler(request.change(context: {
        adminPrincipalContextKey: AdminPrincipal(
          subject: result.subject!,
          username: result.username ?? result.subject!,
          role: adminRole,
        ),
      }));
    };
  };
}

Layer 2: requireWriteAccess#

Guards write operations for full_admin only:

Middleware requireWriteAccess() {
  return (Handler handler) {
    return (Request request) async {
      final principal = request.adminPrincipal;
      if (principal == null) {
        return Response(HttpStatus.unauthorized, ...);
      }

      if (!principal.canWrite) {
        return Response.forbidden(
          jsonEncode({'error': 'Write access requires full_admin role'}),
        );
      }

      return handler(request);
    };
  };
}

Token Introspection#

Unlike the platform backend which uses a JWT secret and whitelist, the admin backend delegates token validation to Keycloak:

class IntrospectResult {
  final bool active;
  final String? subject;
  final String? username;
  final Map<String, dynamic> payload;
}

// Called on every request
final result = await keycloakClient.introspectToken(token);

This approach:

  • Eliminates shared secrets — No JWT secret to manage
  • Enables instant revocation — Keycloak can invalidate tokens server-side
  • Provides full token metadata — Roles, expiry, and claims from introspection response

Development Auth Bypass#

For local development, set ALLOW_DEV_AUTH_BYPASS=true to skip Keycloak introspection. Requests can include custom headers to simulate different admin roles:

Authorization: Bearer dev-token
x-admin-role: full_admin
x-admin-subject: dev-user-id

The CORS middleware allows these headers through for local debugging.

Error Responses#

ErrorStatusCause
Missing authorization header401No Authorization header
Invalid/expired token401Token introspection returned inactive
No admin role 403 Token lacks full_admin or viewer role
Write access denied403viewer role attempted write operation

Feature Role Management#

Beyond admin roles, the system manages per-user feature roles via Keycloak's client-level roles:

ComponentDescription
Default roles functions:read , functions:write , sites:read , sites:write (applied to all users)
Grantable roles container:read , container:write , webhook:read , webhook:write (managed by admins)
Client ID Keycloak client used for role assignment (e.g., stg-dart-cloud-backend)

Admins can only grant/remove roles from the grantable allowlist. This prevents accidental assignment of system-level roles.

Configuration#

// Keycloak connection
keycloakUrl=https://auth.example.com
keycloakRealm=containerpub
keycloakClientId=admin-portal
keycloakClientSecret=your-client-secret

// Admin roles
fullAdminRole=full_admin
viewerRole=viewer

// Feature role management
featureRolesClientId=stg-dart-cloud-backend
adminClientId=admin-portal
defaultUserFeatureRoles=functions:read,functions:write,sites:read,sites:write
backendGrantableFeatureRoles=container:read,container:write,webhook:read,webhook:write

Session Lifecycle#

Login ──► Access Token (5 min) + Refresh Token (30 min)
  │
  ├──► API Requests ──► Introspect Token ──► Extract Roles ──► Allow/Deny
  │
  ├──► Token Expired ──► Refresh Endpoint ──► New Access Token
  │
  └──► Logout ──► Keycloak Logout ──► Tokens Invalidated

Access tokens have a short lifetime (5 minutes) with refresh tokens for longer sessions (30 minutes). Keycloak manages the token lifecycle, including revocation on logout.

Next Steps#