/*
 * Copyright (c) 2018, 2020 Oracle and/or its affiliates. All rights reserved.
 *
 * 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 io.helidon.security.providers.oidc.common;

import java.net.URI;
import java.util.Collections;
import java.util.logging.Logger;

import javax.json.Json;
import javax.json.JsonObject;
import javax.json.JsonReaderFactory;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;

import io.helidon.common.Errors;
import io.helidon.common.configurable.Resource;
import io.helidon.config.Config;
import io.helidon.security.jwt.jwk.JwkKeys;
import io.helidon.security.util.TokenHandler;

import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;

/**
 * Configuration of OIDC usable from all resources that utilize OIDC specification, such as security provider, web server
 * extension and IDCS connectivity.
 * <p>
 * Some of the configuration options below use "resource" type. The following configuration
 * can be used for a resource (example for oidc-metadata key):
 * {@code
 * oidc-metadata-path: "path/on/filesystem"
 * oidc-metadata-resource-path: "class-path/resource"
 * oidc-metadata-url: "URI on the net"
 * oidc-metadata-content-plain: "Value of the resource in plain text"
 * oidc-metadata-content: "Value in base64 encoded bytes"
 * }
 * <p>
 * Configuration options required (under security.providers[].${name}):
 * <table class="config">
 * <caption>Mandatory configuration parameters</caption>
 * <tr>
 *     <th>key</th>
 *     <th>description</th>
 * </tr>
 * <tr>
 *     <td>client-id</td>
 *     <td>Client ID as generated by OIDC server</td>
 * </tr>
 * <tr>
 *     <td>client-secret</td>
 *     <td>Client secret as generated by OIDC server</td>
 * </tr>
 * <tr>
 *     <td>identity-uri</td>
 *     <td>URI of the identity server, base used to retrieve OIDC metadata</td>
 * </tr>
 * <tr>
 *     <td>frontend-uri</td>
 *     <td>Fully URI of the frontend for redirects back from OIDC server (e.g. http://myserver/myApp)</td>
 * </tr>
 * </table>
 *
 * <table class="config">
 * <caption>Optional configuration parameters</caption>
 * <tr>
 *     <th>key</th>
 *     <th>default value</th>
 *     <th>description</th>
 * </tr>
 * <tr>
 *     <td>proxy-protocol</td>
 *     <td>http</td>
 *     <td>Proxy protocol to use when proxy is used.</td>
 * </tr>
 * <tr>
 *     <td>proxy-host</td>
 *     <td>null</td>
 *     <td>Proxy host to use. When defined, triggers usage of proxy for HTTP requests.</td>
 * </tr>
 * <tr>
 *     <td>proxy-port</td>
 *     <td>80</td>
 *     <td>Port of the proxy server to use</td>
 * </tr>
 * <tr>
 *     <td>redirect-uri</td>
 *     <td>/oidc/redirect</td>
 *     <td>URI to register web server component on, used by the OIDC server to
 *          redirect authorization requests to after a user logs in or approves scopes. Note that usually the redirect URI
 *          configured here must be the same one as configured on OIDC server.</td>
 * </tr>
 * <tr>
 *     <td>scope-audience</td>
 *     <td>empty string</td>
 *     <td>Audience of the scope required by this application. This is prefixed to
 *          the scope name when requesting scopes from the identity server.</td>
 * </tr>
 * <tr>
 *     <td>cookie-use</td>
 *     <td>true</td>
 *     <td>Whether to use cookie to store JWT. If used, redirects happen only in case the user
 *              is not authenticated or has insufficient scopes</td>
 * </tr>
 * <tr>
 *     <td>cookie-name</td>
 *     <td>JSESSIONID</td>
 *     <td>Name of the cookie</td>
 * </tr>
 * <tr>
 *     <td>cookie-domain</td>
 *     <td>null</td>
 *     <td>Domain the cookie is valid for. Not used by default</td>
 * </tr>
 * <tr>
 *     <td>cookie-path</td>
 *     <td>/</td>
 *     <td>Path the cookie is valid for.</td>
 * </tr>
 * <tr>
 *     <td>cookie-max-age-seconds</td>
 *     <td>null</td>
 *     <td>When using cookie, used to set MaxAge attribute of the cookie, defining
 *              how long the cookie is valid.</td>
 * </tr>
 * <tr>
 *     <td>cookie-http-only</td>
 *     <td>true</td>
 *     <td>When using cookie, if set to true, the HttpOnly attribute will be configured.</td>
 * </tr>
 * <tr>
 *     <td>cookie-secure</td>
 *     <td>false</td>
 *     <td>When using cookie, if set to true, the Secure attribute will be configured.</td>
 * </tr>
 * <tr>
 *     <td>cookie-same-site</td>
 *     <td>Lax</td>
 *     <td>When using cookie, used to set the SameSite cookie value. Can be "Strict" or "Lax".
 *     Setting this to "Strict" will result in infinite redirects when calling OIDC on a different host.
 *     </td>
 * </tr>
 * <tr>
 *     <td>query-param-use</td>
 *     <td>false</td>
 *     <td>Whether to expect JWT in a query parameter</td>
 * </tr>
 * <tr>
 *     <td>query-param-name</td>
 *     <td>accessToken</td>
 *     <td>Name of a query parameter that contains the JWT token when parameter is used.</td>
 * </tr>
 * <tr>
 *     <td>header-use</td>
 *     <td>false</td>
 *     <td>Whether to expect JWT in a header field.</td>
 * </tr>
 * <tr>
 *     <td>header-token</td>
 *     <td>"Authorization" header with prefix "bearer "</td>
 *     <td>A {@link TokenHandler} configuration to process header containing a JWT</td>
 * </tr>
 * <tr>
 *     <td>oidc-metadata-well-known</td>
 *     <td>true</td>
 *     <td>If set to true, metadata will be loaded from default (well known)
 *          location, unless it is explicitly defined using oidc-metadata-resource. If set to false, it would not be loaded even
 *          if oidc-metadata-resource is not defined. In such a case all URIs must be explicitly
 *          defined (e.g. token-endpoint-uri).</td>
 * </tr>
 * <tr>
 *     <td>oidc-metadata</td>
 *     <td>identity-uri/.well-known/openid-configuration</td>
 *     <td>Resource configuration for OIDC Metadata containing endpoints to various identity services, as well as information
 *     about the identity server</td>
 * </tr>
 * <tr>
 *     <td>token-endpoint-uri</td>
 *     <td>token_endpoint in OIDC metadata, or identity-url/oauth2/v1/token if not available</td>
 *     <td>URI of a token endpoint used to obtain a JWT based on the authentication code.</td>
 * </tr>
 * <tr>
 *     <td>authorization-endpoint-uri</td>
 *     <td>"authorization_endpoint" in OIDC metadata, or identity-uri/oauth2/v1/authorize if not available</td>
 *     <td>URI of an authorization endpoint used to redirect users to for logging-in.</td>
 * </tr>
 * <tr>
 *     <td>validate-with-jwk</td>
 *     <td>true</td>
 *     <td>When true  - validate against jwk defined by "sign-jwk", when false
 *          validate JWT through OIDC Server endpoint "validation-endpoint-uri"</td>
 * </tr>
 * <tr>
 *     <td>sign-jwk</td>
 *     <td>"jwks-uri" in OIDC metadata, or identity-uri/admin/v1/SigningCert/jwk if not available, only needed
 *              when jwt validation is done by us</td>
 *     <td>A resource pointing to JWK with public keys of signing certificates used to validate JWT</td>
 * </tr>
 * <tr>
 *     <td>introspect-endpoint-uri</td>
 *     <td>"introspection_endpoint" in OIDC metadata, or identity-uri/oauth2/v1/introspect</td>
 *     <td>When validate-with-jwk is set to "false", this is the endpoint used</td>
 * </tr>
 * <tr>
 *     <td>base-scopes</td>
 *     <td>{@value DEFAULT_BASE_SCOPES}</td>
 *     <td>Configure scopes to be requested by default. If the scope has a qualifier, it must be included here</td>
 * </tr>
 * <tr>
 *     <td>redirect</td>
 *     <td>true</td>
 *     <td>Whether to redirect to identity server when authentication failed.</td>
 * </tr>
 * <tr>
 *     <td>realm</td>
 *     <td>helidon</td>
 *     <td>Realm returned in HTTP response if redirect is not enabled or possible.</td>
 * </tr>
 * <tr>
 *     <td>redirect-attempt-param</td>
 *     <td>{@value DEFAULT_ATTEMPT_PARAM}</td>
 *     <td>Query parameter holding the number of times we redirected to an identity server. Customizable to prevent
 *     conflicts with application parameters</td>
 * </tr>
 * <tr>
 *     <td>max-redirects</td>
 *     <td>{@value DEFAULT_MAX_REDIRECTS}</td>
 *     <td>Maximal number of times we can redirect to an identity server. When the number is reached, no further redirects
 *     happen and the request finishes with an error (status {@code 401})</td>
 * </tr>
 * </table>
 */
