/**
 * Copyright © 2012-2022 by Data Virtuality GmbH
 */
package org.teiid.auth.openid;

import com.datavirtuality.dv.core.interfaces.DVCaller;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwt;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Arrays;
import java.util.Objects;
import java.util.stream.Collectors;
import org.teiid.adminapi.impl.OpenIDMetadata;
import org.teiid.auth.openid.model.IntrospectionResponse;
import org.teiid.auth.openid.model.JwksKey;
import org.teiid.auth.openid.model.JwksResponse;
import org.teiid.auth.openid.model.OpenIdConfigurationResponse;
import org.teiid.auth.openid.model.TokenResponse;
import org.teiid.core.util.PropertiesUtils;
import org.teiid.dqp.service.SessionServiceException;
import org.teiid.logging.LogConstants;
import org.teiid.logging.LogManager;
import org.teiid.net.TeiidURL;
import org.teiid.runtime.RuntimePlugin;
import org.teiid.security.Credentials;
import org.teiid.security.SecurityHelper;
import org.teiid.security.TeiidLoginContext;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.wildfly.security.auth.server.SecurityIdentity;

import static org.teiid.services.SessionServiceImpl.AT;

/**
 * @author Archil Gabeskiria, Dmitry Pogorelov
 * @date 2022.08.01
 */
public class OpenIDConnectManager {

    public static final String DV_AUTH_SERVER_URL = "dvAuthServerUrl";
    public static final String DV_AUTH_REALM = "dvAuthRealm";
    public static final String DV_AUTH_CLIENT_ID = "dvAuthClientId";
    public static final String DV_AUTH_CLIENT_SECRET = "dvAuthClientSecret";
    public static final String SUBSYSTEM_NAME = "SSO";
    public static final String DOMAIN = AT + SUBSYSTEM_NAME;

    public static final String PROP_CONFIGURATION_URL = "configurationUrl";
    public static final String PROP_CLIENT_ID = "clientId";
    public static final String PROP_CLIENT_SECRET = "clientSecret";
    public static final String PROP_EXPIRES_IN = "expiresIn";
    public static final String PROP_USE_DV_ROLES = "useDvRoles";
    public static final String PROP_PROVIDER = "provider";
    public static final String PROP_REALM = "realm";

    private static final Gson GSON = new GsonBuilder().create();
    private static final long accessTokenRefreshMillis = TimeUnit.MINUTES.toMillis(
            PropertiesUtils.getIntProperty(System.getProperties(), "com.datavirtuality.openIDTokenRefreshMinutes", 1)); // when X minutes or less is left
    public static boolean TEST = false;

    public TeiidLoginContext authenticate(String username, Credentials credentials, Properties properties, String securityDomain,
                                                 SecurityHelper securityHelper, OpenIDMetadata openIDMetadata) throws SessionServiceException {
        String accessToken = properties.getProperty(TeiidURL.CONNECTION.ACCESS_TOKEN);
        String refreshToken = properties.getProperty(TeiidURL.CONNECTION.REFRESH_TOKEN);
        String expiresIn = properties.getProperty(PROP_EXPIRES_IN);
        boolean useDvRoles = Boolean.valueOf(properties.getProperty(PROP_USE_DV_ROLES));
        String provider = properties.getProperty(PROP_PROVIDER);
        String realm = properties.getProperty(PROP_REALM);

        DVCaller.call(258, openIDMetadata);

        String configUrl = openIDMetadata.getConfigUrl();
        String clientId = openIDMetadata.getClientId();
        String clientSecret = openIDMetadata.getClientSecret();
        if (configUrl == null) {
            throw new SessionServiceException(RuntimePlugin.Event.TEIID40200, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40200));
        }

        // Retrieve configuration
        OpenIdConfigurationResponse config = TEST ? new OpenIdConfigurationResponse() : getOpenIdConfiguration(configUrl);
        openIDMetadata.setTokenEndpoint(config.getTokenEndpoint());
        openIDMetadata.setIntrospectionEndpoint(config.getIntrospectionEndpoint());
        openIDMetadata.setUserInfoEndpoint(config.getUserInfoEndpoint());
        openIDMetadata.setJwksEndpoint(config.getJwksEndpoint());
        openIDMetadata.setAccessToken(accessToken);
        openIDMetadata.setRefreshToken(refreshToken);
        openIDMetadata.setUseDvRoles(useDvRoles);
        openIDMetadata.setProvider(provider);
        if (expiresIn != null && !expiresIn.isEmpty())
            openIDMetadata.setTokenExpirationTime(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(Long.valueOf(expiresIn)));

