8000 Remove ability to set up multivalued issuer for OIDC providers by vramik · Pull Request #54682 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ In the case of JWT signed with private key or Client secret as jwt, it is requir

|Issuer
|{project_name} validates issuer claims, in responses from the IDP, against this value.
Only a single issuer is supported. Configure a separate identity provider for each additional issuer.

|Default Scopes
|A list of OIDC scopes {project_name} sends with the authentication request. The default value is `openid`. A space separates each scope.
Expand Down
20 changes: 20 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ General validation also preserves the legacy web origin literals `${authAdminUrl
Trusted Hosts resolves relative logo URLs against the {project_name} frontend URL and checks that host against its allowlist. The client's root URL does not determine where the browser loads a logo.
The pattern executor continues to match literal configured values, including markers and relative URLs, and respects an explicit field selection. Sector identifier URLs in pairwise protocol mappers remain subject to their separate validation.

=== OIDC identity providers accept a single issuer
// https://github.com/keycloak/keycloak/issues/54458

Identity providers based on the OpenID Connect provider configuration no longer accept comma-separated lists in the `Issuer` setting.
This includes the generic OpenID Connect and Keycloak OpenID Connect providers, social providers using that configuration, and custom provider subclasses.
This affects broker login and legacy external-to-internal token exchange.
Each identity provider must trust a single issuer, because federated user links identify users by provider alias and subject, without recording the issuer.
Previously, matching subject identifiers from different trusted issuers could resolve to the same local user.

During a database upgrade, registered providers whose configurations require a single issuer are discovered through their factories.
Those with issuer lists retain the first nonempty issuer, with surrounding whitespace removed, and are disabled.
The same migration runs when importing realm JSON exported by a version before 27.0.0, including exports with no version specified.
A warning identifies each affected provider and its original issuer list.
New configurations and imports exported by 27.0.0 or later reject issuer lists.

Before enabling a migrated provider, verify the selected issuer and review its existing federated user links.
The original issuer cannot be recovered from those links, so {project_name} cannot automatically assign them to separate providers.
Configure a separate provider alias for every additional issuer and establish links to the intended local users after verifying account ownership.
Update applications that select a provider alias, including callers of legacy external-to-internal token exchange, and grant the appropriate exchange permissions to the new providers.

=== Authorization endpoint JSON errors
// https://github.com/keycloak/keycloak/issues/54134

Expand Down
1 change: 1 addition & 0 deletions docs/guides/securing-apps/token-exchange.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ The `subject_token_type` must either be `urn:ietf:params:oauth:token-type:access
If the type is `urn:ietf:params:oauth:token-type:access_token` you specify the `subject_issuer` parameter and it must be the
alias of the configured identity provider. If the type is `urn:ietf:params:oauth:token-type:jwt`, the provider will be matched via
the `iss` (issuer) claim within the JWT which must be the alias of the provider, or a registered issuer within the provider's configuration.
Each OIDC identity provider accepts a single configured issuer. To exchange tokens from multiple issuers, configure a separate identity provider for each issuer.

For validation, if the token is an access token, the provider's user info service will be invoked to validate the token. A successful call
will mean that the access token is valid. If the subject token is a JWT and if the provider has signature validation enabled, that will be attempted,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2026 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.connections.jpa.updater.liquibase.custom;

import java.sql.PreparedStatement;
import java.sql.ResultSet;

import org.keycloak.migration.migrators.OidcIssuerMigration;

import liquibase.exception.CustomChangeException;
import liquibase.statement.core.UpdateStatemen 8000 t;
import liquibase.structure.core.Table;
import org.jboss.logging.Logger;