public final class OidcConfig {
    /**
     * Default name of the header we expect JWT in.
     */
    public static final String PARAM_HEADER_NAME = "X_OIDC_TOKEN_HEADER";

    private static final Logger LOGGER = Logger.getLogger(OidcConfig.class.getName());

    private static final JsonReaderFactory JSON = Json.createReaderFactory(Collections.emptyMap());

    static final int DEFAULT_PROXY_PORT = 80;
    static final String DEFAULT_OIDC_METADATA_URI = "/.well-known/openid-configuration";
    static final String DEFAULT_REDIRECT_URI = "/oidc/redirect";
    static final String DEFAULT_COOKIE_NAME = "JSESSIONID";
    static final boolean DEFAULT_COOKIE_USE = true;
    static final String DEFAULT_COOKIE_PATH = "/";
    static final boolean DEFAULT_COOKIE_HTTP_ONLY = true;
    static final boolean DEFAULT_COOKIE_SECURE = false;
    static final String DEFAULT_COOKIE_SAME_SITE = "Lax";
    static final String DEFAULT_PARAM_NAME = "accessToken";
    static final boolean DEFAULT_PARAM_USE = false;
    static final boolean DEFAULT_HEADER_USE = false;
    static final String DEFAULT_PROXY_PROTOCOL = "http";

    static final String DEFAULT_BASE_SCOPES = "openid";
    static final boolean DEFAULT_JWT_VALIDATE_JWK = true;
    static final boolean DEFAULT_REDIRECT = true;
    static final String DEFAULT_REALM = "helidon";
    static final String DEFAULT_ATTEMPT_PARAM = "h_ra";
    static final int DEFAULT_MAX_REDIRECTS = 5;

