Skip to content

Resolve service account roles when no user session exists#50971

Open
igraecao wants to merge 3 commits into
keycloak:mainfrom
igraecao:fix/50950-service-account-roles-fallback
Open

Resolve service account roles when no user session exists#50971
igraecao wants to merge 3 commits into
keycloak:mainfrom
igraecao:fix/50950-service-account-roles-fallback

Conversation

@igraecao

@igraecao igraecao commented Jul 17, 2026

Copy link
Copy Markdown

Problem

When a service account client uses client_credentials grant with fullScopeAllowed=false, the resulting lightweight access token contains no roles in its body. The role resolution path in resolveLightweightAccessTokenRoles relies on finding a valid user session - but client_credentials does not create a persistent user session. Result: all admin API calls return 403 regardless of assigned roles.

This affects ALL admin REST API endpoints, not just user profile (the originally reported symptom).

Root cause

  1. Token has resource_access: null (lightweight - roles stripped from JWT)
  2. client_credentials creates no persistent user session
  3. findValidSessionForAccessToken returns null
  4. No fallback exists to resolve roles without a session
  5. KeycloakIdentity has empty role attributes - 403 on everything

Fix (V2 - scope-aware)

Adds a fallback in resolveLightweightAccessTokenRoles that:

  1. Guards the fallback to service accounts only - verifies user.getServiceAccountClientLink() matches the issuing client. Expired user tokens cannot regain roles through this path.
  2. Respects client scope mappings - uses TokenManager.getAccess(user, client, clientScopes) which correctly handles fullScopeAllowed=false by intersecting user roles with scope mappings.
  3. Splits resolved roles into realm and client access buckets using the same pattern as token creation.

V1 → V2 changes (addressing review feedback)

  • Fixed: fallback no longer fires for non-service-account subjects (prevents privilege re-escalation after session revocation)
  • Fixed: roles are now filtered through client scope mappings (no longer bypasses fullScopeAllowed=false)
  • Added: regression test covering full-scope, limited-scope, and no-scope scenarios

Test coverage

ServiceAccountScopedRolesTest verifies:

  • fullScopeAllowed=true: all assigned roles resolved, admin API accessible
  • fullScopeAllowed=false + role in scope: only scoped roles appear, access works
  • fullScopeAllowed=false + role NOT in scope: 403 as expected

How to reproduce

# Create client with service account, fullScopeAllowed=false, manage-users assigned
curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
  -d "grant_type=client_credentials&client_id=my-client&client_secret=secret"
# Use returned token against admin API
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8080/admin/realms/master/users"
# Returns 403 (before fix) or 200 (after fix, if role is in scope)

Closes #50950

When a service account uses client_credentials grant with
fullScopeAllowed=false, the access token contains no roles and no user
session is created. resolveLightweightAccessTokenRoles previously returned
silently in this case, leaving the token with empty role attributes.

This caused all admin API permission checks to fail with 403, regardless
of the roles actually assigned to the service account user.

Add a fallback: when no user session is found but the token has a subject
claim, resolve roles directly from the UserModel role mappings.

Closes keycloak#50950
Copilot AI review requested due to automatic review settings July 17, 2026 07:54
@igraecao
igraecao requested a review from a team as a code owner July 17, 2026 07:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds fallback role resolution for sessionless service-account access tokens used by admin authorization.

Changes:

  • Loads service-account roles from the user model when no session exists.
  • Populates realm and client role claims dynamically.

Comment on lines +1690 to 1697
} else if (accessToken.getSubject() != null) {
// Fallback for service accounts using client_credentials grant without a
// persistent user session. Resolve roles directly from the user's role mappings.
UserModel user = session.users().getUserById(realm, accessToken.getSubject());
if (user != null) {
resolveRolesFromUserModel(session, accessToken, realm, user);
}
}
private static void resolveRolesFromUserModel(KeycloakSession session, AccessToken accessToken, RealmModel realm, UserModel user) {
// Resolve realm roles
AccessToken.Access realmAccess = new AccessToken.Access();
user.getRealmRoleMappingsStream().forEach(role -> realmAccess.addRole(role.getName()));
// persistent user session. Resolve roles directly from the user's role mappings.
UserModel user = session.users().getUserById(realm, accessToken.getSubject());
if (user != null) {
resolveRolesFromUserModel(session, accessToken, realm, user);
When a service account uses client_credentials grant with
fullScopeAllowed=false, the lightweight access token contains no roles.
Role resolution from the user session fails because client_credentials
does not create a persistent session.

V2 fixes:
- Only activates for service accounts linked to the issuing client
- Uses TokenManager.getAccess() which respects fullScopeAllowed and
  client scope mappings
- Expired user tokens cannot regain roles through this path

Closes keycloak#50950
Copilot AI review requested due to automatic review settings July 20, 2026 09:23
…50950)

Verifies that client_credentials grant with fullScopeAllowed=false
correctly resolves only scoped roles, and that unscoped roles are
not leaked into the resolved token.

Three cases:
1. fullScopeAllowed=true: all assigned roles appear
2. fullScopeAllowed=false + role in scope: role appears, access works
3. fullScopeAllowed=false + role NOT in scope: 403 as expected

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment on lines +1708 to +1711
Stream<ClientScopeModel> clientScopes = Stream.concat(
client.getClientScopes(true).values().stream(),
Stream.of(client));
Set<RoleModel> allowedRoles = TokenManager.getAccess(user, client, clientScopes);
Comment on lines +76 to +81
ClientRepresentation limitedScopeClient = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId(CLIENT_LIMITED_SCOPE)
.secret(CLIENT_SECRET)
.serviceAccountsEnabled(true)
.fullScopeEnabled(false)
Comment on lines +133 to +134
// Add view-users to the client's scope mappings (but NOT manage-users)
addRealmManagementRoleToClientScope(realmResource, CLIENT_LIMITED_SCOPE, "view-users");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

User profile endpoint returns 403 for clients with manage-users or view-users roles

2 participants