Skip to content

Commit 845403e

Browse files
committed
feat: IDP Userinfo multi attrs and validate sign
1 parent de753b0 commit 845403e

File tree

2 files changed

+116
-40
lines changed

2 files changed

+116
-40
lines changed

docker/keycloak/extensions-24/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<maven.compiler.source>17</maven.compiler.source>
1212
<maven.compiler.target>17</maven.compiler.target>
1313
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
14-
<keycloak.version>24.0.3</keycloak.version>
14+
<keycloak.version>24.0.4</keycloak.version>
1515
</properties>
1616

1717
<build>

docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/IDPUserinfoMapper.java

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
package com.github.bcgov.keycloak.protocol.oidc.mappers;
22

33
import com.fasterxml.jackson.databind.JsonNode;
4+
5+
import jakarta.ws.rs.ProcessingException;
46
import jakarta.ws.rs.client.Client;
57
import jakarta.ws.rs.client.ClientBuilder;
8+
69
import org.jboss.logging.Logger;
10+
import org.keycloak.broker.provider.IdentityBrokerException;
11+
import org.keycloak.crypto.KeyWrapper;
12+
import org.keycloak.crypto.SignatureProvider;
13+
import org.keycloak.jose.JOSE;
14+
import org.keycloak.jose.JOSEParser;
15+
import org.keycloak.jose.jws.JWSInput;
16+
import org.keycloak.keys.loader.PublicKeyStorageManager;
717
import org.keycloak.models.*;
818
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
919
import org.keycloak.protocol.oidc.mappers.*;
@@ -12,33 +22,39 @@
1222
import org.keycloak.representations.IDToken;
1323
import org.keycloak.util.JsonSerialization;
1424

25+
import java.io.IOException;
26+
import java.nio.charset.StandardCharsets;
1527
import java.util.ArrayList;
1628
import java.util.HashMap;
1729
import java.util.List;
1830
import java.util.Map;
19-
import java.util.Base64;
2031

2132
/** @author <a href="mailto:[email protected]">Junmin Ahn</a> */
2233
public class IDPUserinfoMapper extends AbstractOIDCProtocolMapper
2334
implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper {
2435

2536
private static final Logger logger = Logger.getLogger(IDPUserinfoMapper.class);
2637

27-
private static final String BEARER = "Bearer";
28-
2938
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
3039

3140
public static final String CLAIM_VALUE = "claim.value";
3241

33-
public static final String USER_ATTRIBUTE = "userAttribute";
42+
public static final String USER_ATTRIBUTES = "userAttributes";
3443

35-
public static final String DECODE_USERINFO_RESPONSE = "decodeUserInfoResponse";
44+
public static final String SIGNATURE_EXPECTED = "signatureExpected";
45+
46+
public static final String ENCRYPTION_EXPECTED = "encryptionExpected";
3647

3748
static {
38-
configProperties.add(new ProviderConfigProperty(DECODE_USERINFO_RESPONSE, "Decode UserInfo Response",
39-
"Decode response returned from IDP userinfo endpoint", ProviderConfigProperty.BOOLEAN_TYPE, false));
40-
configProperties.add(new ProviderConfigProperty(USER_ATTRIBUTE, "User Attribute",
41-
"User Attribute returned from IDP userinfo endpoint", ProviderConfigProperty.STRING_TYPE, null));
49+
configProperties.add(new ProviderConfigProperty(SIGNATURE_EXPECTED, "Signature Expected",
50+
"Whether the signature should be verified", ProviderConfigProperty.BOOLEAN_TYPE, false));
51+
52+
configProperties.add(new ProviderConfigProperty(ENCRYPTION_EXPECTED, "Encryption Expected",
53+
"Whether the userinfo response requires decryption", ProviderConfigProperty.BOOLEAN_TYPE,
54+
false));
55+
56+
configProperties.add(new ProviderConfigProperty(USER_ATTRIBUTES, "User Attributes",
57+
"List of user attributes returned from IDP userinfo endpoint", ProviderConfigProperty.STRING_TYPE, null));
4258

4359
OIDCAttributeMapperHelper.addTokenClaimNameConfig(configProperties);
4460
OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, IDPUserinfoMapper.class);
@@ -86,17 +102,6 @@ private static JsonNode parseJson(String json) {
86102
}
87103
}
88104

