/*
* 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.common.Assert;
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.util.ByteStringBuilder;
import org.wildfly.security.util.CodePointIterator;
import javax.json.Json;
import javax.json.JsonObject;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import static org.wildfly.security._private.ElytronMessages.log;
import static org.wildfly.security.util.JsonUtil.toAttributes;
/**
* A RFC-7662 (OAuth2 Token Introspection) compliant {@link TokenValidator}.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class OAuth2IntrospectValidator implements TokenValidator {
/**
* Returns a {@link Builder} instance that can be used to configure and create a {@link OAuth2IntrospectValidator}.
*
* @return the {@link Builder}
*/
public static Builder builder() {
return new Builder();
}
private final URL tokenIntrospectionUrl;
private final String clientId;
private final String clientSecret;
private final SSLContext sslContext;
private final HostnameVerifier hostnameVerifier;
OAuth2IntrospectValidator(Builder configuration) {
this.tokenIntrospectionUrl = Assert.checkNotNullParam("tokenIntrospectionUrl", configuration.tokenIntrospectionUrl);
this.clientId = Assert.checkNotNullParam("clientId", configuration.clientId);
this.clientSecret = Assert.checkNotNullParam("clientSecret", configuration.clientSecret);
if (tokenIntrospectionUrl.getProtocol().equalsIgnoreCase("https")) {
Assert.checkNotNullParam("sslContext", configuration.sslContext);
}
this.sslContext = configuration.sslContext;
this.hostnameVerifier = configuration.hostnameVerifier;
}
@Override
public Attributes validate(BearerTokenEvidence evidence) throws RealmUnavailableException {
Assert.checkNotNullParam("evidence", evidence);
try {
JsonObject claims = introspectAccessToken(this.tokenIntrospectionUrl,
this.clientId, this.clientSecret, evidence.getToken(), this.sslContext, this.hostnameVerifier);
if (isValidToken(claims)) {
return toAttributes(claims);
}
} catch (Exception e) {
throw log.tokenRealmOAuth2TokenIntrospectionFailed(e);
}
return null;
}
private boolean isValidToken(JsonObject claims) {
return claims != null && claims.getBoolean("active", false);
}
/**
* Introspects an OAuth2 Access Token using a RFC-7662 compatible endpoint.
*
* @param tokenIntrospectionUrl an {@link URL} pointing to a RFC-7662 compatible endpoint
* @param clientId the identifier of a client within the OAUth2 Authorization Server
* @param clientSecret the secret of the client
* @param token the access token to introspect
* @param sslContext the ssl context
* @param hostnameVerifier the hostname verifier
* @return a @{JsonObject} representing the response from the introspection endpoint or null if
*/
private JsonObject introspectAccessToken(URL tokenIntrospectionUrl, String clientId, String clientSecret, String token, SSLContext sslContext, HostnameVerifier hostnameVerifier) throws RealmUnavailableException {
Assert.checkNotNullParam("clientId", clientId);
Assert.checkNotNullParam("clientSecret", clientSecret);
Assert.checkNotNullParam("token", token);
HttpURLConnection connection = null;
try {
connection = openConnection(tokenIntrospectionUrl, sslContext, hostnameVerifier);
HashMap<String, String> parameters = new HashMap<>();
parameters.put("token", token);
parameters.put("token_type_hint", "access_token");
byte[] params = buildParameters(parameters);
connection.setDoOutput(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
connection.setRequestProperty("Content-Length", String.valueOf(params.length));
connection.setRequestProperty("Authorization", "Basic " + CodePointIterator.ofString(clientId + ":" + clientSecret).asUtf8().base64Encode().drainToString());
try (OutputStream outputStream = connection.getOutputStream()) {
outputStream.write(params);
}
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
return Json.createReader(inputStream).readObject();
}
} catch (IOException ioe) {
if (connection != null && connection.getErrorStream() != null) {
InputStream errorStream = connection.getErrorStream();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(errorStream))) {
StringBuffer response = reader.lines().reduce(new StringBuffer(), StringBuffer::append, (buffer1, buffer2) -> buffer1);
log.errorf(ioe, "Unexpected response from token introspection endpoint [%s]. Response: [%s]", tokenIntrospectionUrl, response);
} catch (IOException e) {
throw log.tokenRealmOAuth2TokenIntrospectionFailed(ioe);
}
} else {
throw log.tokenRealmOAuth2TokenIntrospectionFailed(ioe);
}
} catch (Exception e) {
throw log.tokenRealmOAuth2TokenIntrospectionFailed(e);
}
return null;
}
private HttpURLConnection openConnection(URL url, SSLContext sslContext, HostnameVerifier hostnameVerifier) throws IOException {
Assert.checkNotNullParam("url", url);
boolean isHttps = url.getProtocol().equalsIgnoreCase("https");
try {
log.debugf("Opening connection to token introspection endpoint [%s]", url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
if (isHttps) {
HttpsURLConnection https = (HttpsURLConnection) connection;
https.setSSLSocketFactory(sslContext.getSocketFactory());
if (hostnameVerifier != null) {
https.setHostnameVerifier(hostnameVerifier);
}
}
return connection;
} catch (IOException cause) {
throw cause;
}
}
private byte[] buildParameters(Map<String, String> parameters) throws UnsupportedEncodingException {
ByteStringBuilder params = new ByteStringBuilder();
parameters.entrySet().stream().forEach(entry -> {
if (params.length() > 0) {
params.append('&');
}
params.append(entry.getKey()).append('=').append(entry.getValue());
});
return params.toArray();
}
public static class Builder {
private String clientId;
private String clientSecret;
private URL tokenIntrospectionUrl;
private SSLContext sslContext;
private HostnameVerifier hostnameVerifier;
private Builder() {
}
/**
* An {@link URL} pointing to a RFC-7662 OAuth2 Token Introspection compatible endpoint.
*
* @param url the token introspection endpoint
* @return this instance
*/
public Builder tokenIntrospectionUrl(URL url) {
this.tokenIntrospectionUrl = url;
return this;
}
/**
* <p>The identifier of a client registered within the OAuth2 Authorization Server that will be used to authenticate this server
* in order to validate bearer tokens arriving to this server.
*
* <p>Please note that the client will be usually a confidential client with both an identifier and secret configured in order to
* authenticate against the token introspection endpoint. In this case, the endpoint must support HTTP BASIC authentication using
* the client credentials (both id and secret).
*
* @param clientId the identifier of a client within the OAUth2 Authorization Server
* @return this instance
*/
public Builder clientId(String clientId) {
this.clientId = clientId;
return this;
}
/**
* The secret of the client identified by the given {@link #clientId}.
*
* @param clientSecret the secret of the client
* @return this instance
*/
public Builder clientSecret(String clientSecret) {
this.clientSecret = clientSecret;
return this;
}
/**
* <p>A predefined {@link SSLContext} that will be used to connect to the token introspection endpoint when using SSL/TLS. This configuration is mandatory
* if the given token introspection url is using SSL/TLS.
*
* @param sslContext the SSL context
* @return this instance
*/
public Builder useSslContext(SSLContext sslContext) {
this.sslContext = sslContext;
return this;
}
/**
* A {@link HostnameVerifier} that will be used to validate the hostname when using SSL/TLS. This configuration is mandatory
* if the given token introspection url is using SSL/TLS.
*
* @param hostnameVerifier the hostname verifier
* @return this instance
*/
public Builder useSslHostnameVerifier(HostnameVerifier hostnameVerifier) {
this.hostnameVerifier = hostnameVerifier;
return this;
}
/**
* Returns a {@link OAuth2IntrospectValidator} instance based on all the configuration provided with this builder.
*
* @return a new {@link OAuth2IntrospectValidator} instance with all the given configuration
*/
public OAuth2IntrospectValidator build() {
return new OAuth2IntrospectValidator(this);
}
}
}