public class JpaUpdate27_0_0_OidcIssuerMigration extends CustomKeycloakTask {

private static final Logger LOG = Logger.getLogger(JpaUpdate27_0_0_OidcIssuerMigration.class);

@Override
protected void generateStatementsImpl() throws CustomChangeException {
String sql = "SELECT p.INTERNAL_ID, p.PROVIDER_ID, p.PROVIDER_ALIAS, p.REALM_ID, c.VALUE FROM "
+ getTableName("IDENTITY_PROVIDER") + " p JOIN " + getTableName("IDENTITY_PROVIDER_CONFIG")
+ " c ON c.IDENTITY_PROVIDER_ID = p.INTERNAL_ID WHERE c.NAME = 'issuer' AND c.VALUE LIKE '%,%'";
try (PreparedStatement ps = connection.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String id = rs.getString(1);
String providerId = rs.getString(2);
String alias = rs.getString(3);
String realmId = rs.getString(4);
String issuers = rs.getString(5);
if (!OidcIssuerMigration.isMultiIssuerProvider(kcSession, providerId, issuers)) {
if (OidcIssuerMigration.getFactory(kcSession, providerId) == null) {
LOG.warnf("Identity provider '%s' in realm '%s' configures multiple issuers (%s), but its provider "
+ "'%s' is not installed, so it cannot be migrated. If you install that provider later, "
+ "replace the issuer list with a single issuer and configure a separate identity provider "
+ "for each additional issuer.", alias, realmId, issuers, providerId);
}
continue;
}
String first = OidcIssuerMigration.firstIssuer(issuers);
if (first != null) {
statements.add(new UpdateStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER_CONFIG", Table.class))
Comment thread
pedroigor marked this conversation as resolved.
Resolved
.addNewColumnValue("VALUE", first)
.setWhereClause("IDENTITY_PROVIDER_ID=? AND NAME=?").addWhereParameter(id).addWhereParameter("issuer"));
LOG.warnf("Disabling identity provider '%s' in realm '%s' because it configured multiple issuers (%s). "
+ "The same subject from different issuers could authenticate as the wrong user. Only the first issuer will be retained. "
+ "Review existing federated links before enabling the provider. Configure a separate identity provider for each additional issuer; "
+ "existing user links cannot be split automatically.",
alias, realmId, issuers);
} else {
LOG.warnf("Disabling identity provider '%s' in realm '%s' because its issuer value (%s) "
+ "contains commas but no valid issuer. Correct the issuer before enabling the provider.",
alias, realmId, issuers);
Comment thread
pedroigor marked this conversation as resolved.
Resolved
}
statements.add(new UpdateStatement(null, null, database.correctObjectName("IDENTITY_PROVIDER", Table.class))
.addNewColumnValue("ENABLED", false).setWhereClause("INTERNAL_ID=?").addWhereParameter(id));
}
} catch (Exception e) {
throw new CustomChangeException(getTaskId() + ": Failed to migrate OIDC issuer lists", e);
}
confirmationMessage.append("Migrated OIDC issuer lists and disabled affected identity providers");
}

@Override
protected String getTaskId() {
return "27.0.0-54458-oidc-single-issuer";
}
}
20 changes: 20 additions & 0 deletions model/jpa/src/main/resources/META-INF/jpa-changelog-27.0.0.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ Copyright 2026 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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

<changeSet author="keycloak" id="27.0.0-43241-login-themes">
<customChange class="org.keycloak.connections.jpa.updater.liquibase.custom.JpaUpdate27_0_0_LoginThemes"/>
</changeSet>

<changeSet author="keycloak" id="27.0.0-54458-oidc-single-issuer">
<customChange class="org.keycloak.connections.jpa.updater.liquibase.custom.JpaUpdate27_0_0_OidcIssuerMigration"/>
</changeSet>

</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.migration.migrators;

