Unique issuer for identity providers

Closes #45747

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2026-01-29 17:20:51 +01:00 committed by Marek Posolda
parent 51b764b577
commit a8418b251d
9 changed files with 239 additions and 34 deletions

View file

@ -1,12 +1,13 @@
package org.keycloak.broker.jwtauthorizationgrant;
import org.keycloak.broker.oidc.IssuerValidation;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import static org.keycloak.broker.oidc.OIDCIdentityProviderConfig.JWKS_URL;
import static org.keycloak.common.util.UriUtils.checkUrl;
public class JWTAuthorizationGrantIdentityProviderConfig extends IdentityProviderModel implements JWTAuthorizationGrantConfig {
public class JWTAuthorizationGrantIdentityProviderConfig extends IdentityProviderModel implements JWTAuthorizationGrantConfig, IssuerValidation {
public JWTAuthorizationGrantIdentityProviderConfig() {
}
@ -17,7 +18,7 @@ public class JWTAuthorizationGrantIdentityProviderConfig extends IdentityProvide
@Override
public void validate(RealmModel realm) {
checkUrl(realm.getSslRequired(), getIssuer(), ISSUER);
checkUrl(realm.getSslRequired(), getJwksUrl(), JWKS_URL);
validateIssuer(realm);
}
}

View file

@ -1,20 +1,12 @@
package org.keycloak.broker.kubernetes;
import java.util.Objects;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.broker.oidc.IssuerValidation;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.util.Strings;
import org.keycloak.utils.KeycloakSessionUtil;
import static org.keycloak.common.util.UriUtils.checkUrl;
public class KubernetesIdentityProviderConfig extends IdentityProviderModel {
public static final String ISSUER = OIDCIdentityProviderConfig.ISSUER;
public class KubernetesIdentityProviderConfig extends IdentityProviderModel implements IssuerValidation {
public KubernetesIdentityProviderConfig() {
}
@ -48,19 +40,6 @@ public class KubernetesIdentityProviderConfig extends IdentityProviderModel {
@Override
public void validate(RealmModel realm) {
super.validate(realm);
String issuer = getIssuer();
if (Strings.isEmpty(issuer)) {
throw new IllegalArgumentException(ISSUER + " is required");
}
checkUrl(realm.getSslRequired(), issuer, ISSUER);
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
AlternativeLookupProvider lookupProvider = session.getProvider(AlternativeLookupProvider.class);
IdentityProviderModel existingIdp = lookupProvider.lookupIdentityProviderFromIssuer(session, getIssuer());
if (existingIdp != null && (getInternalId() == null || !Objects.equals(existingIdp.getInternalId(), getInternalId()))) {
throw new IllegalArgumentException("Issuer URL already used for IDP '" + existingIdp.getAlias() + "'");
}
validateIssuer(realm);
}
}

View file

@ -0,0 +1,41 @@
package org.keycloak.broker.oidc;
import java.util.Map;
import java.util.Objects;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.util.Strings;
import org.keycloak.utils.KeycloakSessionUtil;
import static org.keycloak.common.util.UriUtils.checkUrl;
import static org.keycloak.models.IdentityProviderModel.ISSUER;
public interface IssuerValidation {
Map<String, String> getConfig();
String getInternalId();
default void validateIssuer(RealmModel realm) {
String issuer = getConfig().get(ISSUER);
if (Strings.isEmpty(issuer)) {
throw new IllegalArgumentException("Issuer is required");
}
checkUrl(realm.getSslRequired(), issuer, "Issuer");
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
AlternativeLookupProvider lookupProvider = session.getProvider(AlternativeLookupProvider.class);
if (lookupProvider != null) {
IdentityProviderModel existingIdp = lookupProvider.lookupIdentityProviderFromIssuer(session, getConfig().get(ISSUER));
if (existingIdp != null && (getInternalId() == null || !Objects.equals(existingIdp.getInternalId(), getInternalId()))) {
throw new IllegalArgumentException("Issuer URL already used for IDP '" + existingIdp.getAlias() + "', Issuer must be unique if the idp supports JWT Authorization Grant or Federated Client Authentication");
}
}
}
}

View file

@ -26,7 +26,7 @@ import static org.keycloak.common.util.UriUtils.checkUrl;
/**
* @author Pedro Igor
*/
public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig implements JWTAuthorizationGrantConfig {
public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig implements JWTAuthorizationGrantConfig, IssuerValidation {
public static final String JWKS_URL = "jwksUrl";
@ -181,5 +181,8 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp
throw new IllegalArgumentException(String.format("The 'Validating public key' is required when '%s' enabled and 'Use JWKS URL' disabled", optionText));
}
}
if (isJWTAuthorizationGrantEnabled() || isSupportsClientAssertions()) {
validateIssuer(realm);
}
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.social.google;
import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig;
import org.keycloak.broker.oidc.IssuerValidation;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
@ -24,7 +25,7 @@ import org.keycloak.models.RealmModel;
/**
* @author Vlastimil Elias (velias at redhat dot com)
*/
public class GoogleIdentityProviderConfig extends OIDCIdentityProviderConfig implements JWTAuthorizationGrantConfig {
public class GoogleIdentityProviderConfig extends OIDCIdentityProviderConfig implements JWTAuthorizationGrantConfig, IssuerValidation {
public GoogleIdentityProviderConfig(IdentityProviderModel model) {
super(model);
@ -72,5 +73,8 @@ public class GoogleIdentityProviderConfig extends OIDCIdentityProviderConfig imp
if (!GoogleIdentityProvider.ISSUER_URL.equals(getConfig().get(ISSUER))) {
throw new IllegalArgumentException("The issuer url [" + getConfig().get(ISSUER) + "] is invalid");
}
if (isJWTAuthorizationGrantEnabled()) {
validateIssuer(realm);
}
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.identityprovider;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory;
import org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.social.google.GoogleIdentityProviderFactory;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@KeycloakIntegrationTest(config = IdentityProviderIssuerTest.TestServerConfig.class)
public class IdentityProviderIssuerTest extends AbstractIdentityProviderTest {
@Test
public void testCreateUpdateDuplicateIdentityProvider() {
String issuer = "http://localhost:8080";
// JWTAuthorizationGrant idp - JWTAuthorizationGrant idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID, JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID, issuer);
// Kubernetes idp - Kubernetes idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(KubernetesIdentityProviderFactory.PROVIDER_ID, KubernetesIdentityProviderFactory.PROVIDER_ID, issuer);
// JWTAuthorizationGrant idp - Kubernetes idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID, KubernetesIdentityProviderFactory.PROVIDER_ID, issuer);
// JWTAuthorizationGrant idp - OIDC idp: allowed
testCreateIdentityProviderDuplicateAllowed(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, false, false);
// JWTAuthorizationGrant idp - OIDC idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, true, false);
// Kubernetes idp - OIDC idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(KubernetesIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, false, true);
// OIDC idp - OIDC idp: allowed
testCreateIdentityProviderDuplicateAllowed(OIDCIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, false, false);
// OIDC idp - OIDC idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(OIDCIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, true, false);
testCreateIdentityProviderDuplicateNotAllowed(OIDCIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, issuer, false, true);
// Google idp - Google idp: allowed
testCreateIdentityProviderDuplicateAllowed(GoogleIdentityProviderFactory.PROVIDER_ID, GoogleIdentityProviderFactory.PROVIDER_ID, null, false, false);
// Google idp - Google idp: allowed
testCreateIdentityProviderDuplicateAllowed(GoogleIdentityProviderFactory.PROVIDER_ID, GoogleIdentityProviderFactory.PROVIDER_ID, null, false, true);
// Google idp - Google idp: not allowed
testCreateIdentityProviderDuplicateNotAllowed(GoogleIdentityProviderFactory.PROVIDER_ID, GoogleIdentityProviderFactory.PROVIDER_ID, null, true, false);
}
public void testCreateIdentityProviderDuplicateNotAllowed(String providerId1, String providerId2, String issuer) {
testCreateIdentityProviderDuplicateAllowed(providerId1, providerId2, issuer, false, false, false);
}
public void testCreateIdentityProviderDuplicateNotAllowed(String providerId1, String providerId2, String issuer, boolean JWTAuthorizationGrantEnabled, boolean federatedAuthenticationEnabled) {
testCreateIdentityProviderDuplicateAllowed(providerId1, providerId2, issuer, JWTAuthorizationGrantEnabled, federatedAuthenticationEnabled, false);
}
public void testCreateIdentityProviderDuplicateAllowed(String providerId1, String providerId2, String issuer, boolean JWTAuthorizationGrantEnabled, boolean federatedAuthenticationEnabled) {
testCreateIdentityProviderDuplicateAllowed(providerId1, providerId2, issuer, JWTAuthorizationGrantEnabled, federatedAuthenticationEnabled, true);
}
public void testCreateIdentityProviderDuplicateAllowed(String providerId1, String providerId2, String issuer, boolean JWTAuthorizationGrantEnabled, boolean federatedAuthenticationEnabled, boolean allowDuplicate) {
String idp1 = "idp1";
String idp2 = "idp2";
IdentityProviderRepresentation identityProvider1 = createRep(idp1, providerId1, issuer, JWTAuthorizationGrantEnabled, federatedAuthenticationEnabled);
IdentityProviderRepresentation identityProvider2 = createRep(idp2, providerId2, issuer, JWTAuthorizationGrantEnabled, federatedAuthenticationEnabled);
try (Response response = managedRealm.admin().identityProviders().create(identityProvider1)) {
Assertions.assertEquals(201, response.getStatus());
}
managedRealm.cleanup().add(r -> r.identityProviders().get(idp1).remove());
Response response = managedRealm.admin().identityProviders().create(identityProvider2);
if (allowDuplicate) {
Assertions.assertEquals(201, response.getStatus());
managedRealm.cleanup().add(r -> r.identityProviders().get(idp2).remove());
} else {
Assertions.assertEquals(400, response.getStatus());
ErrorRepresentation error = response.readEntity(ErrorRepresentation.class);
assertEquals("Issuer URL already used for IDP '" + idp1 + "', Issuer must be unique if the idp supports JWT Authorization Grant or Federated Client Authentication", error.getErrorMessage());
//create with different issuer only if issuer is present
if (issuer != null) {
identityProvider2.getConfig().put("issuer", "https://localhost2");
response = managedRealm.admin().identityProviders().create(identityProvider2);
Assertions.assertEquals(201, response.getStatus());
managedRealm.cleanup().add(r -> r.identityProviders().get(idp2).remove());
IdentityProviderResource idpResource = managedRealm.admin().identityProviders().get(idp2);
identityProvider2 = idpResource.toRepresentation();
identityProvider2.getConfig().put("issuer", issuer);
try {
idpResource.update(identityProvider2);
Assertions.fail("Duplicate issuer URL not detected");
} catch (WebApplicationException ex) {
Assertions.assertEquals(400, ex.getResponse().getStatus());
error = ex.getResponse().readEntity(ErrorRepresentation.class);
assertEquals("Issuer URL already used for IDP '" + idp1 + "', Issuer must be unique if the idp supports JWT Authorization Grant or Federated Client Authentication", error.getErrorMessage());
}
}
}
managedRealm.runCleanup();
}
public IdentityProviderRepresentation createRep(String alias, String providerId, String issuer, boolean JWTAuthorizationGrantEnabled, boolean federatedAuthenticationEnabled) {
IdentityProviderRepresentation identityProvider = createRep(alias, providerId);
// Use the passed issuer if not null, otherwise default to localhost if not Google/Social
if (issuer != null) {
identityProvider.getConfig().put("issuer", issuer);
} else if (!providerId.equals(GoogleIdentityProviderFactory.PROVIDER_ID)) {
// Default for generic tests if null passed
identityProvider.getConfig().put("issuer", issuer);
}
identityProvider.getConfig().put("jwtAuthorizationGrantEnabled", String.valueOf(JWTAuthorizationGrantEnabled));
identityProvider.getConfig().put("supportsClientAssertions", String.valueOf(federatedAuthenticationEnabled));
identityProvider.getConfig().put("useJwksUrl", "true");
identityProvider.getConfig().put("jwksUrl", issuer);
return identityProvider;
}
public static class TestServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.KUBERNETES_SERVICE_ACCOUNTS, Profile.Feature.JWT_AUTHORIZATION_GRANT);
}
}
}

View file

@ -51,7 +51,7 @@ public class IdentityProviderKubernetesTest extends AbstractIdentityProviderTest
try (Response response = managedRealm.admin().identityProviders().create(identityProvider)) {
Assertions.assertEquals(400, response.getStatus());
ErrorRepresentation error = response.readEntity(ErrorRepresentation.class);
assertEquals("Issuer URL already used for IDP 'kubernetes1'", error.getErrorMessage());
assertEquals("Issuer URL already used for IDP 'kubernetes1', Issuer must be unique if the idp supports JWT Authorization Grant or Federated Client Authentication", error.getErrorMessage());
}
}
@ -84,7 +84,7 @@ public class IdentityProviderKubernetesTest extends AbstractIdentityProviderTest
} catch (WebApplicationException ex) {
Assertions.assertEquals(400, ex.getResponse().getStatus());
ErrorRepresentation error = ex.getResponse().readEntity(ErrorRepresentation.class);
assertEquals("Issuer URL already used for IDP 'kubernetes1'", error.getErrorMessage());
assertEquals("Issuer URL already used for IDP 'kubernetes1', Issuer must be unique if the idp supports JWT Authorization Grant or Federated Client Authentication", error.getErrorMessage());
}
}

