/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License;
 * you may not use this file except in compliance with the Elastic License.
 */
package org.elasticsearch.xpack.security.authc.saml;

import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
import net.shibboleth.utilities.java.support.resolver.ResolverException;
import net.shibboleth.utilities.java.support.xml.BasicParserPool;
import org.apache.http.client.HttpClient;
import org.apache.http.conn.ssl.DefaultHostnameVerifier;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.SpecialPermission;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.CheckedRunnable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.SuppressForbidden;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.lease.Releasable;
import org.elasticsearch.common.lease.Releasables;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.settings.SettingsException;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.util.CollectionUtils;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.util.set.Sets;
import org.elasticsearch.watcher.FileChangesListener;
import org.elasticsearch.watcher.FileWatcher;
import org.elasticsearch.watcher.ResourceWatcherService;
import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.RealmSettings;
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.core.ssl.CertUtils;
import org.elasticsearch.xpack.core.ssl.SSLService;
import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings;
import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.TokenService;
import org.elasticsearch.xpack.security.authc.support.UserRoleMapper;
import org.opensaml.core.criterion.EntityIdCriterion;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.criterion.EntityRoleCriterion;
import org.opensaml.saml.metadata.resolver.MetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.AbstractReloadingMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.FilesystemMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.HTTPMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.PredicateRoleDescriptorResolver;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.core.LogoutRequest;
import org.opensaml.saml.saml2.core.LogoutResponse;
import org.opensaml.saml.saml2.core.NameID;
import org.opensaml.saml.saml2.core.StatusCode;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml.security.impl.MetadataCredentialResolver;
import org.opensaml.security.credential.Credential;
import org.opensaml.security.credential.UsageType;
import org.opensaml.security.criteria.UsageCriterion;
import org.opensaml.security.x509.X509Credential;
import org.opensaml.security.x509.impl.X509KeyManagerX509CredentialAdapter;
import org.opensaml.xmlsec.keyinfo.impl.BasicProviderKeyInfoCredentialResolver;
import org.opensaml.xmlsec.keyinfo.impl.provider.InlineX509DataProvider;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.X509KeyManager;
import java.io.IOException;
import java.nio.file.Path;
import java.security.AccessController;
import java.security.GeneralSecurityException;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.CLOCK_SKEW;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.DN_ATTRIBUTE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.ENCRYPTION_KEY_ALIAS;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.ENCRYPTION_SETTINGS;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.FORCE_AUTHN;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.GROUPS_ATTRIBUTE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.IDP_ENTITY_ID;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.IDP_METADATA_HTTP_REFRESH;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.IDP_METADATA_PATH;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.IDP_SINGLE_LOGOUT;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.MAIL_ATTRIBUTE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.NAMEID_ALLOW_CREATE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.NAMEID_FORMAT;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.NAMEID_SP_QUALIFIER;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.NAME_ATTRIBUTE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.POPULATE_USER_METADATA;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.PRINCIPAL_ATTRIBUTE;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_KEY_ALIAS;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_MESSAGE_TYPES;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_SETTINGS;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SP_ACS;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SP_ENTITY_ID;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SP_LOGOUT;
import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.TYPE;

/**
 * This class is {@link Releasable} because it uses a library that thinks timers and timer tasks
 * are still cool and no chance to opt out
 */
public final class SamlRealm extends Realm implements Releasable {

    public static final String USER_METADATA_NAMEID_VALUE = "saml_" + SamlAttributes.NAMEID_SYNTHENTIC_ATTRIBUTE;
    public static final String USER_METADATA_NAMEID_FORMAT = USER_METADATA_NAMEID_VALUE + "_format";

