Support for JWE IDToken and UserInfo tokens in OIDC brokers

Closes https://github.com/keycloak/keycloak/issues/21254
This commit is contained in:
rmartinc 2023-06-30 09:08:51 +02:00 committed by Pedro Igor
parent 10ff4a0ab3
commit 09e30b3c99
6 changed files with 235 additions and 24 deletions

View file

@ -149,3 +149,7 @@ picked (`Always`), or the global JVM one (`Never`).
Deployments where `Only for ldaps` was used will automatically behave as if `Always` option was
selected for TLS-secured LDAP connections.
= Support for JWE encrypted ID Tokens and UserInfo responses in OpenID Connect providers
The OpenID Connect providers now support https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] for the ID Token and the UserInfo response. The providers use the realm keys defined for the selected encryption algorithm to perform the decryption.

View file

@ -78,3 +78,5 @@ If the user is unauthenticated in the IDP, the client still receives a `login_re
|===
You can import all this configuration data by providing a URL or file that points to OpenID Provider Metadata. If you connect to a {project_name} external IDP, you can import the IDP settings from `<root>{kc_realms_path}/{realm-name}/.well-known/openid-configuration`. This link is a JSON document describing metadata about the IDP.
If you want to use https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] ID Tokens or UserInfo responses in the provider, the IDP needs to know the public key to use with {project_name}. The provider uses the <<realm_keys, realm keys>> defined for the different encryption algorithms to decrypt the tokens. {project_name} provides a standard xref:con-server-oidc-uri-endpoints_{context}[JWKS endpoint] which the IDP can use for downloading the keys automatically.

View file

@ -32,14 +32,18 @@ import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.jose.JOSE;
import org.keycloak.jose.JOSEParser;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.keys.loader.PublicKeyStorageManager;
import org.keycloak.models.AbstractKeycloakTransaction;
import org.keycloak.models.ClientModel;
@ -484,19 +488,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
if (MediaType.APPLICATION_JSON_TYPE.isCompatible(contentMediaType)) {
userInfo = response.asJson();
} else if (APPLICATION_JWT_TYPE.isCompatible(contentMediaType)) {
JWSInput jwsInput;
try {
jwsInput = new JWSInput(response.asString());
} catch (JWSInputException cause) {
throw new RuntimeException("Failed to parse JWT userinfo response", cause);
}
if (verify(jwsInput)) {
userInfo = JsonSerialization.readValue(jwsInput.getContent(), JsonNode.class);
} else {
throw new RuntimeException("Failed to verify signature of userinfo response from [" + userInfoUrl + "].");
}
userInfo = JsonSerialization.readValue(parseTokenInput(accessToken, false), JsonNode.class);
} else {
throw new RuntimeException("Unsupported content-type [" + contentType + "] in response from [" + userInfoUrl + "].");
}
@ -609,6 +601,75 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
}
/**
* Parses a JWT token that can be a JWE, JWS or JWE/JWS. It returns the content
* as a string. If JWS is involved the signature is also validated. A
* IdentityBrokerException is thrown on any error.
*
* @param encodedToken The token in the encoded string format.
* @param shouldBeSigned true if the token should be signed (id token),
* false if the token can be only encrypted and not signed (user info).
* @return The content in string format.
*/
protected String parseTokenInput(String encodedToken, boolean shouldBeSigned) {
if (encodedToken == null) {
throw new IdentityBrokerException("No token from server.");
}
try {
JWSInput jws;
JOSE joseToken = JOSEParser.parse(encodedToken);
if (joseToken instanceof JWE) {
// encrypted JWE token
JWE jwe = (JWE) joseToken;
KeyWrapper key;
if (jwe.getHeader().getKeyId() == null) {
key = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.ENC, jwe.getHeader().getRawAlgorithm());
} else {
key = session.keys().getKey(session.getContext().getRealm(), jwe.getHeader().getKeyId(), KeyUse.ENC, jwe.getHeader().getRawAlgorithm());
}
if (key == null || key.getPrivateKey() == null) {
throw new IdentityBrokerException("Private key not found in the realm to decrypt token algorithm " + jwe.getHeader().getRawAlgorithm());
}
jwe.getKeyStorage().setDecryptionKey(key.getPrivateKey());
jwe.verifyAndDecodeJwe();
String content = new String(jwe.getContent(), StandardCharsets.UTF_8);
try {
// try to decode the token just in case it is a JWS
joseToken = JOSEParser.parse(content);
} catch(Exception e) {
if (shouldBeSigned) {
throw new IdentityBrokerException("Token is not a signed JWS", e);
}
// the token is only a encrypted JWE (user-info)
return content;
}
if (!(joseToken instanceof JWSInput)) {
throw new IdentityBrokerException("Invalid token type");
}
jws = (JWSInput) joseToken;
} else if (joseToken instanceof JWSInput) {
// common signed JWS token
jws = (JWSInput) joseToken;
} else {
throw new IdentityBrokerException("Invalid token type");
}
// verify signature of the JWS
if (!verify(jws)) {
throw new IdentityBrokerException("token signature validation failed");
}
return new String(jws.getContent(), StandardCharsets.UTF_8);
} catch (JWEException e) {
throw new IdentityBrokerException("Invalid token", e);
}
}
public JsonWebToken validateToken(String encodedToken) {
boolean ignoreAudience = false;
@ -616,18 +677,10 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
protected JsonWebToken validateToken(String encodedToken, boolean ignoreAudience) {
if (encodedToken == null) {
throw new IdentityBrokerException("No token from server.");
}
JsonWebToken token;
try {
JWSInput jws = new JWSInput(encodedToken);
if (!verify(jws)) {
throw new IdentityBrokerException("token signature validation failed");
}
token = jws.readJsonContent(JsonWebToken.class);
} catch (JWSInputException e) {
token = JsonSerialization.readValue(parseTokenInput(encodedToken, true), JsonWebToken.class);
} catch (IOException e) {
throw new IdentityBrokerException("Invalid token", e);
}

View file

@ -0,0 +1,117 @@
/*
* Copyright 2023 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 java.util.List;
import java.util.Map;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.keys.KeyProvider;
import org.keycloak.models.utils.DefaultKeyProviders;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
/**
* <p>Tests the broker using a JWE encrypted token for id token and user info. The test
* can be extended to use different algorithms. The default uses RSA-OAEP as
* encryption key management algorithm, A256GCM as the content encryption
* algorithm and RS512 as the signature algorithm.</p>
*
* @author rmartinc
*/
public class KcOidcBrokerJWETest extends AbstractBrokerTest {
private final String encAlg;
private final String encEnc;
private final String sigAlg;
public KcOidcBrokerJWETest() {
this(JWEConstants.RSA_OAEP, JWEConstants.A256GCM, Algorithm.RS512);
}
protected KcOidcBrokerJWETest(String encAlg, String encEnc, String sigAlg) {
this.encAlg = encAlg;
this.encEnc = encEnc;
this.sigAlg = sigAlg;
}
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration() {
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> clientsRepList = super.createProviderClients();
for (ClientRepresentation client : clientsRepList) {
Map<String, String> attrs = client.getAttributes();
// use the certs from the consumer realm to perform the encryption
attrs.put(OIDCConfigAttributes.USE_JWKS_URL, "true");
attrs.put(OIDCConfigAttributes.JWKS_URL, BrokerTestTools.getConsumerRoot() +
"/auth/realms/" + BrokerTestConstants.REALM_CONS_NAME + "/protocol/openid-connect/certs");
// assign the encryption and signature attributes
if (encAlg != null) {
attrs.put(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ALG, encAlg);
attrs.put(OIDCConfigAttributes.USER_INFO_ENCRYPTED_RESPONSE_ALG, encAlg);
}
if (encEnc != null) {
attrs.put(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC, encEnc);
attrs.put(OIDCConfigAttributes.USER_INFO_ENCRYPTED_RESPONSE_ENC, encEnc);
}
if (sigAlg != null) {
attrs.put(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG, sigAlg);
attrs.put(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG, sigAlg);
}
}
return clientsRepList;
}
@Override
public RealmRepresentation createConsumerRealm() {
RealmRepresentation realm = super.createConsumerRealm();
if (encAlg != null) {
// create the RSA component for the encryption in the specified alg
ComponentExportRepresentation component = new ComponentExportRepresentation();
component.setName("rsa-enc-generated");
component.setProviderId("rsa-enc-generated");
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
config.putSingle("priority", DefaultKeyProviders.DEFAULT_PRIORITY);
config.putSingle("keyUse", KeyUse.ENC.name());
config.putSingle("algorithm", encAlg);
component.setConfig(config);
MultivaluedHashMap<String, ComponentExportRepresentation> components = realm.getComponents();
if (components == null) {
components = new MultivaluedHashMap<>();
realm.setComponents(components);
}
components.add(KeyProvider.class.getName(), component);
}
return realm;
}
};
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2023 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.jose.jwe.JWEConstants;
/**
* <p>Extension of the KcOidcBrokerJWETest test to use a different key algorithm (RSA1_5),
* the default content encryption algorithm (A128CBC-HS256) and the default signature
* algorithm (RS256 for id token and none/unsigned for user info).</p>
*
* @author rmartinc
*/
public class KcOidcBrokerJWEUserInfoJustEncryptedTest extends KcOidcBrokerJWETest {
public KcOidcBrokerJWEUserInfoJustEncryptedTest() {
super(JWEConstants.RSA1_5, null, null);
}
}

View file

@ -17,6 +17,8 @@ KcAdmTest
KcAdmCreateTest
SAMLServletAdapterTest
SamlSignatureTest
KcOidcBrokerJWETest
KcOidcBrokerJWEUserInfoJustEncryptedTest
KcSamlBrokerTest
KcSamlFirstBrokerLoginTest
KcSamlEncryptedIdTest