    private final String redirectUri;
    private final boolean useCookie;
    private final String cookieName;
    private final String cookieOptions;
    private final boolean useParam;
    private final String paramName;

    private final URI identityUri;
    private final WebTarget tokenEndpoint;
    private final String cookieValuePrefix;
    private final String scopeAudience;
    private final String redirectUriWithHost;
    private final boolean useHeader;
    private final TokenHandler headerHandler;
    private final String authorizationEndpointUri;
    private final String clientId;
    private final JwkKeys signJwk;
    private final String baseScopes;
    private final boolean validateJwtWithJwk;
    private final WebTarget introspectEndpoint;
    private final String issuer;
    private final String audience;
    private final Client appClient;
    private final Client generalClient;
    private final boolean redirect;
    private final String realm;
    private final String redirectAttemptParam;
    private final int maxRedirects;

    private OidcConfig(Builder builder) {
        this.clientId = builder.clientId;
        this.useCookie = builder.useCookie;
        this.cookieName = builder.cookieName;
        this.cookieValuePrefix = cookieName + "=";
        this.useParam = builder.useParam;
        this.paramName = builder.paramName;
        this.redirectUri = builder.redirectUri;
        this.useHeader = builder.useHeader;
        this.headerHandler = builder.headerHandler;
        this.authorizationEndpointUri = builder.authorizationEndpointUri.toString();
        this.baseScopes = builder.baseScopes;
        this.validateJwtWithJwk = builder.validateJwtWithJwk;
        this.issuer = builder.issuer;
        this.audience = builder.audience;
        this.identityUri = builder.identityUri;
        this.redirect = builder.redirect;
        this.realm = builder.realm;
        this.redirectAttemptParam = builder.redirectAttemptParam;
        this.maxRedirects = builder.maxRedirects;

        if (null == builder.signJwk) {
            this.signJwk = JwkKeys.builder().build();
        } else {
            this.signJwk = builder.signJwk;
        }

        ClientBuilder clientBuilder = ClientBuilder.newBuilder();

        if (builder.proxyHost != null) {
            clientBuilder.property(ClientProperties.PROXY_URI,
                                   builder.proxyUri);
        }

        this.generalClient = clientBuilder.build();

        HttpAuthenticationFeature basicAuth = HttpAuthenticationFeature.basicBuilder()
                .credentials(builder.clientId, builder.clientSecret)
                .build();

        this.appClient = clientBuilder
                .register(basicAuth)
                .build();

        this.tokenEndpoint = appClient.target(builder.tokenEndpointUri);

        if (validateJwtWithJwk) {
            this.introspectEndpoint = null;
        } else {
            this.introspectEndpoint = appClient.target(builder.introspectUri);
        }

        StringBuilder cookieOptionsBuilder = new StringBuilder();
        cookieOptionsBuilder.append(";Path=").append(builder.cookiePath);
        if (builder.cookieHttpOnly) {
            cookieOptionsBuilder.append(";HttpOnly");
        }
        if (!builder.cookieSameSite.isEmpty()) {
            cookieOptionsBuilder.append(";SameSite=").append(builder.cookieSameSite);
        }
        if (builder.cookieMaxAge != null) {
            cookieOptionsBuilder.append(";Max-Age=").append(builder.cookieMaxAge);
        }
        if (builder.cookieDomain != null) {
            cookieOptionsBuilder.append(";Domain=").append(builder.cookieDomain);
        }
        if (builder.cookieSecure) {
            cookieOptionsBuilder.append(";Secure");
        }

        this.cookieOptions = cookieOptionsBuilder.toString();

        LOGGER.finest(() -> "OIDC Cookie options: " + cookieOptions);

        if ((builder.scopeAudience == null) || builder.scopeAudience.trim().isEmpty()) {
            this.scopeAudience = "";
        } else {
            String tmp = builder.scopeAudience.trim();
            if (tmp.endsWith("/")) {
                this.scopeAudience = tmp;
            } else {
                this.scopeAudience = tmp + "/";
            }
        }

        LOGGER.finest(() -> "OIDC Scope audience: " + scopeAudience);

        this.redirectUriWithHost = builder.frontendUri + builder.redirectUri;
        LOGGER.finest(() -> "Redirect URI with host: " + redirectUriWithHost);
    }

    /**
     * Create a builder to programmatically construct OIDC configuration.
     *
     * @return a new builder instance usable for fluent API
     */
    public static Builder builder() {
        return new Builder();
    }

    /**
     * Create a new instance from {@link Config}.
     * The config instance has to be on the node containing keys used by this class (e.g. client-id).
     *
     * @param config configuration used to obtain OIDC integration values
     * @return a new instance of this class configured from provided config
     */
    public static OidcConfig create(Config config) {
        return OidcConfig.builder()
                .config(config)
                .build();
    }

    /**
     * JWK used for signature validation.
     *
     * @return set of keys used use to verify tokens
     * @see Builder#signJwk(JwkKeys)
     */
    public JwkKeys signJwk() {
        return signJwk;
    }

    /**
     * Redirection URI.
     *
     * @return uri the OIDC server redirects back to
     * @see Builder#redirectUri(String)
     */
    public String redirectUri() {
        return redirectUri;
    }

    /**
     * Token endpoint of the OIDC server.
     *
     * @return target the endpoint is on
     * @see Builder#tokenEndpointUri(URI)
     */
    public WebTarget tokenEndpoint() {
        return tokenEndpoint;
    }

