Fix issue with access tokens claims not being imported using OIDC IDP Attribute Mappers (#21627)

Closes #9004


Co-authored-by: Armel Soro <armel@rm3l.org>
This commit is contained in:
Ricardo Martin 2023-08-02 09:36:50 +02:00 committed by GitHub
parent 78262b2b53
commit a8bca522c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 124 additions and 19 deletions

View file

@ -55,6 +55,7 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
private scopesInput = "#scopes";
private storeTokensSwitch = "#storeTokens";
private storedTokensReadable = "#storedTokensReadable";
private isAccessTokenJWT = "#isAccessTokenJWT";
private acceptsPromptNoneForwardFromClientSwitch = "#acceptsPromptNone";
private advancedSettingsToggle = ".pf-c-expandable-section__toggle";
private passLoginHintSwitch = "#passLoginHint";
@ -112,6 +113,11 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
return this;
}
public clickIsAccessTokenJWTSwitch() {
cy.get(this.isAccessTokenJWT).parent().click();
return this;
}
public clickAcceptsPromptNoneForwardFromClientSwitch() {
cy.get(this.acceptsPromptNoneForwardFromClientSwitch).parent().click();
return this;
@ -219,6 +225,11 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
return this;
}
public assertIsAccessTokenJWTTurnedOn(isOn: boolean) {
super.assertSwitchStateOn(cy.get(this.isAccessTokenJWT).parent(), isOn);
return this;
}
public assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(isOn: boolean) {
super.assertSwitchStateOn(
cy.get(this.acceptsPromptNoneForwardFromClientSwitch).parent(),
@ -394,6 +405,8 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
this.assertStoreTokensSwitchTurnedOn(true);
this.clickStoredTokensReadableSwitch();
this.assertStoredTokensReadableTurnedOn(true);
this.clickIsAccessTokenJWTSwitch();
this.assertIsAccessTokenJWTTurnedOn(true);
this.clickTrustEmailSwitch();
this.assertTrustEmailSwitchTurnedOn(true);
this.clickAccountLinkingOnlySwitch();
@ -406,8 +419,10 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
this.selectSyncModeOption(SyncModeOption.legacy);
this.clickRevertBtn();
cy.get(this.advancedSettingsToggle).scrollIntoView().click();
this.assertStoreTokensSwitchTurnedOn(false);
this.assertStoredTokensReadableTurnedOn(false);
this.assertIsAccessTokenJWTTurnedOn(false);
this.assertTrustEmailSwitchTurnedOn(false);
this.assertAccountLinkingOnlySwitchTurnedOn(false);
this.assertHideOnLoginPageSwitchTurnedOn(false);

View file

@ -15,6 +15,7 @@
"logoutUrl": "End session endpoint to use to logout user from external IDP.",
"backchannelLogout": "Does the external IDP support backchannel logout?",
"disableUserInfo": "Disable usage of User Info service to obtain additional user information? Default is to use this OIDC service.",
"isAccessTokenJWT": "The Access Token received from the Identity Provider is a JWT and its claims will be accessible for mappers.",
"userInfoUrl": "The User Info Url. This is optional.",
"issuer": "The issuer identifier for the issuer of the response. If not provided, no validation will be performed.",
"scopes": "The scopes to be sent when asking for authorization. It can be a space-separated list of scopes. Defaults to 'openid'.",

View file

@ -99,6 +99,7 @@
"logoutUrl": "Logout URL",
"backchannelLogout": "Backchannel logout",
"disableUserInfo": "Disable user info",
"isAccessTokenJWT": "Access Token is JWT",
"userInfoUrl": "User Info URL",
"issuer": "Issuer",
"scopes": "Scopes",

View file

@ -132,6 +132,9 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
<SwitchField field="config.disableUserInfo" label="disableUserInfo" />
</>
)}
{isOIDC && (
<SwitchField field="config.isAccessTokenJWT" label="isAccessTokenJWT" />
)}
<SwitchField field="trustEmail" label="trustEmail" fieldType="boolean" />
<SwitchField
field="linkOnly"

View file

