Support for JWE IDToken and UserInfo tokens in OIDC brokers
Closes https://github.com/keycloak/keycloak/issues/21254
This commit is contained in:
parent
10ff4a0ab3
commit
09e30b3c99
6 changed files with 235 additions and 24 deletions
|
@ -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.
|
||||
|
|
|
@ -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.
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ KcAdmTest
|
|||
KcAdmCreateTest
|
||||
SAMLServletAdapterTest
|
||||
SamlSignatureTest
|
||||
KcOidcBrokerJWETest
|
||||
KcOidcBrokerJWEUserInfoJustEncryptedTest
|
||||
KcSamlBrokerTest
|
||||
KcSamlFirstBrokerLoginTest
|
||||
KcSamlEncryptedIdTest
|
||||
|
|
Loading…
Reference in a new issue