import org.keycloak.migration.ModelVersion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.keycloak.migration.ModelVersion;
import org.keycloak.migration.migrators.MigrateTo8_0_0;
import org.keycloak.migration.migrators.MigrationUtils;
import org.keycloak.migration.migrators.OidcIssuerMigration;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
Expand Down Expand Up @@ -216,6 +217,10 @@ public void importRealm(RealmRepresentation rep, RealmModel newRealm, Runnable u
ModelVersion version = DefaultMigrationManager.getModelVersionFromRep(rep);

convertDeprecatedSocialProviders(rep);
// Migrate before identity provider configurations are validated during import.
if (version == null || version.lessThan(OidcIssuerMigration.VERSION)) {
OidcIssuerMigration.migrateImport(session, rep);
Comment thread
pedroigor marked this conversation as resolved.
Resolved
}
convertDeprecatedApplications(session, rep);
convertDeprecatedClientTemplates(rep);

Expand Down Expand Up @@ -650,7 +655,11 @@ private static Map<String, ClientScopeModel> createClientScopes(KeycloakSession
private static void importIdentityProviders(RealmRepresentation rep, RealmModel newRealm, KeycloakSession session) {
if (rep.getIdentityProviders() != null) {
for (IdentityProviderRepresentation representation : rep.getIdentityProviders()) {
session.identityProviders().create(RepresentationToModel.toModel(newRealm, representation, session));
try {
session.identityProviders().create(RepresentationToModel.toModel(newRealm, representation, session));
} catch (IllegalArgumentException e) {
throw new ModelValidationException("Invalid configuration for identity provider '" + representation.getAlias() + "': " + e.getMessage());
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2026 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.broker.provider;

/**
* Identifies identity provider configurations that accept a single issuer. Config subclasses inherit
* this capability so migrations can discover affected providers through their factories.
*/
public interface SingleIssuerIdentityProviderConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2026 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.migration.migrators;

import java.util.Arrays;

import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.IdentityProviderFactory;
import org.keycloak.broker.provider.SingleIssuerIdentityProviderConfig;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.idm.RealmRepresentation;

import org.jboss.logging.Logger;

/**
* Migration of legacy issuer lists for providers with single-issuer configurations. Federated links do not record an issuer,
* so a migrated provider must be reviewed before it can safely be enabled again.
* <p>
* This intentionally does not implement {@link Migration} and is not registered in {@code DefaultMigrationManager}.
* Identity providers are validated eagerly while a realm representation is imported (see
* {@code OIDCIdentityProviderConfig#validate}), before the standard post-import {@code migrateImport} hooks ever run,
* so a multi-issuer configuration would fail validation before any registered migrator got a chance to fix it.
* Instead, {@link #migrateImport(KeycloakSession, RealmRepresentation)} is called directly against the representation
* before identity providers are created.
*/
public final class OidcIssuerMigration {

public static final ModelVersion VERSION = new ModelVersion("27.0.0");
private static final Logger LOG = Logger.getLogger(OidcIssuerMigration.class);

private OidcIssuerMigration() {
}

/**
* Resolves the factory for the given provider id, or {@code null} when that provider is not installed.
*/
public static IdentityProviderFactory<?> getFactory(KeycloakSession session, String providerId) {
IdentityProviderFactory<?> factory = (IdentityProviderFactory<?>) session.getKeycloakSessionFactory()
.getProviderFactory(IdentityProvider.class, providerId);
if (factory == null) {
factory = (IdentityProviderFactory<?>) session.getKeycloakSessionFactory()
.getProviderFactory(SocialIdentityProvider.class, providerId);
}
return factory;
}

public static boolean isMultiIssuerProvider(KeycloakSession session, String providerId, String issuer) {
if (issuer == null || !issuer.contains(",")) {
return false;
}
IdentityProviderFactory<?> factory = getFactory(session, providerId);
return factory != null && factory.createConfig() instanceof SingleIssuerIdentityProviderConfig;
}

public static String firstIssuer(String issuers) {
return Arrays.stream(issuers.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.findFirst()
.orElse(null);
}

public static void migrateImport(KeycloakSession session, RealmRepresentation realm) {
if (realm.getIdentityProviders() == null) {
return;
}
realm.getIdentityProviders().forEach(idp -> {
if (idp.getConfig() == null) {
return;
}
String issuers = idp.getConfig().get(IdentityProviderModel.ISSUER);
if (isMultiIssuerProvider(session, idp.getProviderId(), issuers)) {
String first = firstIssuer(issuers);
if (first != null) {
idp.getConfig().put(IdentityProviderModel.ISSUER, first);
LOG.warnf("Disabled identity provider '%s' in imported realm '%s' because it configured multiple issuers (%s). "
+ "The same subject from different issuers could authenticate as the wrong user. Only the first issuer was retained. "
+ "Review existing federated links before enabling the provider. Configure a separate identity provider for each additional issuer; "
+ "existing user links cannot be split automatically.",
idp.getAlias(), realm.getRealm(), issuers);
} else {
Comment thread
pedroigor marked this conversation as resolved.
Resolved
LOG.warnf("Disabled identity provider '%s' in imported realm '%s' because its issuer value (%s) "
+ "contains commas but no valid issuer. Correct the issuer before enabling the provider.",
idp.getAlias(), realm.getRealm(), issuers);
}
idp.setEnabled(false);
}
});
}
}
Loading
Loading
0