    public static final String CONTEXT_TOKEN_DATA = "_xpack_saml_tokendata";
    public static final String TOKEN_METADATA_NAMEID_VALUE = "saml_nameid_val";
    public static final String TOKEN_METADATA_NAMEID_FORMAT = "saml_nameid_fmt";
    public static final String TOKEN_METADATA_NAMEID_QUALIFIER = "saml_nameid_qual";
    public static final String TOKEN_METADATA_NAMEID_SP_QUALIFIER = "saml_nameid_sp_qual";
    public static final String TOKEN_METADATA_NAMEID_SP_PROVIDED_ID = "saml_nameid_sp_id";
    public static final String TOKEN_METADATA_SESSION = "saml_session";
    public static final String TOKEN_METADATA_REALM = "saml_realm";
    // Although we only use this for IDP metadata loading, the SSLServer only loads configurations where "ssl." is a top-level element
    // in the realm group configuration, so it has to have this name.

    private final List<Releasable> releasables;

    private final SamlAuthenticator authenticator;
    private final SamlLogoutRequestHandler logoutHandler;
    private final UserRoleMapper roleMapper;

    private final Supplier<EntityDescriptor> idpDescriptor;

    private final SpConfiguration serviceProvider;
    private final SamlAuthnRequestBuilder.NameIDPolicySettings nameIdPolicy;
    private final Boolean forceAuthn;
    private final boolean useSingleLogout;
    private final Boolean populateUserMetadata;

    private final AttributeParser principalAttribute;
    private final AttributeParser groupsAttribute;
    private final AttributeParser dnAttribute;
    private final AttributeParser nameAttribute;
    private final AttributeParser mailAttribute;


    /**
     * Factory for SAML realm.
     * This is not a constructor as it needs to initialise a number of components before delegating to
     * {@link #SamlRealm}
     */
    public static SamlRealm create(RealmConfig config, SSLService sslService, ResourceWatcherService watcherService,
                                   UserRoleMapper roleMapper) throws Exception {
        final Logger logger = config.logger(SamlRealm.class);
        SamlUtils.initialize(logger);

        if (TokenService.isTokenServiceEnabled(config.globalSettings()) == false) {
            throw new IllegalStateException("SAML requires that the token service be enabled ("
                    + XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey() + ")");
        }

        final Tuple<AbstractReloadingMetadataResolver, Supplier<EntityDescriptor>> tuple
                = initializeResolver(logger, config, sslService, watcherService);
        final AbstractReloadingMetadataResolver metadataResolver = tuple.v1();
        final Supplier<EntityDescriptor> idpDescriptor = tuple.v2();

        final SpConfiguration serviceProvider = getSpConfiguration(config);

        final Clock clock = Clock.systemUTC();
        final IdpConfiguration idpConfiguration = getIdpConfiguration(config, metadataResolver, idpDescriptor);
        final TimeValue maxSkew = CLOCK_SKEW.get(config.settings());
        final SamlAuthenticator authenticator = new SamlAuthenticator(config, clock, idpConfiguration, serviceProvider, maxSkew);
        final SamlLogoutRequestHandler logoutHandler =
                new SamlLogoutRequestHandler(config, clock, idpConfiguration, serviceProvider, maxSkew);

        final SamlRealm realm = new SamlRealm(config, roleMapper, authenticator, logoutHandler, idpDescriptor, serviceProvider);

        // the metadata resolver needs to be destroyed since it runs a timer task in the background and destroying stops it!
        realm.releasables.add(() -> metadataResolver.destroy());

        return realm;
    }

    // For testing
    SamlRealm(RealmConfig config, UserRoleMapper roleMapper, SamlAuthenticator authenticator, SamlLogoutRequestHandler logoutHandler,
              Supplier<EntityDescriptor> idpDescriptor, SpConfiguration spConfiguration) throws Exception {
        super(TYPE, config);

        this.roleMapper = roleMapper;
        this.authenticator = authenticator;
        this.logoutHandler = logoutHandler;

        this.idpDescriptor = idpDescriptor;
        this.serviceProvider = spConfiguration;

        this.nameIdPolicy = new SamlAuthnRequestBuilder.NameIDPolicySettings(require(config, NAMEID_FORMAT),
                    NAMEID_ALLOW_CREATE.get(config.settings()), NAMEID_SP_QUALIFIER.get(config.settings()));
        this.forceAuthn = FORCE_AUTHN.exists(config.settings()) ? FORCE_AUTHN.get(config.settings()) : null;
        this.useSingleLogout = IDP_SINGLE_LOGOUT.get(config.settings());
        this.populateUserMetadata = POPULATE_USER_METADATA.get(config.settings());
        this.principalAttribute = AttributeParser.forSetting(logger, PRINCIPAL_ATTRIBUTE, config, true);

        this.groupsAttribute = AttributeParser.forSetting(logger, GROUPS_ATTRIBUTE, config, false);
        this.dnAttribute = AttributeParser.forSetting(logger, DN_ATTRIBUTE, config, false);
        this.nameAttribute = AttributeParser.forSetting(logger, NAME_ATTRIBUTE, config, false);
        this.mailAttribute = AttributeParser.forSetting(logger, MAIL_ATTRIBUTE, config, false);

        this.releasables = new ArrayList<>();
    }

