KEYCLOAK-18139 SecureResponseTypeExecutor: polishing for FAPI 1 final

This commit is contained in:
Takashi Norimatsu 2021-05-25 11:49:26 +09:00 committed by Marek Posolda
parent d4374f37ae
commit 6e7898039b
4 changed files with 216 additions and 11 deletions

View file

@ -17,29 +17,73 @@
package org.keycloak.services.clientpolicy.executor; package org.keycloak.services.clientpolicy.executor;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext; import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext;
import com.fasterxml.jackson.annotation.JsonProperty;
/** /**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a> * @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/ */
public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> { public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider<SecureResponseTypeExecutor.Configuration> {
private static final Logger logger = Logger.getLogger(SecureResponseTypeExecutor.class); private static final Logger logger = Logger.getLogger(SecureResponseTypeExecutor.class);
protected final KeycloakSession session; protected final KeycloakSession session;
private Configuration configuration;
public SecureResponseTypeExecutor(KeycloakSession session) { public SecureResponseTypeExecutor(KeycloakSession session) {
this.session = session; this.session = session;
} }
@Override
public void setupConfiguration(Configuration config) {
this.configuration = config;
}
@Override
public Class<Configuration> getExecutorConfigurationClass() {
return Configuration.class;
}
public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation {
@JsonProperty("auto-configure")
protected Boolean autoConfigure;
@JsonProperty("allow-token-response-type")
protected Boolean allowTokenResponseType;
public Boolean isAutoConfigure() {
return autoConfigure;
}
public void setAutoConfigure(Boolean autoConfigure) {
this.autoConfigure = autoConfigure;
}
public Boolean isAllowTokenResponseType() {
return allowTokenResponseType;
}
public void setAllowTokenResponseType(Boolean allowTokenResponseType) {
this.allowTokenResponseType = allowTokenResponseType;
}
}
@Override @Override
public String getProviderId() { public String getProviderId() {
return SecureResponseTypeExecutorFactory.PROVIDER_ID; return SecureResponseTypeExecutorFactory.PROVIDER_ID;
@ -48,6 +92,12 @@ public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider<
@Override @Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER:
case UPDATE:
ClientCRUDContext clientUpdateContext = (ClientCRUDContext)context;
autoConfigure(clientUpdateContext.getProposedClientRepresentation());
validate(clientUpdateContext.getProposedClientRepresentation());
break;
case AUTHORIZATION_REQUEST: case AUTHORIZATION_REQUEST:
AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context; AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context;
executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(), executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(),
@ -66,17 +116,51 @@ public class SecureResponseTypeExecutor implements ClientPolicyExecutorProvider<
String redirectUri) throws ClientPolicyException { String redirectUri) throws ClientPolicyException {
logger.trace("Authz Endpoint - authz request"); logger.trace("Authz Endpoint - authz request");
if (parsedResponseType.hasResponseType(OIDCResponseType.CODE) && parsedResponseType.hasResponseType(OIDCResponseType.ID_TOKEN)) { if (isHybridFlow(parsedResponseType)) {
if (parsedResponseType.hasResponseType(OIDCResponseType.TOKEN)) { if (parsedResponseType.hasResponseType(OIDCResponseType.TOKEN)) {
if (isAllowTokenResponseType()) {
logger.trace("Passed. response_type = code id_token token"); logger.trace("Passed. response_type = code id_token token");
return;
}
} else { } else {
logger.trace("Passed. response_type = code id_token"); logger.trace("Passed. response_type = code id_token");
}
return; return;
} }
}
logger.tracev("invalid response_type = {0}", parsedResponseType); logger.tracev("invalid response_type = {0}", parsedResponseType);
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid response_type"); throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid response_type");
} }
private boolean isHybridFlow(OIDCResponseType parsedResponseType) {
return parsedResponseType.hasResponseType(OIDCResponseType.CODE) && parsedResponseType.hasResponseType(OIDCResponseType.ID_TOKEN);
}
private boolean isAllowTokenResponseType() {
return configuration != null && Optional.ofNullable(configuration.isAllowTokenResponseType()).orElse(Boolean.FALSE).booleanValue();
}
private void autoConfigure(ClientRepresentation rep) {
if (isAutoConfigure()) {
Map<String, String> attributes = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>());
attributes.put(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE, Boolean.TRUE.toString());
rep.setAttributes(attributes);
}
}
private boolean isAutoConfigure() {
return configuration != null && Optional.ofNullable(configuration.isAutoConfigure()).orElse(Boolean.FALSE).booleanValue();
}
private void validate(ClientRepresentation rep) throws ClientPolicyException {
if (!isIdTokenAsDetachedSignature(rep)) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Invalid client metadata: ID Token as detached signature in disabled");
}
}
private boolean isIdTokenAsDetachedSignature(ClientRepresentation rep) {
if (rep.getAttributes() == null) return false;
return Boolean.valueOf(Optional.ofNullable(rep.getAttributes().get(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE)).orElse(Boolean.FALSE.toString()));
}
} }

