/*
 * 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.common.bytes.ByteStringBuilder;
import org.wildfly.common.iteration.CodePointIterator;
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 jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
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.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

import static org.wildfly.security.auth.realm.token._private.ElytronMessages.log;
import static org.wildfly.security.json.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;
    private final int connectionTimeout;
    private final int readTimeout;

    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;
        this.connectionTimeout = configuration.connectionTimeout;
        this.readTimeout = configuration.readTimeout;
    }

    @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 (final InputStream inputStream = new BufferedInputStream(connection.getInputStream());
                    final JsonReader jsonReader = Json.createReader(inputStream)) {
                return jsonReader.readObject();
            }
        } catch (IOException ioe) {
            if (connection != null && connection.getErrorStream() != null) {
                InputStream errorStream = connection.getErrorStream();

                try (BufferedReader reader = new BufferedReader(new InputStreamReader(errorStream, StandardCharsets.UTF_8))) {
                    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);
                }
            }
            connection.setConnectTimeout(connectionTimeout);
            connection.setReadTimeout(readTimeout);

            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 int connectionTimeout;
        private int readTimeout;

        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;
        }

        /**
         * Sets the connection timeout to a specified timeout, in milliseconds. A non-zero value specifies the timeout when connecting
         * to a resource. A timeout of zero is interpreted as an infinite timeout.
         * @param connectionTimeout the connection timeout
         * @return this instance
         */
        public Builder connectionTimeout(int connectionTimeout) {
            this.connectionTimeout = connectionTimeout;
            return this;
        }

        /**
         * Sets the read timeout to a specified timeout, in milliseconds. A non-zero value specifies the timeout when reading
         * from Input stream when a connection is established to a resource. A timeout of zero is interpreted as an infinite timeout.
         * @param readTimeout the read timeout
         * @return this instance
         */
        public Builder readTimeout(int readTimeout) {
            this.readTimeout = readTimeout;
            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);
        }
    }
}