View file

@ -494,6 +494,7 @@ public class IdentityProviderOidcTest extends AbstractIdentityProviderTest {
// Successful update when JWKS URL set
oidcConfig.setJwksUrl("https://foo");
oidcConfig.setIssuer("https://foo");
resource.update(representation);
managedRealm.cleanup().add(r -> r.identityProviders().get(id).remove());

View file

@ -45,8 +45,8 @@ public class FederatedClientAuthConflictsTest {
@Test
public void testDuplicatedIssuers() {
createIdp("idp1", "http://127.0.0.1:8500");
createIdp("idp2", "http://127.0.0.1:8500");
createIdp("idp1", "http://127.0.0.1:8500", false);
createIdp("idp2", "http://127.0.0.1:8500", false);
createClient("myclient", "external1", "idp1");
@ -67,7 +67,7 @@ public class FederatedClientAuthConflictsTest {
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("myclient", events.poll().getClientId());
createIdp("idp2", "http://127.0.0.1:8500");
IdentityProviderRepresentation idp2 = createIdp("idp2", "http://127.0.0.1:8500", false);
clientRep.getAttributes().put(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, "idp2");
realm.admin().clients().get(clientRep.getId()).update(clientRep);
@ -79,8 +79,12 @@ public class FederatedClientAuthConflictsTest {
// Update old entry, so next read will invalidate it
idp1.getConfig().put(IdentityProviderModel.ISSUER, "http://127.0.0.1:8501");
idp1.getConfig().put(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "false");
realm.admin().identityProviders().get(idp1.getAlias()).update(idp1);
idp2.getConfig().put(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true");
realm.admin().identityProviders().get(idp2.getAlias()).update(idp2);
// Should succeed as entry is updated in the cache
events.clear();
response = oAuthClient.clientCredentialsGrantRequest().clientJwt(createDefaultToken("external1", "http://127.0.0.1:8500")).send();
@ -116,11 +120,15 @@ public class FederatedClientAuthConflictsTest {
}
private IdentityProviderRepresentation createIdp(String alias, String issuer) {
return createIdp(alias, issuer, true);
}
private IdentityProviderRepresentation createIdp(String alias, String issuer, boolean supportsClientAssertions) {
IdentityProviderRepresentation rep = IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(alias)
.setAttribute(IdentityProviderModel.ISSUER, issuer)
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true")
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, String.valueOf(supportsClientAssertions))
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")