        String configIssuer = config.getIssuer();
        if (configIssuer != null && !configIssuer.isEmpty() && configIssuer.contains("/realms/")) {
            String[] issuerParts = configIssuer.split("/realms/");
            openIDMetadata.setServerUrl(issuerParts[0]);
            openIDMetadata.setRealm(issuerParts[1]);
        } else {
            // let's try to get serverUrl from the configUrl
            String serverUrl = getAuthServerUrl(openIDMetadata.getConfigUrl());
            openIDMetadata.setServerUrl(serverUrl);
            openIDMetadata.setRealm(realm);
        }

        SecurityIdentity openIDSubject;
        // Introspect token
        if ( (accessToken != null && !accessToken.isEmpty()) || (refreshToken != null && !refreshToken.isEmpty())) {
            LogManager.logDetail(LogConstants.CTX_RUNTIME, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40203, "Introspecting access token"));
            IntrospectionResponse introspection;
            if (TEST) {
                introspection = new IntrospectionResponse();
                introspection.setActive(true);
                introspection.setClientId(openIDMetadata.getClientId());
                introspection.setUsername(username);
                introspection.setExp(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(Long.valueOf(expiresIn)));
            } else {
                introspection = getIntrospection(openIDMetadata, false);
            }
            if (introspection == null) {
                openIDMetadata.setTokenExpirationTime(0);
                if (checkAndRefreshAccessToken(openIDMetadata, true)) {
                    LogManager.logDetail(LogConstants.CTX_RUNTIME, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40203, "Refreshing access token"));
                    introspection = getIntrospection(openIDMetadata, true);
                }

