fix: OIDC client registration scope handling auth enforcement (#51311) - #51635
fix: OIDC client registration scope handling auth enforcement (#51311)#51635poliglots wants to merge 6 commits into
Conversation
…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>
There was a problem hiding this comment.
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.
| OIDCClientRegistrationContext oidcContext = new OIDCClientRegistrationContext(session, client, this, clientOIDC); | ||
| client = update(clientId, oidcContext); |
| 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()); | ||
| } |
| 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(); |
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
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>
There was a problem hiding this comment.
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.getexpects the client's internal UUID, not itsclient_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
updateClientScopescannot move a requested scope back from optional to default:addClientScopeignores a scope name that is already assigned (seemodel/jpa/.../JpaRealmProvider.java:1376-1380). An update withscope: "profile phone"therefore demotes the existing defaultprofilescope; 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);
| 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>
There was a problem hiding this comment.
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
ClientRepresentationupdate that suppliesoptionalClientScopesbut omitsdefaultClientScopespreviously removed/demoted the omitted defaults viaRepresentationToModel.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>
There was a problem hiding this comment.
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 atAbstractClientRegistrationProvider.java:197and returns a representation whose default-scope list is always non-null (ModelToRepresentation.java:914). Consequently this post-update block never restores anything, and supplyingscoperemoves all existing default client scopes; preserve them afterrequireUpdate()but beforeupdateClientScopes()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>
There was a problem hiding this comment.
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), whileauth.requireUpdate()is only called insideAbstractClientRegistrationProvider.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 Createdresponse with the new UUID only in theLocationheader (services/.../ClientsResource.java:201-203), soreadEntity(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>
There was a problem hiding this comment.
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), whileauth.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 pathclientIdfor 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);
Move scope-handling block after auth enforcement in updateOIDC endpoint.
The PUT /realms/{realm}/clients-registrations/openid-connect/{clientId} endpoint had three defects:
ID-type confusion: scope-handling used getClientById() (UUID lookup) instead of getClientByClientId() (client_id lookup), returning null.
Missing null-check: oldClient.getClientScopes(true) dereferenced null, throwing NullPointerException on any request with a scope field.
Auth ordering: scope-handling ran BEFORE auth.requireUpdate(), making the NPE reachable without authentication (HTTP 500 with stack traces).
Fixes:
Regression tests added for authenticated scope update and unauthenticated request handling.
Fixes #51311