View file

@ -17,7 +17,8 @@
package org.keycloak.services.clientpolicy.executor; package org.keycloak.services.clientpolicy.executor;
import java.util.Collections; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import org.keycloak.Config.Scope; import org.keycloak.Config.Scope;
@ -32,6 +33,14 @@ public class SecureResponseTypeExecutorFactory implements ClientPolicyExecutorPr
public static final String PROVIDER_ID = "secure-response-type"; public static final String PROVIDER_ID = "secure-response-type";
public static final String AUTO_CONFIGURE = "auto-configure";
public static final String ALLOW_TOKEN_RESPONSE_TYPE = "allow-token-response-type";
private static final ProviderConfigProperty AUTO_CONFIGURE_PROPERTY = new ProviderConfigProperty(
AUTO_CONFIGURE, "Auto-configure", "If On, then the during client creation or update, the configuration of the client will be auto-configured to use ID token returned from authorization endpoint as detached signature.", ProviderConfigProperty.BOOLEAN_TYPE, false);
private static final ProviderConfigProperty ALLOW_TOKEN_RESPONSE_TYPE_PROPERTY = new ProviderConfigProperty(
ALLOW_TOKEN_RESPONSE_TYPE, "Allow-token-response-type", "If On, then it allows an access token returned from authorization endpoint in hybrid flow.", ProviderConfigProperty.BOOLEAN_TYPE, false);
@Override @Override
public ClientPolicyExecutorProvider create(KeycloakSession session) { public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new SecureResponseTypeExecutor(session); return new SecureResponseTypeExecutor(session);
@ -56,12 +65,12 @@ public class SecureResponseTypeExecutorFactory implements ClientPolicyExecutorPr
@Override @Override
public String getHelpText() { public String getHelpText() {
return "The executor checks whether the client sent its authorization request with code id_token or code id_token token in its response type by following Financial-grade API Security Profile : Read and Write API Security Profile."; return "The executor checks whether the client sent its authorization request with code id_token or code id_token token in its response type depending on its setting.";
} }
@Override @Override
public List<ProviderConfigProperty> getConfigProperties() { public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList(); return new ArrayList<>(Arrays.asList(AUTO_CONFIGURE_PROPERTY, ALLOW_TOKEN_RESPONSE_TYPE_PROPERTY));
} }
} }

View file