89-
private static String decodeUserInfoResponse(String token) {
90-
try {
91-
String[] tokenParts = token.split("\\.");
92-
Base64.Decoder decoder = Base64.getUrlDecoder();
93-
String payload = new String(decoder.decode(tokenParts[1]));
94-
return payload;
95-
} catch (Exception e) {
96-
return null;
97-
}
98-
}
99-
100105
@Override
101106
protected void setClaim(
102107
IDToken token,
@@ -108,6 +113,8 @@ protected void setClaim(
108113
String idp = userSession.getNotes().get("identity_provider");
109114
RealmModel realm = userSession.getRealm();
110115
IdentityProviderModel identityProviderConfig = realm.getIdentityProviderByAlias(idp);
116+
JsonNode userInfo;
117+
JWSInput jws;
111118

112119
if (identityProviderConfig.isStoreToken()) {
113120
IdentityProviderModel identityProviderModel = realm.getIdentityProviderByAlias(idp);
@@ -119,28 +126,56 @@ protected void setClaim(
119126
String brokerToken = identity.getToken();
120127
AccessTokenResponse brokerAccessToken = parseTokenString(brokerToken);
121128
Client httpClient = ClientBuilder.newClient();
122-
String userinfoString = httpClient
123-
.target(userInfoUrl)
124-
.request()
125-
.header("Authorization", "Bearer " + brokerAccessToken.getToken())
126-
.get(String.class);
127-
boolean decode = Boolean.parseBoolean(mappingModel.getConfig().get(DECODE_USERINFO_RESPONSE));
128-
if (decode) {
129-
userinfoString = decodeUserInfoResponse(userinfoString);
130-
}
129+
String userinfoResponse;
130+
131131
try {
132-
JsonNode jsonNode = parseJson(userinfoString);
133-
if (jsonNode == null) {
134-
logger.error("null response returned from [" + idp + "] userinfo URL");
132+
userinfoResponse = httpClient
133+
.target(userInfoUrl)
134+
.request()
135+
.header("Authorization", "Bearer " + brokerAccessToken.getToken())
136+
.get(String.class);
137+
} catch (Exception e) {
138+
throw new ProcessingException("Failed to call userinfo endpoint", e);
139+
}
140+
141+
Boolean signatureExpected = Boolean.parseBoolean(mappingModel.getConfig().get(SIGNATURE_EXPECTED));
142+
143+
if (signatureExpected) {
144+
145+
try {
146+
JOSE joseToken = JOSEParser.parse(userinfoResponse);
147+
148+
// common signed JWS token
149+
jws = (JWSInput) joseToken;
150+
151+
} catch (Exception e) {
152+
throw new IdentityBrokerException("Error parsing userinfo response", e);
153+
}
154+
155+
// verify signature of the JWS
156+
if (!verify(keycloakSession, jws)) {
157+
throw new IdentityBrokerException("token signature validation failed");
158+
}
159+
160+
try {
161+
userInfo = JsonSerialization.readValue(new String(jws.getContent(), StandardCharsets.UTF_8),
162+
JsonNode.class);
163+
} catch (IOException e) {
164+
throw new IdentityBrokerException("Error parsing userinfo content", e);
135165
}
166+
} else {
167+
userInfo = parseJson(userinfoResponse);
168+
}
169+
170+
// process string value of user attributes
171+
String userAttributes = mappingModel.getConfig().get(USER_ATTRIBUTES);
172+
String[] userAttributesArr = userAttributes == null ? new String[0] : userAttributes.split(",");
173+
174+
if (userAttributesArr.length > 0) {
136175
Map<String, Object> otherClaims = token.getOtherClaims();
137-
otherClaims.put(
138-
mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME),
139-
jsonNode.get(mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME)));
140-
} catch (NullPointerException e) {
141-
logger.errorf("'%s' returned invalid response", idp);
142-
} catch (Exception e) {
143-
logger.errorf("unable to fetch attributes from userinfo endpoint '%s'", userInfoUrl);
176+
for (String userAttribute : userAttributesArr) {
177+
otherClaims.put(userAttribute, getJsonProperty(userInfo, userAttribute));
178+
}
144179
}
145180
} else {
146181
logger.error("Identity Provider [" + idp + "] does not have userinfo URL.");
@@ -165,4 +200,45 @@ public static ProtocolMapperModel create(
165200
mapper.setConfig(config);
166201
return mapper;
167202
}
203+
204+
protected boolean verify(KeycloakSession session, JWSInput jws) {
205+
206+
try {
207+
KeyWrapper key = PublicKeyStorageManager.getIdentityProviderKeyWrapper(session, session.getContext().getRealm(),
208+
getConfig(),
209+
jws);
210+
if (key == null) {
211+
logger.debugf("[IDP Userinfo] Failed to verify userinfo JWT signature, public key not found for algorithm %s",
212+
jws.getHeader().getRawAlgorithm());
213+
return false;
214+
}
215+
String algorithm = jws.getHeader().getRawAlgorithm();
216+
if (key.getAlgorithm() == null) {
217+
key.setAlgorithm(algorithm);
218+
}
219+
SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm);
220+
if (signatureProvider == null) {
221+
logger.debugf("Failed to verify userinfo JWT, signature provider not found for algorithm %s", algorithm);
222+
return false;
223+
}
224+
225+
return signatureProvider.verifier(key).verify(jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8),
226+
jws.getSignature());
227+
} catch (Exception e) {
228+
logger.debug("Failed to verify signature of userinfo JWT", e);
229+
return false;
230+
}
231+
}
232+
233+
public String getJsonProperty(JsonNode jsonNode, String name) {
234+
if (jsonNode.has(name) && !jsonNode.get(name).isNull()) {
235+
String s = jsonNode.get(name).asText();
236+
if (s != null && !s.isEmpty())
237+
return s;
238+
else
239+
return null;
240+
}
241+
242+
return null;
243+
}
168244
}

0 commit comments

Comments
 (0)