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.
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: Bearerheader - 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": "[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-admin→full_adminFull Admin→full_adminFULL_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