Skip to content

fix: OIDC client registration scope handling auth enforcement (#51311) - #51635

Open
poliglots wants to merge 6 commits into
keycloak:mainfrom
poliglots:main
Open

fix: OIDC client registration scope handling auth enforcement (#51311)#51635
poliglots wants to merge 6 commits into
keycloak:mainfrom
poliglots:main

Conversation

@poliglots

Copy link
Copy Markdown

Move scope-handling block after auth enforcement in updateOIDC endpoint.

The PUT /realms/{realm}/clients-registrations/openid-connect/{clientId} endpoint had three defects:

  1. ID-type confusion: scope-handling used getClientById() (UUID lookup) instead of getClientByClientId() (client_id lookup), returning null.

  2. Missing null-check: oldClient.getClientScopes(true) dereferenced null, throwing NullPointerException on any request with a scope field.

  3. Auth ordering: scope-handling ran BEFORE auth.requireUpdate(), making the NPE reachable without authentication (HTTP 500 with stack traces).

Fixes:

  • Use getClientByClientId() for correct client lookup
  • Add null-check with INVALID_CLIENT_METADATA error response
  • Move scope-handling after update() so auth is enforced first

Regression tests added for authenticated scope update and unauthenticated request handling.

Fixes #51311

…ak#51311)

Move scope-handling block after auth enforcement in updateOIDC endpoint.

The PUT /realms/{realm}/clients-registrations/openid-connect/{clientId}
endpoint had three defects:

1. ID-type confusion: scope-handling used getClientById() (UUID lookup)
   instead of getClientByClientId() (client_id lookup), returning null.

2. Missing null-check: oldClient.getClientScopes(true) dereferenced null,
   throwing NullPointerException on any request with a scope field.

3. Auth ordering: scope-handling ran BEFORE auth.requireUpdate(), making
   the NPE reachable without authentication (HTTP 500 with stack traces).

Fixes:
- Use getClientByClientId() for correct client lookup
- Add null-check with INVALID_CLIENT_METADATA error response
- Move scope-handling after update() so auth is enforced first

Regression tests added for authenticated scope update and
unauthenticated request handling.

Fixes keycloak#51311

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>
@poliglots
poliglots requested a review from a team as a code owner August 12, 2026 06:13
Copilot AI balanced review requested due to automatic review settings August 12, 2026 06:13
@poliglots
poliglots requested review from a team as code owners August 12, 2026 06:13

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

Hardens OIDC dynamic client updates against pre-authentication scope-handling failures.

Changes:

  • Enforces update authentication before scope processing.
  • Uses client-ID lookup with null handling.
  • Adds regression tests for scoped updates and unauthorized requests.

Reviewed changes

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

File Description
OIDCClientRegistrationProvider.java Reorders and hardens scope handling.
OIDCClientRegistrationTest.java Adds scope-update regression tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +141 to +142
OIDCClientRegistrationContext oidcContext = new OIDCClientRegistrationContext(session, client, this, clientOIDC);
client = update(clientId, oidcContext);
Comment on lines +255 to +270
public void updateNonExistentClientWithScopeField() throws ClientRegistrationException {
OIDCClientRepresentation nonExistent = new OIDCClientRepresentation();
nonExistent.setClientId("non-existent-client-" + UUID.randomUUID().toString());
nonExistent.setScope("openid profile");
nonExistent.setRedirectUris(Collections.singletonList("http://example.com"));

try {
reg.oidc().update(nonExistent);
fail("Expected ClientRegistrationException for non-existent client");
} catch (ClientRegistrationException e) {
// Auth is enforced before scope-handling, so the error is about client not found
// (the update() method performs auth + lookup). The key fix is that no NPE is thrown.
assertTrue(e.getMessage().contains("Client not found")
|| e.getMessage().contains("unauthorized"),
"Expected 'Client not found' or 'unauthorized' error, got: " + e.getMessage());
}
Comment on lines +232 to +245
response.setScope("openid profile email");
response.setRedirectUris(Collections.singletonList("http://updated-redirect"));

OIDCClientRepresentation updated = reg.oidc().update(response);

assertNotNull(updated);
assertNotNull(updated.getClientId());
assertEquals("http://updated-redirect", updated.getRedirectUris().get(0));

// Verify via admin API that the client was updated correctly
ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(response.getClientId());
ClientRepresentation rep = clientResource.toRepresentation();
assertNotNull(rep);
assertTrue(CollectionUtil.collectionEquals(Arrays.asList("openid", "profile", "email"), rep.getOptionalClientScopes()));
*/
@Test
public void updateClientWithScopeField() throws ClientRegistrationException {
OIDCClientRepresentation response = create();
poliglots added a commit to poliglots/keycloak that referenced this pull request Aug 12, 2026
   1. Preserve existing default client scopes before updateClientScopes() call
   2. Clear auth state in unauthenticated test and assert exact 401
   3. Use configured optional scopes (phone address) in test assertions
   4. Use explicit client_id to properly test ID-type confusion regression
Copilot AI review requested due to automatic review settings August 12, 2026 08:13
   1. Preserve existing default client scopes before updateClientScopes() call
   2. Clear auth state in unauthenticated test and assert exact 401
   3. Use configured optional scopes (phone address) in test assertions
   4. Use explicit client_id to properly test ID-type confusion regression

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>

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 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java:249

  • ClientsResource.get expects the client's internal UUID, not its client_id; once this test creates the intended ID mismatch, this lookup returns a missing resource instead of verifying the update. Resolve the resource by client ID first.
        ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get("test-client-with-explicit-id");

services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java:216

  • Restoring defaults after updateClientScopes cannot move a requested scope back from optional to default: addClientScope ignores a scope name that is already assigned (see model/jpa/.../JpaRealmProvider.java:1376-1380). An update with scope: "profile phone" therefore demotes the existing default profile scope; populate the representation's defaults before reconciliation instead.
        // Restore the previously-existing default scopes that were not overridden by the request.
        if (!existingDefaultScopes.isEmpty()) {
            for (Map.Entry<String, ClientScopeModel> entry : existingDefaultScopes.entrySet()) {
                if (rep.getDefaultClientScopes() == null || !rep.getDefaultClientScopes().contains(entry.getKey())) {
                    client.addClientScope(entry.getValue(), true);

Comment on lines +229 to +232
OIDCClientRepresentation client = createRep();
client.setClientId("test-client-with-explicit-id");
OIDCClientRepresentation response = reg.oidc().create(client);
reg.auth(Auth.token(response));
1. Set existing defaults on rep before updateClientScopes() instead of
   restoring after (post-hoc addClientScope cannot toggle default flag)

2. Look up client by client_id using findAll().stream() instead of
   clients().get() which expects internal UUID

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>
Copilot AI review requested due to automatic review settings August 12, 2026 08:46

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 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java:203

  • This condition also changes the default client-registration endpoint: a ClientRepresentation update that supplies optionalClientScopes but omits defaultClientScopes previously removed/demoted the omitted defaults via RepresentationToModel.updateClientScopes, but now silently preserves them. Restrict this preservation to the OIDC registration path so this security fix does not introduce a breaking semantic change for the default endpoint.
        if (rep.getDefaultClientScopes() == null && rep.getOptionalClientScopes() != null) {

…51635)

Move default-scope preservation from AbstractClientRegistrationProvider
(base class) to OIDCClientRegistrationProvider (OIDC-specific path only).
This prevents a breaking semantic change for the default client-registration
endpoint, which previously removed omitted defaults via updateClientScopes.

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>
Copilot AI review requested due to automatic review settings August 12, 2026 09:04

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 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (3)

services/src/main/java/org/keycloak/services/clientregistration/oidc/OIDCClientRegistrationProvider.java:141

  • update() has already reconciled scopes at AbstractClientRegistrationProvider.java:197 and returns a representation whose default-scope list is always non-null (ModelToRepresentation.java:914). Consequently this post-update block never restores anything, and supplying scope removes all existing default client scopes; preserve them after requireUpdate() but before updateClientScopes() runs.
            client = update(clientId, oidcContext);

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java:232

  • The OIDC registration POST rejects every non-null client_id (OIDCClientRegistrationProvider.java:90-92), so this setup returns 400 and never exercises the update path. Create the explicit-ID client through the admin API, then authenticate the OIDC update with a regenerated registration access token.
        OIDCClientRepresentation client = createRep();
        client.setClientId("test-client-with-explicit-id");
        OIDCClientRepresentation response = reg.oidc().create(client);
        reg.auth(Auth.token(response));

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java:256

  • This only verifies the requested optional scopes, so the test passes even though the updated implementation removes every existing default scope. Also assert that the realm's default OIDC scopes remain attached to cover the preservation behavior described by the production code.
        assertTrue(CollectionUtil.collectionEquals(Arrays.asList("phone", "address"), rep.getOptionalClientScopes()));

…loak#51635)

Move existing default scope preservation into OIDCClientRegistrationProvider
before update() is called, so updateClientScopes sees defaults in the
representation and reconciles correctly instead of silently demoting them.

Fix test to create client via admin API (OIDC POST rejects explicit
client_id), generate registration access token, and assert both optional
and default scope preservation.

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>
Copilot AI review requested due to automatic review settings August 12, 2026 09:50

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 no new comments.

Suppressed comments (2)

services/src/main/java/org/keycloak/services/clientregistration/oidc/OIDCClientRegistrationProvider.java:149

  • This scope lookup still runs before update() (line 158), while auth.requireUpdate() is only called inside AbstractClientRegistrationProvider.update(). An unauthenticated request for a nonexistent client therefore returns this 404 instead of the intended 401, so the new ordering regression test will fail; preserve the scopes only after the update authorization boundary (likely by separating authorization from mutation).
            if (clientOIDC.getScope() != null) {
                ClientModel oldClient = session.getContext().getRealm().getClientByClientId(clientOIDC.getClientId());
                if (oldClient == null) {
                    throw new ErrorResponseException(ErrorCodes.INVALID_CLIENT_METADATA, "Client not found: " + clientOIDC.getClientId(), Response.Status.NOT_FOUND);

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java:244

  • The admin create endpoint returns an empty 201 Created response with the new UUID only in the Location header (services/.../ClientsResource.java:201-203), so readEntity(ClientRepresentation.class) fails and this regression test never reaches the update. Resolve the created client through the existing admin helper instead.
        ClientRepresentation created = createResponse.readEntity(ClientRepresentation.class);

Replace broken createResponse.readEntity(ClientRepresentation.class) with
AdminApiUtil.findClientByClientId() since the admin API create endpoint
returns an empty body with the UUID only in the Location header.

Signed-off-by: Shatrughan Rai <polyglot.dev@outlook.com>
Copilot AI review requested due to automatic review settings August 12, 2026 10:16

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 no new comments.

Suppressed comments (1)

services/src/main/java/org/keycloak/services/clientregistration/oidc/OIDCClientRegistrationProvider.java:149

  • This scope lookup still runs before update(clientId, oidcContext), while auth.requireUpdate(...) is only called inside that method (AbstractClientRegistrationProvider.java:175). Consequently, the new unauthenticated test receives this 404 instead of the expected 401, and client lookup still occurs before authentication; move the preservation logic to an authenticated pre-reconciliation point and use the path clientId for that lookup.
            if (clientOIDC.getScope() != null) {
                ClientModel oldClient = session.getContext().getRealm().getClientByClientId(clientOIDC.getClientId());
                if (oldClient == null) {
                    throw new ErrorResponseException(ErrorCodes.INVALID_CLIENT_METADATA, "Client not found: " + clientOIDC.getClientId(), Response.Status.NOT_FOUND);

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.

Unauthenticated NullPointerException (HTTP 500) on OIDC Dynamic Client Registration PUT via the "scope" field (CWE-476)

3 participants