Keycloak Integration
Integrate Keycloak as the identity provider for your marketplace application. This guide covers versions 24 through 26, highlighting the behavioral differences between each release.
Prerequisites
- Keycloak Server: Version 24.0 or higher
- Realm: A dedicated realm for marketplace users
- Confidential Client with:
- Client ID and Client Secret
- Direct Access Grants enabled (for ROPC password grant)
- Valid Redirect URIs configured
- Service Account Roles:
manage-users,view-userson therealm-managementclient
- Admin Credentials: For the Admin REST API (user provisioning)
Keycloak Configuration
-
Create a Realm (or use existing):
Realm Name: gw-marketplace -
Create a Confidential Client:
Client ID: gw-app-clientClient Protocol: openid-connectAccess Type: confidentialDirect Access Grants: EnabledService Accounts: Enabled -
Add Protocol Mappers (for custom claims in tokens):
gwUserId-- User Attribute mapper, claim name:gwUserIdorgId-- User Attribute mapper, claim name:orgIdgwApplicationId-- User Attribute mapper, claim name:gwApplicationId
-
Create Realm Roles:
gw-user-- Assigned to all marketplace usersorg-admin-- Assigned to organization administrators
Environment Variables
# Keycloak Server
KEYCLOAK_URL=https://keycloak.your-domain.com
# Realm Configuration
KEYCLOAK_REALM=gw-marketplace
# Client Credentials (for token exchange)
KEYCLOAK_CLIENT_ID=gw-app-client
KEYCLOAK_CLIENT_SECRET=your-client-secret
# Admin Credentials (for user provisioning via Admin REST API)
KEYCLOAK_ADMIN_USER=admin
KEYCLOAK_ADMIN_PASSWORD=your-admin-password
# Marketplace Configuration
MARKETPLACE_APP_ID=your-registered-app-id
Exchange Flow
sequenceDiagram
participant SDK as Provider SDK
participant Backend as Your Backend
participant KC as Keycloak
SDK->>Backend: POST /auth/exchange (Bearer marketplace-jwt)
Backend->>Backend: Validate JWT (signature, iss, applicationId, exp)
Backend->>KC: POST /realms/master/.../token (admin credentials)
KC-->>Backend: Admin access token
Backend->>KC: GET /admin/realms/{realm}/users?username=gw-{userId}
alt User exists
KC-->>Backend: User record
Backend->>KC: PUT /admin/realms/{realm}/users/{id} (update attributes)
else User not found
Backend->>KC: POST /admin/realms/{realm}/users (create)
KC-->>Backend: 201 + Location header
Backend->>KC: POST .../role-mappings/realm (assign roles)
end
Backend->>KC: POST /realms/{realm}/.../token (ROPC grant)
KC-->>Backend: access_token + refresh_token
Backend-->>SDK: Tokens + user info
Backend Implementation
import { createHash } from 'crypto';
// Deterministic password derivation -- no password storage needed
function derivedPassword(userId: string, clientSecret: string): string {
return createHash('sha256')
.update(`${userId}${clientSecret}`)
.digest('hex');
}
// Admin token acquisition (cached, refreshed before expiry)
async function getAdminToken(): Promise<string> {
const params = new URLSearchParams({
grant_type: 'password',
client_id: 'admin-cli',
username: process.env.KEYCLOAK_ADMIN_USER!,
password: process.env.KEYCLOAK_ADMIN_PASSWORD!,
});
const res = await fetch(
`${process.env.KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);
if (!res.ok) throw new Error(`Admin token failed: ${res.status}`);
const data = await res.json();
return data.access_token;
}
// Find user by deterministic username
async function findUser(userId: string, adminToken: string) {
const username = `gw-${userId}`;
const res = await fetch(
`${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users?username=${encodeURIComponent(username)}&exact=true`,
{ headers: { Authorization: `Bearer ${adminToken}` } },
);
if (!res.ok) throw new Error(`User search failed: ${res.status}`);
const users = await res.json();
return users.length > 0 ? users[0] : null;
}
// Create user with credentials and custom attributes
async function createUser(claims: any, adminToken: string) {
const username = `gw-${claims.userId}`;
const password = derivedPassword(claims.userId, process.env.KEYCLOAK_CLIENT_SECRET!);
const res = await fetch(
`${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
email: claims.email ?? `${claims.userId}@gw-marketplace.local`,
enabled: true,
emailVerified: true,
attributes: {
gwUserId: [claims.userId],
orgId: [claims.orgId],
gwApplicationId: [claims.applicationId],
},
credentials: [{ type: 'password', value: password, temporary: false }],
}),
},
);
if (!res.ok) throw new Error(`Create user failed: ${res.status}`);
// Extract user ID from Location header
const location = res.headers.get('location');
return location ? location.split('/').pop()! : null;
}
// Obtain tokens via ROPC grant
async function getUserTokens(username: string, password: string) {
const params = new URLSearchParams({
grant_type: 'password',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
username,
password,
scope: 'openid',
});
const res = await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);
if (!res.ok) throw new Error(`Token grant failed: ${res.status}`);
return res.json();
}
Version Differences (v24 / v25 / v26)
Keycloak introduced breaking changes across versions 24 through 26. The following table summarizes what differs:
| Behavior | v24 | v25 | v26 |
|---|---|---|---|
requiredActions quirk | Credentials with temporary:false may still set UPDATE_PASSWORD | Fixed -- no workaround needed | Fixed |
| Attribute updates | GET+PUT merge (partial merge preserves existing attrs) | GET+PUT merge (same as v24) | Full replacement -- no merge, no GET needed |
session_state in tokens | Present as UUID | Present as UUID | Removed -- use sid (base64, 24 chars) instead |
| Persistent sessions | Not enabled by default | Enabled by default | Enabled by default |
| Username mutability | Read-only after creation | Read-only after creation | Read-only after creation |
v24: requiredActions Workaround
Keycloak 24 has a known quirk where creating a user with temporary: false on the credential may still set requiredActions: ["UPDATE_PASSWORD"]. This blocks the ROPC password grant. You must clear required actions after user creation:
// KC 24 only: clear spurious requiredActions
async function ensureLoginReady(userId: string, adminToken: string) {
const url = `${KEYCLOAK_URL}/admin/realms/${REALM}/users/${userId}`;
const getRes = await fetch(url, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const user = await getRes.json();
if (user.requiredActions?.length > 0) {
await fetch(url, {
method: 'PUT',
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, requiredActions: [], emailVerified: true, enabled: true }),
});
}
}
v25: Persistent Sessions
KC 25 enables persistent sessions by default. User sessions survive server restarts without external session stores. No code change is required, but be aware that refresh tokens remain valid across restarts unless explicitly revoked.
v26: Attribute Full Replacement
KC 26 changed attribute handling from partial merge to full replacement. When you PUT attributes, the provided set becomes the complete attribute set -- any attributes not included are removed.
// KC 24/25: GET+PUT merge (preserves other attributes)
async function updateAttributesV24(userId: string, attrs: Record<string, string>) {
const user = await getUser(userId);
const merged = { ...(user.attributes ?? {}) };
for (const [k, v] of Object.entries(attrs)) merged[k] = [v];
await putUser(userId, { ...user, attributes: merged });
}
// KC 26: Full replacement (no GET needed, but all attrs must be provided)
async function updateAttributesV26(userId: string, attrs: Record<string, string>) {
const attributes: Record<string, string[]> = {};
for (const [k, v] of Object.entries(attrs)) attributes[k] = [v];
await putUser(userId, { attributes });
}
v26: Session ID Format Change
KC 26 removes session_state from access tokens entirely. If your code reads session_state, migrate to the sid claim which is now a base64-encoded string (24 characters) rather than a UUID.
// KC 24/25
const sessionId = payload.session_state; // UUID: "d4f5a6b7-1234-5678-9abc-def012345678"
// KC 26
const sessionId = payload.sid; // Base64: "YWJjZGVmZ2hpamtsbW5v"
Token Structure
Keycloak access tokens contain roles under realm_access.roles:
{
"exp": 1714000000,
"iss": "https://keycloak.example.com/realms/gw-marketplace",
"preferred_username": "gw-user-abc123",
"email": "user@example.com",
"realm_access": {
"roles": ["gw-user", "org-admin"]
},
"gwUserId": "user-abc123",
"orgId": "org-456",
"gwApplicationId": "app-789"
}
Session Revocation
Revoke sessions on the OIDC logout endpoint using the refresh token:
async function revokeSession(refreshToken: string) {
const params = new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
refresh_token: refreshToken,
});
await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);
}
Admin REST API Reference
Base URL: {KEYCLOAK_URL}/admin/realms/{REALM}
| Operation | Method | Endpoint |
|---|---|---|
| Search Users | GET | /users?username={username}&exact=true |
| Create User | POST | /users |
| Get User | GET | /users/{id} |
| Update User | PUT | /users/{id} |
| Reset Password | PUT | /users/{id}/reset-password |
| Get Role | GET | /roles/{roleName} |
| Assign Roles | POST | /users/{id}/role-mappings/realm |
| Remove Roles | DELETE | /users/{id}/role-mappings/realm |
Common Gotchas
-
409 Conflict on user creation: Another user with the same email already exists. Look up by email and adopt the existing user instead of failing.
-
"Action required" on token grant: KC 24 sets
requiredActionseven withtemporary: false. CallensureLoginReady()before attempting the password grant. -
Attributes silently lost on KC 26: Because KC 26 uses full replacement, a PUT that only includes your GW attributes will remove any attributes set by other systems. Include the complete attribute set.
-
Admin token expiration: Admin tokens have a short lifetime (default 60s). Cache the token and refresh it proactively -- check expiry before each admin API call.
-
CORS errors on frontend: Ensure your application's origin is listed in the client's "Web Origins" setting. Use
+to automatically allow all valid redirect URIs. -
Client must be confidential: Public clients cannot use client secrets for the ROPC grant. Set Access Type to "confidential" in the client settings.