[KEYCLOAK-9809] Support private_key_jwt authentication for external IdP
This commit is contained in:
parent
69359eab23
commit
c35718cb87
7 changed files with 261 additions and 26 deletions
|
@ -30,10 +30,17 @@ import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken;
|
|||
import org.keycloak.broker.provider.IdentityProvider;
|
||||
import org.keycloak.broker.provider.util.SimpleHttp;
|
||||
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.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.jose.jws.JWSBuilder;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.FederatedIdentityModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
@ -41,14 +48,18 @@ import org.keycloak.models.RealmModel;
|
|||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.representations.AccessTokenResponse;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.ErrorPage;
|
||||
import org.keycloak.services.ErrorResponseException;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.vault.VaultStringSecret;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
|
@ -127,20 +138,20 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
|
|||
}
|
||||
|
||||
protected String extractTokenFromResponse(String response, String tokenName) {
|
||||
if(response == null)
|
||||
return null;
|
||||
|
||||
if(response == null)
|
||||
return null;
|
||||
|
||||
if (response.startsWith("{")) {
|
||||
try {
|
||||
JsonNode node = mapper.readTree(response);
|
||||
if(node.has(tokenName)){
|
||||
String s = node.get(tokenName).textValue();
|
||||
if(s == null || s.trim().isEmpty())
|
||||
return null;
|
||||
return s;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = mapper.readTree(response);
|
||||
if(node.has(tokenName)){
|
||||
String s = node.get(tokenName).textValue();
|
||||
if(s == null || s.trim().isEmpty())
|
||||
return null;
|
||||
return s;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (IOException 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) {
|
||||
if (jsonNode.has(name) && !jsonNode.get(name).isNull()) {
|
||||
String s = jsonNode.get(name).asText();
|
||||
if(s != null && !s.isEmpty())
|
||||
return s;
|
||||
else
|
||||
return null;
|
||||
String s = jsonNode.get(name).asText();
|
||||
if(s != null && !s.isEmpty())
|
||||
return s;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -443,15 +454,50 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
|
|||
}
|
||||
|
||||
public SimpleHttp generateTokenRequest(String authorizationCode) {
|
||||
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) {
|
||||
return SimpleHttp.doPost(getConfig().getTokenUrl(), session)
|
||||
.param(OAUTH2_PARAMETER_CODE, authorizationCode)
|
||||
.param(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId())
|
||||
.param(OAUTH2_PARAMETER_CLIENT_SECRET, vaultStringSecret.get().orElse(getConfig().getClientSecret()))
|
||||
.param(OAUTH2_PARAMETER_REDIRECT_URI, session.getContext().getUri().getAbsolutePath().toString())
|
||||
.param(OAUTH2_PARAMETER_GRANT_TYPE, OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE);
|
||||
SimpleHttp tokenRequest = SimpleHttp.doPost(getConfig().getTokenUrl(), session)
|
||||
.param(OAUTH2_PARAMETER_CODE, authorizationCode)
|
||||
.param(OAUTH2_PARAMETER_REDIRECT_URI, session.getContext().getUri().getAbsolutePath().toString())
|
||||
.param(OAUTH2_PARAMETER_GRANT_TYPE, OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE);
|
||||
if (getConfig().isJWTAuthentication()) {
|
||||
String jws = new JWSBuilder().type(OAuth2Constants.JWT).jsonContent(generateToken()).sign(getSignatureContext());
|
||||
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) {
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
package org.keycloak.broker.oidc;
|
||||
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
|
||||
/**
|
||||
* @author Pedro Igor
|
||||
|
@ -59,6 +60,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
|
|||
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() {
|
||||
return getConfig().get("clientSecret");
|
||||
}
|
||||
|
@ -82,6 +91,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
|
|||
public void setLoginHint(boolean 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() {
|
||||
return Boolean.valueOf(getConfig().get("uiLocales"));
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType;
|
|||
import org.keycloak.events.admin.OperationType;
|
||||
import org.keycloak.events.admin.ResourceType;
|
||||
import org.keycloak.models.utils.StripSecretsUtils;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.representations.idm.AdminEventRepresentation;
|
||||
import org.keycloak.representations.idm.ComponentRepresentation;
|
||||
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.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThat;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
@ -139,6 +141,34 @@ public class IdentityProviderTest extends AbstractAdminTest {
|
|||
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
|
||||
public void testUpdate() {
|
||||
IdentityProviderRepresentation newIdentityProvider = createRep("update-identity-provider", "oidc");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -581,6 +581,12 @@ backchannel-logout=Backchannel Logout
|
|||
backchannel-logout.tooltip=Does the external IDP support backchannel logout?
|
||||
user-info-url=User Info URL
|
||||
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.
|
||||
client-secret=Client Secret
|
||||
show-secret=Show secret
|
||||
|
|
|
@ -166,6 +166,22 @@
|
|||
</div>
|
||||
<kc-tooltip>{{:: 'user-info-url.tooltip' | translate}}</kc-tooltip>
|
||||
</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">
|
||||
<label class="col-md-2 control-label" for="clientId"><span class="required">*</span> {{:: 'client-id' | translate}}</label>
|
||||
<div class="col-md-6">
|
||||
|
@ -174,9 +190,9 @@
|
|||
<kc-tooltip>{{:: 'identity-provider.client-id.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<kc-tooltip>{{:: 'client-secret.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue