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:
| Feature | Platform Backend | Admin Backend |
|---|---|---|
| Keycloak Client | dart-cloud-backend | admin-portal |
| Auth Flow | Direct Grant + introspection | Direct Grant + introspection |
| Role System | None (per-user features via client roles) | full_admin / viewer |
| User Management | Registration + auto-migration | View-only (uses admin APIs) |
| Token Storage | Keycloak server | Keycloak server |
| Token Validation | Introspection + legacy JWT fallback | Introspection only |
| Feature Roles | Client-level roles per user | Manages 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 │ │
└──────────┘ │ └─────────────┘ │
└──────────────────┘
- 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:
{
"email": "admin@example.com",
"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": "admin@example.com"
}
GET /api/admin/auth/me
Get the currently authenticated admin user's profile.
Headers:
Authorization: Bearer <access-token>Response:
{
"subject": "keycloak-user-id",
"username": "admin@example.com",
"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-admin → full_admin
- Full Admin → full_admin
- FULL_ADMIN → full_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
| Error | Status | Cause |
|---|---|---|
| Missing authorization header | 401 | No Authorization header |
| Invalid/expired token | 401 | Token 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:
| Component | Description |
|---|---|
| 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
- Read Admin Backend Overview for server architecture
- Read Admin API Reference for all admin endpoints
- Read User Management for users, orgs, tiers, and feature roles