/*
 * Copyright 2017-2019, Strimzi authors.
 * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
 */
package io.strimzi.kafka.oauth.validator;

import com.fasterxml.jackson.databind.JsonNode;
import io.strimzi.kafka.oauth.common.JSONUtil;
import io.strimzi.kafka.oauth.common.PrincipalExtractor;
import io.strimzi.kafka.oauth.common.TimeUtil;
import io.strimzi.kafka.oauth.common.TokenInfo;
import org.apache.kafka.common.utils.Time;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

import static io.strimzi.kafka.oauth.common.HttpUtil.post;
import static io.strimzi.kafka.oauth.common.HttpUtil.get;
import static io.strimzi.kafka.oauth.common.LogUtil.mask;
import static io.strimzi.kafka.oauth.common.OAuthAuthenticator.base64encode;
import static io.strimzi.kafka.oauth.validator.TokenValidationException.Status;

public class OAuthIntrospectionValidator implements TokenValidator {

    private static final Logger log = LoggerFactory.getLogger(OAuthIntrospectionValidator.class);

    private final URI introspectionURI;
    private final String validIssuerURI;
    private final URI userInfoURI;
    private final String validTokenType;
    private final String clientId;
    private final String clientSecret;
    private final String audience;
    private final SSLSocketFactory socketFactory;
    private final HostnameVerifier hostnameVerifier;
    private final PrincipalExtractor principalExtractor;

    public OAuthIntrospectionValidator(String introspectionEndpointUri,
                                       SSLSocketFactory socketFactory,
                                       HostnameVerifier verifier,
                                       PrincipalExtractor principalExtractor,
                                       String issuerUri,
                                       String userInfoUri,
                                       String validTokenType,
                                       String clientId,
                                       String clientSecret,
                                       String audience) {

        if (introspectionEndpointUri == null) {
            throw new IllegalArgumentException("introspectionEndpointUri == null");
        }

        try {
            this.introspectionURI = new URI(introspectionEndpointUri);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException("Invalid introspection endpoint uri: " + introspectionEndpointUri, e);
        }

        if (socketFactory != null && !"https".equals(introspectionURI.getScheme())) {
            throw new IllegalArgumentException("SSL socket factory set but introspectionEndpointUri not 'https'");
        }
        this.socketFactory = socketFactory;

        if (verifier != null && !"https".equals(introspectionURI.getScheme())) {
            throw new IllegalArgumentException("Certificate hostname verifier set but keysEndpointUri not 'https'");
        }
        this.hostnameVerifier = verifier;

        this.principalExtractor = principalExtractor != null ? principalExtractor : new PrincipalExtractor();

        if (issuerUri != null) {
            try {
                new URI(issuerUri);
            } catch (URISyntaxException e) {
                throw new IllegalArgumentException("Invalid issuer uri: " + issuerUri, e);
            }
        }
        this.validIssuerURI = issuerUri;

        if (userInfoUri != null) {
            try {
                this.userInfoURI = new URI(userInfoUri);
            } catch (URISyntaxException e) {
                throw new IllegalArgumentException("Invalid userInfo uri: " + userInfoUri, e);
            }
        } else {
            this.userInfoURI = null;
        }

        this.validTokenType = validTokenType;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.audience = audience;

        if (log.isDebugEnabled()) {
            log.debug("Configured OAuthIntrospectionValidator:\n    introspectionEndpointUri: " + introspectionURI
                    + "\n    sslSocketFactory: " + socketFactory
                    + "\n    hostnameVerifier: " + hostnameVerifier
                    + "\n    principalExtractor: " + principalExtractor
                    + "\n    validIssuerUri: " + validIssuerURI
                    + "\n    userInfoUri: " + userInfoURI
                    + "\n    validTokenType: " + validTokenType
                    + "\n    clientId: " + clientId
                    + "\n    clientSecret: " + mask(clientSecret));
        }
    }

    @SuppressWarnings("checkstyle:NPathComplexity")
    public TokenInfo validate(String token) {

        String authorization = clientSecret != null ?
                "Basic " + base64encode(clientId + ':' + clientSecret) :
                null;

        StringBuilder body = new StringBuilder("token=").append(token);

        JsonNode response;
        try {
            response = post(introspectionURI, socketFactory, hostnameVerifier, authorization,
                    "application/x-www-form-urlencoded", body.toString(), JsonNode.class);
        } catch (IOException e) {
            throw new RuntimeException("Failed to introspect token - send, fetch or parse failed: ", e);
        }

        boolean active;
        try {
            active = response.get("active").asBoolean();
        } catch (Exception e) {
            throw new RuntimeException("Failed to introspect token - invalid response: \"active\" attribute is missing or not a boolean (" + response.get("active") + ")");
        }

        if (!active) {
            throw new TokenExpiredException("Token has expired");
        }

        JsonNode value = null;

        value = response.get("exp");
        if (value == null) {
            throw new IllegalStateException("Introspection response contains no expires information (\"exp\"): " + response);
        }
        long expiresMillis = 1000 * value.asLong();
        if (Time.SYSTEM.milliseconds() > expiresMillis)    {
            throw new TokenExpiredException("The token expired at: " + expiresMillis + " (" +
                    TimeUtil.formatIsoDateTimeUTC(expiresMillis) + ")");
        }

        value = response.get("iat");
        long iat = value == null ? 0 : 1000 * value.asLong();

        String principal = principalExtractor.getPrincipal(response);
        if (principal == null) {
            if (userInfoURI != null) {
                principal = getPrincipalFromUserInfoEndpoint(token);
            }
            if (principal == null && !principalExtractor.isConfigured()) {
                principal = principalExtractor.getSub(response);
            }
            if (principal == null) {
                throw new RuntimeException("Failed to extract principal - check usernameClaim, fallbackUsernameClaim configuration");
            }
        }
        performOptionalChecks(response);

        value = response.get("scope");
        String scopes = value != null ? String.join(" ", JSONUtil.asListOfString(value)) : null;

        return new TokenInfo(token, scopes, principal, iat, expiresMillis);
    }

    String getPrincipalFromUserInfoEndpoint(String token) {
        String authorization = "Bearer " + token;
        JsonNode response;
        try {
            response = get(userInfoURI, socketFactory, hostnameVerifier, authorization, JsonNode.class);
        } catch (IOException e) {
            throw new RuntimeException("Request to User Info Endpoint failed: ", e);
        }
        // apply principalExtractor
        String principal = principalExtractor.getPrincipal(response);

        if (principal == null && !principalExtractor.isConfigured()) {
            principal = principalExtractor.getSub(response);
        }
        return principal;
    }

    private void performOptionalChecks(JsonNode response) {
        JsonNode value;
        if (validIssuerURI != null) {
            value = response.get("iss");
            if (value == null || !validIssuerURI.equals(value.asText())) {
                throw new TokenValidationException("Token check failed - invalid issuer: " + value)
                        .status(Status.INVALID_TOKEN);
            }
        }

        if (validTokenType != null) {
            value = response.get("token_type");
            if (value == null || !validTokenType.equals(value.asText())) {
                throw new TokenValidationException("Token check failed - invalid token type: " + value + " (should be '" + validTokenType + "')" + (value == null ? ". Consider not setting OAUTH_VALID_TOKEN_TYPE." : ""))
                        .status(Status.UNSUPPORTED_TOKEN_TYPE);
            }
        }

        if (audience != null) {
            value = response.get("aud");
            if (value == null || !audience.equals(value.asText())) {
                throw new TokenValidationException("Token check failed - invalid audience: " + value)
                        .status(Status.INVALID_TOKEN);
            }
        }
    }
}
