Skip to content
This repository was archived by the owner on Dec 4, 2023. It is now read-only.

Commit 3aba19f

Browse files
authored
Merge pull request #584 from microsoft/trboehre/jwt-cert
Merge pull request #567 from microsoft/trboehre/expiredcert
2 parents 934db11 + 2c5df81 commit 3aba19f

File tree

10 files changed

+445
-118
lines changed

10 files changed

+445
-118
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.bot.connector.authentication;
5+
6+
import com.auth0.jwk.Jwk;
7+
import com.auth0.jwk.JwkException;
8+
import com.auth0.jwk.SigningKeyNotFoundException;
9+
import com.auth0.jwk.UrlJwkProvider;
10+
import com.fasterxml.jackson.core.type.TypeReference;
11+
import com.fasterxml.jackson.databind.ObjectMapper;
12+
13+
import java.io.IOException;
14+
import java.net.URL;
15+
import java.security.interfaces.RSAPublicKey;
16+
import java.time.Duration;
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.stream.Collectors;
21+
22+
import org.slf4j.Logger;
23+
import org.slf4j.LoggerFactory;
24+
25+
/**
26+
* Maintains a cache of OpenID metadata keys.
27+
*/
28+
class CachingOpenIdMetadata implements OpenIdMetadata {
29+
private static final Logger LOGGER = LoggerFactory.getLogger(CachingOpenIdMetadata.class);
30+
private static final int CACHE_DAYS = 5;
31+
32+
private String url;
33+
private long lastUpdated;
34+
private ObjectMapper mapper;
35+
private Map<String, Jwk> keyCache = new HashMap<>();
36+
private final Object sync = new Object();
37+
38+
/**
39+
* Constructs a OpenIdMetaData cache for a url.
40+
*
41+
* @param withUrl The url.
42+
*/
43+
CachingOpenIdMetadata(String withUrl) {
44+
url = withUrl;
45+
mapper = new ObjectMapper().findAndRegisterModules();
46+
}
47+
48+
/**
49+
* Gets a openid key.
50+
*
51+
* <p>
52+
* Note: This could trigger a cache refresh, which will incur network calls.
53+
* </p>
54+
*
55+
* @param keyId The JWT key.
56+
* @return The cached key.
57+
*/
58+
@Override
59+
public OpenIdMetadataKey getKey(String keyId) {
60+
synchronized (sync) {
61+
// If keys are more than 5 days old, refresh them
62+
if (lastUpdated < System.currentTimeMillis() - Duration.ofDays(CACHE_DAYS).toMillis()) {
63+
refreshCache();
64+
}
65+
66+
// Search the cache even if we failed to refresh
67+
return findKey(keyId);
68+
}
69+
}
70+
71+
private void refreshCache() {
72+
keyCache.clear();
73+
74+
try {
75+
URL openIdUrl = new URL(this.url);
76+
HashMap<String, String> openIdConf =
77+
this.mapper.readValue(openIdUrl, new TypeReference<HashMap<String, Object>>() {
78+
});
79+
URL keysUrl = new URL(openIdConf.get("jwks_uri"));
80+
lastUpdated = System.currentTimeMillis();
81+
UrlJwkProvider provider = new UrlJwkProvider(keysUrl);
82+
keyCache = provider.getAll().stream().collect(Collectors.toMap(Jwk::getId, jwk -> jwk));
83+
} catch (IOException e) {
84+
LOGGER.error(String.format("Failed to load openID config: %s", e.getMessage()));
85+
lastUpdated = 0;
86+
} catch (SigningKeyNotFoundException keyexception) {
87+
LOGGER.error("refreshCache", keyexception);
88+
lastUpdated = 0;
89+
}
90+
}
91+
92+
@SuppressWarnings("unchecked")
93+
private OpenIdMetadataKey findKey(String keyId) {
94+
if (!keyCache.containsKey(keyId)) {
95+
LOGGER.warn("findKey: keyId " + keyId + " doesn't exist.");
96+
return null;
97+
}
98+
99+
try {
100+
Jwk jwk = keyCache.get(keyId);
101+
OpenIdMetadataKey key = new OpenIdMetadataKey();
102+
key.key = (RSAPublicKey) jwk.getPublicKey();
103+
key.endorsements = (List<String>) jwk.getAdditionalAttributes().get("endorsements");
104+
key.certificateChain = jwk.getCertificateChain();
105+
return key;
106+
} catch (JwkException e) {
107+
String errorDescription = String.format("Failed to load keys: %s", e.getMessage());
108+
LOGGER.warn(errorDescription);
109+
}
110+
return null;
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.bot.connector.authentication;
5+
6+
import java.util.concurrent.ConcurrentHashMap;
7+
import java.util.concurrent.ConcurrentMap;
8+
9+
/**
10+
* Maintains a cache of OpenIdMetadata objects.
11+
*/
12+
public class CachingOpenIdMetadataResolver implements OpenIdMetadataResolver {
13+
private static final ConcurrentMap<String, CachingOpenIdMetadata> OPENID_METADATA_CACHE =
14+
new ConcurrentHashMap<>();
15+
16+
/**
17+
* Gets the OpenIdMetadata object for the specified key.
18+
* @param metadataUrl The key
19+
* @return The OpenIdMetadata object. If the key is not found, an new OpenIdMetadata
20+
* object is created.
21+
*/
22+
@Override
23+
public OpenIdMetadata get(String metadataUrl) {
24+
return OPENID_METADATA_CACHE
25+
.computeIfAbsent(metadataUrl, key -> new CachingOpenIdMetadata(metadataUrl));
26+
}
27+
}

libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/EmulatorValidation.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ private EmulatorValidation() {
2323
* TO BOT FROM EMULATOR: Token validation parameters when connecting to a
2424
* channel.
2525
*/
26-
private static final TokenValidationParameters TOKENVALIDATIONPARAMETERS =
26+
public static final TokenValidationParameters TOKENVALIDATIONPARAMETERS =
2727
new TokenValidationParameters() {
2828
{
2929
this.validateIssuer = true;

libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/GovernmentChannelValidation.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public final class GovernmentChannelValidation {
2020
* TO BOT FROM GOVERNMENT CHANNEL: Token validation parameters when connecting
2121
* to a bot.
2222
*/
23-
private static final TokenValidationParameters TOKENVALIDATIONPARAMETERS =
23+
public static final TokenValidationParameters TOKENVALIDATIONPARAMETERS =
2424
new TokenValidationParameters() {
2525
{
2626
this.validateIssuer = true;

libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenExtractor.java

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,28 @@
99
import com.auth0.jwt.interfaces.DecodedJWT;
1010
import com.auth0.jwt.interfaces.Verification;
1111
import com.microsoft.bot.connector.ExecutorFactory;
12+
import java.io.ByteArrayInputStream;
13+
import java.security.cert.CertificateFactory;
14+
import java.security.cert.X509Certificate;
15+
import java.util.Base64;
16+
import java.util.Date;
1217
import org.apache.commons.lang3.StringUtils;
1318
import org.slf4j.Logger;
1419
import org.slf4j.LoggerFactory;
1520

1621
import java.util.ArrayList;
1722
import java.util.List;
1823
import java.util.concurrent.CompletableFuture;
19-
import java.util.concurrent.ConcurrentHashMap;
20-
import java.util.concurrent.ConcurrentMap;
2124

2225
/**
2326
* Extracts relevant data from JWT Tokens.
2427
*/
2528
public class JwtTokenExtractor {
26-
private static final Logger LOGGER = LoggerFactory.getLogger(OpenIdMetadata.class);
27-
private static final ConcurrentMap<String, OpenIdMetadata> OPENID_METADATA_CACHE =
28-
new ConcurrentHashMap<>();
29+
private static final Logger LOGGER = LoggerFactory.getLogger(CachingOpenIdMetadata.class);
2930

3031
private TokenValidationParameters tokenValidationParameters;
3132
private List<String> allowedSigningAlgorithms;
33+
private OpenIdMetadataResolver openIdMetadataResolver;
3234
private OpenIdMetadata openIdMetadata;
3335

3436
/**
@@ -43,13 +45,18 @@ public JwtTokenExtractor(
4345
String withMetadataUrl,
4446
List<String> withAllowedSigningAlgorithms
4547
) {
46-
4748
this.tokenValidationParameters =
4849
new TokenValidationParameters(withTokenValidationParameters);
4950
this.tokenValidationParameters.requireSignedTokens = true;
5051
this.allowedSigningAlgorithms = withAllowedSigningAlgorithms;
51-
this.openIdMetadata = OPENID_METADATA_CACHE
52-
.computeIfAbsent(withMetadataUrl, key -> new OpenIdMetadata(withMetadataUrl));
52+
53+
if (tokenValidationParameters.issuerSigningKeyResolver == null) {
54+
this.openIdMetadataResolver = new CachingOpenIdMetadataResolver();
55+
} else {
56+
this.openIdMetadataResolver = tokenValidationParameters.issuerSigningKeyResolver;
57+
}
58+
59+
this.openIdMetadata = this.openIdMetadataResolver.get(withMetadataUrl);
5360
}
5461

5562
/**
@@ -143,13 +150,27 @@ private CompletableFuture<ClaimsIdentity> validateToken(
143150
try {
144151
verification.build().verify(token);
145152

153+
// If specified, validate the signing certificate.
154+
if (
155+
tokenValidationParameters.validateIssuerSigningKey
156+
&& key.certificateChain != null
157+
&& key.certificateChain.size() > 0
158+
) {
159+
// Note that decodeCertificate will return null if the cert could not
160+
// be decoded. This would likely be the case if it were in an unexpected
161+
// encoding. Going to err on the side of ignoring this check.
162+
// May want to reconsider this and throw on null cert.
163+
X509Certificate cert = decodeCertificate(key.certificateChain.get(0));
164+
if (cert != null && !isCertValid(cert)) {
165+
throw new JWTVerificationException("Signing certificate is not valid");
166+
}
167+
}
168+
146169
// Note: On the Emulator Code Path, the endorsements collection is null so the
147-
// validation code
148-
// below won't run. This is normal.
170+
// validation code below won't run. This is normal.
149171
if (key.endorsements != null) {
150172
// Validate Channel / Token Endorsements. For this, the channelID present on the
151-
// Activity
152-
// needs to be matched by an endorsement.
173+
// Activity needs to be matched by an endorsement.
153174
boolean isEndorsed =
154175
EndorsementsValidator.validate(channelId, key.endorsements);
155176
if (!isEndorsed) {
@@ -162,8 +183,7 @@ private CompletableFuture<ClaimsIdentity> validateToken(
162183
}
163184

164185
// Verify that additional endorsements are satisfied. If no additional
165-
// endorsements are expected,
166-
// the requirement is satisfied as well
186+
// endorsements are expected, the requirement is satisfied as well
167187
boolean additionalEndorsementsSatisfied = requiredEndorsements.stream()
168188
.allMatch(
169189
(endorsement) -> EndorsementsValidator
@@ -195,4 +215,22 @@ private CompletableFuture<ClaimsIdentity> validateToken(
195215
}
196216
}, ExecutorFactory.getExecutor());
197217
}
218+
219+
private X509Certificate decodeCertificate(String certStr) {
220+
try {
221+
byte[] decoded = Base64.getDecoder().decode(certStr);
222+
return (X509Certificate) CertificateFactory
223+
.getInstance("X.509").generateCertificate(new ByteArrayInputStream(decoded));
224+
} catch (Throwable t) {
225+
return null;
226+
}
227+
}
228+
229+
private boolean isCertValid(X509Certificate cert) {
230+
long now = new Date().getTime();
231+
long clockskew = tokenValidationParameters.clockSkew.toMillis();
232+
long startValid = cert.getNotBefore().getTime() - clockskew;
233+
long endValid = cert.getNotAfter().getTime() + clockskew;
234+
return now >= startValid && now <= endValid;
235+
}
198236
}

libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/OpenIdMetadata.java

Lines changed: 6 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,108 +3,15 @@
33

44
package com.microsoft.bot.connector.authentication;
55

6-
import com.auth0.jwk.Jwk;
7-
import com.auth0.jwk.JwkException;
8-
import com.auth0.jwk.SigningKeyNotFoundException;
9-
import com.auth0.jwk.UrlJwkProvider;
10-
import com.fasterxml.jackson.core.type.TypeReference;
11-
import com.fasterxml.jackson.databind.ObjectMapper;
12-
13-
import java.io.IOException;
14-
import java.net.URL;
15-
import java.security.interfaces.RSAPublicKey;
16-
import java.time.Duration;
17-
import java.util.HashMap;
18-
import java.util.List;
19-
import java.util.Map;
20-
import java.util.stream.Collectors;
21-
22-
import org.slf4j.Logger;
23-
import org.slf4j.LoggerFactory;
24-
256
/**
26-
* Maintains a cache of OpenID metadata keys.
7+
* Fetches Jwk data.
278
*/
28-
class OpenIdMetadata {
29-
private static final Logger LOGGER = LoggerFactory.getLogger(OpenIdMetadata.class);
30-
private static final int CACHE_DAYS = 5;
31-
32-
private String url;
33-
private long lastUpdated;
34-
private ObjectMapper mapper;
35-
private Map<String, Jwk> keyCache = new HashMap<>();
36-
private final Object sync = new Object();
37-
38-
/**
39-
* Constructs a OpenIdMetaData cache for a url.
40-
*
41-
* @param withUrl The url.
42-
*/
43-
OpenIdMetadata(String withUrl) {
44-
url = withUrl;
45-
mapper = new ObjectMapper().findAndRegisterModules();
46-
}
9+
public interface OpenIdMetadata {
4710

4811
/**
49-
* Gets a openid key.
50-
*
51-
* <p>
52-
* Note: This could trigger a cache refresh, which will incur network calls.
53-
* </p>
54-
*
55-
* @param keyId The JWT key.
56-
* @return The cached key.
12+
* Returns the partial Jwk data for a key.
13+
* @param keyId The key id.
14+
* @return The Jwk data.
5715
*/
58-
public OpenIdMetadataKey getKey(String keyId) {
59-
synchronized (sync) {
60-
// If keys are more than 5 days old, refresh them
61-
if (lastUpdated < System.currentTimeMillis() - Duration.ofDays(CACHE_DAYS).toMillis()) {
62-
refreshCache();
63-
}
64-
65-
// Search the cache even if we failed to refresh
66-
return findKey(keyId);
67-
}
68-
}
69-
70-
private void refreshCache() {
71-
keyCache.clear();
72-
73-
try {
74-
URL openIdUrl = new URL(this.url);
75-
HashMap<String, String> openIdConf =
76-
this.mapper.readValue(openIdUrl, new TypeReference<HashMap<String, Object>>() {
77-
});
78-
URL keysUrl = new URL(openIdConf.get("jwks_uri"));
79-
lastUpdated = System.currentTimeMillis();
80-
UrlJwkProvider provider = new UrlJwkProvider(keysUrl);
81-
keyCache = provider.getAll().stream().collect(Collectors.toMap(Jwk::getId, jwk -> jwk));
82-
} catch (IOException e) {
83-
LOGGER.error(String.format("Failed to load openID config: %s", e.getMessage()));
84-
lastUpdated = 0;
85-
} catch (SigningKeyNotFoundException keyexception) {
86-
LOGGER.error("refreshCache", keyexception);
87-
lastUpdated = 0;
88-
}
89-
}
90-
91-
@SuppressWarnings("unchecked")
92-
private OpenIdMetadataKey findKey(String keyId) {
93-
if (!keyCache.containsKey(keyId)) {
94-
LOGGER.warn("findKey: keyId " + keyId + " doesn't exist.");
95-
return null;
96-
}
97-
98-
try {
99-
Jwk jwk = keyCache.get(keyId);
100-
OpenIdMetadataKey key = new OpenIdMetadataKey();
101-
key.key = (RSAPublicKey) jwk.getPublicKey();
102-
key.endorsements = (List<String>) jwk.getAdditionalAttributes().get("endorsements");
103-
return key;
104-
} catch (JwkException e) {
105-
String errorDescription = String.format("Failed to load keys: %s", e.getMessage());
106-
LOGGER.warn(errorDescription);
107-
}
108-
return null;
109-
}
16+
OpenIdMetadataKey getKey(String keyId);
11017
}

0 commit comments

Comments
 (0)