Skip to content

Commit a5b8a77

Browse files
authored
Merge pull request #5330 from IBM/fix/keycloak-sso-role-claims
fix(sso): merge Keycloak realm/client roles from access_token, not just userinfo/id_token
2 parents b8b4d94 + 771ecd0 commit a5b8a77

5 files changed

Lines changed: 212 additions & 18 deletions

File tree

.secrets.baseline

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-06-18T13:53:55Z",
6+
"generated_at": "2026-06-20T09:31:24Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -2896,31 +2896,31 @@
28962896
"hashed_secret": "c58994eefb19ae2d0bfc26a106de438359e53fb6",
28972897
"is_secret": false,
28982898
"is_verified": false,
2899-
"line_number": 314,
2899+
"line_number": 325,
29002900
"type": "Secret Keyword",
29012901
"verified_result": null
29022902
},
29032903
{
29042904
"hashed_secret": "8c9fdbd88e905d33102d0be537adeab8595fe0da",
29052905
"is_secret": false,
29062906
"is_verified": false,
2907-
"line_number": 344,
2907+
"line_number": 355,
29082908
"type": "Secret Keyword",
29092909
"verified_result": null
29102910
},
29112911
{
29122912
"hashed_secret": "6ea1c94703f93074853165df24e0ded6917472af",
29132913
"is_secret": false,
29142914
"is_verified": false,
2915-
"line_number": 370,
2915+
"line_number": 381,
29162916
"type": "Secret Keyword",
29172917
"verified_result": null
29182918
},
29192919
{
29202920
"hashed_secret": "b05b46675e3b6cecdb5ed7d0c24a8a183abd4de8",
29212921
"is_secret": false,
29222922
"is_verified": false,
2923-
"line_number": 740,
2923+
"line_number": 751,
29242924
"type": "Secret Keyword",
29252925
"verified_result": null
29262926
}
@@ -3896,7 +3896,7 @@
38963896
"hashed_secret": "1e5c2f367f02e47a8c160cda1cd9d91decbac441",
38973897
"is_secret": false,
38983898
"is_verified": false,
3899-
"line_number": 172,
3899+
"line_number": 200,
39003900
"type": "Secret Keyword",
39013901
"verified_result": null
39023902
}

docs/docs/manage/sso-keycloak-tutorial.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,26 @@ To include roles in JWT tokens:
240240
**realm roles**:
241241

242242
- Maps realm roles to `realm_access.roles` claim
243-
- Should be enabled by default
243+
- Exists by default
244244

245245
**client roles**:
246246

247247
- Maps client roles to `resource_access.{client_id}.roles` claim
248-
- Should be enabled by default
248+
- Exists by default
249249

250250
If missing, create them manually using **Add mapper****By configuration****User Realm Role** or **User Client Role**.
251251

252+
**Important — enable on ID token and userinfo, not just the access token:**
253+
254+
Keycloak's built-in **realm roles** and **client roles** mappers ship with only **Add to access token** turned on. **Add to ID token** and **Add to userinfo** are off by default. ContextForge reads roles from the userinfo response (and falls back to the ID token in some configurations) — it does not parse the access token — so with the defaults left as-is, `realm_access`/`resource_access` never reach the gateway and `SSO_KEYCLOAK_ROLE_MAPPINGS`/`SSO_KEYCLOAK_MAP_REALM_ROLES` silently have nothing to map, even though the role is assigned correctly in Keycloak.
255+
256+
For each of the **realm roles** and **client roles** mappers, open it and switch on:
257+
258+
- **Add to ID token**
259+
- **Add to userinfo**
260+
261+
(in addition to the default **Add to access token**). The local `infra/keycloak/realm-export.json` dev seed already configures these mappers correctly — this step is only needed when wiring up your own Keycloak realm by hand.
262+
252263
## Step 5: Configure User Attributes and Groups
253264

254265
### 5.1 Create Groups (Optional)
@@ -839,10 +850,11 @@ SSO_KEYCLOAK_PUBLIC_BASE_URL=http://localhost:8180 # Browser-facing (auth URL,
839850
**Problem**: User roles not included in JWT token
840851
**Solution**: Configure role mappers and enable role mapping
841852