    static String require(RealmConfig config, Setting<String> setting) {
        final String value = setting.get(config.settings());
        if (value.isEmpty()) {
            throw new IllegalArgumentException("The configuration setting [" + RealmSettings.getFullSettingKey(config, setting)
                    + "] is required");
        }
        return value;
    }

    private static IdpConfiguration getIdpConfiguration(RealmConfig config, MetadataResolver metadataResolver,
                                                        Supplier<EntityDescriptor> idpDescriptor) {
        final MetadataCredentialResolver resolver = new MetadataCredentialResolver();

        final PredicateRoleDescriptorResolver roleDescriptorResolver = new PredicateRoleDescriptorResolver(metadataResolver);
        resolver.setRoleDescriptorResolver(roleDescriptorResolver);

        final InlineX509DataProvider keyInfoProvider = new InlineX509DataProvider();
        resolver.setKeyInfoCredentialResolver(new BasicProviderKeyInfoCredentialResolver(Collections.singletonList(keyInfoProvider)));

        try {
            roleDescriptorResolver.initialize();
            resolver.initialize();
        } catch (ComponentInitializationException e) {
            throw new IllegalStateException("Cannot initialise SAML IDP resolvers for realm " + config.name(), e);
        }

        final String entityID = idpDescriptor.get().getEntityID();
        return new IdpConfiguration(entityID, () -> {
            try {
                final Iterable<Credential> credentials = resolver.resolve(new CriteriaSet(
                        new EntityIdCriterion(entityID),
                        new EntityRoleCriterion(IDPSSODescriptor.DEFAULT_ELEMENT_NAME),
                        new UsageCriterion(UsageType.SIGNING)));
                return CollectionUtils.iterableAsArrayList(credentials);
            } catch (ResolverException e) {
                throw new IllegalStateException("Cannot resolve SAML IDP credentials resolver for realm " + config.name(), e);
            }
        });
    }

    static SpConfiguration getSpConfiguration(RealmConfig config) throws IOException, GeneralSecurityException {
        final String serviceProviderId = require(config, SP_ENTITY_ID);
        final String assertionConsumerServiceURL = require(config, SP_ACS);
        final String logoutUrl = SP_LOGOUT.get(config.settings());
        return new SpConfiguration(serviceProviderId, assertionConsumerServiceURL,
                logoutUrl, buildSigningConfiguration(config), buildEncryptionCredential(config));
    }


    // Package-private for testing
    static List<X509Credential> buildEncryptionCredential(RealmConfig config) throws IOException, GeneralSecurityException {
        return buildCredential(config, ENCRYPTION_SETTINGS, ENCRYPTION_KEY_ALIAS, true);
    }

    static SigningConfiguration buildSigningConfiguration(RealmConfig config) throws IOException, GeneralSecurityException {
        final List<X509Credential> credentials = buildCredential(config, SIGNING_SETTINGS, SIGNING_KEY_ALIAS, false);

        if (credentials == null || credentials.isEmpty()) {
            if (SIGNING_MESSAGE_TYPES.exists(config.settings())) {
                throw new IllegalArgumentException("The setting [" + RealmSettings.getFullSettingKey(config, SIGNING_MESSAGE_TYPES)
                        + "] cannot be specified if there are no signing credentials");
            } else {
                return new SigningConfiguration(Collections.emptySet(), null);
            }
        } else {
            final List<String> types = SIGNING_MESSAGE_TYPES.get(config.settings());
            return new SigningConfiguration(Sets.newHashSet(types), credentials.get(0));
        }
    }

