[KEYCLOAK-9809] Support private_key_jwt authentication for external IdP

This commit is contained in:
madgaet 2019-09-11 00:08:34 +02:00 committed by Marek Posolda
parent 69359eab23
commit c35718cb87
7 changed files with 261 additions and 26 deletions

View file

@ -30,10 +30,17 @@ import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken;
import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.AsymmetricSignatureProvider;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.MacSignatureSignerContext;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -41,14 +48,18 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.vault.VaultStringSecret; import org.keycloak.vault.VaultStringSecret;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
@ -127,20 +138,20 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
} }
protected String extractTokenFromResponse(String response, String tokenName) { protected String extractTokenFromResponse(String response, String tokenName) {
if(response == null) if(response == null)
return null; return null;
if (response.startsWith("{")) { if (response.startsWith("{")) {
try { try {
JsonNode node = mapper.readTree(response); JsonNode node = mapper.readTree(response);
if(node.has(tokenName)){ if(node.has(tokenName)){
String s = node.get(tokenName).textValue(); String s = node.get(tokenName).textValue();
if(s == null || s.trim().isEmpty()) if(s == null || s.trim().isEmpty())
return null; return null;
return s; return s;
} else { } else {
return null; return null;
} }
} catch (IOException e) { } catch (IOException e) {
throw new IdentityBrokerException("Could not extract token [" + tokenName + "] from response [" + response + "] due: " + e.getMessage(), e); throw new IdentityBrokerException("Could not extract token [" + tokenName + "] from response [" + response + "] due: " + e.getMessage(), e);
} }
@ -356,11 +367,11 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
*/ */
public String getJsonProperty(JsonNode jsonNode, String name) { public String getJsonProperty(JsonNode jsonNode, String name) {
if (jsonNode.has(name) && !jsonNode.get(name).isNull()) { if (jsonNode.has(name) && !jsonNode.get(name).isNull()) {
String s = jsonNode.get(name).asText(); String s = jsonNode.get(name).asText();
if(s != null && !s.isEmpty()) if(s != null && !s.isEmpty())
return s; return s;
else else
return null; return null;
} }
return null; return null;
@ -443,15 +454,50 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
} }
public SimpleHttp generateTokenRequest(String authorizationCode) { public SimpleHttp generateTokenRequest(String authorizationCode) {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) { SimpleHttp tokenRequest = SimpleHttp.doPost(getConfig().getTokenUrl(), session)
return SimpleHttp.doPost(getConfig().getTokenUrl(), session) .param(OAUTH2_PARAMETER_CODE, authorizationCode)
.param(OAUTH2_PARAMETER_CODE, authorizationCode) .param(OAUTH2_PARAMETER_REDIRECT_URI, session.getContext().getUri().getAbsolutePath().toString())
.param(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId()) .param(OAUTH2_PARAMETER_GRANT_TYPE, OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE);
.param(OAUTH2_PARAMETER_CLIENT_SECRET, vaultStringSecret.get().orElse(getConfig().getClientSecret())) if (getConfig().isJWTAuthentication()) {
.param(OAUTH2_PARAMETER_REDIRECT_URI, session.getContext().getUri().getAbsolutePath().toString()) String jws = new JWSBuilder().type(OAuth2Constants.JWT).jsonContent(generateToken()).sign(getSignatureContext());
.param(OAUTH2_PARAMETER_GRANT_TYPE, OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE); return tokenRequest
.param(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)
.param(OAuth2Constants.CLIENT_ASSERTION, jws);
} else {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) {
return tokenRequest
.param(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId())
.param(OAUTH2_PARAMETER_CLIENT_SECRET, vaultStringSecret.get().orElse(getConfig().getClientSecret()));
}
} }
} }
protected JsonWebToken generateToken() {
JsonWebToken jwt = new JsonWebToken();
jwt.id(KeycloakModelUtils.generateId());
jwt.type(OAuth2Constants.JWT);
jwt.issuer(getConfig().getClientId());
jwt.subject(getConfig().getClientId());
jwt.audience(getConfig().getTokenUrl());
jwt.issuedNow();
jwt.expiration(Time.currentTime() + realm.getAccessCodeLifespan());
return jwt;
}
protected SignatureSignerContext getSignatureContext() {
if (getConfig().getClientAuthMethod().equals(OIDCLoginProtocol.CLIENT_SECRET_JWT)) {
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) {
KeyWrapper key = new KeyWrapper();
key.setAlgorithm(Algorithm.HS256);
byte[] decodedSecret = vaultStringSecret.get().orElse(getConfig().getClientSecret()).getBytes();
SecretKey secret = new SecretKeySpec(decodedSecret, 0, decodedSecret.length, Algorithm.HS256);
key.setSecretKey(secret);
return new MacSignatureSignerContext(key);
}
}
return new AsymmetricSignatureProvider(session, Algorithm.RS256).signer();
}
} }
protected String getProfileEndpointForValidation(EventBuilder event) { protected String getProfileEndpointForValidation(EventBuilder event) {

View file

@ -17,6 +17,7 @@
package org.keycloak.broker.oidc; package org.keycloak.broker.oidc;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
/** /**
* @author Pedro Igor * @author Pedro Igor
@ -59,6 +60,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
getConfig().put("clientId", clientId); getConfig().put("clientId", clientId);
} }
public String getClientAuthMethod() {
return getConfig().getOrDefault("clientAuthMethod", OIDCLoginProtocol.CLIENT_SECRET_POST);
}
public void setClientAuthMethod(String clientAuth) {
getConfig().put("clientAuthMethod", clientAuth);
}
public String getClientSecret() { public String getClientSecret() {
return getConfig().get("clientSecret"); return getConfig().get("clientSecret");
} }
@ -82,6 +91,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public void setLoginHint(boolean loginHint) { public void setLoginHint(boolean loginHint) {
getConfig().put("loginHint", String.valueOf(loginHint)); getConfig().put("loginHint", String.valueOf(loginHint));
} }
public boolean isJWTAuthentication() {
if (getClientAuthMethod().equals(OIDCLoginProtocol.CLIENT_SECRET_JWT)
|| getClientAuthMethod().equals(OIDCLoginProtocol.PRIVATE_KEY_JWT)) {
return true;
}
return false;
}
public boolean isUiLocales() { public boolean isUiLocales() {
return Boolean.valueOf(getConfig().get("uiLocales")); return Boolean.valueOf(getConfig().get("uiLocales"));

View file

@ -29,6 +29,7 @@ import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.utils.StripSecretsUtils; import org.keycloak.models.utils.StripSecretsUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.AdminEventRepresentation; import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation; import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
@ -68,6 +69,7 @@ import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat; import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -139,6 +141,34 @@ public class IdentityProviderTest extends AbstractAdminTest {
assertEquals(ComponentRepresentation.SECRET_VALUE, rep.getConfig().get("clientSecret")); assertEquals(ComponentRepresentation.SECRET_VALUE, rep.getConfig().get("clientSecret"));
} }
@Test
public void testCreateWithJWT() {
IdentityProviderRepresentation newIdentityProvider = createRep("new-identity-provider", "oidc");
newIdentityProvider.getConfig().put("clientId", "clientId");
newIdentityProvider.getConfig().put("clientAuthMethod", OIDCLoginProtocol.PRIVATE_KEY_JWT);
create(newIdentityProvider);
IdentityProviderResource identityProviderResource = realm.identityProviders().get("new-identity-provider");
assertNotNull(identityProviderResource);
IdentityProviderRepresentation representation = identityProviderResource.toRepresentation();
assertNotNull(representation);
assertNotNull(representation.getInternalId());
assertEquals("new-identity-provider", representation.getAlias());
assertEquals("oidc", representation.getProviderId());
assertEquals("clientId", representation.getConfig().get("clientId"));
assertNull(representation.getConfig().get("clientSecret"));
assertEquals(OIDCLoginProtocol.PRIVATE_KEY_JWT, representation.getConfig().get("clientAuthMethod"));
assertTrue(representation.isEnabled());
assertFalse(representation.isStoreToken());
assertFalse(representation.isTrustEmail());
}
@Test @Test
public void testUpdate() { public void testUpdate() {
IdentityProviderRepresentation newIdentityProvider = createRep("update-identity-provider", "oidc"); IdentityProviderRepresentation newIdentityProvider = createRep("update-identity-provider", "oidc");

View file

@ -0,0 +1,48 @@
package org.keycloak.testsuite.broker;
import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_SECRET;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_PROVIDER_ID;
import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider;
import java.util.List;
import java.util.Map;
import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testsuite.arquillian.SuiteContext;
public class KcOidcBrokerClientSecretJwtTest extends KcOidcBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfigurationWithJWTAuthentication();
}
private class KcOidcBrokerConfigurationWithJWTAuthentication extends KcOidcBrokerConfiguration {
@Override
public List<ClientRepresentation> createProviderClients(SuiteContext suiteContext) {
List<ClientRepresentation> clientsRepList = super.createProviderClients(suiteContext);
log.info("Update provider clients to accept JWT authentication");
for (ClientRepresentation client: clientsRepList) {
client.setClientAuthenticatorType(JWTClientSecretAuthenticator.PROVIDER_ID);
client.setSecret(CLIENT_SECRET);
}
return clientsRepList;
}
@Override
public IdentityProviderRepresentation setUpIdentityProvider(SuiteContext suiteContext) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(suiteContext, config);
config.put("clientAuthMethod", OIDCLoginProtocol.CLIENT_SECRET_JWT);
return idp;
}
}
}

View file

@ -0,0 +1,72 @@
/*
* 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.testsuite.broker;
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.crypto.Algorithm;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.KeysMetadataRepresentation.KeyMetadataRepresentation;
import org.keycloak.testsuite.arquillian.SuiteContext;
import org.keycloak.testsuite.util.KeyUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_PROVIDER_ID;
import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider;
public class KcOidcBrokerPrivateKeyJwtTest extends KcOidcBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfigurationWithJWTAuthentication();
}
private class KcOidcBrokerConfigurationWithJWTAuthentication extends KcOidcBrokerConfiguration {
@Override
public List<ClientRepresentation> createProviderClients(SuiteContext suiteContext) {
List<ClientRepresentation> clientsRepList = super.createProviderClients(suiteContext);
log.info("Update provider clients to accept JWT authentication");
KeyMetadataRepresentation keyRep = KeyUtils.getActiveKey(adminClient.realm(consumerRealmName()).keys().getKeyMetadata(), Algorithm.RS256);
for (ClientRepresentation client: clientsRepList) {
client.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
if (client.getAttributes() == null) {
client.setAttributes(new HashMap<String, String>());
}
client.getAttributes().put(JWTClientAuthenticator.CERTIFICATE_ATTR, keyRep.getCertificate());
}
return clientsRepList;
}
@Override
public IdentityProviderRepresentation setUpIdentityProvider(SuiteContext suiteContext) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(suiteContext, config);
config.put("clientSecret", null);
config.put("clientAuthMethod", OIDCLoginProtocol.PRIVATE_KEY_JWT);
return idp;
}
}
}

View file

@ -581,6 +581,12 @@ backchannel-logout=Backchannel Logout
backchannel-logout.tooltip=Does the external IDP support backchannel logout? backchannel-logout.tooltip=Does the external IDP support backchannel logout?
user-info-url=User Info URL user-info-url=User Info URL
user-info-url.tooltip=The User Info Url. This is optional. user-info-url.tooltip=The User Info Url. This is optional.
client-auth=Client Authentication
client-auth.tooltip=The client authentication methdod (cfr. https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication).
client-auth.client_secret_post=Client secret sent as post
client-auth.client_secret_basic=Client secret sent as basic auth
client-auth.client_secret_jwt=Client secret as jwt
client-auth.private_key_jwt=JWT signed with private key
identity-provider.client-id.tooltip=The client or client identifier registered within the identity provider. identity-provider.client-id.tooltip=The client or client identifier registered within the identity provider.
client-secret=Client Secret client-secret=Client Secret
show-secret=Show secret show-secret=Show secret

View file

@ -166,6 +166,22 @@
</div> </div>
<kc-tooltip>{{:: 'user-info-url.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'user-info-url.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientAuth"><span class="required">*</span> {{:: 'client-auth' | translate}}</label>
<div class="col-md-6">
<div>
<select class="form-control" id="clientAuth"
ng-model="identityProvider.config.clientAuthMethod"
required>
<option id="clientAuth_post" name="clientAuth" value="client_secret_post" selected>{{:: 'client-auth.client_secret_post' | translate}}</option>
<!-- <option id="clientAuth_basic" name="clientAuth" value="client_secret_basic">{{:: 'client-auth.client_secret_basic' | translate}}</option> -->
<option id="clientAuth_secret_jwt" name="clientAuth" value="client_secret_jwt">{{:: 'client-auth.client_secret_jwt' | translate}}</option>
<option id="clientAuth_privatekey_jwt" name="clientAuth" value="private_key_jwt">{{:: 'client-auth.private_key_jwt' | translate}}</option>
</select>
</div>
</div>
<kc-tooltip>{{:: 'client-auth.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix"> <div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientId"><span class="required">*</span> {{:: 'client-id' | translate}}</label> <label class="col-md-2 control-label" for="clientId"><span class="required">*</span> {{:: 'client-id' | translate}}</label>
<div class="col-md-6"> <div class="col-md-6">
@ -174,9 +190,9 @@
<kc-tooltip>{{:: 'identity-provider.client-id.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'identity-provider.client-id.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group clearfix"> <div class="form-group clearfix">
<label class="col-md-2 control-label" for="clientSecret"><span class="required">*</span> {{:: 'client-secret' | translate}}</label> <label class="col-md-2 control-label" for="clientSecret"><span data-ng-show="identityProvider.config.clientAuthMethod != 'private_key_jwt'" class="required">*</span> {{:: 'client-secret' | translate}}</label>
<div class="col-md-6"> <div class="col-md-6">
<input class="form-control" id="clientSecret" kc-password ng-model="identityProvider.config.clientSecret" required> <input class="form-control" id="clientSecret" kc-password ng-model="identityProvider.config.clientSecret" ng-required="identityProvider.config.clientAuthMethod != 'private_key_jwt'">
</div> </div>
<kc-tooltip>{{:: 'client-secret.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'client-secret.tooltip' | translate}}</kc-tooltip>
</div> </div>