    /**
     * Whether to use query parameter to get the information from request.
     *
     * @return if query parameter should be used
     * @see Builder#useParam(Boolean)
     */
    public boolean useParam() {
        return useParam;
    }

    /**
     * Query parameter name.
     *
     * @return name of the query parameter to use
     * @see Builder#paramName(String)
     */
    public String paramName() {
        return paramName;
    }

    /**
     * Whether to use cooke to get the information from request.
     *
     * @return if cookie should be used
     * @see Builder#useCookie(Boolean)
     */
    public boolean useCookie() {
        return useCookie;
    }

    /**
     * Cookie name.
     *
     * @return name of the cookie to use
     * @see Builder#cookieName(String)
     */
    public String cookieName() {
        return cookieName;
    }

    /**
     * Additional options of the cookie to use.
     *
     * @return cookie options to use in cookie string
     * @see Builder#cookieHttpOnly(Boolean)
     * @see Builder#cookieDomain(String)
     */
    public String cookieOptions() {
        return cookieOptions;
    }

    /**
     * Whether to use HTTP header to get the information from request.
     *
     * @return if header should be used
     * @see Builder#useHeader(Boolean)
     */
    public boolean useHeader() {
        return useHeader;
    }

    /**
     * {@link TokenHandler} to extract header information from request.
     *
     * @return handler to extract header
     * @see Builder#headerTokenHandler(TokenHandler)
     */
    public TokenHandler headerHandler() {
        return headerHandler;
    }

    /**
     * Prefix of a cookie header formed by name and "=".
     *
     * @return prefix of cookie value
     * @see Builder#cookieName(String)
     */
    public String cookieValuePrefix() {
        return cookieValuePrefix;
    }

    /**
     * Audience URI of custom scopes.
     *
     * @return scope audience
     * @see Builder#scopeAudience(String)
     */
    public String scopeAudience() {
        return scopeAudience;
    }

    /**
     * Authorization endpoint.
     *
     * @return authorization endpoint uri as a string
     * @see Builder#authorizationEndpointUri(URI)
     */
    public String authorizationEndpointUri() {
        return authorizationEndpointUri;
    }

    /**
     * Client id of this client.
     *
     * @return client id
     * @see Builder#clientId(String)
     */
    public String clientId() {
        return clientId;
    }

    /**
     * Redirect URI with host information.
     *
     * @return redirect URI
     * @see Builder#redirectUri(String)
     */
    public String redirectUriWithHost() {
        return redirectUriWithHost;
    }

    /**
     * Base scopes to require from OIDC server.
     *
     * @return base scopes
     * @see Builder#baseScopes(String)
     */
    public String baseScopes() {
        return baseScopes;
    }

    /**
     * Whether to validate JWT with JWK information (e.g. verify signatures locally).
     *
     * @return if we should validate JWT with JWK
     * @see Builder#validateJwtWithJwk(Boolean)
     */
    public boolean validateJwtWithJwk() {
        return validateJwtWithJwk;
    }

    /**
     * Token introspection endpoint.
     *
     * @return introspection endpoint
     * @see Builder#introspectEndpointUri(URI)
     */
    public WebTarget introspectEndpoint() {
        return introspectEndpoint;
    }

    /**
     * Token issuer.
     *
     * @return token issuer
     * @see Builder#issuer(String)
     */
    public String issuer() {
        return issuer;
    }

    /**
     * Expected token audience.
     *
     * @return audience
     * @see Builder#audience(String)
     */
    public String audience() {
        return audience;
    }

    /**
     * Identity server URI.
     *
     * @return identity server URI
     * @see Builder#identityUri(URI)
     */
    public URI identityUri() {
        return identityUri;
    }

    /**
     * Client with configured proxy with no security.
     *
     * @return client for general use.
     */
    public Client generalClient() {
        return generalClient;
    }

    /**
     * Client with configured proxy and security of this OIDC client.
     *
     * @return client for communication with OIDC server
     */
    public Client appClient() {
        return appClient;
    }

    /**
     * Whether to redirect to identity server if user is not authenticated.
     *
     * @return whether to redirect, defaults to true
     */
    public boolean shouldRedirect() {
        return redirect;
    }

    /**
     * Realm to use for WWW-Authenticate response (if needed).
     *
     * @return realm name
     */
    public String realm() {
        return realm;
    }

    /**
     * Name of the parameter used in state passed to OIDC to store the number of attempted redirects.
     * This is to prevent infinite redirects.
     *
     * @return name of the query parameter
     */
    public String redirectAttemptParam() {
        return redirectAttemptParam;
    }

    /**
     * Maximal number of redirects allowed between Helidon and OIDC provider.
     *
     * @return maximal number of redirects
     */
    public int maxRedirects() {
        return maxRedirects;
    }

    /**
     * A fluent API {@link io.helidon.common.Builder} to build instances of {@link OidcConfig}.
     */
    public static class Builder implements io.helidon.common.Builder<OidcConfig> {
        private String issuer;
        private String audience;
        private String baseScopes = DEFAULT_BASE_SCOPES;
        private String proxyUri;
        // mandatory properties
        private URI identityUri;
        private String clientId;
        private String clientSecret;
        private String redirectUri = DEFAULT_REDIRECT_URI;
        private boolean useCookie = DEFAULT_COOKIE_USE;
        private String cookieName = DEFAULT_COOKIE_NAME;
        private String cookieDomain;
        private String cookiePath = DEFAULT_COOKIE_PATH;
        private Long cookieMaxAge;
        private boolean cookieHttpOnly = DEFAULT_COOKIE_HTTP_ONLY;
        private boolean cookieSecure = DEFAULT_COOKIE_SECURE;
        private String cookieSameSite = DEFAULT_COOKIE_SAME_SITE;
        private boolean cookieSameSiteDefault = true;