    private static List<X509Credential> buildCredential(RealmConfig config, X509KeyPairSettings keyPairSettings,
            Setting<String> aliasSetting, final boolean allowMultiple) {
        final X509KeyManager keyManager = CertUtils.getKeyManager(keyPairSettings, config.settings(), null, config.env());
        if (keyManager == null) {
            return null;
        }

        final Set<String> aliases = new HashSet<>();
        final String configuredAlias = aliasSetting.get(config.settings());
        if (Strings.isNullOrEmpty(configuredAlias)) {

            final String[] serverAliases = keyManager.getServerAliases("RSA", null);
            if (serverAliases != null) {
                aliases.addAll(Arrays.asList(serverAliases));
            }

            if (aliases.isEmpty()) {
                throw new IllegalArgumentException(
                        "The configured key store for " + RealmSettings.getFullSettingKey(config, keyPairSettings.getPrefix())
                                + " does not contain any RSA key pairs");
            } else if (allowMultiple == false && aliases.size() > 1) {
                throw new IllegalArgumentException(
                        "The configured key store for " + RealmSettings.getFullSettingKey(config, keyPairSettings.getPrefix())
                                + " has multiple keys but no alias has been specified (from setting "
                                + RealmSettings.getFullSettingKey(config, aliasSetting) + ")");
            }
        } else {
            aliases.add(configuredAlias);
        }

        final List<X509Credential> credentials = new ArrayList<>();
        for (String alias : aliases) {
            if (keyManager.getPrivateKey(alias) == null) {
                throw new IllegalArgumentException(
                        "The configured key store for " + RealmSettings.getFullSettingKey(config, keyPairSettings.getPrefix())
                                + " does not have a key associated with alias [" + alias + "] "
                                + ((Strings.isNullOrEmpty(configuredAlias) == false)
                                        ? "(from setting " + RealmSettings.getFullSettingKey(config, aliasSetting) + ")"
                                        : ""));
            }

            final String keyType = keyManager.getPrivateKey(alias).getAlgorithm();
            if (keyType.equals("RSA") == false) {
                throw new IllegalArgumentException("The key associated with alias [" + alias + "] " + "(from setting "
                        + RealmSettings.getFullSettingKey(config, aliasSetting) + ") uses unsupported key algorithm type [" + keyType
                        + "], only RSA is supported");
            }
            credentials.add(new X509KeyManagerX509CredentialAdapter(keyManager, alias));
        }

        return credentials;
    }

