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

View file

@ -15,6 +15,7 @@
"logoutUrl": "End session endpoint to use to logout user from external IDP.", "logoutUrl": "End session endpoint to use to logout user from external IDP.",
"backchannelLogout": "Does the external IDP support backchannel logout?", "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.", "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.", "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.", "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'.", "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", "logoutUrl": "Logout URL",
"backchannelLogout": "Backchannel logout", "backchannelLogout": "Backchannel logout",
"disableUserInfo": "Disable user info", "disableUserInfo": "Disable user info",
"isAccessTokenJWT": "Access Token is JWT",
"userInfoUrl": "User Info URL", "userInfoUrl": "User Info URL",
"issuer": "Issuer", "issuer": "Issuer",
"scopes": "Scopes", "scopes": "Scopes",

View file

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

View file

@ -31,8 +31,6 @@ import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel; 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.AdminAction;
import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ErrorResponseException;
@ -53,10 +51,9 @@ import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForM
*/ */
public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider { public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN";
public KeycloakOIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { public KeycloakOIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
super(session, config); super(session, config);
config.setAccessTokenJwt(true); // force access token JWT
} }
@Override @Override
@ -64,13 +61,6 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
return new KeycloakEndpoint(callback, realm, event, this); 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 { protected static class KeycloakEndpoint extends OIDCEndpoint {
private KeycloakOIDCIdentityProvider provider; 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 VALIDATED_ID_TOKEN = "VALIDATED_ID_TOKEN";
public static final String ACCESS_TOKEN_EXPIRATION = "accessTokenExpiration"; public static final String ACCESS_TOKEN_EXPIRATION = "accessTokenExpiration";
public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER"; 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"; private static final String BROKER_NONCE_PARAM = "BROKER_NONCE";
public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
@ -257,8 +258,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
} }
protected void processAccessTokenResponse(BrokeredIdentityContext context, AccessTokenResponse response) { 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) { 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 static org.keycloak.common.util.UriUtils.checkUrl;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.enums.SslRequired; import org.keycloak.common.enums.SslRequired;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import java.util.Arrays;
/** /**
* @author Pedro Igor * @author Pedro Igor
*/ */
@ -34,6 +31,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String USE_JWKS_URL = "useJwksUrl"; public static final String USE_JWKS_URL = "useJwksUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature"; public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) { public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel); super(identityProviderModel);
@ -87,6 +85,14 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
getConfig().put(VALIDATE_SIGNATURE, String.valueOf(validateSignature)); 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() { public boolean isUseJwksUrl() {
return Boolean.valueOf(getConfig().get(USE_JWKS_URL)); return Boolean.valueOf(getConfig().get(USE_JWKS_URL));
} }

View file

@ -78,7 +78,6 @@ public class GoogleIdentityProvider extends OIDCIdentityProvider implements Soci
return true; return true;
} }
@Override @Override
public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) { public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); 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.setSecret(CLIENT_SECRET);
client.setRedirectUris(Collections.singletonList(getConsumerRoot() + 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() + 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("+")); 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;
}
};
}
}