@ -133,6 +133,7 @@ import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExec
import org.keycloak.services.clientpolicy.executor.SecureClientUrisExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureClientUrisExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor;
import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutor;
import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureSessionEnforceExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureSessionEnforceExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmExecutor;
@ -867,6 +868,13 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
return config; return config;
} }
protected SecureResponseTypeExecutor.Configuration createSecureResponseTypeExecutor(Boolean autoConfigure, Boolean allowTokenResponseType) {
SecureResponseTypeExecutor.Configuration config = new SecureResponseTypeExecutor.Configuration();
if (autoConfigure != null) config.setAutoConfigure(autoConfigure);
if (allowTokenResponseType != null) config.setAllowTokenResponseType(allowTokenResponseType);
return config;
}
protected SecureSigningAlgorithmForSignedJwtExecutor.Configuration createSecureSigningAlgorithmForSignedJwtEnforceExecutorConfig(Boolean requireClientAssertion) { protected SecureSigningAlgorithmForSignedJwtExecutor.Configuration createSecureSigningAlgorithmForSignedJwtEnforceExecutorConfig(Boolean requireClientAssertion) {
SecureSigningAlgorithmForSignedJwtExecutor.Configuration config = new SecureSigningAlgorithmForSignedJwtExecutor.Configuration(); SecureSigningAlgorithmForSignedJwtExecutor.Configuration config = new SecureSigningAlgorithmForSignedJwtExecutor.Configuration();
config.setRequireClientAssertion(requireClientAssertion); config.setRequireClientAssertion(requireClientAssertion);

View file

@ -65,6 +65,7 @@ import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
@ -978,8 +979,8 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR));
assertEquals("invalid response_type", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); assertEquals("invalid response_type", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION));
oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN + " " + OIDCResponseType.TOKEN); oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN);
oauth.nonce("cie8cjcwiw"); oauth.nonce("vbwe566fsfffds");
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
EventRepresentation loginEvent = events.expectLogin().client(clientId).assertEvent(); EventRepresentation loginEvent = events.expectLogin().client(clientId).assertEvent();
@ -993,8 +994,16 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
oauth.doLogout(res.getRefreshToken(), clientSecret); oauth.doLogout(res.getRefreshToken(), clientSecret);
events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent(); events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent();
oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN); // update profiles
oauth.nonce("vbwe566fsfffds"); json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "O Primeiro Perfil")
.addExecutor(SecureResponseTypeExecutorFactory.PROVIDER_ID, createSecureResponseTypeExecutor(Boolean.FALSE, Boolean.TRUE))
.toRepresentation()
).toString();
updateProfiles(json);
oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN + " " + OIDCResponseType.TOKEN); // token response type allowed
oauth.nonce("cie8cjcwiw");
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
loginEvent = events.expectLogin().client(clientId).assertEvent(); loginEvent = events.expectLogin().client(clientId).assertEvent();
@ -1009,6 +1018,101 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent(); events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent();
} }
@Test
public void testSecureResponseTypeExecutorAllowTokenResponseType() throws Exception {
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "O Primeiro Perfil")
.addExecutor(SecureResponseTypeExecutorFactory.PROVIDER_ID, createSecureResponseTypeExecutor(null, Boolean.TRUE))
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Den Forsta Policyn", Boolean.TRUE)
.addCondition(ClientUpdaterContextConditionFactory.PROVIDER_ID,
createClientUpdateContextConditionConfig(Arrays.asList(
ClientUpdaterContextConditionFactory.BY_AUTHENTICATED_USER,
ClientUpdaterContextConditionFactory.BY_INITIAL_ACCESS_TOKEN,
ClientUpdaterContextConditionFactory.BY_REGISTRATION_ACCESS_TOKEN)))
.addCondition(ClientRolesConditionFactory.PROVIDER_ID,
createClientRolesConditionConfig(Arrays.asList(SAMPLE_CLIENT_ROLE)))
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
// create by Admin REST API
try {
createClientByAdmin(generateSuffixedName("App-by-Admin"), (ClientRepresentation clientRep) -> {
clientRep.setSecret("secret");
});
fail();
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getMessage());
}
// update profiles
json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "O Primeiro Perfil")
.addExecutor(SecureResponseTypeExecutorFactory.PROVIDER_ID, createSecureResponseTypeExecutor(Boolean.TRUE, null))
.toRepresentation()
).toString();
updateProfiles(json);
String cId = null;
String clientId = generateSuffixedName(CLIENT_NAME);
String clientSecret = "secret";
try {
cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setSecret(clientSecret);
clientRep.setStandardFlowEnabled(Boolean.TRUE);
clientRep.setImplicitFlowEnabled(Boolean.TRUE);
clientRep.setPublicClient(Boolean.FALSE);
});
} catch (ClientPolicyException e) {
fail();
}
ClientRepresentation cRep = getClientByAdmin(cId);
assertEquals(Boolean.TRUE.toString(), cRep.getAttributes().get(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE));
adminClient.realm(REALM_NAME).clients().get(cId).roles().create(RoleBuilder.create().name(SAMPLE_CLIENT_ROLE).build());
oauth.clientId(clientId);
oauth.openLoginForm();
assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR));
assertEquals("invalid response_type", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION));
oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN);
oauth.nonce("LIVieviDie028f");
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
EventRepresentation loginEvent = events.expectLogin().client(clientId).assertEvent();
String sessionId = loginEvent.getSessionId();
String codeId = loginEvent.getDetails().get(Details.CODE_ID);
String code = new OAuthClient.AuthorizationEndpointResponse(oauth).getCode();
IDToken idToken = oauth.verifyIDToken(new OAuthClient.AuthorizationEndpointResponse(oauth).getIdToken());
// confirm ID token as detached signature does not include authenticated user's claims
Assert.assertNull(idToken.getEmailVerified());
Assert.assertNull(idToken.getName());
Assert.assertNull(idToken.getPreferredUsername());
Assert.assertNull(idToken.getGivenName());
Assert.assertNull(idToken.getFamilyName());
Assert.assertNull(idToken.getEmail());
assertEquals("LIVieviDie028f", idToken.getNonce());
// confirm an access token not returned
Assert.assertNull(new OAuthClient.AuthorizationEndpointResponse(oauth).getAccessToken());
OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret);
assertEquals(200, res.getStatusCode());
events.expectCodeToToken(codeId, sessionId).client(clientId).assertEvent();
oauth.doLogout(res.getRefreshToken(), clientSecret);
events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent();
}
@Test @Test
public void testSecureRequestObjectExecutor() throws Exception, URISyntaxException, IOException { public void testSecureRequestObjectExecutor() throws Exception, URISyntaxException, IOException {
Integer availablePeriod = Integer.valueOf(SecureRequestObjectExecutor.DEFAULT_AVAILABLE_PERIOD + 400); Integer availablePeriod = Integer.valueOf(SecureRequestObjectExecutor.DEFAULT_AVAILABLE_PERIOD + 400);