    public static List<SamlRealm> findSamlRealms(Realms realms, String realmName, String acsUrl) {
        Stream<SamlRealm> stream = realms.stream().filter(r -> r instanceof SamlRealm).map(r -> (SamlRealm) r);
        if (Strings.hasText(realmName)) {
            stream = stream.filter(r -> realmName.equals(r.name()));
        }
        if (Strings.hasText(acsUrl)) {
            stream = stream.filter(r -> acsUrl.equals(r.assertionConsumerServiceURL()));
        }
        return stream.collect(Collectors.toList());
    }

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof SamlToken;
    }

    /**
     * Always returns {@code null} as there is no support for reading a SAML token out of a request
     *
     * @see org.elasticsearch.xpack.security.action.saml.TransportSamlAuthenticateAction
     */
    @Override
    public AuthenticationToken token(ThreadContext threadContext) {
        return null;
    }

    @Override
    public void authenticate(AuthenticationToken authenticationToken, ActionListener<AuthenticationResult> listener) {
        if (authenticationToken instanceof SamlToken) {
            try {
                final SamlToken token = (SamlToken) authenticationToken;
                final SamlAttributes attributes = authenticator.authenticate(token);
                logger.debug("Parsed token [{}] to attributes [{}]", token, attributes);
                buildUser(attributes, listener);
            } catch (ElasticsearchSecurityException e) {
                if (SamlUtils.isSamlException(e)) {
                    listener.onResponse(AuthenticationResult.unsuccessful("Provided SAML response is not valid for realm " + this, e));
                } else {
                    listener.onFailure(e);
                }
            }
        } else {
            listener.onResponse(AuthenticationResult.notHandled());
        }
    }

    private void buildUser(SamlAttributes attributes, ActionListener<AuthenticationResult> listener) {
        final String principal = resolveSingleValueAttribute(attributes, principalAttribute, PRINCIPAL_ATTRIBUTE.name());
        if (Strings.isNullOrEmpty(principal)) {
            listener.onResponse(AuthenticationResult.unsuccessful(
                    principalAttribute + " not found in " + attributes.attributes(), null));
            return;
        }

        final Map<String, Object> userMeta = new HashMap<>();
        if (populateUserMetadata) {
            for (SamlAttributes.SamlAttribute a : attributes.attributes()) {
                userMeta.put("saml(" + a.name + ")", a.values);
                if (Strings.hasText(a.friendlyName)) {
                    userMeta.put("saml_" + a.friendlyName, a.values);
                }
            }
        }
        if (attributes.name() != null) {
            userMeta.put(USER_METADATA_NAMEID_VALUE, attributes.name().value);
            userMeta.put(USER_METADATA_NAMEID_FORMAT, attributes.name().format);
        }

        final Map<String, Object> tokenMetadata = createTokenMetadata(attributes.name(), attributes.session());

        final List<String> groups = groupsAttribute.getAttribute(attributes);
        final String dn = resolveSingleValueAttribute(attributes, dnAttribute, DN_ATTRIBUTE.name());
        final String name = resolveSingleValueAttribute(attributes, nameAttribute, NAME_ATTRIBUTE.name());
        final String mail = resolveSingleValueAttribute(attributes, mailAttribute, MAIL_ATTRIBUTE.name());
        UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMeta, config);
        roleMapper.resolveRoles(userData, ActionListener.wrap(roles -> {
            final User user = new User(principal, roles.toArray(new String[roles.size()]), name, mail, userMeta, true);
            config.threadContext().putTransient(CONTEXT_TOKEN_DATA, tokenMetadata);
            listener.onResponse(AuthenticationResult.success(user));
        }, listener::onFailure));
    }

    public Map<String, Object> createTokenMetadata(SamlNameId nameId, String session) {
        final Map<String, Object> tokenMeta = new HashMap<>();
        if (nameId != null) {
            tokenMeta.put(TOKEN_METADATA_NAMEID_VALUE, nameId.value);
            tokenMeta.put(TOKEN_METADATA_NAMEID_FORMAT, nameId.format);
            tokenMeta.put(TOKEN_METADATA_NAMEID_QUALIFIER, nameId.idpNameQualifier);
            tokenMeta.put(TOKEN_METADATA_NAMEID_SP_QUALIFIER, nameId.spNameQualifier);
            tokenMeta.put(TOKEN_METADATA_NAMEID_SP_PROVIDED_ID, nameId.spProvidedId);
        } else {
            tokenMeta.put(TOKEN_METADATA_NAMEID_VALUE, null);
            tokenMeta.put(TOKEN_METADATA_NAMEID_FORMAT, null);
            tokenMeta.put(TOKEN_METADATA_NAMEID_QUALIFIER, null);
            tokenMeta.put(TOKEN_METADATA_NAMEID_SP_QUALIFIER, null);
            tokenMeta.put(TOKEN_METADATA_NAMEID_SP_PROVIDED_ID, null);
        }
        tokenMeta.put(TOKEN_METADATA_SESSION, session);
        tokenMeta.put(TOKEN_METADATA_REALM, name());
        return tokenMeta;
    }

    private String resolveSingleValueAttribute(SamlAttributes attributes, AttributeParser parser, String name) {
        final List<String> list = parser.getAttribute(attributes);
        switch (list.size()) {
            case 0:
                return null;
            case 1:
                return list.get(0);
            default:
                logger.info("SAML assertion contains multiple values for attribute [{}] returning first one", name);
                return list.get(0);
        }
    }

    @Override
    public void lookupUser(String username, ActionListener<User> listener) {
        // saml will not support user lookup initially
        listener.onResponse(null);
    }

    static Tuple<AbstractReloadingMetadataResolver, Supplier<EntityDescriptor>> initializeResolver(Logger logger, RealmConfig config,
                                                                                                   SSLService sslService,
                                                                                                   ResourceWatcherService watcherService)
            throws ResolverException, ComponentInitializationException, PrivilegedActionException, IOException {
        final String metadataUrl = require(config, IDP_METADATA_PATH);
        if (metadataUrl.startsWith("http://")) {
            throw new IllegalArgumentException("The [http] protocol is not supported as it is insecure. Use [https] instead");
        } else if (metadataUrl.startsWith("https://")) {
            return parseHttpMetadata(metadataUrl, config, sslService);
        } else {
            return parseFileSystemMetadata(logger, metadataUrl, config, watcherService);
        }
    }

    private static Tuple<AbstractReloadingMetadataResolver, Supplier<EntityDescriptor>> parseHttpMetadata(String metadataUrl,
                                                                                                          RealmConfig config,
                                                                                                          SSLService sslService)
            throws ResolverException, ComponentInitializationException, PrivilegedActionException {
        final String entityId = require(config, IDP_ENTITY_ID);

        HttpClientBuilder builder = HttpClientBuilder.create();
        // ssl setup
        Settings sslSettings = config.settings().getByPrefix(SamlRealmSettings.SSL_PREFIX);
        boolean isHostnameVerificationEnabled = sslService.getVerificationMode(sslSettings, Settings.EMPTY).isHostnameVerificationEnabled();
        HostnameVerifier verifier = isHostnameVerificationEnabled ? new DefaultHostnameVerifier() : NoopHostnameVerifier.INSTANCE;
        SSLConnectionSocketFactory factory = new SSLConnectionSocketFactory(sslService.sslSocketFactory(sslSettings), verifier);
        builder.setSSLSocketFactory(factory);

        HTTPMetadataResolver resolver = new PrivilegedHTTPMetadataResolver(builder.build(), metadataUrl);
        TimeValue refresh = IDP_METADATA_HTTP_REFRESH.get(config.settings());
        resolver.setMinRefreshDelay(refresh.millis());
        resolver.setMaxRefreshDelay(refresh.millis());
        initialiseResolver(resolver, config);

        return new Tuple<>(resolver, () -> {
            // for some reason the resolver supports its own trust engine and custom socket factories.
            // we do not use these as we'd rather rely on the JDK versions for TLS security!
            SpecialPermission.check();
            try {
                return AccessController.doPrivileged((PrivilegedExceptionAction<EntityDescriptor>)
                        () -> resolveEntityDescriptor(resolver, entityId, metadataUrl));
            } catch (PrivilegedActionException e) {
                throw ExceptionsHelper.convertToRuntime((Exception) ExceptionsHelper.unwrapCause(e));
            }
        });
    }

    private static final class PrivilegedHTTPMetadataResolver extends HTTPMetadataResolver {

        PrivilegedHTTPMetadataResolver(final HttpClient client, final String metadataURL) throws ResolverException {
            super(client, metadataURL);
        }

        @Override
        protected byte[] fetchMetadata() throws ResolverException {
            try {
                return AccessController.doPrivileged(
                        (PrivilegedExceptionAction<byte[]>) () -> PrivilegedHTTPMetadataResolver.super.fetchMetadata());
            } catch (final PrivilegedActionException e) {
                throw (ResolverException) e.getCause();
            }
        }

    }

    @SuppressForbidden(reason = "uses toFile")
    private static Tuple<AbstractReloadingMetadataResolver, Supplier<EntityDescriptor>> parseFileSystemMetadata(
            Logger logger, String metadataPath, RealmConfig config, ResourceWatcherService watcherService)
            throws ResolverException, ComponentInitializationException, IOException, PrivilegedActionException {

        final String entityId = require(config, IDP_ENTITY_ID);
        final Path path = config.env().configFile().resolve(metadataPath);
        final FilesystemMetadataResolver resolver = new FilesystemMetadataResolver(path.toFile());

        if (IDP_METADATA_HTTP_REFRESH.exists(config.settings())) {
            logger.info("Ignoring setting [{}] because the IdP metadata is being loaded from a file",
                    RealmSettings.getFullSettingKey(config, IDP_METADATA_HTTP_REFRESH));
        }

        // We don't want to rely on the internal OpenSAML refresh timer, but we can't turn it off, so just set it to run once a day.
        // @TODO : Submit a patch to OpenSAML to optionally disable the timer
        final long oneDayMs = TimeValue.timeValueHours(24).millis();
        resolver.setMinRefreshDelay(oneDayMs);
        resolver.setMaxRefreshDelay(oneDayMs);
        initialiseResolver(resolver, config);

        FileWatcher watcher = new FileWatcher(path);
        watcher.addListener(new FileListener(logger, resolver::refresh));
        watcherService.add(watcher, ResourceWatcherService.Frequency.MEDIUM);
        return new Tuple<>(resolver, () -> resolveEntityDescriptor(resolver, entityId, path.toString()));
    }

    private static EntityDescriptor resolveEntityDescriptor(AbstractReloadingMetadataResolver resolver, String entityId,
                                                            String sourceLocation) {
        try {
            final EntityDescriptor descriptor = resolver.resolveSingle(new CriteriaSet(new EntityIdCriterion(entityId)));
            if (descriptor == null) {
                throw SamlUtils.samlException("Cannot find metadata for entity [{}] in [{}]", entityId, sourceLocation);
            }
            return descriptor;
        } catch (ResolverException e) {
            throw SamlUtils.samlException("Cannot resolve entity metadata", e);
        }
    }


    @Override
    public void close() {
        Releasables.close(releasables);
    }

    private static void initialiseResolver(AbstractReloadingMetadataResolver resolver, RealmConfig config)
            throws ComponentInitializationException, PrivilegedActionException {
        resolver.setRequireValidMetadata(true);
        BasicParserPool pool = new BasicParserPool();
        pool.initialize();
        resolver.setParserPool(pool);
        resolver.setId(config.name());
        SpecialPermission.check();
        AccessController.doPrivileged((PrivilegedExceptionAction<Object>) () -> {
            resolver.initialize();
            return null;
        });
    }

    public String serviceProviderEntityId() {
        return this.serviceProvider.getEntityId();
    }

    public String assertionConsumerServiceURL() {
        return this.serviceProvider.getAscUrl();
    }

    public AuthnRequest buildAuthenticationRequest() {
        final AuthnRequest authnRequest = new SamlAuthnRequestBuilder(
                serviceProvider,
                SAMLConstants.SAML2_POST_BINDING_URI,
                idpDescriptor.get(),
                SAMLConstants.SAML2_REDIRECT_BINDING_URI,
                Clock.systemUTC())
                .nameIDPolicy(nameIdPolicy)
                .forceAuthn(forceAuthn)
                .build();
        if (logger.isTraceEnabled()) {
            logger.trace("Constructed SAML Authentication Request: {}", SamlUtils.samlObjectToString(authnRequest));
        }
        return authnRequest;
    }

    /**
     * Creates a SAML {@link LogoutRequest Single LogOut request} for the provided session, if the
     * realm and IdP configuration support SLO. Otherwise returns {@code null}
     *
     * @see SamlRealmSettings#IDP_SINGLE_LOGOUT
     */
    public LogoutRequest buildLogoutRequest(NameID nameId, String session) {
        if (useSingleLogout) {
            final LogoutRequest logoutRequest = new SamlLogoutRequestMessageBuilder(
                    Clock.systemUTC(), serviceProvider, idpDescriptor.get(), nameId, session).build();
            if (logoutRequest != null && logger.isTraceEnabled()) {
                logger.trace("Constructed SAML Logout Request: {}", SamlUtils.samlObjectToString(logoutRequest));
            }
            return logoutRequest;
        } else {
            return null;
        }

    }

    /**
     * Creates a SAML {@link org.opensaml.saml.saml2.core.LogoutResponse} to the provided requestID
     */
    public LogoutResponse buildLogoutResponse(String inResponseTo) {
        final LogoutResponse logoutResponse = new SamlLogoutResponseBuilder(
                Clock.systemUTC(), serviceProvider, idpDescriptor.get(), inResponseTo, StatusCode.SUCCESS).build();
        if (logoutResponse != null && logger.isTraceEnabled()) {
            logger.trace("Constructed SAML Logout Response: {}", SamlUtils.samlObjectToString(logoutResponse));
        }
        return logoutResponse;
    }

    public SigningConfiguration getSigningConfiguration() {
        return serviceProvider.getSigningConfiguration();
    }

    public SamlLogoutRequestHandler getLogoutHandler() {
        return this.logoutHandler;
    }

    private static class FileListener implements FileChangesListener {

        private final Logger logger;
        private final CheckedRunnable<Exception> onChange;

        private FileListener(Logger logger, CheckedRunnable<Exception> onChange) {
            this.logger = logger;
            this.onChange = onChange;
        }

        @Override
        public void onFileCreated(Path file) {
            onFileChanged(file);
        }

        @Override
        public void onFileDeleted(Path file) {
            onFileChanged(file);
        }

        @Override
        public void onFileChanged(Path file) {
            try {
                onChange.run();
            } catch (Exception e) {
                logger.warn(new ParameterizedMessage("An error occurred while reloading file {}", file), e);
            }
        }
    }

    static final class AttributeParser {
        private final String name;
        private final Function<SamlAttributes, List<String>> parser;

        AttributeParser(String name, Function<SamlAttributes, List<String>> parser) {
            this.name = name;
            this.parser = parser;
        }

        List<String> getAttribute(SamlAttributes attributes) {
            return parser.apply(attributes);
        }

        @Override
        public String toString() {
            return name;
        }

        static AttributeParser forSetting(Logger logger, SamlRealmSettings.AttributeSetting setting, RealmConfig realmConfig,
                                          boolean required) {
            final Settings settings = realmConfig.settings();
            if (setting.getAttribute().exists(settings)) {
                String attributeName = setting.getAttribute().get(settings);
                if (setting.getPattern().exists(settings)) {
                    Pattern regex = Pattern.compile(setting.getPattern().get(settings));
                    return new AttributeParser(
                            "SAML Attribute [" + attributeName + "] with pattern [" + regex.pattern() + "] for [" + setting.name() + "]",
                            attributes -> attributes.getAttributeValues(attributeName).stream().map(s -> {
                                final Matcher matcher = regex.matcher(s);
                                if (matcher.find() == false) {
                                    logger.debug("Attribute [{}] is [{}], which does not match [{}]", attributeName, s, regex.pattern());
                                    return null;
                                }
                                final String value = matcher.group(1);
                                if (Strings.isNullOrEmpty(value)) {
                                    logger.debug("Attribute [{}] is [{}], which does match [{}] but group(1) is empty",
                                            attributeName, s, regex.pattern());
                                    return null;
                                }
                                return value;
                            }).filter(Objects::nonNull).collect(Collectors.toList())
                    );
                } else {
                    return new AttributeParser(
                            "SAML Attribute [" + attributeName + "] for [" + setting.name() + "]",
                            attributes -> attributes.getAttributeValues(attributeName));
                }
            } else if (required) {
                throw new SettingsException("Setting" + RealmSettings.getFullSettingKey(realmConfig, setting.getAttribute())
                        + " is required");
            } else if (setting.getPattern().exists(settings)) {
                throw new SettingsException("Setting" + RealmSettings.getFullSettingKey(realmConfig, setting.getPattern())
                        + " cannot be set unless " + RealmSettings.getFullSettingKey(realmConfig, setting.getAttribute()) + " is also set");
            } else {
                return new AttributeParser("No SAML attribute for [" + setting.name() + "]", attributes -> Collections.emptyList());
            }
        }

    }
}