        private boolean useParam = DEFAULT_PARAM_USE;
        private String paramName = DEFAULT_PARAM_NAME;

        // optional properties
        private String proxyProtocol = DEFAULT_PROXY_PROTOCOL;
        private String proxyHost;
        private int proxyPort = DEFAULT_PROXY_PORT;

        private String scopeAudience;
        private JsonObject oidcMetadata;
        private String frontendUri;

        private boolean useHeader = OidcConfig.DEFAULT_HEADER_USE;
        private TokenHandler headerHandler = TokenHandler.builder()
                .tokenHeader("Authorization")
                .tokenPrefix("bearer ")
                .build();

        private URI tokenEndpointUri;
        private URI authorizationEndpointUri;
        private JwkKeys signJwk;
        private boolean oidcMetadataWellKnown = true;

        private boolean validateJwtWithJwk = DEFAULT_JWT_VALIDATE_JWK;
        private URI introspectUri;
        private boolean redirect = DEFAULT_REDIRECT;
        private String realm = DEFAULT_REALM;
        private String redirectAttemptParam = DEFAULT_ATTEMPT_PARAM;
        private int maxRedirects = DEFAULT_MAX_REDIRECTS;

        @Override
        public OidcConfig build() {
            if ((null == proxyUri) && (null != proxyHost)) {
                this.proxyUri = proxyProtocol
                        + "://"
                        + proxyHost
                        + ":"
                        + proxyPort;
            }

            Errors.Collector collector = Errors.collector();
            // validate
            if (null == clientId) {
                collector.fatal("Client Id must be configured (\"client-id\" key in config)");
            }
            if (null == clientSecret) {
                collector.fatal("Client Secret must be configured (\"client-secret\" key in config)");
            }

            if (null == identityUri) {
                collector.fatal("Identity URI must be configured  (\"identity-uri\" key in config)");
            }

            // first set of validations
            collector.collect().checkValid();
            collector = Errors.collector();

            loadOidcMetadata(collector);

            this.tokenEndpointUri = getOidcEndpoint(collector,
                                                    tokenEndpointUri,
                                                    "token_endpoint",
                                                    "/oauth2/v1/token");

            this.authorizationEndpointUri = getOidcEndpoint(collector,
                                                            authorizationEndpointUri,
                                                            "authorization_endpoint",
                                                            "/oauth2/v1/authorize");

            if (validateJwtWithJwk) {
                if (null == signJwk) {
                    // not configured - use default location
                    URI jwkUri = getOidcEndpoint(collector,
                                                 null,
                                                 "jwks_uri",
                                                 null);
                    if (null != jwkUri) {
                        this.signJwk = JwkKeys.builder()
                                .resource(Resource.create(jwkUri))
                                .build();
                    }
                }
            } else {
                this.introspectUri = getOidcEndpoint(collector,
                                                     introspectUri,
                                                     "introspection_endpoint",
                                                     "/oauth2/v1/introspect");
            }

            if ((null == issuer) && (null != oidcMetadata)) {
                this.issuer = oidcMetadata.getString("issuer");
            }

            if ((null == audience) && (null != identityUri)) {
                this.audience = identityUri.toString();
            }

            collector.collect().checkValid();

            if (cookieSameSiteDefault && useCookie) {
                LOGGER.warning("Cookie Same-Site is now set to \"Lax\" by default. Used to be \"Strict\" in previous "
                                       + "versions of Helidon. This is to prevent infinite redirects.");
            }

            return new OidcConfig(this);
        }

        private URI getOidcEndpoint(Errors.Collector collector,
                                    URI currentValue,
                                    String metaKey,
                                    String defaultUri) {
            if (null == currentValue) {
                if (null == oidcMetadata) {
                    collector.fatal("When " + metaKey + " is not explicitly defined, the OIDC metadata must exist");
                } else {
                    String endpoint = oidcMetadata.getString(metaKey);
                    if (null == endpoint) {
                        if (null == identityUri) {
                            collector
                                    .fatal(metaKey + " URI is not defined in well known configuration");
                        } else {
                            if (null == defaultUri) {
                                collector
                                        .fatal(metaKey + " default URI is not defined and URI was not in OIDC metadata");
                            } else {
                                return URI.create(identityUri + defaultUri);
                            }
                        }
                    } else {
                        URI uri = URI.create(endpoint);
                        LOGGER.finest(() -> metaKey + " loaded from well known metadata: " + uri);
                        return uri;
                    }
                }
            } else {
                LOGGER.finest(() -> metaKey + " explicitly configured: " + currentValue);
                return currentValue;
            }

            return currentValue;
        }

        private void loadOidcMetadata(Errors.Collector collector) {
            if ((null == oidcMetadata) && oidcMetadataWellKnown) {
                try {
                    String wellKnown = identityUri + OidcConfig.DEFAULT_OIDC_METADATA_URI;
                    oidcMetadata = JSON.createReader(Resource.create(URI.create(wellKnown)).stream()).readObject();
                    LOGGER.finest(() -> "OIDC Metadata loaded from well known URI: " + wellKnown);
                } catch (Exception e) {
                    collector.fatal(e, "Failed to load metadata: " + e.getClass().getName() + ": " + e.getMessage());
                }
            }
        }

