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:
parent
906a276fd5
commit
c2b132171d
9 changed files with 198 additions and 1 deletions
|
@ -35,6 +35,7 @@ import java.security.PrivateKey;
|
|||
public class JWSBuilder {
|
||||
String type;
|
||||
String kid;
|
||||
String x5t;
|
||||
String contentType;
|
||||
byte[] contentBytes;
|
||||
|
||||
|
@ -48,6 +49,11 @@ public class JWSBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public JWSBuilder x5t(String x5t) {
|
||||
this.x5t = x5t;
|
||||
return this;
|
||||
}
|
||||
|
||||
public JWSBuilder contentType(String type) {
|
||||
this.contentType = type;
|
||||
return this;
|
||||
|
@ -74,6 +80,7 @@ public class JWSBuilder {
|
|||
|
||||
if (type != null) builder.append(",\"typ\" : \"").append(type).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("\"");
|
||||
builder.append("}");
|
||||
return Base64Url.encode(builder.toString().getBytes(StandardCharsets.UTF_8));
|
||||
|
|
|
@ -103,6 +103,8 @@ describe("OIDC identity provider test", () => {
|
|||
providerBaseAdvancedSettingsPage.assertClientAssertionAudienceInputEqual(
|
||||
"http://localhost:8180",
|
||||
);
|
||||
//JWT X509 Headers
|
||||
providerBaseAdvancedSettingsPage.assertOIDCJWTX509HeadersSwitch();
|
||||
//OIDC Advanced Settings
|
||||
providerBaseAdvancedSettingsPage.assertOIDCSettingsAdvancedSwitches();
|
||||
providerBaseAdvancedSettingsPage.selectPromptOption(PromptSelect.none);
|
||||
|
|
|
@ -86,6 +86,7 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
|||
#clientAuth = "#clientAuthentication";
|
||||
#clientAssertionSigningAlg = "#clientAssertionSigningAlg";
|
||||
#clientAssertionAudienceInput = "#clientAssertionAudience";
|
||||
#jwtX509HeadersSwitch = "#jwtX509HeadersEnabled";
|
||||
|
||||
public clickSaveBtn() {
|
||||
cy.findByTestId(this.#saveBtn).click();
|
||||
|
@ -408,6 +409,39 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
|||
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() {
|
||||
cy.get(this.#advancedSettingsToggle).scrollIntoView().click();
|
||||
|
||||
|
|
|
@ -352,6 +352,7 @@ buildIn=Built-in
|
|||
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.
|
||||
algorithmNotSpecified=Algorithm not specified
|
||||
jwtX509HeadersEnabled=Add X.509 Headers to the JWT
|
||||
rememberMe=Remember me
|
||||
flow.registration=Registration flow
|
||||
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.
|
||||
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.
|
||||
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
|
||||
cibaExpiresIn=Expires In
|
||||
dynamicScopeFormatHelp=This is the regular expression that the system will use to extract the scope name and variable.
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
|||
|
||||
import { HelpItem } from "ui-shared";
|
||||
import { ClientIdSecret } from "../component/ClientIdSecret";
|
||||
import { SwitchField } from "../component/SwitchField";
|
||||
import { sortProviders } from "../../util";
|
||||
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
|
||||
import { TextField } from "../component/TextField";
|
||||
|
@ -131,6 +132,12 @@ export const OIDCAuthentication = ({ create = true }: { create?: boolean }) => {
|
|||
label="clientAssertionAudience"
|
||||
/>
|
||||
)}
|
||||
{clientAuthMethod === "private_key_jwt" && (
|
||||
<SwitchField
|
||||
field="config.jwtX509HeadersEnabled"
|
||||
label="jwtX509HeadersEnabled"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,6 +19,8 @@ package org.keycloak.broker.oidc;
|
|||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.crypto.KeyType;
|
||||
import org.keycloak.crypto.KeyUse;
|
||||
import org.keycloak.http.HttpRequest;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
|
@ -42,6 +44,8 @@ import org.keycloak.events.Details;
|
|||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
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.models.ClientModel;
|
||||
import org.keycloak.models.FederatedIdentityModel;
|
||||
|
@ -78,8 +82,11 @@ import jakarta.ws.rs.core.UriBuilder;
|
|||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
@ -405,7 +412,24 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
|
|||
public SimpleHttp authenticateTokenRequest(final SimpleHttp tokenRequest) {
|
||||
|
||||
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
|
||||
.param(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)
|
||||
.param(OAuth2Constants.CLIENT_ASSERTION, jws)
|
||||
|
|
|
@ -35,6 +35,8 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
|
|||
public static final String PKCE_ENABLED = "pkceEnabled";
|
||||
public static final String PKCE_METHOD = "pkceMethod";
|
||||
|
||||
public static final String JWT_X509_HEADERS_ENABLED = "jwtX509HeadersEnabled";
|
||||
|
||||
public OAuth2IdentityProviderConfig(IdentityProviderModel model) {
|
||||
super(model);
|
||||
}
|
||||
|
@ -163,6 +165,19 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
|
|||
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
|
||||
public void validate(RealmModel realm) {
|
||||
SslRequired sslRequired = realm.getSslRequired();
|
||||
|
|
|
@ -334,6 +334,39 @@ public class IdentityProviderTest extends AbstractAdminTest {
|
|||
assertEquals("clientId", representation.getConfig().get("clientId"));
|
||||
assertNull(representation.getConfig().get("clientSecret"));
|
||||
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());
|
||||
assertFalse(representation.isStoreToken());
|
||||
assertFalse(representation.isTrustEmail());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in a new issue