842-
1. Verify role mappers exist:
853+
1. Verify role mappers exist and reach the ID token / userinfo:
843854

844855
- Go to **Client scopes****roles****Mappers**
845856
- Ensure **realm roles** and **client roles** mappers exist
857+
- Open each mapper and confirm **Add to ID token** and **Add to userinfo** are both ON — Keycloak ships these mappers with only **Add to access token** enabled by default, and ContextForge reads roles from userinfo/ID token, not the access token. This is the most common cause of "role assigned in Keycloak but gateway treats user as default role" — see [Step 4.4](#44-configure-role-mappers).
846858

847859
2. Enable role mapping in gateway:
848860
```bash

infra/keycloak/realm-export.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,34 @@
9494
"userinfo.token.claim": "true",
9595
"claim.name": "groups"
9696
}
97+
},
98+
{
99+
"name": "realm roles (id+userinfo)",
100+
"protocol": "openid-connect",
101+
"protocolMapper": "oidc-usermodel-realm-role-mapper",
102+
"consentRequired": false,
103+
"config": {
104+
"multivalued": "true",
105+
"id.token.claim": "true",
106+
"access.token.claim": "true",
107+
"userinfo.token.claim": "true",
108+
"claim.name": "realm_access.roles",
109+
"jsonType.label": "String"
110+
}
111+
},
112+
{
113+
"name": "client roles (id+userinfo)",
114+
"protocol": "openid-connect",
115+
"protocolMapper": "oidc-usermodel-client-role-mapper",
116+
"consentRequired": false,
117+
"config": {
118+
"multivalued": "true",
119+
"id.token.claim": "true",
120+
"access.token.claim": "true",
121+
"userinfo.token.claim": "true",
122+
"claim.name": "resource_access.${client_id}.roles",
123+
"jsonType.label": "String"
124+
}
97125
}
98126
]
99127
}

mcpgateway/services/sso_service.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,10 +1450,23 @@ async def _enrich_user_data_from_claims(
14501450
return
14511451

14521452
# Keycloak: merge realm_access, resource_access, and groups from id_token
1453-
if provider.id == "keycloak" and verified_id_token_claims:
1453+
# and, failing that, the access_token. Keycloak's built-in "realm roles"
1454+
# and "client roles" client-scope mappers default access.token.claim=true
1455+
# but id.token.claim=userinfo.token.claim=false, so on a stock Keycloak
1456+
# setup these claims are normally present ONLY on the access_token —
1457+
# never on the userinfo response or id_token. The access_token was
1458+
# already received directly from the trusted token endpoint, so decoding
1459+
# it here (without re-verifying the signature) carries the same trust
1460+
# level as the verified_id_token_claims fallback above.
1461+
if provider.id == "keycloak":
1462+
access_token_claims = self._decode_jwt_claims(access_token) or {}
14541463
for claim in ["realm_access", "resource_access", "groups"]:
1455-
if claim in verified_id_token_claims and claim not in user_data:
1464+
if claim in user_data:
1465+
continue
1466+
if verified_id_token_claims and claim in verified_id_token_claims:
14561467
user_data[claim] = verified_id_token_claims[claim]
1468+
elif claim in access_token_claims:
1469+
user_data[claim] = access_token_claims[claim]
14571470
return
14581471

14591472
# Generic OIDC (including Okta, IBM Verify, and any custom provider):
@@ -1577,7 +1590,15 @@ async def _get_user_info(self, provider: SSOProvider, access_token: str, token_d
15771590
public_base_url,
15781591
metadata.get("base_url"),
15791592
)
1580-
return self._normalize_user_info(provider, verified_id_token_claims)
1593+
fallback_claims = dict(verified_id_token_claims)
1594+
# id_token also lacks realm_access/resource_access/groups by default
1595+
# on a stock Keycloak setup (see _enrich_user_data_from_claims) -
1596+
# pull them from the access_token, which carries them by default.
1597+
access_token_claims = self._decode_jwt_claims(access_token) or {}
1598+
for claim in ["realm_access", "resource_access", "groups"]:
1599+
if claim not in fallback_claims and claim in access_token_claims:
1600+
fallback_claims[claim] = access_token_claims[claim]
1601+
return self._normalize_user_info(provider, fallback_claims)
15811602

15821603
logger.error("User info request failed for %s: HTTP %s - %s", provider.name, response.status_code, response.text)
15831604

@@ -2283,6 +2304,12 @@ async def _map_groups_to_roles(self, user_email: str, user_groups: List[str], pr
22832304
logger.debug("No role mappings configured for provider %s, skipping role sync", provider.id)
22842305
return role_assignments
22852306

2307+
# Match role_mappings keys case-insensitively, consistent with _should_user_be_admin().
2308+
# Without this, an IdP group/role casing that differs from the configured mapping key
2309+
# (e.g. Keycloak role "gateway-admin" vs a mapping key "Gateway-Admin") would silently
2310+
# skip RBAC role assignment even though is_admin could already be granted.
2311+
lower_role_mappings = {str(k).lower(): v for k, v in role_mappings.items()}
2312+
22862313
personal_team_id: Optional[str] = None
22872314
personal_team_checked = False
22882315

@@ -2339,10 +2366,9 @@ async def _resolve_team_scope_id_if_needed(role_scope: str) -> Optional[str]:
23392366
# Batch role lookups: collect all role names that need to be looked up
23402367
role_names_to_lookup = set()
23412368
for group in user_groups:
2342-
if group in role_mappings:
2343-
role_name = role_mappings[group]
2344-
if role_name not in ["admin", settings.default_admin_role]:
2345-
role_names_to_lookup.add(role_name)
2369+
role_name = lower_role_mappings.get(group.lower())
2370+
if role_name and role_name not in ["admin", settings.default_admin_role]:
2371+
role_names_to_lookup.add(role_name)
23462372

23472373
# Add default role to lookup if needed
23482374
if has_provider_default_role and provider_default_role:
@@ -2361,8 +2387,8 @@ async def _resolve_team_scope_id_if_needed(role_scope: str) -> Optional[str]:
23612387

23622388
# Process role mappings for ALL providers
23632389
for group in user_groups:
2364-
if group in role_mappings:
2365-
role_name = role_mappings[group]
2390+
role_name = lower_role_mappings.get(group.lower())
2391+
if role_name:
23662392
# Special case for "admin" shorthand or configured admin role name
23672393
if role_name in ["admin", settings.default_admin_role]:
23682394
role_assignments.append({"role_name": settings.default_admin_role, "scope": "global", "scope_id": None})

tests/unit/mcpgateway/services/test_sso_service.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ async def test_exchange_code_for_tokens_basic_auth_header_format(self, sso_servi
754754
assert auth_header.startswith("Basic ")
755755
# Verify it's valid base64
756756
import base64
757+
757758
payload = auth_header[len("Basic "):]
758759
decoded = base64.b64decode(payload).decode("utf-8")
759760
assert decoded.startswith("test-client-id:")
@@ -809,6 +810,7 @@ async def test_build_basic_auth_header_url_encodes_special_chars(self, sso_servi
809810
header = sso_service._build_basic_auth_header("client@id", "secret:+?")
810811
assert header.startswith("Basic ")
811812
import base64
813+
812814
payload = base64.b64decode(header[len("Basic "):]).decode("utf-8")
813815
assert payload == "client%40id:secret%3A%2B%3F"
814816

@@ -1287,6 +1289,94 @@ async def test_get_user_info_keycloak_falls_back_to_id_token_when_userinfo_fails
12871289
assert "admin" in result["groups"]
12881290
assert "/team" in result["groups"]
12891291

1292+
@pytest.mark.asyncio
1293+
async def test_get_user_info_keycloak_merges_realm_access_from_access_token_when_userinfo_succeeds(self, sso_service):
1294+
"""Keycloak's default mappers only put realm_access/resource_access on the access_token
1295+
(userinfo.token.claim and id.token.claim default to false). Even when userinfo returns
1296+
200 and there's no id_token claim to fall back to, the gateway should still extract
1297+
roles by decoding the access_token it already received from the token endpoint."""
1298+
# Standard
1299+
import base64
1300+
1301+
# Third-Party
1302+
import orjson
1303+
1304+
user_response = MagicMock()
1305+
user_response.status_code = 200
1306+
user_response.json.return_value = {"email": "user@kc.com", "name": "KC User", "preferred_username": "kcuser", "sub": "kc-123"}
1307+
1308+
mock_client = AsyncMock()
1309+
mock_client.get = AsyncMock(return_value=user_response)
1310+
1311+
provider = _make_provider(
1312+
id="keycloak",
1313+
name="keycloak",
1314+
provider_type="oidc",
1315+
provider_metadata={"map_realm_roles": True, "map_client_roles": True},
1316+
)
1317+
1318+
access_token_payload = orjson.dumps({"realm_access": {"roles": ["gateway-admin"]}, "resource_access": {"app": {"roles": ["edit"]}}})
1319+
access_token_payload_b64 = base64.urlsafe_b64encode(access_token_payload).decode().rstrip("=")
1320+
fake_access_token = f"eyJhbGciOiJSUzI1NiJ9.{access_token_payload_b64}.sig"
1321+
1322+
# No id_token / verified id_token claims at all - the bug surfaces even without
1323+
# the split-host fallback machinery being involved.
1324+
token_data = {"access_token": fake_access_token}
1325+
1326+
with patch("mcpgateway.services.http_client_service.get_http_client", new_callable=AsyncMock) as mock_get_client, patch("mcpgateway.services.sso_service.settings") as mock_settings:
1327+
mock_get_client.return_value = mock_client
1328+
mock_settings.sso_github_admin_orgs = []
1329+
result = await sso_service._get_user_info(provider, fake_access_token, token_data)
1330+
1331+
assert result is not None
1332+
assert result["provider"] == "keycloak"
1333+
assert "gateway-admin" in result["groups"]
1334+
assert "app:edit" in result["groups"]
1335+
1336+
@pytest.mark.asyncio
1337+
async def test_get_user_info_keycloak_fallback_merges_realm_access_from_access_token_when_id_token_lacks_it(self, sso_service):
1338+
"""On the 401 + split-host fallback path, the id_token may itself lack realm_access
1339+
(same default-mapper gap as userinfo). The access_token, already in hand from the
1340+
token endpoint, should be decoded to fill in the missing roles."""
1341+
# Standard
1342+
import base64
1343+
1344+
# Third-Party
1345+
import orjson
1346+
1347+
fail_response = MagicMock()
1348+
fail_response.status_code = 401
1349+
fail_response.text = ""
1350+
1351+
mock_client = AsyncMock()
1352+
mock_client.get = AsyncMock(return_value=fail_response)
1353+
1354+
provider = _make_provider(
1355+
id="keycloak",
1356+
name="keycloak",
1357+
provider_type="oidc",
1358+
provider_metadata={"map_realm_roles": True, "map_client_roles": False, "base_url": "http://keycloak:8080", "public_base_url": "http://localhost:8180"},
1359+
)
1360+
1361+
# id_token claims have no realm_access (matches Keycloak's default mapper config).
1362+
id_token_claims = {"sub": "kc-123", "email": "user@kc.com", "name": "KC User", "preferred_username": "kcuser"}
1363+
1364+
access_token_payload = orjson.dumps({"realm_access": {"roles": ["gateway-admin"]}})
1365+
access_token_payload_b64 = base64.urlsafe_b64encode(access_token_payload).decode().rstrip("=")
1366+
fake_access_token = f"eyJhbGciOiJSUzI1NiJ9.{access_token_payload_b64}.sig"
1367+
1368+
token_data = {"access_token": fake_access_token, "_verified_id_token_claims": id_token_claims}
1369+
1370+
with patch("mcpgateway.services.http_client_service.get_http_client", new_callable=AsyncMock) as mock_get_client, patch("mcpgateway.services.sso_service.settings") as mock_settings:
1371+
mock_get_client.return_value = mock_client
1372+
mock_settings.sso_github_admin_orgs = []
1373+
result = await sso_service._get_user_info(provider, fake_access_token, token_data)
1374+
1375+
assert result is not None
1376+
assert result["provider"] == "keycloak"
1377+
assert result["email"] == "user@kc.com"
1378+
assert "gateway-admin" in result["groups"]
1379+
12901380
@pytest.mark.asyncio
12911381
async def test_get_user_info_generic_oidc_merges_groups_from_id_token(self, sso_service):
12921382
"""Generic OIDC provider merges configured groups claim from id_token when userinfo omits it."""
@@ -2959,6 +3049,43 @@ async def test_role_mapping_with_custom_role(self, sso_service):
29593049
assert len(result) == 1
29603050
assert result[0]["role_name"] == "developer"
29613051

3052+
@pytest.mark.asyncio
3053+
async def test_role_mapping_matches_case_insensitively(self, sso_service):
3054+
"""A group/role name whose casing differs from the configured role_mappings key
3055+
must still resolve. _should_user_be_admin() already matches case-insensitively;
3056+
_map_groups_to_roles() must do the same so is_admin and the RBAC role assignment
3057+
stay consistent (otherwise a user can get is_admin=True with no platform_admin
3058+
role row, or vice versa)."""
3059+
mock_role = SimpleNamespace(name="developer", scope="team", id="r1")
3060+
provider = _make_provider(provider_metadata={"role_mappings": {"Dev-Group": "developer"}})
3061+
3062+
with patch("mcpgateway.services.sso_service.settings") as mock_settings, patch("mcpgateway.services.role_service.RoleService") as MockRoleService:
3063+
mock_settings.sso_entra_admin_groups = []
3064+
mock_settings.sso_entra_default_role = None
3065+
mock_settings.sso_entra_role_mappings = {}
3066+
role_svc_instance = AsyncMock()
3067+
role_svc_instance.get_role_by_name = AsyncMock(return_value=mock_role)
3068+
MockRoleService.return_value = role_svc_instance
3069+
# IdP returned "dev-group" (lowercase), mapping key is "Dev-Group".
3070+
result = await sso_service._map_groups_to_roles("user@test.com", ["dev-group"], provider)
3071+
3072+
assert len(result) == 1
3073+
assert result[0]["role_name"] == "developer"
3074+
3075+
@pytest.mark.asyncio
3076+
async def test_admin_shorthand_matches_case_insensitively(self, sso_service):
3077+
"""Same case-insensitivity guarantee for the 'admin'/default_admin_role shorthand path."""
3078+
provider = _make_provider(provider_metadata={"role_mappings": {"Gateway-Admin": "admin"}})
3079+
with patch("mcpgateway.services.sso_service.settings") as mock_settings, patch("mcpgateway.services.role_service.RoleService"):
3080+
mock_settings.sso_entra_admin_groups = []
3081+
mock_settings.sso_entra_default_role = None
3082+
mock_settings.sso_entra_role_mappings = {}
3083+
mock_settings.default_admin_role = "platform_admin"
3084+
# IdP returned the role with Keycloak-typical lowercase-hyphen casing.
3085+
result = await sso_service._map_groups_to_roles("user@test.com", ["gateway-admin"], provider)
3086+
3087+
assert any(r["role_name"] == "platform_admin" for r in result)
3088+
29623089
@pytest.mark.asyncio
29633090
async def test_entra_default_role_fallback(self, sso_service):
29643091
mock_role = SimpleNamespace(name="viewer", scope="global", id="r2")
@@ -4707,6 +4834,7 @@ async def test_get_user_info_adfs_with_id_token(self, sso_service):
47074834
@pytest.mark.asyncio
47084835
async def test_get_user_info_adfs_missing_id_token(self, sso_service):
47094836
"""ADFS provider raises error when id_token is missing."""
4837+
# First-Party
47104838
from mcpgateway.services.sso_service import SSOProviderConfigError
47114839

47124840
provider = _make_provider(id="adfs", name="adfs", provider_type="oidc")

0 commit comments

Comments
 (0)