        /**
         * Update this builder with values from configuration.
         *
         * @param config configuration located on node with OIDC configuration keys (e.g. client-id)
         * @return updated builder instance
         */
        public Builder config(Config config) {
            // mandatory configuration
            config.get("client-id").asString().ifPresent(this::clientId);
            config.get("client-secret").asString().ifPresent(this::clientSecret);
            config.get("identity-uri").as(URI.class).ifPresent(this::identityUri);
            config.get("frontend-uri").asString().ifPresent(this::frontendUri);

            // environment
            config.get("proxy-protocol")
                    .asString()
                    .ifPresent(this::proxyProtocol);
            config.get("proxy-host").asString().ifPresent(this::proxyHost);
            config.get("proxy-port").asInt().ifPresent(this::proxyPort);

            // our application
            config.get("redirect-uri").asString().ifPresent(this::redirectUri);
            config.get("scope-audience").asString().ifPresent(this::scopeAudience);

            // token handling
            config.get("cookie-use").asBoolean().ifPresent(this::useCookie);
            config.get("cookie-name").asString().ifPresent(this::cookieName);
            config.get("cookie-domain").asString().ifPresent(this::cookieDomain);
            config.get("cookie-path").asString().ifPresent(this::cookiePath);
            config.get("cookie-max-age-seconds").asLong().ifPresent(this::cookieMaxAgeSeconds);
            config.get("cookie-http-only").asBoolean().ifPresent(this::cookieHttpOnly);
            config.get("cookie-secure").asBoolean().ifPresent(this::cookieSecure);
            config.get("cookie-same-site").asString().ifPresent(this::cookieSameSite);
            config.get("query-param-use").asBoolean().ifPresent(this::useParam);
            config.get("query-param-name").asString().ifPresent(this::paramName);
            config.get("header-use").asBoolean().ifPresent(this::useHeader);
            config.get("header-token").as(TokenHandler.class).ifPresent(this::headerTokenHandler);

            // OIDC server configuration
            config.get("base-scopes").asString().ifPresent(this::baseScopes);
            Resource.create(config, "oidc-metadata").ifPresent(this::oidcMetadata);
            config.get("oidc-metadata-well-known").asBoolean().ifPresent(this::oidcMetadataWellKnown);
            Resource.create(config, "sign-jwk").ifPresent(this::signJwk);
            config.get("token-endpoint-uri").as(URI.class).ifPresent(this::tokenEndpointUri);
            config.get("authorization-endpoint-uri").as(URI.class).ifPresent(this::authorizationEndpointUri);

            config.get("introspect-endpoint-uri").as(URI.class).ifPresent(this::introspectEndpointUri);
            config.get("validate-with-jwk").asBoolean().ifPresent(this::validateJwtWithJwk);
            config.get("issuer").asString().ifPresent(this::issuer);
            config.get("audience").asString().ifPresent(this::audience);

            config.get("redirect").asBoolean().ifPresent(this::redirect);
            config.get("redirect-attempt-param").asString().ifPresent(this::redirectAttemptParam);
            config.get("max-redirects").asInt().ifPresent(this::maxRedirects);

            return this;
        }

        /**
         * By default the client should redirect to the identity server for the user to log in.
         * This behavior can be overridden by setting redirect to false. When token is not present in the request, the client
         * will not redirect and just return appropriate error response code.
         *
         * @param redirect Whether to redirect to OIDC server in case the request does not contain sufficient information to
         *                 authenticate the user, defaults to true
         * @return updated builder instance
         */
        public Builder redirect(boolean redirect) {
            this.redirect = redirect;
            return this;
        }

        /**
         * Realm to return when not redirecting and an error occurs that sends back WWW-Authenticate header.
         *
         * @param realm realm name
         * @return updated builder instance
         */
        public Builder realm(String realm) {
            this.realm = realm;
            return this;
        }

        /**
         * Audience of issued tokens.
         *
         * @param audience audience to validate
         * @return updated builder instance
         */
        public Builder audience(String audience) {
            this.audience = audience;
            return this;
        }

        /**
         * Issuer of issued tokens.
         *
         * @param issuer expected issuer to validate
         * @return updated builder instance
         */
        public Builder issuer(String issuer) {
            this.issuer = issuer;
            return this;
        }

        /**
         * Use JWK (a set of keys to validate signatures of JWT) to validate tokens.
         * Use this method when you want to use default values for JWK or introspection endpoint URI.
         *
         * @param useJwk when set to true, jwk is used, when set to false, introspect endpoint is used
         * @return updated builder instance
         */
        public Builder validateJwtWithJwk(Boolean useJwk) {
            this.validateJwtWithJwk = useJwk;
            return this;
        }

        /**
         * Endpoint to use to validate JWT.
         * Either use this or set {@link #signJwk(JwkKeys)} or {@link #signJwk(Resource)}.
         *
         * @param uri URI of introspection endpoint
         * @return updated builder instance
         */
        public Builder introspectEndpointUri(URI uri) {
            validateJwtWithJwk(false);
            this.introspectUri = uri;
            return this;
        }

        /**
         * Configure base scopes.
         * By default this is {@value DEFAULT_BASE_SCOPES}.
         * If scope has a qualifier, it must be used here.
         *
         * @param scopes Space separated scopes to be required by default from OIDC server
         * @return updated builder instance
         */
        public Builder baseScopes(String scopes) {
            this.baseScopes = scopes;
            return this;
        }

