Skip to content

Commit

Permalink
Allow for multiple public keys and multiple public key endpoints in r…
Browse files Browse the repository at this point in the history
…adar-auth

Also allow for EC public keys next to RSA public keys. This will allow clients of the radar-auth library to use any combination of public keys and server public key endpoints, increasing the flexibility. This will also be necessary when MP switches to EC public keys, since there will be a transition period where there are clients with RSA signatures and clients with EC signatures.
  • Loading branch information
dennyverbeeck committed Jun 7, 2018
1 parent bb7306c commit b06e8df
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 144 deletions.
31 changes: 19 additions & 12 deletions radar-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,37 @@ compile group: 'org.radarcns', name: 'radar-auth', version: '0.3.6'

The library expects the identity server configuration in a file called `radar-is.yml`. Either set
the environment variable `RADAR_IS_CONFIG_LOCATION` to the full path of the file, or put the file
somewhere on the classpath. The file should define `resourceName` and either of `publicKeyEndpoint`
or `publicKey`. If both are specified, `publicKey` has the priority.
somewhere on the classpath. The file should define `resourceName` and at least one of
`publicKeyEndpoints` or `publicKeys`. You can specify both, than public keys fetched from the
endpoints as well as public keys defined in the file will be used for validation.

| Variable name | Description |
|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `resourceName` | The name of this resource. It has to appear in the `audience` claim of a JWT token in order for the token to be accepted. |
| `publicKeyEndpoint` | Server endpoint that provides the public key of the keypair used to sign the JWTs. The expected response from this endpoint is a JSON structure containing two fields: `alg` and `value`, where `alg` should be equal to `SHA256withRSA`, and `value` should be equal to the public key in PEM format. |
| `publicKey` | PEM formatted public key for JWT validation. You can use YAML [literal style] to conveniently specify a multiline value for a variable. Also handy for testing scenario's where you don't necessarily have access to the public key endpoint. |
| Variable name | Description |
|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `resourceName` | The name of this resource. It has to appear in the `audience` claim of a JWT token in order for the token to be accepted. |
| `publicKeyEndpoints` | List of server endpoints that provide the public key of the keypair used to sign the JWTs. The expected response from this endpoint is a JSON structure containing two fields: `alg` and `value`, where `alg` should be equal to `SHA256withRSA` or ``SHA256withECDSA`, and `value` should be equal to the public key in PEM format. |
| `publicKeys` | List of PEM formatted public keys for JWT validation. You can use YAML [literal style] to conveniently specify a multiline value for a variable. Also handy for testing scenario's where you don't necessarily have access to the public key endpoint. Entries can be RSA or EC public keys. |

For example:

```yaml
resourceName: resource_name
publicKeyEndpoint: http://localhost:8080/oauth/token_key
publicKeyEndpoints:
- http://localhost:8080/oauth/token_key
```
or
```yaml
resourceName: res_ManagementPortal
publicKey: |-
-----BEGIN PUBLIC KEY-----
MIICHDANBgkqhkiG9w0BAQEFAAOCAgkAMIICBAKCAfsAqM4o+hVAdF2QATQBmpehSMyhdqKvwh9mrfnxDNtctZYlpiQXMbq4uqRgp98aBy6bMKKr3k0rSXTzr27Y+tdLUWXqbl4y8kKm8rGZo9gTbPyhqPm4f4OIxMRJcuhQ7f8qBY87w9buzClQeUs3h5f+DUVRUfB9FnDtim+ma3mFqYh38TMnrBapCtG+7iVKRFgGv6JWiNTql+oVBPNuUX3koc5/zO6IhrD49vBbsjaRWTJV2xMNll82gPvVLtgQNA2t7iGnUPhfKDj1NInZeg79NzFnWAa9Jtc1r2Q7D68MiJhYZN2QAlZS1GfbELnRAeUmSxT5i3BHu23iz9zluhIhYe1vhA1QWk2HsriGL9w+iFqzYlk5P3GCAE+nfNmM/6GIp1ehzW+/4+xgik5rOakCWw4vewmSBWOrV/XZvT2ZT3AA6zIByWdERyMOVJmd9rqPH1FIDtQk8h2jFTqIvBda727DHXeUB9J4hHQTzQmvOxPMipwDslxWOjnG4nbq6Exme0o/ELMOxt+4APH6KW+LqCNl5jGdbKxySLQyNgfUjhXJ06U1b8JHPheTnWcKO+cMmhyheUkZmLMLK2mlAsR+JJeBDY1/jd7+q6hgymeJzoDoXJj4LARiYZ+StRr/E0+P8DrprWYZPi496VIzwgV8otV9fVz29V501rcCAwEAAQ==
-----END PUBLIC KEY-----
publicKeys:
- |-
-----BEGIN PUBLIC KEY-----
MIICHDANBgkqhkiG9w0BAQEFAAOCAgkAMIICBAKCAfsAqM4o+hVAdF2QATQBmpehSMyhdqKvwh9mrfnxDNtctZYlpiQXMbq4uqRgp98aBy6bMKKr3k0rSXTzr27Y+tdLUWXqbl4y8kKm8rGZo9gTbPyhqPm4f4OIxMRJcuhQ7f8qBY87w9buzClQeUs3h5f+DUVRUfB9FnDtim+ma3mFqYh38TMnrBapCtG+7iVKRFgGv6JWiNTql+oVBPNuUX3koc5/zO6IhrD49vBbsjaRWTJV2xMNll82gPvVLtgQNA2t7iGnUPhfKDj1NInZeg79NzFnWAa9Jtc1r2Q7D68MiJhYZN2QAlZS1GfbELnRAeUmSxT5i3BHu23iz9zluhIhYe1vhA1QWk2HsriGL9w+iFqzYlk5P3GCAE+nfNmM/6GIp1ehzW+/4+xgik5rOakCWw4vewmSBWOrV/XZvT2ZT3AA6zIByWdERyMOVJmd9rqPH1FIDtQk8h2jFTqIvBda727DHXeUB9J4hHQTzQmvOxPMipwDslxWOjnG4nbq6Exme0o/ELMOxt+4APH6KW+LqCNl5jGdbKxySLQyNgfUjhXJ06U1b8JHPheTnWcKO+cMmhyheUkZmLMLK2mlAsR+JJeBDY1/jd7+q6hgymeJzoDoXJj4LARiYZ+StRr/E0+P8DrprWYZPi496VIzwgV8otV9fVz29V501rcCAwEAAQ==
-----END PUBLIC KEY-----
- |-
-----BEGIN EC PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvmBia5inhASHAVrFBB5JAh0ne/aKb6z/sCIuWzKp/azFcD/OPJ2H6RPLn3t7XA4oAa2FR3GB4ZhU7SCh20FUhA==
-----END EC PUBLIC KEY-----
```
Usage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,31 @@
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.bouncycastle.util.io.pem.PemReader;
import org.radarcns.auth.config.ServerConfig;
import org.radarcns.auth.config.YamlServerConfig;
import org.radarcns.auth.exception.TokenValidationException;
import org.radarcns.auth.token.JwtRadarToken;
import org.radarcns.auth.token.RadarToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.InputStream;
import java.io.StringReader;
import java.net.URI;
import java.net.URLConnection;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.bouncycastle.util.io.pem.PemReader;
import org.radarcns.auth.config.ServerConfig;
import org.radarcns.auth.config.YamlServerConfig;
import org.radarcns.auth.exception.TokenValidationException;
import org.radarcns.auth.token.JwtRadarToken;
import org.radarcns.auth.token.RadarToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Validates JWT token signed by the Management Portal. It is synchronized and may be used from
Expand All @@ -44,7 +49,7 @@ public class TokenValidator {
JwtRadarToken.GRANT_TYPE_CLAIM, JwtRadarToken.SCOPE_CLAIM);

private final ServerConfig config;
private JWTVerifier verifier;
private List<JWTVerifier> verifiers = new LinkedList<>();

// If a client presents a token with an invalid signature, it might be the keypair was changed.
// In that case we need to fetch it again, but we don't want a malicious client to be able to
Expand Down Expand Up @@ -110,39 +115,41 @@ public TokenValidator(ServerConfig config, long fetchTimeout) {
* @throws TokenValidationException If the token can not be validated.
*/
public RadarToken validateAccessToken(String token) throws TokenValidationException {
try {
DecodedJWT jwt = getVerifier().verify(token);
Set<String> claims = jwt.getClaims().keySet();
Set<String> missing = REQUIRED_CLAIMS.stream()
.filter(c -> !claims.contains(c))
.collect(Collectors.toSet());
if (!missing.isEmpty()) {
throw new TokenValidationException("The following required claims were missing "
+ "from the token: " + String.join(", ", missing));
for (JWTVerifier verifier : getVerifiers()) {
try {
DecodedJWT jwt = verifier.verify(token);
Set<String> claims = jwt.getClaims().keySet();
Set<String> missing = REQUIRED_CLAIMS.stream()
.filter(c -> !claims.contains(c)).collect(Collectors.toSet());
if (!missing.isEmpty()) {
throw new TokenValidationException("The following required claims were "
+ "missing from the token: " + String.join(", ", missing));
}
return new JwtRadarToken(jwt);
} catch (SignatureVerificationException sve) {
log.warn("Client presented a token with an incorrect signature, fetching public key"
+ " again. Token: {}", token);
refresh();
return validateAccessToken(token);
} catch (JWTVerificationException ex) {
throw new TokenValidationException(ex);
}
return new JwtRadarToken(jwt);
} catch (SignatureVerificationException sve) {
log.warn("Client presented a token with an incorrect signature, fetching public key"
+ " again. Token: {}", token);
refresh();
return validateAccessToken(token);
} catch (JWTVerificationException ex) {
throw new TokenValidationException(ex);
}
throw new TokenValidationException("No registered validator could authenticate this token");
}

private JWTVerifier getVerifier() {
private List<JWTVerifier> getVerifiers() {
synchronized (this) {
if (verifier != null) {
return verifier;
if (!verifiers.isEmpty()) {
return verifiers;
}
}

JWTVerifier localVerifier = loadVerifier();
List<JWTVerifier> localVerifiers = loadVerifiers();

synchronized (this) {
verifier = localVerifier;
return verifier;
verifiers = localVerifiers;
return verifiers;
}
}

Expand All @@ -151,13 +158,13 @@ private JWTVerifier getVerifier() {
* @throws TokenValidationException if the public key could not be refreshed.
*/
public void refresh() throws TokenValidationException {
JWTVerifier localVerifier = loadVerifier();
List<JWTVerifier> localVerifiers = loadVerifiers();
synchronized (this) {
this.verifier = localVerifier;
this.verifiers = localVerifiers;
}
}

private JWTVerifier loadVerifier() throws TokenValidationException {
private List<JWTVerifier> loadVerifiers() throws TokenValidationException {
synchronized (this) {
// whether successful or not, do not request the key more than once per minute
if (Instant.now().isBefore(lastFetch.plus(fetchTimeout))) {
Expand All @@ -169,47 +176,49 @@ private JWTVerifier loadVerifier() throws TokenValidationException {
lastFetch = Instant.now();
}

RSAPublicKey publicKey;
if (config.getPublicKey() == null) {
publicKey = publicKeyFromServer();
} else {
publicKey = config.getPublicKey();
List<PublicKey> publicKeys = new LinkedList<>();
if (config.getPublicKeyEndpoints() != null) {
publicKeys.addAll(config.getPublicKeyEndpoints().stream()
.map(this::publicKeyFromServer).collect(Collectors.toList()));
}
Algorithm alg = Algorithm.RSA256(publicKey, null);
// we successfully fetched the public key, reset the timer
return JWT.require(alg)
if (config.getPublicKeys() != null) {
publicKeys.addAll(config.getPublicKeys());
}

// Create a verifier for each public key we have in our config
return publicKeys.stream().map(key -> JWT.require(publicKeyToAlgorithm(key))
.withAudience(config.getResourceName())
.build();
.build())
.collect(Collectors.toList());
}

private RSAPublicKey publicKeyFromServer() throws TokenValidationException {
log.info("Getting the JWT public key at " + config.getPublicKeyEndpoint());

private PublicKey publicKeyFromServer(URI serverUri) throws TokenValidationException {
log.info("Getting the JWT public key at " + serverUri);
try {
URLConnection connection = config.getPublicKeyEndpoint().toURL().openConnection();
URLConnection connection = serverUri.toURL().openConnection();
connection.setRequestProperty("Accept", "application/json");
try (InputStream inputStream = connection.getInputStream()) {
ObjectMapper mapper = new ObjectMapper();
JsonNode publicKeyInfo = mapper.readTree(inputStream);

// We expect RSA algorithm, and deny to trust the public key otherwise, see also
// We expect RSA or ECDSA algorithm, and deny to trust the public key otherwise
// https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
if (!publicKeyInfo.get("alg").asText().equals("SHA256withRSA")) {
throw new TokenValidationException("The identity server reported the following "
+ "signing algorithm: " + publicKeyInfo.get("alg")
+ ". Expected SHA256withRSA.");
if (publicKeyInfo.get("alg").asText().equals("SHA256withRSA")) {
return rsaPublicKeyFromString(publicKeyInfo.get("value").asText());
}

String keyString = publicKeyInfo.get("value").asText();
return publicKeyFromString(keyString);
if (publicKeyInfo.get("alg").asText().equals("SHA256withECDSA")) {
return ecPublicKeyFromString(publicKeyInfo.get("value").asText());
}
throw new TokenValidationException("The identity server reported the following "
+ "signing algorithm: " + publicKeyInfo.get("alg")
+ ". Expected SHA256withRSA or SHA256withECDSA.");
}
} catch (Exception ex) {
throw new TokenValidationException(ex);
}
}

private RSAPublicKey publicKeyFromString(String keyString) throws TokenValidationException {
log.debug("Parsing public key: " + keyString);
private RSAPublicKey rsaPublicKeyFromString(String keyString) throws TokenValidationException {
log.debug("Parsing RSA public key: " + keyString);
try (PemReader pemReader = new PemReader(new StringReader(keyString))) {
byte[] keyBytes = pemReader.readPemObject().getContent();
pemReader.close();
Expand All @@ -220,4 +229,29 @@ private RSAPublicKey publicKeyFromString(String keyString) throws TokenValidatio
throw new TokenValidationException(ex);
}
}

private ECPublicKey ecPublicKeyFromString(String keyString) throws TokenValidationException {
log.debug("Parsing EC public key: " + keyString);
try (PemReader pemReader = new PemReader(new StringReader(keyString))) {
byte[] keyBytes = pemReader.readPemObject().getContent();
pemReader.close();
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("ECDSA");
return (ECPublicKey) kf.generatePublic(spec);
} catch (Exception ex) {
throw new TokenValidationException(ex);
}
}

private Algorithm publicKeyToAlgorithm(PublicKey key) throws IllegalArgumentException {
switch (key.getAlgorithm()) {
case "EC":
return Algorithm.ECDSA256((ECPublicKey) key, null);
case "RSA":
return Algorithm.RSA256((RSAPublicKey) key, null);
default:
throw new IllegalArgumentException("Unsupported public key algorithm: {}. "
+ "Expected either EC or RSA.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package org.radarcns.auth.config;

import java.net.URI;
import java.security.interfaces.RSAPublicKey;
import java.security.PublicKey;
import java.util.List;

public interface ServerConfig {

/**
* Get the public key endpoint as a URI.
* @return The public key endpoint URI, or <code>null</code> if not defined
*/
URI getPublicKeyEndpoint();
List<URI> getPublicKeyEndpoints();

/**
* The name of this resource. It should be in the list of allowed resources for the OAuth
Expand All @@ -19,9 +20,9 @@ public interface ServerConfig {
String getResourceName();

/**
* Get the public key set in the config file.
* @return The public key, or <code>null</code> if not defined
* Get the public keys set in the config file.
* @return The public keys, or <code>null</code> if not defined
*/
RSAPublicKey getPublicKey();
List<PublicKey> getPublicKeys();

}
Loading

0 comments on commit b06e8df

Please sign in to comment.