                if (introspection == null)
                    throw new SessionServiceException(RuntimePlugin.Event.TEIID40202, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40202));
            }

            // create subject
            openIDSubject = createSubject(securityHelper, openIDMetadata, securityDomain, introspection.getClientId(), introspection.getUsername());
            openIDMetadata.setTokenExpirationTime(TimeUnit.SECONDS.toMillis(introspection.getExp()));
        } else {
            // Retrieve token
            TokenResponse tokenResponse;
            try {
                Map<String, String> additionalParams = new HashMap<>();
                additionalParams.put("username", username);
                additionalParams.put("password", new String(credentials.getCredentialsAsCharArray()));

                if (TEST) {
                    tokenResponse = new TokenResponse();
                    tokenResponse.setAccessToken("eyJhbGciOiAiSFMyNTYifQ.eyJzdWIiOiAiIn0.");
                    tokenResponse.setExpiresIn(Long.MAX_VALUE);
                } else
                    tokenResponse = getToken(config.getTokenEndpoint(), "password", clientId, clientSecret, false, additionalParams);
                openIDMetadata.setAccessToken(tokenResponse.getAccessToken());
                openIDMetadata.setRefreshToken(tokenResponse.getRefreshToken());
            } catch (Exception e) {
                LogManager.logError(LogConstants.CTX_RUNTIME, e, "Error retrieving a token");
                throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40201));
            }

            // create subject
            openIDSubject = createSubject(securityHelper, openIDMetadata, securityDomain, clientId, username);
            openIDMetadata.setTokenExpirationTime(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(tokenResponse.getExpiresIn()));
        }

        Principal principal = openIDSubject != null ? openIDSubject.getPrincipal() : null;
        if (principal != null)
            username = principal.getName();
        return new TeiidLoginContext(username + AT + securityDomain, openIDSubject, securityDomain);
    }

    public static boolean checkAndRefreshAccessToken(OpenIDMetadata openIDMetadata) {
        return checkAndRefreshAccessToken(openIDMetadata, false);
    }

    private static boolean checkAndRefreshAccessToken(OpenIDMetadata openIDMetadata, boolean force) {
        long timeLeft = openIDMetadata.getTokenExpirationTime() - System.currentTimeMillis();
        if (openIDMetadata.getRefreshToken() == null || openIDMetadata.getRefreshToken().isEmpty() ||
                (!force && (timeLeft <= 0 || timeLeft > accessTokenRefreshMillis))) {
            return false;
        }
        long initialTime = System.currentTimeMillis();
        TokenResponse tokenResponse = refreshAccessToken(openIDMetadata.getTokenEndpoint(),
                openIDMetadata.getRefreshToken(), openIDMetadata.getClientId(), openIDMetadata.getClientSecret(), openIDMetadata.isPublicClient(), openIDMetadata.getScope());

        openIDMetadata.setAccessToken(tokenResponse.getAccessToken());
        // every time when we refresh access token the refresh token can be updated as well
        if (tokenResponse.getRefreshToken() != null)
            openIDMetadata.setRefreshToken(tokenResponse.getRefreshToken());
        openIDMetadata.setTokenExpirationTime(initialTime + TimeUnit.SECONDS.toMillis(tokenResponse.getExpiresIn()));

        return true;
    }

    private OpenIdConfigurationResponse getOpenIdConfiguration(String configurationEndpoint) throws SessionServiceException {
        String responseJson = HttpClient.get(configurationEndpoint);
        if (responseJson == null) {
            throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94255, configurationEndpoint));
        }
        return GSON.fromJson(responseJson, OpenIdConfigurationResponse.class);
    }

    private static TokenResponse refreshAccessToken(String tokenEndpoint, String refreshToken, String clientId, String clientSecret, boolean publicClient, String scope) {

        Map<String, String> additionalParams = new HashMap<>();
        additionalParams.put("refresh_token", refreshToken);
        if (scope != null && !scope.isEmpty()) {
            additionalParams.put("scope", scope);
        }

        return getToken(tokenEndpoint, "refresh_token", clientId, clientSecret, publicClient, additionalParams);
    }

    private IntrospectionResponse getIntrospection(OpenIDMetadata openIDMetadata, boolean throwException) throws SessionServiceException {
        IntrospectionResponse introspection = introspectToken(openIDMetadata, throwException);
        if (introspection != null && Boolean.TRUE.equals(introspection.getActive()) && introspection.getExp() != null) {
            return introspection;
        }
        LogManager.logDetail(LogConstants.CTX_RUNTIME, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40203, "The access token is not active or \"exp\" claim in it is null"));
        return null;
    }

    private IntrospectionResponse introspectToken(OpenIDMetadata openIDMetadata, boolean throwException) throws SessionServiceException {
        IntrospectionResponse result = null;
        Map<String, String> additionalHeaders = new HashMap<>();
        String responseJson;
        if (!openIDMetadata.isIntrospectToken()) {
            Claims claims = getJwtClaims(openIDMetadata.getAccessToken(), openIDMetadata.getJwksEndpoint(), openIDMetadata.getClientId(),
                    openIDMetadata.getAudiences(), openIDMetadata.getIssuer(), !openIDMetadata.isIntrospectToken());
            // it's a special claim value which contains the client type (public or confidential), needed only for EntraID
            if (claims.containsKey("appidacr")) {
                String clientType = (String) claims.get("appidacr");
                if ("0".equals(clientType)) {
                    openIDMetadata.setPublicClient(true);
                }
            }
            if (throwException) {
                // just skip and fake the validation against userInfo endpoint (needed for EntraID in the case of Custom API tokens)
                result = new IntrospectionResponse();
                result.setExp(convertMillisecondsToSeconds(claims.getExpiration().getTime()));
                result.setActive(true);
                result.setClientId(openIDMetadata.getClientId());
            }
        } else if (openIDMetadata.getIntrospectionEndpoint() != null) {
            StringBuilder params = getAuthorizationParams(new StringBuilder(), openIDMetadata.getClientId(), openIDMetadata.getClientSecret(), openIDMetadata.isPublicClient());
            // client auth if needed
            /*if (openIDMetadata.getClientId() != null && openIDMetadata.getClientSecret() != null) {
                additionalHeaders.put("Authorization", basicAuth(openIDMetadata.getClientId(), openIDMetadata.getClientSecret()));
            }*/
            if (params.length() > 0)
                params.append("&");
            params.append("token=").append(openIDMetadata.getAccessToken());
            // post
            responseJson = HttpClient.post(openIDMetadata.getIntrospectionEndpoint(), params.toString(), additionalHeaders);
            if (responseJson != null)
                result = GSON.fromJson(responseJson, IntrospectionResponse.class);
            else if (throwException)
                throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94255, openIDMetadata.getIntrospectionEndpoint()));
        } else if (openIDMetadata.getUserInfoEndpoint() != null) {
            additionalHeaders.put("Authorization", bearerAuth(openIDMetadata.getAccessToken()));
            // get
            responseJson = HttpClient.get(openIDMetadata.getUserInfoEndpoint(), additionalHeaders);
            if (responseJson != null) {
                result = GSON.fromJson(responseJson, IntrospectionResponse.class);
                if (result.getSub() != null) {
                    result.setClientId(openIDMetadata.getClientId());
                    result.setActive(true);
                    if (result.getUsername() == null)
                        result.setUsername(result.getSub());
                    if (result.getExp() == null) {
                        if (openIDMetadata.getTokenExpirationTime() > 0)
                            result.setExp(convertMillisecondsToSeconds(openIDMetadata.getTokenExpirationTime()));
                        else {
                            try {
                                Claims claims = getJwtClaims(openIDMetadata.getAccessToken(), openIDMetadata.getJwksEndpoint(),
                                        openIDMetadata.getClientId(), openIDMetadata.getAudiences(), openIDMetadata.getIssuer(), !openIDMetadata.isIntrospectToken());
                                if (claims.getExpiration() != null) {
                                    result.setExp(convertMillisecondsToSeconds(claims.getExpiration().getTime()));
                                }
                            } catch (Exception e) {
                                LogManager.logDetail(LogConstants.CTX_RUNTIME, RuntimePlugin.Util.gs(RuntimePlugin.Event.TEIID40203, "It's not possible to read \"exp\" claim in JWT of access token"));
                            }
                        }
                    }
                }
            } else if (throwException)
                throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94255,  openIDMetadata.getUserInfoEndpoint()));
        } else if (throwException)
            throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94242));

        return result;
    }

    private long convertMillisecondsToSeconds(long milliseconds) {
        return TimeUnit.MILLISECONDS.toSeconds(milliseconds - System.currentTimeMillis());
    }

    private SecurityIdentity createSubject(SecurityHelper securityHelper, OpenIDMetadata openIDMetadata, String securityDomain, String clientId, String username) throws SessionServiceException {
        String token = openIDMetadata.getAccessToken();
        if (token == null || token.isEmpty()) {
            return null;
        }

        // extract roles from the token
        List<String> roles = null;
        try {
            String jwksEndpoint = openIDMetadata.getJwksEndpoint();
            String audiences = openIDMetadata.getAudiences();
            String issuer = openIDMetadata.getIssuer();
            boolean checkAudiences = !openIDMetadata.isIntrospectToken();
            Claims claims = getJwtClaims(token, jwksEndpoint, clientId, audiences, issuer, checkAudiences);

            // getting username
            if (!TEST) {
                if (openIDMetadata.getUsernameClaim() != null) {
                    username = claims.get(openIDMetadata.getUsernameClaim(), String.class);
                } else if (claims.containsKey("preferred_username")) {
                    username = claims.get("preferred_username", String.class);
                } else if (claims.getSubject() != null)
                    username = claims.getSubject();
            }

            /*if (username != null && username.contains("@"))
                username = username.substring(0, username.indexOf("@"));*/

            // load users and roles from the provider
            String provider = openIDMetadata.getProvider();
            Properties props = new Properties();
            boolean loadUsers = true;
            String baseMessage = "The %s property is empty, users/roles won't be loaded from the %s provider.";
            if (openIDMetadata.getServerUrl() == null || openIDMetadata.getServerUrl().isEmpty()) {
                LogManager.logInfo(LogConstants.CTX_RUNTIME, String.format(baseMessage, "serverUrl", provider));
                loadUsers = false;
            } else
                props.setProperty(DV_AUTH_SERVER_URL, openIDMetadata.getServerUrl());

            if (openIDMetadata.getRealm() == null || openIDMetadata.getRealm().isEmpty()) {
                LogManager.logInfo(LogConstants.CTX_RUNTIME, String.format(baseMessage, "realm", provider));
                loadUsers = false;
            } else
                props.setProperty(DV_AUTH_REALM, openIDMetadata.getRealm());

            if (loadUsers) {
                props.setProperty(DV_AUTH_CLIENT_ID, openIDMetadata.getClientId());
                props.setProperty(DV_AUTH_CLIENT_SECRET, openIDMetadata.getClientSecret());
                DVCaller.call(241, provider, props, username);
            }

            // getting roles
            if (openIDMetadata.isUseDvRoles()) {
                // let's try to get DV roles
                roles = (List<String>) DVCaller.callWithThrow(SessionServiceException.class, 237, getDomainName(username), null);
                // if there is still no the domain user let's try to get a role by no domain name
                if (roles == null)
                    roles = (List<String>) DVCaller.callWithThrow(SessionServiceException.class, 237, username, null);
            } else {
                if (openIDMetadata.getRolesClaim() != null) {
                    roles = getRoles(claims, openIDMetadata.getRolesClaim());
                } else if (claims.containsKey("resource_access")) {
                    Map<String, Object> resourceAccess = claims.get("resource_access", Map.class);
                    Map<String, Object> account = null;
                    if (clientId != null && resourceAccess.containsKey(clientId))
                        account = (Map<String, Object>) resourceAccess.get(clientId);
                    else if (resourceAccess.containsKey("account"))
                        account = (Map<String, Object>) resourceAccess.get("account");
                    roles = getRoles(account, "roles");
                } else if (claims.containsKey("roles")) {
                    roles = getRoles(claims, "roles");
                }

                roles = (List<String>) DVCaller.callWithThrow(SessionServiceException.class, 237, getDomainName(username), roles);
            }
        } catch (SessionServiceException e) {
            throw e;
        } catch (Exception e) {
            LogManager.logError(LogConstants.CTX_RUNTIME, e, "Error parsing token");
        }

        return securityHelper.createAdHocIdentity(securityDomain, getDomainName(username), roles);
    }

    private String getDomainName(String name) {
        return name + DOMAIN;
    }

    private Claims getJwtClaims(String token, String jwksEndpoint, String clientId, String audiences, String issuer, boolean checkAudiences) throws SessionServiceException {
        try {
            JwtParser jwtParser = Jwts.parserBuilder().build();
            if (jwtParser.isSigned(token)) {
                JwksResponse jwksResponse = null;
                if (jwksEndpoint != null) {
                    String responseJson = HttpClient.get(jwksEndpoint);
                    if (responseJson != null)
                        jwksResponse = GSON.fromJson(responseJson, JwksResponse.class);
                }
                if (jwksResponse != null && jwksResponse.getJwksKeys() != null) {
                    Jws<Claims> parsedToken = null;
                    for (JwksKey jwksKey : jwksResponse.getJwksKeys()) {
                        try {
                            // at the moment we support only RSA algorithm
                            if (jwksKey.getKeyType().equalsIgnoreCase("RSA")) {
                                KeyFactory keyFactory = KeyFactory.getInstance(jwksKey.getKeyType());
                                BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(jwksKey.getModulus()));
                                BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(jwksKey.getExponent()));
                                RSAPublicKey pubKey = (RSAPublicKey) keyFactory.generatePublic(new RSAPublicKeySpec(modulus, exponent));

                                jwtParser = Jwts.parserBuilder().setSigningKey(pubKey).build();
                                parsedToken = jwtParser.parseClaimsJws(token);
                                break;
                            }
                        } catch (Exception e) {
                            LogManager.logDetail(LogConstants.CTX_RUNTIME, e, "Error verifying JWS token");
                        }
                    }
                    if (parsedToken == null)
                        throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94243));
                    return validateJwtClaims(parsedToken.getBody(), clientId, audiences, issuer, checkAudiences);
                } else {
                    // skip verification
                    String[] splitToken = token.split("\\" + JwtParser.SEPARATOR_CHAR);
                    token = splitToken[0] + JwtParser.SEPARATOR_CHAR + splitToken[1] + JwtParser.SEPARATOR_CHAR;
                }
            }
            Jwt<Header, Claims> parsedToken = jwtParser.parseClaimsJwt(token);
            return validateJwtClaims(parsedToken.getBody(), clientId, audiences, issuer, checkAudiences);
        } catch (Exception e) {
            throw new SessionServiceException(e, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94244));
        }
    }

    private Claims validateJwtClaims(Claims claims, String clientId, String audiences, String issuer, boolean checkAudiences) throws SessionServiceException {
        boolean check = audiences != null || (clientId != null && checkAudiences);
        if (claims.getAudience() != null && check) {
            String[] audValues = audiences != null ? audiences.split(",") : new String[] {clientId, "api://" + clientId}; // the api:// is needed only for EntraID
            if (Arrays.stream(audValues).filter(Objects::nonNull).noneMatch(aud -> aud.equals(claims.getAudience())))
                throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94245));
        }

        if (claims.getIssuer() != null && issuer != null) {
            if (!claims.getIssuer().equals(issuer)) {
                throw new SessionServiceException(RuntimePlugin.Event.TEIID40201, RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94245));
            }
        }

        return claims;
    }

    private List<String> getRoles(Map<String, Object> account, String rolesClaim) {
        List<String> roles = null;
        if (account != null && account.containsKey(rolesClaim)) {
            roles = (List<String>) account.get(rolesClaim);
            roles = roles.stream().map(s -> getDomainName(s)).collect(Collectors.toList());
        }
        return roles;
    }

    private static TokenResponse getToken(String tokenEndpoint, String grantType, String clientId, String clientSecret,
                                          boolean publicClient, Map<String, String> additionalParams) {

        Map<String, String> additionalHeaders = new HashMap<>();
        StringBuilder params = getAuthorizationParams(new StringBuilder(), clientId, clientSecret, publicClient);

        // client auth if needed
        /*if (clientId != null && clientSecret != null) {
            additionalHeaders.put("Authorization", basicAuth(clientId, clientSecret));
        }*/

        // build params
        if (params.length() > 0)
            params.append("&");
        params.append("grant_type=").append(grantType);
        if (additionalParams != null) {
            for (Map.Entry<String, String> entry : additionalParams.entrySet()) {
                params.append("&")
                        .append(entry.getKey())
                        .append("=")
                        .append(entry.getValue());
            }
        }

        // post
        String responseJson = HttpClient.post(tokenEndpoint, params.toString(), additionalHeaders);

        if (responseJson == null) {
            throw new RuntimeException(RuntimePlugin.Util.gs(RuntimePlugin.Event.DVCORE94255, tokenEndpoint));
        }
        return GSON.fromJson(responseJson, TokenResponse.class);
    }

    private static StringBuilder getAuthorizationParams(StringBuilder params, String clientId, String clientSecret, boolean publicClient) {
        if (clientId != null && clientSecret != null && !publicClient) {
            if (params.length() > 0)
                params.append("&");
            params.append("client_id=").append(clientId);
            params.append("&");
            params.append("client_secret=").append(clientSecret);
        }

        return params;
    }

    private static String basicAuth(String clientId, String clientSecret) {
        byte[] credentials = (clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8);
        String encodedCredentials = Base64.getEncoder().encodeToString(credentials);
        return "Basic " + encodedCredentials;
    }

    private static String bearerAuth(String token) {
        return "Bearer " + token;
    }

    public static String getAuthServerUrl(String serverUrl) {
        try {
            URL configURL = new URL(serverUrl);
            StringBuilder result = new StringBuilder();
            result.append(configURL.getProtocol()).append("://").append(configURL.getHost());
            if (configURL.getPort() > 0)
                result.append(":").append(configURL.getPort());

            String path = configURL.getPath();
            if (path != null) {
                int realmsIndex = path.indexOf("/realms");
                if (realmsIndex > 0) {
                    String prefixPath = path.substring(0, realmsIndex);
                    result.append(prefixPath);
                }
            }

            return result.toString();
        } catch (MalformedURLException e) {
            LogManager.logError(LogConstants.CTX_RUNTIME, e, "Malformed DV_AUTH_SERVER_URL or configurationUrl properties");
        }
        return null;
    }
}