Add X509 thumbprint to JWT when using private_key_jwt

Closes keycloak#12946

Signed-off-by: MikeTangoEcho <mathieu.thine@gmail.com>
This commit is contained in:
MikeTangoEcho 2023-08-24 18:42:22 +02:00 committed by Marek Posolda
parent 906a276fd5
commit c2b132171d
9 changed files with 198 additions and 1 deletions

View file

@ -35,6 +35,7 @@ import java.security.PrivateKey;
public class JWSBuilder { public class JWSBuilder {
String type; String type;
String kid; String kid;
String x5t;
String contentType; String contentType;
byte[] contentBytes; byte[] contentBytes;
@ -48,6 +49,11 @@ public class JWSBuilder {
return this; return this;
} }
public JWSBuilder x5t(String x5t) {
this.x5t = x5t;
return this;
}
public JWSBuilder contentType(String type) { public JWSBuilder contentType(String type) {
this.contentType = type; this.contentType = type;
return this; return this;
@ -74,6 +80,7 @@ public class JWSBuilder {
if (type != null) builder.append(",\"typ\" : \"").append(type).append("\""); if (type != null) builder.append(",\"typ\" : \"").append(type).append("\"");
if (kid != null) builder.append(",\"kid\" : \"").append(kid).append("\""); if (kid != null) builder.append(",\"kid\" : \"").append(kid).append("\"");
if (x5t != null) builder.append(",\"x5t\" : \"").append(x5t).append("\"");
if (contentType != null) builder.append(",\"cty\":\"").append(contentType).append("\""); if (contentType != null) builder.append(",\"cty\":\"").append(contentType).append("\"");
builder.append("}"); builder.append("}");
return Base64Url.encode(builder.toString().getBytes(StandardCharsets.UTF_8)); return Base64Url.encode(builder.toString().getBytes(StandardCharsets.UTF_8));

View file

@ -103,6 +103,8 @@ describe("OIDC identity provider test", () => {
providerBaseAdvancedSettingsPage.assertClientAssertionAudienceInputEqual( providerBaseAdvancedSettingsPage.assertClientAssertionAudienceInputEqual(
"http://localhost:8180", "http://localhost:8180",
); );
//JWT X509 Headers
providerBaseAdvancedSettingsPage.assertOIDCJWTX509HeadersSwitch();
//OIDC Advanced Settings //OIDC Advanced Settings
providerBaseAdvancedSettingsPage.assertOIDCSettingsAdvancedSwitches(); providerBaseAdvancedSettingsPage.assertOIDCSettingsAdvancedSwitches();
providerBaseAdvancedSettingsPage.selectPromptOption(PromptSelect.none); providerBaseAdvancedSettingsPage.selectPromptOption(PromptSelect.none);

View file

@ -86,6 +86,7 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
#clientAuth = "#clientAuthentication"; #clientAuth = "#clientAuthentication";
#clientAssertionSigningAlg = "#clientAssertionSigningAlg"; #clientAssertionSigningAlg = "#clientAssertionSigningAlg";
#clientAssertionAudienceInput = "#clientAssertionAudience"; #clientAssertionAudienceInput = "#clientAssertionAudience";
#jwtX509HeadersSwitch = "#jwtX509HeadersEnabled";
public clickSaveBtn() { public clickSaveBtn() {
cy.findByTestId(this.#saveBtn).click(); cy.findByTestId(this.#saveBtn).click();
@ -408,6 +409,39 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
return this; return this;
} }
public assertOIDCJWTX509HeadersSwitch() {
cy.findByTestId("jump-link-openid-connect-settings").click();
cy.get(this.#clientAuth)
.click()
.get(".pf-c-select__menu-item")
.contains(ClientAuthentication.post)
.click();
cy.get(this.#jwtX509HeadersSwitch).should("not.exist");
cy.get(this.#clientAuth)
.click()
.get(".pf-c-select__menu-item")
.contains(ClientAuthentication.basicAuth)
.click();
cy.get(this.#jwtX509HeadersSwitch).should("not.exist");
cy.get(this.#clientAuth)
.click()
.get(".pf-c-select__menu-item")
.contains(ClientAuthentication.jwt)
.click();
cy.get(this.#jwtX509HeadersSwitch).should("not.exist");
cy.get(this.#clientAuth)
.click()
.get(".pf-c-select__menu-item")
.contains(ClientAuthentication.jwtPrivKey)
.click();
cy.get(this.#jwtX509HeadersSwitch).should("exist");
super.assertSwitchStateOff(cy.get(this.#jwtX509HeadersSwitch));
cy.get(this.#jwtX509HeadersSwitch).parent().click();
super.assertSwitchStateOn(cy.get(this.#jwtX509HeadersSwitch));
return this;
}
public assertOIDCSettingsAdvancedSwitches() { public assertOIDCSettingsAdvancedSwitches() {
cy.get(this.#advancedSettingsToggle).scrollIntoView().click(); cy.get(this.#advancedSettingsToggle).scrollIntoView().click();

View file

@ -352,6 +352,7 @@ buildIn=Built-in
roleCreateExplain=This is some description roleCreateExplain=This is some description
scopePermissions.identityProviders.token-exchange-description=Policies that decide which clients are allowed exchange tokens for an external token minted by this identity provider. scopePermissions.identityProviders.token-exchange-description=Policies that decide which clients are allowed exchange tokens for an external token minted by this identity provider.
algorithmNotSpecified=Algorithm not specified algorithmNotSpecified=Algorithm not specified
jwtX509HeadersEnabled=Add X.509 Headers to the JWT
rememberMe=Remember me rememberMe=Remember me
flow.registration=Registration flow flow.registration=Registration flow
showLess=Show less showLess=Show less
@ -2844,6 +2845,7 @@ authenticationExplain=Authentication is the area where you can configure and man
passwordPoliciesHelp.hashIterations=The number of times a password is hashed before storage or verification. Default\: 27,500. passwordPoliciesHelp.hashIterations=The number of times a password is hashed before storage or verification. Default\: 27,500.
dropNonexistingGroupsDuringSync=Drop non-existing groups during sync dropNonexistingGroupsDuringSync=Drop non-existing groups during sync
clientAssertionSigningAlgHelp=Signature algorithm to create JWT assertion as client authentication. In the case of JWT signed with private key or JWT signed with client secret, it is required. If no algorithm is specified, the following algorithm is adapted. RS256 is adapted in the case of JWT signed with private key. HS256 is adapted in the case of JWT signed with client secret. clientAssertionSigningAlgHelp=Signature algorithm to create JWT assertion as client authentication. In the case of JWT signed with private key or JWT signed with client secret, it is required. If no algorithm is specified, the following algorithm is adapted. RS256 is adapted in the case of JWT signed with private key. HS256 is adapted in the case of JWT signed with client secret.
jwtX509HeadersEnabledHelp=If enabled, the x5t (X.509 Certificate SHA-1 Thumbprint) header will be added to the JWT to reference the certificate used to sign it. Otherwise, the kid (Key ID) header will be used instead.
addProvider_other=Add {{provider}} providers addProvider_other=Add {{provider}} providers
cibaExpiresIn=Expires In cibaExpiresIn=Expires In
dynamicScopeFormatHelp=This is the regular expression that the system will use to extract the scope name and variable. dynamicScopeFormatHelp=This is the regular expression that the system will use to extract the scope name and variable.

View file

@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { ClientIdSecret } from "../component/ClientIdSecret"; import { ClientIdSecret } from "../component/ClientIdSecret";
import { SwitchField } from "../component/SwitchField";
import { sortProviders } from "../../util"; import { sortProviders } from "../../util";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { TextField } from "../component/TextField"; import { TextField } from "../component/TextField";
@ -131,6 +132,12 @@ export const OIDCAuthentication = ({ create = true }: { create?: boolean }) => {
label="clientAssertionAudience" label="clientAssertionAudience"
/> />
)} )}
{clientAuthMethod === "private_key_jwt" && (
<SwitchField
field="config.jwtX509HeadersEnabled"
label="jwtX509HeadersEnabled"
/>
)}
</> </>
); );
}; };

View file

@ -19,6 +19,8 @@ package org.keycloak.broker.oidc;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyUse;
import org.keycloak.http.HttpRequest; import org.keycloak.http.HttpRequest;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
@ -42,6 +44,8 @@ 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.jwk.JWKBuilder;
import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.jose.jws.JWSBuilder; 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;
@ -78,8 +82,11 @@ import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -405,7 +412,24 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
public SimpleHttp authenticateTokenRequest(final SimpleHttp tokenRequest) { public SimpleHttp authenticateTokenRequest(final SimpleHttp tokenRequest) {
if (getConfig().isJWTAuthentication()) { if (getConfig().isJWTAuthentication()) {
String jws = new JWSBuilder().type(OAuth2Constants.JWT).jsonContent(generateToken()).sign(getSignatureContext()); String sha1x509Thumbprint = null;
SignatureSignerContext signer = getSignatureContext();
if (getConfig().isJwtX509HeadersEnabled()) {
KeyWrapper key = session.keys().getKey(session.getContext().getRealm(), signer.getKid(), KeyUse.SIG, signer.getAlgorithm());
if (key != null
&& key.getStatus().isEnabled()
&& key.getPublicKey() != null
&& key.getUse().equals(KeyUse.SIG)
&& key.getType().equals(KeyType.RSA)) {
JWKBuilder builder = JWKBuilder.create().kid(key.getKid()).algorithm(key.getAlgorithmOrDefault());
List<X509Certificate> certificates = Optional.ofNullable(key.getCertificateChain())
.filter(certs -> !certs.isEmpty())
.orElseGet(() -> Collections.singletonList(key.getCertificate()));
RSAPublicJWK jwk = (RSAPublicJWK) builder.rsa(key.getPublicKey(), certificates, key.getUse());
sha1x509Thumbprint = jwk.getSha1x509Thumbprint();
}
}
String jws = new JWSBuilder().type(OAuth2Constants.JWT).x5t(sha1x509Thumbprint).jsonContent(generateToken()).sign(signer);
return tokenRequest return tokenRequest
.param(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT) .param(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)
.param(OAuth2Constants.CLIENT_ASSERTION, jws) .param(OAuth2Constants.CLIENT_ASSERTION, jws)

View file

@ -35,6 +35,8 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public static final String PKCE_ENABLED = "pkceEnabled"; public static final String PKCE_ENABLED = "pkceEnabled";
public static final String PKCE_METHOD = "pkceMethod"; public static final String PKCE_METHOD = "pkceMethod";
public static final String JWT_X509_HEADERS_ENABLED = "jwtX509HeadersEnabled";
public OAuth2IdentityProviderConfig(IdentityProviderModel model) { public OAuth2IdentityProviderConfig(IdentityProviderModel model) {
super(model); super(model);
} }
@ -163,6 +165,19 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
getConfig().put("clientAssertionAudience", audience); getConfig().put("clientAssertionAudience", audience);
} }
public boolean isJwtX509HeadersEnabled() {
if (getClientAuthMethod().equals(OIDCLoginProtocol.PRIVATE_KEY_JWT)
&& Boolean.parseBoolean(getConfig().getOrDefault(JWT_X509_HEADERS_ENABLED, "false"))) {
return true;
}
return false;
}
public void setJwtX509HeadersEnabled(boolean enabled) {
getConfig().put(JWT_X509_HEADERS_ENABLED, String.valueOf(enabled));
}
@Override @Override
public void validate(RealmModel realm) { public void validate(RealmModel realm) {
SslRequired sslRequired = realm.getSslRequired(); SslRequired sslRequired = realm.getSslRequired();

View file

@ -334,6 +334,39 @@ public class IdentityProviderTest extends AbstractAdminTest {
assertEquals("clientId", representation.getConfig().get("clientId")); assertEquals("clientId", representation.getConfig().get("clientId"));
assertNull(representation.getConfig().get("clientSecret")); assertNull(representation.getConfig().get("clientSecret"));
assertEquals(OIDCLoginProtocol.PRIVATE_KEY_JWT, representation.getConfig().get("clientAuthMethod")); assertEquals(OIDCLoginProtocol.PRIVATE_KEY_JWT, representation.getConfig().get("clientAuthMethod"));
assertNull(representation.getConfig().get("jwtX509HeadersEnabled"));
assertTrue(representation.isEnabled());
assertFalse(representation.isStoreToken());
assertFalse(representation.isTrustEmail());
}
@Test
public void testCreateWithJWTAndX509Headers() {
IdentityProviderRepresentation newIdentityProvider = createRep("new-identity-provider", "oidc");
newIdentityProvider.getConfig().put(IdentityProviderModel.SYNC_MODE, "IMPORT");
newIdentityProvider.getConfig().put("clientId", "clientId");
newIdentityProvider.getConfig().put("clientAuthMethod", OIDCLoginProtocol.PRIVATE_KEY_JWT);
newIdentityProvider.getConfig().put("jwtX509HeadersEnabled", "true");
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("IMPORT", representation.getConfig().get(IdentityProviderMapperModel.SYNC_MODE));
assertEquals("clientId", representation.getConfig().get("clientId"));
assertNull(representation.getConfig().get("clientSecret"));
assertEquals(OIDCLoginProtocol.PRIVATE_KEY_JWT, representation.getConfig().get("clientAuthMethod"));
assertEquals("true", representation.getConfig().get("jwtX509HeadersEnabled"));
assertTrue(representation.isEnabled()); assertTrue(representation.isEnabled());
assertFalse(representation.isStoreToken()); assertFalse(representation.isStoreToken());
assertFalse(representation.isTrustEmail()); assertFalse(representation.isTrustEmail());

View file

@ -0,0 +1,73 @@
/*
* 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.models.IdentityProviderSyncMode;
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.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 KcOidcBrokerPrivateKeyJwtWithX509HeadersTest extends AbstractBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfigurationWithJWTAuthentication();
}
private class KcOidcBrokerConfigurationWithJWTAuthentication extends KcOidcBrokerConfiguration {
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> clientsRepList = super.createProviderClients();
log.info("Update provider clients to accept JWT authentication");
KeyMetadataRepresentation keyRep = KeyUtils.findActiveSigningKey(adminClient.realm(consumerRealmName()), 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(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
config.put("clientSecret", null);
config.put("clientAuthMethod", OIDCLoginProtocol.PRIVATE_KEY_JWT);
config.put("jwtX509HeadersEnabled", "true");
return idp;
}
}
}