@ -31,8 +31,6 @@ import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.adapters.action.AdminAction;
import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.services.ErrorResponseException;
@ -53,10 +51,9 @@ import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForM
*/
public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN";
public KeycloakOIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
super(session, config);
config.setAccessTokenJwt(true); // force access token JWT
}
@Override
@ -64,13 +61,6 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
return new KeycloakEndpoint(callback, realm, event, this);
}
@Override
protected void processAccessTokenResponse(BrokeredIdentityContext context, AccessTokenResponse response) {
// Don't verify audience on accessToken as it may not be there. It was verified on IDToken already
JsonWebToken access = validateToken(response.getToken(), true);
context.getContextData().put(VALIDATED_ACCESS_TOKEN, access);
}
protected static class KeycloakEndpoint extends OIDCEndpoint {
private KeycloakOIDCIdentityProvider provider;

View file

@ -97,6 +97,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public static final String VALIDATED_ID_TOKEN = "VALIDATED_ID_TOKEN";
public static final String ACCESS_TOKEN_EXPIRATION = "accessTokenExpiration";
public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER";
public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN";
private static final String BROKER_NONCE_PARAM = "BROKER_NONCE";
public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
@ -257,8 +258,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
protected void processAccessTokenResponse(BrokeredIdentityContext context, AccessTokenResponse response) {
// Don't verify audience on accessToken as it may not be there. It was verified on IDToken already
if (getConfig().isAccessTokenJwt()) {
JsonWebToken access = validateToken(response.getToken(), true);
context.getContextData().put(VALIDATED_ACCESS_TOKEN, access);
}
}
protected SimpleHttp getRefreshTokenRequest(KeycloakSession session, String refreshToken, String clientId, String clientSecret) {

View file

@ -18,13 +18,10 @@ package org.keycloak.broker.oidc;
import static org.keycloak.common.util.UriUtils.checkUrl;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import java.util.Arrays;
/**
* @author Pedro Igor
*/
@ -34,6 +31,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String USE_JWKS_URL = "useJwksUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
@ -87,6 +85,14 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
getConfig().put(VALIDATE_SIGNATURE, String.valueOf(validateSignature));
}
public void setAccessTokenJwt(boolean accessTokenJwt) {
getConfig().put(IS_ACCESS_TOKEN_JWT, String.valueOf(accessTokenJwt));
}
public boolean isAccessTokenJwt() {
return Boolean.parseBoolean(getConfig().get(IS_ACCESS_TOKEN_JWT));
}
public boolean isUseJwksUrl() {
return Boolean.valueOf(getConfig().get(USE_JWKS_URL));
}

View file

@ -78,7 +78,6 @@ public class GoogleIdentityProvider extends OIDCIdentityProvider implements Soci
return true;
}
@Override
public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER);

View file

@ -0,0 +1,29 @@
package org.keycloak.testsuite.broker;
import java.util.List;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
public class KcOidcAccessTokenOnlyClaimsUserAttributeMapperTest extends OidcUserAttributeMapperTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration() {
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> clientsRepList = super.createProviderClients();
clientsRepList.stream()
.flatMap(clientRepresentation -> clientRepresentation.getProtocolMappers().stream())
.map(ProtocolMapperRepresentation::getConfig)
.forEach(protocolMapperConfig -> {
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false");
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "false");
});
return clientsRepList;
}
};
}
}

View file

@ -73,10 +73,10 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
client.setSecret(CLIENT_SECRET);
client.setRedirectUris(Collections.singletonList(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*"));
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + getIDPAlias() + "/endpoint/*"));
client.setAdminUrl(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint");
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + getIDPAlias() + "/endpoint");
OIDCAdvancedConfigWrapper.fromClientRepresentation(client).setPostLogoutRedirectUris(Collections.singletonList("+"));

View file

@ -0,0 +1,57 @@
package org.keycloak.testsuite.broker;
import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider;
import java.util.List;
import java.util.Map;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
public class OidcAccessTokenOnlyClaimsUserAttributeMapperTest extends OidcUserAttributeMapperTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration() {
private static final String OIDC_IDP_ALIAS = "oidc-idp";
@Override
public IdentityProviderRepresentation setUpIdentityProvider(
IdentityProviderSyncMode syncMode) {
final IdentityProviderRepresentation idp = createIdentityProvider(OIDC_IDP_ALIAS,
OIDCIdentityProviderFactory.PROVIDER_ID);
final Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
config.put(OIDCIdentityProviderConfig.IS_ACCESS_TOKEN_JWT, "true");
return idp;
}
@Override
public String getIDPAlias() {
return OIDC_IDP_ALIAS;
}
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> clientsRepList = super.createProviderClients();
clientsRepList.stream()
.flatMap(clientRepresentation -> clientRepresentation.getProtocolMappers().stream())
.map(ProtocolMapperRepresentation::getConfig)
.forEach(protocolMapperConfig -> {
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false");
protocolMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "false");
});
return clientsRepList;
}
};
}
}