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.

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 Client dart-cloud-backend admin-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

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

Auth Endpoints

POST /api/admin/auth/login

Authenticate admin user via Keycloak.

Request:

json
{
  "email": "admin@example.com",
  "password": "securepassword"
}

Response:

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

POST /api/admin/auth/refresh

Refresh an expired access token.

Request:

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

Response:

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

POST /api/admin/auth/logout

Invalidate the current session.

Request:

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

Response:

json
{
  "message": "Logout successful"
}

POST /api/admin/auth/verify-session

Verify the current access token is still valid.

Headers:

yaml
Authorization: Bearer <access-token>

Response:

json
{
  "valid": true,
  "subject": "keycloak-user-id",
  "username": "admin@example.com"
}

GET /api/admin/auth/me

Get the currently authenticated admin user's profile.

Headers:

yaml
Authorization: Bearer <access-token>

Response:

json
{
  "subject": "keycloak-user-id",
  "username": "admin@example.com",
  "role": "full_admin"
}

AdminPrincipal

After successful authentication, the middleware constructs an AdminPrincipal:

dart
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

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

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

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

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

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

yaml
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 header 401 No Authorization header
Invalid/expired token401Token introspection returned inactive
No admin role 403 Token lacks full_admin or viewer role
Write access denied 403 viewer 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

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

Diagram
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