/*
* JBoss, Home of Professional Open Source.
* Copyright 2016 Red Hat, Inc., and individual 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.wildfly.security.auth.realm.token.validator;
import org.wildfly.security.auth.realm.token.TokenValidator;
import org.wildfly.security.auth.server.RealmUnavailableException;
import org.wildfly.security.authz.Attributes;
import org.wildfly.security.evidence.BearerTokenEvidence;
import org.wildfly.security.pem.Pem;
import org.wildfly.security.pem.PemEntry;
import org.wildfly.security.util.ByteIterator;
import org.wildfly.security.util.CodePointIterator;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonString;
import javax.json.JsonValue;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.util.Base64;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import static java.util.Arrays.asList;
import static org.wildfly.common.Assert.checkNotNullParam;
import static org.wildfly.security._private.ElytronMessages.log;
import static org.wildfly.security.util.JsonUtil.toAttributes;
/**
* <p>A {@link TokenValidator} capable of validating and parsing JWT. Most of the validations performed by this validator are
* based on RFC-7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants).
*
* <p>This validator can also be used as a JWT parser only. In this case, for security reasons, you need to make sure that
* JWT validations such as issuer, audience and signature checks are performed before obtaining identities from this realm.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class JwtValidator implements TokenValidator {
/**
* Returns a {@link Builder} instance that can be used to configure and create a {@link JwtValidator}.
*
* @return a {@link Builder} instance
*/
public static Builder builder() {
return new Builder();
}
private final Set<String> issuers;
private final Set<String> audiences;
private final PublicKey publicKey;
JwtValidator(Builder configuration) {
this.issuers = checkNotNullParam("issuers", configuration.issuers);
this.audiences = checkNotNullParam("audience", configuration.audience);
this.publicKey = configuration.publicKey;
if (issuers.isEmpty()) {
log.tokenRealmJwtWarnNoIssuerIgnoringIssuerCheck();
}
if (audiences.isEmpty()) {
log.tokenRealmJwtWarnNoAudienceIgnoringAudienceCheck();
}
if (publicKey == null) {
log.tokenRealmJwtWarnNoPublicKeyIgnoringSignatureCheck();
}
}
@Override
public Attributes validate(BearerTokenEvidence evidence) throws RealmUnavailableException {
checkNotNullParam("evidence", evidence);
String jwt = evidence.getToken();
String[] parts = jwt.split("\\.");
if (parts.length < 3) {
throw log.tokenRealmJwtInvalidFormat();
}
String encodedHeader = parts[0];
String encodedClaims = parts[1];
String encodedSignature = parts[2];
JsonObject claims = extractClaims(encodedClaims);
if (verifySignature(encodedHeader, encodedClaims, encodedSignature)
&& hasValidIssuer(claims)
&& hasValidAudience(claims)
&& verifyTimeConstraints(claims)) {
return toAttributes(claims);
}
return null;
}
private boolean verifyTimeConstraints(JsonObject claims) {
int currentTime = currentTimeInSeconds();
boolean expired = currentTime > claims.getInt("exp", -1);
if (expired) {
log.debug("Token expired");
return false;
}
if (claims.containsKey("nbf")) {
boolean notBefore = currentTime >= claims.getInt("nbf");
if (!notBefore) {
log.debugf("Token is before [%s]", notBefore);
return false;
}
}
return true;
}
private JsonObject extractClaims(String encodedClaims) throws RealmUnavailableException {
try {
Base64.Decoder urlDecoder = Base64.getUrlDecoder();
CodePointIterator decodedClaims = CodePointIterator.ofUtf8Bytes(urlDecoder.decode(encodedClaims));
return Json.createReader(decodedClaims.asUtf8().asInputStream()).readObject();
} catch (Exception cause) {
throw log.tokenRealmJwtParseFailed(cause);
}
}
private boolean verifySignature(String encodedHeader, String encodedClaims, String encodedSignature) throws RealmUnavailableException {
if (publicKey == null) {
return true;
}
try {
Base64.Decoder urlDecoder = Base64.getUrlDecoder();
byte[] decodedSignature = urlDecoder.decode(encodedSignature);
boolean verify = ByteIterator.ofBytes(decodedSignature).verify(createSignature(encodedHeader, encodedClaims));
if (!verify) {
log.debug("Signature verification failed");
}
return verify;
} catch (Exception cause) {
throw log.tokenRealmJwtSignatureCheckFailed(cause);
}
}
private boolean hasValidAudience(JsonObject claims) throws RealmUnavailableException {
JsonValue audience = claims.get("aud");
if (audience == null) {
log.debug("Token does not contain an audience claim");
return false;
}
JsonArray audClaimArray;
if (JsonValue.ValueType.STRING.equals(audience.getValueType())) {
audClaimArray = Json.createArrayBuilder().add(audience).build();
} else {
audClaimArray = (JsonArray) audience;
}
boolean valid = audClaimArray.stream()
.map(jsonValue -> (JsonString) jsonValue)
.anyMatch(audience1 -> audiences.contains(audience1.getString())) || audiences.isEmpty();
if (!valid) {
log.debugf("Audience check failed. Provided [%s] but was expected [%s].", audClaimArray.toArray(), this.audiences);
}
return valid;
}
private boolean hasValidIssuer(JsonObject claims) throws RealmUnavailableException {
String issuer = claims.getString("iss", null);
if (issuer == null) {
return false;
}
boolean valid = this.issuers.contains(issuer) || this.issuers.isEmpty();
if (!valid) {
log.debugf("Issuer check failed. Provided [%s] but was expected [%s].", issuer, this.issuers);
}
return valid;
}
private Signature createSignature(String encodedHeader, String encodedClaims) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, RealmUnavailableException {
Signature signature = Signature.getInstance(resolveAlgorithm(encodedHeader));
signature.initVerify(this.publicKey);
signature.update((encodedHeader + "." + encodedClaims).getBytes());
return signature;
}
private String resolveAlgorithm(String part) throws RealmUnavailableException {
byte[] headerDecoded = Base64.getUrlDecoder().decode(part);
JsonObject headers = Json.createReader(ByteIterator.ofBytes(headerDecoded).asInputStream()).readObject();
JsonString algClaim = (JsonString) headers.get("alg");
if (algClaim == null) {
throw log.tokenRealmJwtSignatureInvalidAlgorithm("not_provided");
}
String algorithm = algClaim.getString();
log.debugf("Token is using algorithm [%s]", algorithm);
switch (algorithm) {
case "RS256":
return "SHA256withRSA";
case "RS384":
return "SHA384withRSA";
case "RS512":
return "SHA512withRSA";
default:
throw log.tokenRealmJwtSignatureInvalidAlgorithm(algorithm);
}
}
private int currentTimeInSeconds() {
return ((int) (System.currentTimeMillis() / 1000));
}
public static class Builder {
private Set<String> issuers = new LinkedHashSet<>();
private Set<String> audience = new LinkedHashSet<>();
private PublicKey publicKey;
private Builder() {
}
/**
* <p>Defines one or more string values representing an unique identifier for the entities that are allowed as issuers of a given JWT. During validation
* JWT tokens must have a <code>iss</code> claim that contains one of the values defined here.
*
* <p>If not provided, the validator will not perform validations based on the issuer claim.
*
* @param issuer one or more string values representing the valid issuers
* @return this instance
*/
public Builder issuer(String... issuer) {
this.issuers.addAll(asList(issuer));
return this;
}
/**
* <p>Defines one or more string values representing the audiences supported by this configuration. During validation JWT tokens
* must have an <code>aud</code> claim that contains one of the values defined here.
*
* <p>If not provided, the validator will not perform validations based on the audience claim.
*
* @param audience one or more string values representing the valid audiences
* @return this instance
*/
public Builder audience(String... audience) {
this.audience.addAll(asList(audience));
return this;
}
/**
* <p>A public key in its PEM format used to validate the signature.
*
* <p>If not provided, the validator will not validate signatures.
*
* @param publicKeyPem the public key in its PEM format
* @return this instance
*/
public Builder publicKey(byte[] publicKeyPem) {
Iterator<PemEntry<?>> pemEntryIterator = Pem.parsePemContent(CodePointIterator.ofUtf8Bytes(publicKeyPem));
PublicKey publicKey = pemEntryIterator.next().tryCast(PublicKey.class);
if (publicKey == null) {
throw log.tokenRealmJwtInvalidPublicKeyPem();
}
this.publicKey = publicKey;
return this;
}
/**
* <p>A {@link PublicKey} format used to validate the signature.
*
* <p>If not provided, the validator will not validate signatures.
*
* @param publicKey the public key in its PEM format
* @return this instance
*/
public Builder publicKey(PublicKey publicKey) {
this.publicKey = publicKey;
return this;
}
/**
* Returns a {@link JwtValidator} instance based on all the configuration provided with this builder.
*
* @return a new {@link JwtValidator} instance with all the given configuration
*/
public JwtValidator build() {
return new JwtValidator(this);
}
}
}