        /**
         * If set to true, metadata will be loaded from default (well known)
         * location, unless it is explicitly defined using oidc-metadata-resource. If set to false, it would not be loaded
         * even if oidc-metadata-resource is not defined. In such a case all URIs must be explicitly defined (e.g.
         * token-endpoint-uri).
         *
         * @param useWellKnown whether to use well known location for OIDC metadata
         * @return updated builder instance
         */
        public Builder oidcMetadataWellKnown(Boolean useWellKnown) {
            this.oidcMetadataWellKnown = useWellKnown;
            return this;
        }

        /**
         * A resource pointing to JWK with public keys of signing certificates used
         * to validate JWT.
         *
         * @param resource Resource pointing to the JWK
         * @return updated builder instance
         */
        public Builder signJwk(Resource resource) {
            validateJwtWithJwk(true);
            this.signJwk = JwkKeys.builder().resource(resource).build();
            return this;
        }

        /**
         * Set {@link JwkKeys} to use for JWT validation.
         *
         * @param jwk JwkKeys instance to get public keys used to sign JWT
         * @return udpated builder instance
         */
        public Builder signJwk(JwkKeys jwk) {
            validateJwtWithJwk(true);
            this.signJwk = jwk;
            return this;
        }

        /**
         * Resource configuration for OIDC Metadata
         * containing endpoints to various identity services, as well as information about the identity server.
         *
         * @param resource resource pointing to the JSON structure
         * @return udpated builder instance
         */
        public Builder oidcMetadata(Resource resource) {
            this.oidcMetadata = JSON.createReader(resource.stream()).readObject();
            return this;
        }

        /**
         * JsonObject with the OIDC Metadata.
         *
         * @param metadata metadata JSON
         * @return updated builder instance
         * @see #oidcMetadata(Resource)
         */
        public Builder oidcMetadata(JsonObject metadata) {
            this.oidcMetadata = metadata;
            return this;
        }

        /**
         * A {@link TokenHandler} to
         * process header containing a JWT.
         * Default is "Authorization" header with a prefix "bearer ".
         *
         * @param tokenHandler token handler to use
         * @return updated builder instance
         */
        public Builder headerTokenHandler(TokenHandler tokenHandler) {
            this.headerHandler = tokenHandler;
            return this;
        }

        /**
         * Whether to expect JWT in a header field.
         *
         * @param useHeader set to true to use a header extracted with {@link #headerTokenHandler(TokenHandler)}
         * @return updated builder instance
         */
        public Builder useHeader(Boolean useHeader) {
            this.useHeader = useHeader;
            return this;
        }

        /**
         * Audience of the scope required by this application. This is prefixed to
         * the scope name when requesting scopes from the identity server.
         * Defaults to empty string.
         *
         * @param audience audience, if provided, end with "/" to append the scope correctly
         * @return updated builder instance
         */
        public Builder scopeAudience(String audience) {
            this.scopeAudience = audience;
            return this;
        }

        /**
         * When using cookie, used to set the SameSite cookie value. Can be
         * "Strict" or "Lax"
         *
         * @param sameSite SameSite cookie attribute value
         * @return updated builder instance
         */
        public Builder cookieSameSite(String sameSite) {
            this.cookieSameSite = sameSite;
            this.cookieSameSiteDefault = false;
            return this;
        }

        /**
         * When using cookie, if set to true, the Secure attribute will be configured.
         * Defaults to false.
         *
         * @param secure whether the cookie should be secure (true) or not (false)
         * @return updated builder instance
         */
        public Builder cookieSecure(Boolean secure) {
            this.cookieSecure = secure;
            return this;
        }

        /**
         * When using cookie, if set to true, the HttpOnly attribute will be configured.
         * Defaults to {@value #DEFAULT_COOKIE_HTTP_ONLY}.
         *
         * @param httpOnly whether the cookie should be HttpOnly (true) or not (false)
         * @return updated builder instance
         */
        public Builder cookieHttpOnly(Boolean httpOnly) {
            this.cookieHttpOnly = httpOnly;
            return this;
        }

        /**
         * When using cookie, used to set MaxAge attribute of the cookie, defining how long
         * the cookie is valid.
         * Not used by default.
         *
         * @param age age in seconds
         * @return updated builder instance
         */
        public Builder cookieMaxAgeSeconds(long age) {
            this.cookieMaxAge = age;
            return this;
        }

        /**
         * Path the cookie is valid for.
         * Defaults to "/".
         *
         * @param path the path to use as value of cookie "Path" attribute
         * @return updated builder instance
         */
        public Builder cookiePath(String path) {
            this.cookiePath = path;
            return this;
        }

        /**
         * Domain the cookie is valid for.
         * Not used by default.
         *
         * @param domain domain to use as value of cookie "Domain" attribute
         * @return updated builder instance
         */
        public Builder cookieDomain(String domain) {
            this.cookieDomain = domain;
            return this;
        }

        /**
         * Full URI of this application that is visible from user browser.
         * Used to redirect request back from identity server after successful login.
         *
         * @param uri the frontend URI, such as "http://my.server.com/myApp
         * @return updated builder instance
         */
        public Builder frontendUri(String uri) {
            this.frontendUri = uri;
            return this;
        }

        /**
         * URI of a token endpoint used to obtain a JWT based on the authentication
         * code.
         * If not defined, it is obtained from {@link #oidcMetadata(Resource)}, if that is not defined
         * an attempt is made to use {@link #identityUri(URI)}/oauth2/v1/token.
         *
         * @param uri URI to use for token endpoint
         * @return updated builder instance
         */
        public Builder tokenEndpointUri(URI uri) {
            this.tokenEndpointUri = uri;
            return this;
        }

        /**
         * URI of an authorization endpoint used to redirect users to for logging-in.
         *
         * If not defined, it is obtained from {@link #oidcMetadata(Resource)}, if that is not defined
         * an attempt is made to use {@link #identityUri(URI)}/oauth2/v1/authorize.
         *
         * @param uri URI to use for token endpoint
         * @return updated builder instance
         */
        public Builder authorizationEndpointUri(URI uri) {
            this.authorizationEndpointUri = uri;
            return this;
        }

        /**
         * Name of the cookie to use.
         * Defaults to {@value #DEFAULT_COOKIE_NAME}.
         *
         * @param cookieName name of a cookie
         * @return updated builder instance
         */
        public Builder cookieName(String cookieName) {
            this.cookieName = cookieName;
            return this;
        }

        /**
         * Whether to use cookie to store JWT between requests.
         * Defaults to {@value #DEFAULT_COOKIE_USE}.
         *
         * @param useCookie whether to use cookie to store JWT (true) or not (false))
         * @return updated builder instance
         */
        public Builder useCookie(Boolean useCookie) {
            this.useCookie = useCookie;
            return this;
        }

        /**
         * Name of a query parameter that contains the JWT token when parameter is used.
         *
         * @param paramName name of the query parameter to expect
         * @return updated builder instance
         */
        public Builder paramName(String paramName) {
            this.paramName = paramName;
            return this;
        }

        /**
         * Whether to use a query parameter to send JWT token from application to this
         * server.
         *
         * @param useParam whether to use a query parameter (true) or not (false)
         * @return updated builder instance
         * @see #paramName(String)
         */
        public Builder useParam(Boolean useParam) {
            this.useParam = useParam;
            return this;
        }

        /**
         * URI of the identity server, base used to retrieve OIDC metadata.
         *
         * @param uri full URI of an identity server (such as "http://tenantid.identity.oraclecloud.com")
         * @return updated builder instance
         */
        public Builder identityUri(URI uri) {
            this.identityUri = uri;
            return this;
        }

        /**
         * Proxy protocol to use when proxy is used.
         * Defaults to {@value #DEFAULT_PROXY_PROTOCOL}.
         *
         * @param protocol protocol to use (such as https)
         * @return updated builder instance
         */
        public Builder proxyProtocol(String protocol) {
            this.proxyProtocol = protocol;
            return this;
        }

        /**
         * Proxy host to use. When defined, triggers usage of proxy for HTTP requests.
         * Setting to empty String has the same meaning as setting to null - disables proxy.
         *
         * @param proxyHost host of the proxy
         * @return updated builder instance
         * @see #proxyProtocol(String)
         * @see #proxyPort(int)
         */
        public Builder proxyHost(String proxyHost) {
            if ((null == proxyHost) || proxyHost.isEmpty()) {
                this.proxyHost = null;
            } else {
                this.proxyHost = proxyHost;
            }
            return this;
        }

        /**
         * Proxy port.
         * Defaults to {@value #DEFAULT_PROXY_PORT}
         *
         * @param proxyPort port of the proxy server to use
         * @return updated builder instance
         */
        public Builder proxyPort(int proxyPort) {
            this.proxyPort = proxyPort;
            return this;
        }

        /**
         * Client ID as generated by OIDC server.
         *
         * @param clientId the client id of this application.
         * @return updated builder instance
         */
        public Builder clientId(String clientId) {
            this.clientId = clientId;
            return this;
        }

        /**
         * Client secret as generated by OIDC server.
         * Used to authenticate this application with the server when requesting
         * JWT based on a code.
         *
         * @param clientSecret secret to use
         * @return updated builder instance
         */
        public Builder clientSecret(String clientSecret) {
            this.clientSecret = clientSecret;
            return this;
        }

        /**
         * URI to register web server component on, used by the OIDC server to
         * redirect authorization requests to after a user logs in or approves
         * scopes.
         * Note that usually the redirect URI configured here must be the
         * same one as configured on OIDC server.
         *
         * <p>
         * Defaults to {@value #DEFAULT_REDIRECT_URI}
         *
         * @param redirectUri the URI (path without protocol, host and port) used to redirect requests back to us
         * @return updated builder instance
         */
        public Builder redirectUri(String redirectUri) {
            this.redirectUri = redirectUri;
            return this;
        }

        /**
         * Configure the parameter used to store the number of attempts in redirect.
         * <p>
         * Defaults to {@value #DEFAULT_ATTEMPT_PARAM}
         * @param paramName name of the parameter used in the state parameter
         * @return updated builder instance
         */
        public Builder redirectAttemptParam(String paramName) {
            this.redirectAttemptParam = paramName;
            return this;
        }

        /**
         * Configure maximal number of redirects when redirecting to an OIDC provider within a single authentication
         * attempt.
         * <p>
         * Defaults to {@value #DEFAULT_MAX_REDIRECTS}
         * @param maxRedirects maximal number of redirects from Helidon to OIDC provider
         * @return updated builder instance
         */
        public Builder maxRedirects(int maxRedirects) {
            this.maxRedirects = maxRedirects;
            return this;
        }
    }
}
