[KEYCLOAK-11707] Add support for Elytron credential store vault
- Adds the elytron-cs-keystore provider that reads secrets from a keystore-backed elytron credential store - Introduces an abstract provider and factory that unifies code that is common to the existing implementations - Introduces a VaultKeyResolver interface to allow the creation of different algorithms to combine the realm and key names when constructing the vault entry id - Introduces a keyResolvers property to the existing implementation via superclass that allows for the configuration of one or more VaultKeyResolvers, creating a fallback mechanism in which different key formats are tried in the order they were declared when retrieving a secret from the vault - Adds more tests for the files-plaintext provider using the new key resolvers - Adds a VaultTestExecutionDecider to skip the elytron-cs-keystore tests when running in Undertow. This is needed because the new provider is available only as a Wildfly extension
This commit is contained in:
parent
26458125cb
commit
9f69386a53
28 changed files with 1236 additions and 55 deletions
|
@ -36,5 +36,6 @@
|
||||||
<module name="org.jboss.logging"/>
|
<module name="org.jboss.logging"/>
|
||||||
<module name="org.jboss.modules"/>
|
<module name="org.jboss.modules"/>
|
||||||
<module name="org.jboss.resteasy.resteasy-jaxrs"/>
|
<module name="org.jboss.resteasy.resteasy-jaxrs"/>
|
||||||
|
<module name="org.wildfly.security.elytron"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</module>
|
</module>
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code VaultKeyResolver} is a {@link BiFunction} whose implementation of the {@link #apply(Object, Object)} method takes
|
||||||
|
* two {@link String}s representing the realm name and the key name (as used in {@code ${vault.key}} expressions) and returns
|
||||||
|
* another {@link String} representing the final constructed key that is to be used when obtaining secrets from the vault.
|
||||||
|
* <p/>
|
||||||
|
* Implementations essentially define the algorithm that is to be used to combine the realm and the key to create the name
|
||||||
|
* that represents an entry in the vault.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public interface VaultKeyResolver extends BiFunction<String, String, String> {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class that is meant to be extended by implementations of {@link VaultProvider} that want to have support for
|
||||||
|
* key resolvers.
|
||||||
|
* <p/>
|
||||||
|
* This class implements the {@link #obtainSecret(String)} method by iterating through the configured resolvers in order and,
|
||||||
|
* using the final key name provided by each resolver, calls the {@link #obtainSecretInternal(String)} method that must be
|
||||||
|
* implemented by sub-classes. If {@link #obtainSecretInternal(String)} returns a non-empty secret, it is immediately returned;
|
||||||
|
* otherwise the implementation tries again using the next configured resolver until a non-empty secret is obtained or all
|
||||||
|
* resolvers have been tried, in which case an empty {@link VaultRawSecret} is returned.
|
||||||
|
* <p/>
|
||||||
|
* Concrete implementations must, in addition to implementing the {@link #obtainSecretInternal(String)} method, ensure that
|
||||||
|
* each constructor calls the {@link AbstractVaultProvider#AbstractVaultProvider(String, List)} constructor from this class
|
||||||
|
* so that the realm and list of key resolvers are properly initialized.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public abstract class AbstractVaultProvider implements VaultProvider {
|
||||||
|
|
||||||
|
protected final String realm;
|
||||||
|
protected final List<VaultKeyResolver> resolvers;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of {@code AbstractVaultProvider} with the specified realm and list of key resolvers.
|
||||||
|
*
|
||||||
|
* @param realm the name of the keycloak realm.
|
||||||
|
* @param configuredResolvers a {@link List} containing the configured key resolvers.
|
||||||
|
*/
|
||||||
|
public AbstractVaultProvider(final String realm, final List<VaultKeyResolver> configuredResolvers) {
|
||||||
|
this.realm = realm;
|
||||||
|
this.resolvers = configuredResolvers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VaultRawSecret obtainSecret(String vaultSecretId) {
|
||||||
|
for (VaultKeyResolver resolver : this.resolvers) {
|
||||||
|
VaultRawSecret secret = this.obtainSecretInternal(resolver.apply(this.realm, vaultSecretId));
|
||||||
|
if (secret != null && secret.get().isPresent()) {
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultVaultRawSecret.forBuffer(Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subclasses of {@code AbstractVaultProvider} must implement this method. It is meant to be implemented in the same
|
||||||
|
* way as the {@link #obtainSecret(String)} method from the {@link VaultProvider} interface, but the specified vault
|
||||||
|
* key must be used as is - i.e. implementations should refrain from processing the key again as the format was already
|
||||||
|
* defined by one of the configured key resolvers.
|
||||||
|
*
|
||||||
|
* @param vaultKey a {@link String} representing the name of the entry that is being fetched from the vault.
|
||||||
|
* @return a {@link VaultRawSecret} representing the obtained secret. It can be a empty secret if no secret could be
|
||||||
|
* obtained using the specified vault key.
|
||||||
|
*/
|
||||||
|
protected abstract VaultRawSecret obtainSecretInternal(final String vaultKey);
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class that is meant to be extended by implementations of {@link VaultProviderFactory} that want to offer support
|
||||||
|
* for the configuration of key resolvers.
|
||||||
|
* <p/>
|
||||||
|
* It implements the {@link #init(Config.Scope)} method, where is looks for the {@code keyResolvers} property. The value is
|
||||||
|
* a comma-separated list of key resolver names. It then verifies if the resolver names match one of the available key resolver
|
||||||
|
* implementations and then creates a list of {@link VaultKeyResolver} instances that subclasses can pass to {@link VaultProvider}
|
||||||
|
* instances on {@link #create(KeycloakSession)}.
|
||||||
|
* <p/>
|
||||||
|
* The list of currently available resolvers follows:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code KEY_ONLY}: only the key name is used as is, realm is ignored;</li>
|
||||||
|
* <li>{@code REALM_UNDERSCORE_KEY}: realm and key are combined using an underscore ({@code '_'}) character. Any occurrences of
|
||||||
|
* underscore in both the realm and key are escaped by an additional underscore character;</li>
|
||||||
|
* <li>{@code REALM_FILESEPARATOR_KEY}: realm and key are combined using the platform file separator character. It might not be
|
||||||
|
* suitable for every vault provider but it enables the grouping of secrets using a directory structure;</li>
|
||||||
|
* <li>{@code FACTORY_PROVIDED}: the format of the constructed key is determined by the factory's {@link #getFactoryResolver()}
|
||||||
|
* implementation. it allows for the customization of the final key format by extending the factory and overriding the
|
||||||
|
* {@link #getFactoryResolver()} method.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p/>
|
||||||
|
* <b><i>Note</i></b>: When extending the standard factories to use the {@code FACTORY_PROVIDED} resolver, it is important to also
|
||||||
|
* override the {@link #getId()} method so that the custom factory has its own id and as such can be configured in the keycloak
|
||||||
|
* server.
|
||||||
|
* <p/>
|
||||||
|
* If no resolver is explicitly configured for the factory, it defaults to using the {@code REALM_UNDERSCORE_KEY} resolver.
|
||||||
|
* When one or more resolvers are explicitly configured, this factory iterates through them in order and for each one attempts
|
||||||
|
* to obtain the respective {@link VaultKeyResolver} implementation. If it fails (for example, the name doesn't match one of
|
||||||
|
* the existing resolvers), it logs a message and ignores the resolver. If it fails to load all configured resolvers, it
|
||||||
|
* throws a {@link VaultConfigurationException}.
|
||||||
|
* <p/>
|
||||||
|
* Concrete implementations must also make sure to call the {@code super.init(config)} in their own {@link #init(Config.Scope)}
|
||||||
|
* implementations so tha the processing of the key resolvers is performed correctly.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public abstract class AbstractVaultProviderFactory implements VaultProviderFactory {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||||
|
|
||||||
|
protected static final String KEY_RESOLVERS = "keyResolvers";
|
||||||
|
|
||||||
|
protected List<VaultKeyResolver> keyResolvers = new LinkedList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(Config.Scope config) {
|
||||||
|
String resolverNames = config.get(KEY_RESOLVERS);
|
||||||
|
if (resolverNames != null) {
|
||||||
|
for (String resolverName : resolverNames.split(",")) {
|
||||||
|
VaultKeyResolver resolver = this.getVaultKeyResolver(resolverName);
|
||||||
|
if (resolver != null) {
|
||||||
|
this.keyResolvers.add(resolver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.keyResolvers.isEmpty()) {
|
||||||
|
throw new VaultConfigurationException("Unable to initialize factory - all provided key resolvers are invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no resolver configured - add the default REALM_UNDERSCORE_KEY resolver.
|
||||||
|
if (this.keyResolvers.isEmpty()) {
|
||||||
|
logger.debugf("Key resolver is undefined - using %s by default", AvailableResolvers.REALM_UNDERSCORE_KEY.name());
|
||||||
|
this.keyResolvers.add(AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the {@link VaultKeyResolver} implementation that is provided by the factory itself. By default this method
|
||||||
|
* throws an {@link UnsupportedOperationException}, so an attempt to use the {@code FACTORY_PROVIDED} resolver on a
|
||||||
|
* factory that doesn't override this method will result in a failure to use this resolver.
|
||||||
|
*
|
||||||
|
* @return the factory-provided {@link VaultKeyResolver}.
|
||||||
|
*/
|
||||||
|
protected VaultKeyResolver getFactoryResolver() {
|
||||||
|
throw new UnsupportedOperationException("getFactoryResolver not implemented by factory " + getClass().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the name of realm from the {@link KeycloakSession}.
|
||||||
|
*
|
||||||
|
* @param session a reference to the {@link KeycloakSession}.
|
||||||
|
* @return the name of the realm.
|
||||||
|
*/
|
||||||
|
protected String getRealmName(KeycloakSession session) {
|
||||||
|
return session.getContext().getRealm().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the key resolver with the specified name.
|
||||||
|
*
|
||||||
|
* @param resolverName the name of the resolver.
|
||||||
|
* @return the {@link VaultKeyResolver} that corresponds to the name or {@code null} if the resolver could not be retrieved.
|
||||||
|
*/
|
||||||
|
private VaultKeyResolver getVaultKeyResolver(final String resolverName) {
|
||||||
|
try {
|
||||||
|
AvailableResolvers value = AvailableResolvers.valueOf(resolverName.trim().toUpperCase());
|
||||||
|
return value == AvailableResolvers.FACTORY_PROVIDED ? this.getFactoryResolver() : value.getVaultKeyResolver();
|
||||||
|
}
|
||||||
|
catch(Exception e) {
|
||||||
|
logger.debugf(e,"Invalid key resolver: %s - skipping", resolverName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum containing the available {@link VaultKeyResolver}s. The name used in the factory configuration must match the
|
||||||
|
* name one of the enum members.
|
||||||
|
*/
|
||||||
|
protected enum AvailableResolvers {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignores the realm, only the vault key is used when retrieving a secret from the vault. This is useful when we want
|
||||||
|
* all realms to share the secrets, so instead of replicating entries for all existing realms in the vault one can
|
||||||
|
* simply use key directly and all realms will obtain the same secret.
|
||||||
|
*/
|
||||||
|
KEY_ONLY((realm, key) -> key),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The realm is prepended to the vault key and they are separated by an underscore ({@code '_'}) character. If either
|
||||||
|
* the realm or the key contains an underscore, it is escaped by another underscore character.
|
||||||
|
*/
|
||||||
|
REALM_UNDERSCORE_KEY((realm, key) -> realm.replaceAll("_", "__") + "_" + key.replaceAll("_", "__")),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The realm is prepended to the vault key and they are separated by the platform file separator character. Not all
|
||||||
|
* providers might support this format but it is useful when a directory structure is used to group secrets per realm.
|
||||||
|
*/
|
||||||
|
REALM_FILESEPARATOR_KEY((realm, key) -> realm + File.separator + key),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format of the vault key is determined by the factory's {@code getFactoryResolver} implementation. This allows
|
||||||
|
* for the customization of the vault key format by extending the factory and overriding the {@code getFactoryResolver}
|
||||||
|
* method. It is instantiated with a null resolver because we can't access the factory from the enum's static context.
|
||||||
|
*/
|
||||||
|
FACTORY_PROVIDED(null);
|
||||||
|
|
||||||
|
private VaultKeyResolver resolver;
|
||||||
|
|
||||||
|
AvailableResolvers(final VaultKeyResolver resolver) {
|
||||||
|
this.resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
VaultKeyResolver getVaultKeyResolver() {
|
||||||
|
return this.resolver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,30 +11,32 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A text-based vault provider, which stores each secret in a separate file. The file name needs to match a
|
* A text-based vault provider, which stores each secret in a separate file. The file name needs to match a vault secret id (or
|
||||||
* vault secret id (or a key for short). A typical vault directory layout looks like this:
|
* a key for short) and follows the format provided by the configured {@link VaultKeyResolver}. A typical vault directory
|
||||||
|
* layout looks like this:
|
||||||
* <pre>
|
* <pre>
|
||||||
* ${VAULT}/realma__key1 (contains secret for key 1)
|
* ${VAULT}/realma__key1 (contains secret for key 1)
|
||||||
* ${VAULT}/realma__key2 (contains secret for key 2)
|
* ${VAULT}/realma__key2 (contains secret for key 2)
|
||||||
* etc...
|
* etc...
|
||||||
* </pre>
|
* </pre>
|
||||||
* Note, that each key needs is prefixed by realm name. This kind of layout is used by Kubernetes by default
|
* Note, that in this case each key is prefixed by realm name. This particular kind of layout is used by Kubernetes by default
|
||||||
* (when mounting a volume into the pod).
|
* (when mounting a volume into the pod) and can be used by selecting the {@code REALM_UNDERSCORE_KEY} resolver (which is
|
||||||
|
* the default resolver when none is defined). Other layouts are available through different resolvers.
|
||||||
*
|
*
|
||||||
* See https://kubernetes.io/docs/concepts/configuration/secret/
|
* See https://kubernetes.io/docs/concepts/configuration/secret/
|
||||||
* See https://github.com/keycloak/keycloak-community/blob/master/design/secure-credentials-store.md#plain-text-file-per-secret-kubernetes--openshift
|
* See https://github.com/keycloak/keycloak-community/blob/master/design/secure-credentials-store.md#plain-text-file-per-secret-kubernetes--openshift
|
||||||
*
|
*
|
||||||
* @author Sebastian Łaskawiec
|
* @author Sebastian Łaskawiec
|
||||||
*/
|
*/
|
||||||
public class FilesPlainTextVaultProvider implements VaultProvider {
|
public class FilesPlainTextVaultProvider extends AbstractVaultProvider {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||||
|
|
||||||
private final Path vaultPath;
|
private final Path vaultPath;
|
||||||
private final String realmName;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new {@link FilesPlainTextVaultProvider}.
|
* Creates a new {@link FilesPlainTextVaultProvider}.
|
||||||
|
@ -42,15 +44,15 @@ public class FilesPlainTextVaultProvider implements VaultProvider {
|
||||||
* @param path A path to a vault. Can not be null.
|
* @param path A path to a vault. Can not be null.
|
||||||
* @param realmName A realm name. Can not be null.
|
* @param realmName A realm name. Can not be null.
|
||||||
*/
|
*/
|
||||||
public FilesPlainTextVaultProvider(@Nonnull Path path, @Nonnull String realmName) {
|
public FilesPlainTextVaultProvider(@Nonnull Path path, @Nonnull String realmName, @Nonnull List<VaultKeyResolver> resolvers) {
|
||||||
|
super(realmName, resolvers);
|
||||||
this.vaultPath = path;
|
this.vaultPath = path;
|
||||||
this.realmName = realmName;
|
|
||||||
logger.debugf("PlainTextVaultProvider will operate in %s directory", vaultPath.toAbsolutePath());
|
logger.debugf("PlainTextVaultProvider will operate in %s directory", vaultPath.toAbsolutePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public VaultRawSecret obtainSecret(String vaultSecretId) {
|
protected VaultRawSecret obtainSecretInternal(String vaultSecretId) {
|
||||||
Path secretPath = resolveSecretPath(vaultSecretId);
|
Path secretPath = vaultPath.resolve(vaultSecretId);
|
||||||
if (!Files.exists(secretPath)) {
|
if (!Files.exists(secretPath)) {
|
||||||
logger.warnf("Cannot find secret %s in %s", vaultSecretId, secretPath);
|
logger.warnf("Cannot find secret %s in %s", vaultSecretId, secretPath);
|
||||||
return DefaultVaultRawSecret.forBuffer(Optional.empty());
|
return DefaultVaultRawSecret.forBuffer(Optional.empty());
|
||||||
|
@ -68,14 +70,4 @@ public class FilesPlainTextVaultProvider implements VaultProvider {
|
||||||
public void close() {
|
public void close() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A method that resolves the exact secret location.
|
|
||||||
*
|
|
||||||
* @param vaultSecretId Secret ID.
|
|
||||||
* @return Path for the secret.
|
|
||||||
*/
|
|
||||||
protected Path resolveSecretPath(String vaultSecretId) {
|
|
||||||
return vaultPath.resolve(realmName.replaceAll("_", "__") + "_" + vaultSecretId.replaceAll("_", "__"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import java.nio.file.Paths;
|
||||||
*
|
*
|
||||||
* @author Sebastian Łaskawiec
|
* @author Sebastian Łaskawiec
|
||||||
*/
|
*/
|
||||||
public class FilesPlainTextVaultProviderFactory implements VaultProviderFactory {
|
public class FilesPlainTextVaultProviderFactory extends AbstractVaultProviderFactory {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||||
|
|
||||||
|
@ -27,14 +27,16 @@ public class FilesPlainTextVaultProviderFactory implements VaultProviderFactory
|
||||||
@Override
|
@Override
|
||||||
public VaultProvider create(KeycloakSession session) {
|
public VaultProvider create(KeycloakSession session) {
|
||||||
if (vaultDirectory == null) {
|
if (vaultDirectory == null) {
|
||||||
logger.debug("Can not create a vault since it's disabled or not initialized correctly");
|
logger.debug("Can not create a vault since it's not initialized correctly");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new FilesPlainTextVaultProvider(vaultPath, getRealmName(session));
|
return new FilesPlainTextVaultProvider(vaultPath, getRealmName(session), super.keyResolvers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Config.Scope config) {
|
public void init(Config.Scope config) {
|
||||||
|
super.init(config);
|
||||||
|
|
||||||
vaultDirectory = config.get("dir");
|
vaultDirectory = config.get("dir");
|
||||||
if (vaultDirectory == null) {
|
if (vaultDirectory == null) {
|
||||||
logger.debug("PlainTextVaultProviderFactory not configured");
|
logger.debug("PlainTextVaultProviderFactory not configured");
|
||||||
|
@ -61,7 +63,4 @@ public class FilesPlainTextVaultProviderFactory implements VaultProviderFactory
|
||||||
return PROVIDER_ID;
|
return PROVIDER_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected String getRealmName(KeycloakSession session) {
|
|
||||||
return session.getContext().getRealm().getName();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This exception is thrown when the factory fails to init due to a configuration error.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class VaultConfigurationException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new {@code VaultConfigurationException}.
|
||||||
|
*
|
||||||
|
* @param message the exception message.
|
||||||
|
*/
|
||||||
|
public VaultConfigurationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,3 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
package org.keycloak.vault;
|
package org.keycloak.vault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -82,7 +82,7 @@ public class PlainTextVaultProviderFactoryTest {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String get(String key) {
|
public String get(String key) {
|
||||||
return vaultDirectory;
|
return "dir".equals(key) ? vaultDirectory : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -5,6 +5,9 @@ import org.junit.Test;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
|
@ -22,7 +25,8 @@ public class PlainTextVaultProviderTest {
|
||||||
@Test
|
@Test
|
||||||
public void shouldObtainSecret() throws Exception {
|
public void shouldObtainSecret() throws Exception {
|
||||||
//given
|
//given
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test");
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver()));
|
||||||
|
|
||||||
//when
|
//when
|
||||||
VaultRawSecret secret1 = provider.obtainSecret("key1");
|
VaultRawSecret secret1 = provider.obtainSecret("key1");
|
||||||
|
@ -36,8 +40,8 @@ public class PlainTextVaultProviderTest {
|
||||||
@Test
|
@Test
|
||||||
public void shouldReplaceUnderscoreWithTwoUnderscores() throws Exception {
|
public void shouldReplaceUnderscoreWithTwoUnderscores() throws Exception {
|
||||||
//given
|
//given
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test_realm");
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test_realm",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver()));
|
||||||
//when
|
//when
|
||||||
VaultRawSecret secret1 = provider.obtainSecret("underscore_key1");
|
VaultRawSecret secret1 = provider.obtainSecret("underscore_key1");
|
||||||
|
|
||||||
|
@ -50,7 +54,9 @@ public class PlainTextVaultProviderTest {
|
||||||
@Test
|
@Test
|
||||||
public void shouldReturnEmptyOptionalOnMissingSecret() throws Exception {
|
public void shouldReturnEmptyOptionalOnMissingSecret() throws Exception {
|
||||||
//given
|
//given
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test");
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver()));
|
||||||
|
|
||||||
|
|
||||||
//when
|
//when
|
||||||
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
|
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
|
||||||
|
@ -63,7 +69,8 @@ public class PlainTextVaultProviderTest {
|
||||||
@Test
|
@Test
|
||||||
public void shouldOperateOnNonExistingVaultDirectory() throws Exception {
|
public void shouldOperateOnNonExistingVaultDirectory() throws Exception {
|
||||||
//given
|
//given
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.NON_EXISTING.getPath(), "test");
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.NON_EXISTING.getPath(), "test",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver()));
|
||||||
|
|
||||||
//when
|
//when
|
||||||
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
|
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
|
||||||
|
@ -73,6 +80,37 @@ public class PlainTextVaultProviderTest {
|
||||||
assertFalse(secret.get().isPresent());
|
assertFalse(secret.get().isPresent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldOperateOnRealmDirectory() throws Exception {
|
||||||
|
//given
|
||||||
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_FILESEPARATOR_KEY.getVaultKeyResolver()));
|
||||||
|
|
||||||
|
//when
|
||||||
|
VaultRawSecret secret = provider.obtainSecret("key2");
|
||||||
|
|
||||||
|
//then
|
||||||
|
assertNotNull(secret);
|
||||||
|
assertNotNull(secret.get().get());
|
||||||
|
assertThat(secret, secretContains("secret2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldObtainSecretWithMultipleResolvers() throws Exception {
|
||||||
|
//given
|
||||||
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(Scenario.EXISTING.getPath(), "test",
|
||||||
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.REALM_UNDERSCORE_KEY.getVaultKeyResolver(),
|
||||||
|
AbstractVaultProviderFactory.AvailableResolvers.REALM_FILESEPARATOR_KEY.getVaultKeyResolver()));
|
||||||
|
|
||||||
|
//when (there's no test_key2 file matching the realm_underscore_key resolver, but we have a test/key2 file that matches the realm_fileseparator_key resolver)
|
||||||
|
VaultRawSecret secret = provider.obtainSecret("key2");
|
||||||
|
|
||||||
|
//then
|
||||||
|
assertNotNull(secret);
|
||||||
|
assertNotNull(secret.get().get());
|
||||||
|
assertThat(secret, secretContains("secret2"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldReflectChangesInASecretFile() throws Exception {
|
public void shouldReflectChangesInASecretFile() throws Exception {
|
||||||
//given
|
//given
|
||||||
|
@ -80,12 +118,8 @@ public class PlainTextVaultProviderTest {
|
||||||
Path vaultDirectory = temporarySecretFile.getParent();
|
Path vaultDirectory = temporarySecretFile.getParent();
|
||||||
String secretName = temporarySecretFile.getFileName().toString();
|
String secretName = temporarySecretFile.getFileName().toString();
|
||||||
|
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(vaultDirectory, "ignored") {
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(vaultDirectory, "ignored",
|
||||||
@Override
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.KEY_ONLY.getVaultKeyResolver()));
|
||||||
protected Path resolveSecretPath(String vaultSecretId) {
|
|
||||||
return vaultDirectory.resolve(vaultSecretId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//when
|
//when
|
||||||
String secret1AsString = null;
|
String secret1AsString = null;
|
||||||
|
@ -113,12 +147,8 @@ public class PlainTextVaultProviderTest {
|
||||||
Path vaultDirectory = temporarySecretFile.getParent();
|
Path vaultDirectory = temporarySecretFile.getParent();
|
||||||
String secretName = temporarySecretFile.getFileName().toString();
|
String secretName = temporarySecretFile.getFileName().toString();
|
||||||
|
|
||||||
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(vaultDirectory, "ignored") {
|
FilesPlainTextVaultProvider provider = new FilesPlainTextVaultProvider(vaultDirectory, "ignored",
|
||||||
@Override
|
Arrays.asList(AbstractVaultProviderFactory.AvailableResolvers.KEY_ONLY.getVaultKeyResolver()));
|
||||||
protected Path resolveSecretPath(String vaultSecretId) {
|
|
||||||
return vaultDirectory.resolve(vaultSecretId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Files.write(temporarySecretFile, "secret".getBytes());
|
Files.write(temporarySecretFile, "secret".getBytes());
|
||||||
|
|
||||||
|
|
1
services/src/test/resources/org/keycloak/vault/test/key2
Normal file
1
services/src/test/resources/org/keycloak/vault/test/key2
Normal file
|
@ -0,0 +1 @@
|
||||||
|
secret2
|
Binary file not shown.
|
@ -243,6 +243,7 @@
|
||||||
<include>master_ldap__bindCredential</include>
|
<include>master_ldap__bindCredential</include>
|
||||||
<include>test_ldap__bindCredential</include>
|
<include>test_ldap__bindCredential</include>
|
||||||
<include>admin-client-test_ldap__bindCredential</include>
|
<include>admin-client-test_ldap__bindCredential</include>
|
||||||
|
<include>credential-store.p12</include>
|
||||||
</includes>
|
</includes>
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -460,7 +460,7 @@ public class AuthServerTestEnricher {
|
||||||
testContextProducer.set(testContext);
|
testContextProducer.set(testContext);
|
||||||
|
|
||||||
if (event.getTestClass().isAnnotationPresent(EnableVault.class)) {
|
if (event.getTestClass().isAnnotationPresent(EnableVault.class)) {
|
||||||
VaultUtils.enableVault(suiteContext);
|
VaultUtils.enableVault(suiteContext, event.getTestClass().getAnnotation(EnableVault.class).providerId());
|
||||||
restartAuthServer();
|
restartAuthServer();
|
||||||
testContext.reconnectAdminClient();
|
testContext.reconnectAdminClient();
|
||||||
}
|
}
|
||||||
|
@ -585,7 +585,7 @@ public class AuthServerTestEnricher {
|
||||||
removeTestRealms(testContext, adminClient);
|
removeTestRealms(testContext, adminClient);
|
||||||
|
|
||||||
if (event.getTestClass().isAnnotationPresent(EnableVault.class)) {
|
if (event.getTestClass().isAnnotationPresent(EnableVault.class)) {
|
||||||
VaultUtils.disableVault(suiteContext);
|
VaultUtils.disableVault(suiteContext, event.getTestClass().getAnnotation(EnableVault.class).providerId());
|
||||||
restartAuthServer();
|
restartAuthServer();
|
||||||
testContext.reconnectAdminClient();
|
testContext.reconnectAdminClient();
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,8 @@ public class KeycloakArquillianExtension implements LoadableExtension {
|
||||||
.observer(H2TestEnricher.class);
|
.observer(H2TestEnricher.class);
|
||||||
builder
|
builder
|
||||||
.service(TestExecutionDecider.class, MigrationTestExecutionDecider.class)
|
.service(TestExecutionDecider.class, MigrationTestExecutionDecider.class)
|
||||||
.service(TestExecutionDecider.class, AdapterTestExecutionDecider.class);
|
.service(TestExecutionDecider.class, AdapterTestExecutionDecider.class)
|
||||||
|
.service(TestExecutionDecider.class, VaultTestExecutionDecider.class);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.override(ResourceProvider.class, URLResourceProvider.class, URLProvider.class)
|
.override(ResourceProvider.class, URLResourceProvider.class, URLProvider.class)
|
||||||
|
|
|
@ -89,7 +89,7 @@ public final class TestContext {
|
||||||
this.appServerBackendsInfo.addAll(appServerBackendsInfo);
|
this.appServerBackendsInfo.addAll(appServerBackendsInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Class getTestClass() {
|
public Class<?> getTestClass() {
|
||||||
return testClass;
|
return testClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.testsuite.arquillian;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import org.jboss.arquillian.core.api.Instance;
|
||||||
|
import org.jboss.arquillian.core.api.annotation.Inject;
|
||||||
|
import org.jboss.arquillian.test.spi.execution.ExecutionDecision;
|
||||||
|
import org.jboss.arquillian.test.spi.execution.TestExecutionDecider;
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.EnableVault;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link TestExecutionDecider} that skips tests annotated with {@link EnableVault} with the Elytron credential store
|
||||||
|
* provider on Undertow as this particular provider is available as a WildFly extension only.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class VaultTestExecutionDecider implements TestExecutionDecider {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private Instance<TestContext> testContextInstance;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExecutionDecision decide(Method method) {
|
||||||
|
TestContext testContext = testContextInstance.get();
|
||||||
|
// if test was annotated with EnableVault, check if it has selected the elytron credential store provider.
|
||||||
|
if (testContext.getTestClass().isAnnotationPresent(EnableVault.class)) {
|
||||||
|
EnableVault.PROVIDER_ID providerId = testContext.getTestClass().getAnnotation(EnableVault.class).providerId();
|
||||||
|
if (providerId == EnableVault.PROVIDER_ID.ELYTRON_CS_KEYSTORE) {
|
||||||
|
// if the auth server is undertow, skip the test.
|
||||||
|
SuiteContext suiteContext = testContext.getSuiteContext();
|
||||||
|
if (suiteContext != null && suiteContext.getAuthServerInfo() != null && suiteContext.getAuthServerInfo().isUndertow()) {
|
||||||
|
return ExecutionDecision.dontExecute("@EnableVault with Elytron credential store provider not supported on Undertow, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ExecutionDecision.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int precedence() {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
package org.keycloak.testsuite.arquillian.annotation;
|
package org.keycloak.testsuite.arquillian.annotation;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
|
@ -11,4 +28,33 @@ import java.lang.annotation.Target;
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target({ElementType.TYPE})
|
@Target({ElementType.TYPE})
|
||||||
public @interface EnableVault {
|
public @interface EnableVault {
|
||||||
|
|
||||||
|
enum PROVIDER_ID {
|
||||||
|
|
||||||
|
PLAINTEXT("files-plaintext", "/subsystem=keycloak-server/spi=vault/provider=files-plaintext/:add(enabled=true, " +
|
||||||
|
"properties={dir => \"${jboss.home.dir}/standalone/configuration/vault\"})"),
|
||||||
|
|
||||||
|
ELYTRON_CS_KEYSTORE("elytron-cs-keystore", "/subsystem=keycloak-server/spi=vault/provider=elytron-cs-keystore/:add(enabled=true, " +
|
||||||
|
"properties={location => \"${jboss.home.dir}/standalone/configuration/vault/credential-store.p12\", " +
|
||||||
|
"secret => \"MASK-3u2HNQaMogJJ8VP7J6gRIl;12345678;321\", keyStoreType => \"PKCS12\"})");
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String cliInstallationCommand;
|
||||||
|
|
||||||
|
PROVIDER_ID(final String name, final String cliInstallationCommand) {
|
||||||
|
this.name = name;
|
||||||
|
this.cliInstallationCommand = cliInstallationCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCliInstallationCommand() {
|
||||||
|
return this.cliInstallationCommand;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PROVIDER_ID providerId() default PROVIDER_ID.PLAINTEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
package org.keycloak.testsuite.util;
|
package org.keycloak.testsuite.util;
|
||||||
|
|
||||||
|
|
||||||
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
|
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
|
||||||
import org.keycloak.testsuite.arquillian.SuiteContext;
|
import org.keycloak.testsuite.arquillian.SuiteContext;
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.EnableVault;
|
||||||
import org.wildfly.extras.creaper.core.online.CliException;
|
import org.wildfly.extras.creaper.core.online.CliException;
|
||||||
import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
|
import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
|
||||||
|
|
||||||
|
@ -15,20 +33,21 @@ import java.util.concurrent.TimeoutException;
|
||||||
*/
|
*/
|
||||||
public class VaultUtils {
|
public class VaultUtils {
|
||||||
|
|
||||||
public static void enableVault(SuiteContext suiteContext) throws IOException, CliException, TimeoutException, InterruptedException {
|
public static void enableVault(SuiteContext suiteContext, EnableVault.PROVIDER_ID provider) throws IOException, CliException, TimeoutException, InterruptedException {
|
||||||
if (suiteContext.getAuthServerInfo().isUndertow()) {
|
if (suiteContext.getAuthServerInfo().isUndertow()) {
|
||||||
System.setProperty("keycloak.vault.plaintext.provider.enabled", "true");
|
System.setProperty("keycloak.vault." + provider.getName() + ".provider.enabled", "true");
|
||||||
} else {
|
} else {
|
||||||
OnlineManagementClient client = AuthServerTestEnricher.getManagementClient();
|
OnlineManagementClient client = AuthServerTestEnricher.getManagementClient();
|
||||||
client.execute("/subsystem=keycloak-server/spi=vault/:add");
|
// configure the selected provider and set it as the default vault provider.
|
||||||
client.execute("/subsystem=keycloak-server/spi=vault/provider=files-plaintext/:add(enabled=true,properties={dir => \"${jboss.home.dir}/standalone/configuration/vault\"})");
|
client.execute("/subsystem=keycloak-server/spi=vault/:add(default-provider=" + provider.getName() + ")");
|
||||||
|
client.execute(provider.getCliInstallationCommand());
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void disableVault(SuiteContext suiteContext) throws IOException, CliException, TimeoutException, InterruptedException {
|
public static void disableVault(SuiteContext suiteContext, EnableVault.PROVIDER_ID provider) throws IOException, CliException, TimeoutException, InterruptedException {
|
||||||
if (suiteContext.getAuthServerInfo().isUndertow()) {
|
if (suiteContext.getAuthServerInfo().isUndertow()) {
|
||||||
System.setProperty("keycloak.vault.plaintext.provider.enabled", "false");
|
System.setProperty("keycloak.vault." + provider.getName() + ".provider.enabled", "false");
|
||||||
} else {
|
} else {
|
||||||
OnlineManagementClient client = AuthServerTestEnricher.getManagementClient();
|
OnlineManagementClient client = AuthServerTestEnricher.getManagementClient();
|
||||||
client.execute("/subsystem=keycloak-server/spi=vault/:remove");
|
client.execute("/subsystem=keycloak-server/spi=vault/:remove");
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.testsuite.vault;
|
||||||
|
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.EnableVault;
|
||||||
|
import org.keycloak.vault.VaultTranscriber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the usage of the {@link VaultTranscriber} on the server side. The tests attempt to obtain the transcriber from
|
||||||
|
* the session and then use it to obtain secrets from the configured provider.
|
||||||
|
* <p/>
|
||||||
|
* This test differs from the superclass in that it uses the {@code elytron-cs-keystore} provider to obtain secrets.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
@EnableVault(providerId = EnableVault.PROVIDER_ID.ELYTRON_CS_KEYSTORE)
|
||||||
|
public class KeycloakElytronCSVaultTest extends KeycloakVaultTest {
|
||||||
|
// run the same tests of the superclass using the elytron credential store provider.
|
||||||
|
}
|
|
@ -198,7 +198,7 @@
|
||||||
"vault": {
|
"vault": {
|
||||||
"files-plaintext": {
|
"files-plaintext": {
|
||||||
"dir": "target/dependency/vault",
|
"dir": "target/dependency/vault",
|
||||||
"enabled": "${keycloak.vault.plaintext.provider.enabled:false}"
|
"enabled": "${keycloak.vault.files-plaintext.provider.enabled:false}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,5 +55,10 @@
|
||||||
<artifactId>keycloak-services</artifactId>
|
<artifactId>keycloak-services</artifactId>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.CharBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.wildfly.security.credential.PasswordCredential;
|
||||||
|
import org.wildfly.security.credential.store.CredentialStore;
|
||||||
|
import org.wildfly.security.credential.store.CredentialStoreException;
|
||||||
|
import org.wildfly.security.password.interfaces.ClearPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link VaultProvider} implementation that uses the Elytron keystore-based credential store implementation to retrieve secrets.
|
||||||
|
* Elytron credential stores can be created and managed using either the elytron subsystem in WildFly/EAP or the elytron tool.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class ElytronCSKeyStoreProvider extends AbstractVaultProvider {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||||
|
|
||||||
|
private final CredentialStore credentialStore;
|
||||||
|
|
||||||
|
public ElytronCSKeyStoreProvider(final CredentialStore store, final String realmName, final List<VaultKeyResolver> resolvers) {
|
||||||
|
super(realmName, resolvers);
|
||||||
|
this.credentialStore = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected VaultRawSecret obtainSecretInternal(String vaultSecretId) {
|
||||||
|
try {
|
||||||
|
PasswordCredential credential = this.credentialStore.retrieve(vaultSecretId, PasswordCredential.class);
|
||||||
|
if (credential == null) {
|
||||||
|
// alias not found, password type doesn't match entry, or algorithm (clear) doesn't match entry.
|
||||||
|
logger.debugf("Cannot find secret %s in credential store", vaultSecretId);
|
||||||
|
return DefaultVaultRawSecret.forBuffer(Optional.empty());
|
||||||
|
}
|
||||||
|
char[] secret = credential.getPassword().castAndApply(ClearPassword.class, ClearPassword::getPassword);
|
||||||
|
ByteBuffer buffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(secret));
|
||||||
|
return DefaultVaultRawSecret.forBuffer(Optional.of(buffer));
|
||||||
|
} catch (CredentialStoreException e) {
|
||||||
|
// this might happen if there is an error when trying to retrieve the secret from the store.
|
||||||
|
logger.debugf(e, "Unable to retrieve secret %s from credential store", vaultSecretId);
|
||||||
|
return DefaultVaultRawSecret.forBuffer(Optional.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,197 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.security.spec.AlgorithmParameterSpec;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
import org.wildfly.security.auth.SupportLevel;
|
||||||
|
import org.wildfly.security.auth.server.IdentityCredentials;
|
||||||
|
import org.wildfly.security.credential.Credential;
|
||||||
|
import org.wildfly.security.credential.PasswordCredential;
|
||||||
|
import org.wildfly.security.credential.source.CredentialSource;
|
||||||
|
import org.wildfly.security.credential.store.CredentialStore;
|
||||||
|
import org.wildfly.security.credential.store.CredentialStoreException;
|
||||||
|
import org.wildfly.security.credential.store.WildFlyElytronCredentialStoreProvider;
|
||||||
|
import org.wildfly.security.credential.store.impl.KeyStoreCredentialStore;
|
||||||
|
import org.wildfly.security.password.interfaces.ClearPassword;
|
||||||
|
import org.wildfly.security.util.PasswordBasedEncryptionUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link VaultProviderFactory} implementation that creates and configures {@link ElytronCSKeyStoreProvider}s. The following
|
||||||
|
* configuration attributes are available for the {@code ElytronCSKeyStoreProviderFactory}:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>location (required)</b>: the path to he keystore file that contains the secrets. This file is created and managed by Elytron
|
||||||
|
* using either the {@code elytron} subsystem in WildFly/EAP or the {@code elytron-tool.sh} script.</li>
|
||||||
|
* <li><b>secret (required)</b>: the keystore master secret. Can be specified in clear text form or in masked form. The masked form
|
||||||
|
* can be generated using the {@code elytron-tool.sh} script. For further details, check the Elytron tool documentation.</li>
|
||||||
|
* <li><b>keyStoreType (optional)</b>: the keystore type. Defaults to {@code JCEKS}.</li>
|
||||||
|
* <li><b>keyResolvers (optional)</b>: a comma-separated list of vault key resolvers. Defaults to {@code REALM_UNDERSCORE_KEY}.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p/>
|
||||||
|
* If any of the required configuration attributes is missing, the factory logs a debug message indicating that it has not
|
||||||
|
* been properly configured and will return {@code null} when {@link #create(KeycloakSession)} is called.
|
||||||
|
* <p/>
|
||||||
|
* If the factory has been properly configured but the {@code location} attribute points to a keystore that does not exist,
|
||||||
|
* a {@link VaultNotFoundException} is raised on init. Similarly, if the key resolvers are configured and none of the specified
|
||||||
|
* resolvers is valid, a {@link VaultConfigurationException} is raised on init.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class ElytronCSKeyStoreProviderFactory extends AbstractVaultProviderFactory {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
|
||||||
|
|
||||||
|
private static final String PROVIDER_ID = "elytron-cs-keystore";
|
||||||
|
|
||||||
|
static final String CS_LOCATION = "location";
|
||||||
|
static final String CS_SECRET = "secret";
|
||||||
|
static final String CS_KEYSTORE_TYPE = "keyStoreType";
|
||||||
|
static final String JCEKS = "JCEKS";
|
||||||
|
|
||||||
|
private String credentialStoreLocation;
|
||||||
|
private String credentialStoreType;
|
||||||
|
private String credentialStoreSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VaultProvider create(KeycloakSession session) {
|
||||||
|
if (this.credentialStoreLocation == null || this.credentialStoreSecret == null) {
|
||||||
|
logger.debug("Can not create an elytron-based vault provider since it's not initialized correctly");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, String> attributes = new HashMap<>();
|
||||||
|
attributes.put(CS_LOCATION, this.credentialStoreLocation);
|
||||||
|
attributes.put(CS_KEYSTORE_TYPE, this.credentialStoreType);
|
||||||
|
|
||||||
|
CredentialStore credentialStore;
|
||||||
|
try {
|
||||||
|
credentialStore = CredentialStore.getInstance(KeyStoreCredentialStore.KEY_STORE_CREDENTIAL_STORE);
|
||||||
|
credentialStore.initialize(attributes, new CredentialStore.CredentialSourceProtectionParameter(
|
||||||
|
this.getCredentialSource(this.credentialStoreSecret)));
|
||||||
|
} catch (NoSuchAlgorithmException | CredentialStoreException e) {
|
||||||
|
logger.debug("Error instantiating credential store", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ElytronCSKeyStoreProvider(credentialStore, getRealmName(session), super.keyResolvers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(Config.Scope config) {
|
||||||
|
super.init(config);
|
||||||
|
|
||||||
|
this.credentialStoreLocation = config.get(CS_LOCATION);
|
||||||
|
if (this.credentialStoreLocation == null) {
|
||||||
|
logger.debug("ElytronCSKeyStoreProviderFactory not properly configured - missing store location");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Files.exists(Paths.get(this.credentialStoreLocation))) {
|
||||||
|
throw new VaultNotFoundException("The " + this.credentialStoreLocation + " file doesn't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.credentialStoreSecret = config.get(CS_SECRET);
|
||||||
|
if (this.credentialStoreSecret == null) {
|
||||||
|
logger.debug("ElytronCSKeyStoreProviderFactory not properly configured - missing store secret");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.credentialStoreType = config.get(CS_KEYSTORE_TYPE, JCEKS);
|
||||||
|
|
||||||
|
// install the elytron credential store provider.
|
||||||
|
Security.addProvider(WildFlyElytronCredentialStoreProvider.getInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postInit(KeycloakSessionFactory factory) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
// remove the elytron credential store provider.
|
||||||
|
Security.removeProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getId() {
|
||||||
|
return PROVIDER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the {@code CredentialSource} to be used as a protection parameter when initializing the Elytron credential
|
||||||
|
* store. The source is essentially a wrapper for the credential store secret. The credential store secret can be specified
|
||||||
|
* in clear text form or in masked form. Check the Elytron tool documentation for instruction on how to mask the credential
|
||||||
|
* store secret.
|
||||||
|
* <p/>
|
||||||
|
* <b>Note: </b>This logic should ideally be provided directly by Elytron but is currently missing.
|
||||||
|
*
|
||||||
|
* @param secret the secret obtained from the {@link ElytronCSKeyStoreProviderFactory} configuration.
|
||||||
|
* @return the constructed {@code CredentialSource}.
|
||||||
|
*/
|
||||||
|
protected CredentialSource getCredentialSource(final String secret) {
|
||||||
|
if (secret != null && secret.startsWith("MASK-")) {
|
||||||
|
return new CredentialSource() {
|
||||||
|
@Override
|
||||||
|
public SupportLevel getCredentialAcquireSupport(Class<? extends Credential> credentialType, String algorithmName, AlgorithmParameterSpec parameterSpec) throws IOException {
|
||||||
|
return credentialType == PasswordCredential.class ? SupportLevel.SUPPORTED : SupportLevel.UNSUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <C extends Credential> C getCredential(Class<C> credentialType, String algorithmName, AlgorithmParameterSpec parameterSpec) throws IOException {
|
||||||
|
String[] part = secret.substring(5).split(";"); // strip "MASK-" and split by ';'
|
||||||
|
if (part.length != 3) {
|
||||||
|
throw new IOException("Masked password command has the wrong format.%nUsage: MASK-<encoded secret>;<salt>;<iteration count> " +
|
||||||
|
"where <salt>=UTF-8 characters, <iteration count>=reasonable sized positive integer");
|
||||||
|
}
|
||||||
|
String salt = part[1];
|
||||||
|
final int iterationCount;
|
||||||
|
try {
|
||||||
|
iterationCount = Integer.parseInt(part[2]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new IOException("Masked password command has the wrong format.%nUsage: MASK-<encoded secret>;<salt>;<iteration count> " +
|
||||||
|
"where <salt>=UTF-8 characters, <iteration count>=reasonable sized positive integer");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PasswordBasedEncryptionUtil decryptUtil = new PasswordBasedEncryptionUtil.Builder()
|
||||||
|
.picketBoxCompatibility().salt(salt).iteration(iterationCount).decryptMode()
|
||||||
|
.build();
|
||||||
|
return credentialType.cast(new PasswordCredential(ClearPassword.createRaw(ClearPassword.ALGORITHM_CLEAR,
|
||||||
|
decryptUtil.decodeAndDecrypt(part[0]))));
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return IdentityCredentials.NONE.withCredential(new PasswordCredential(
|
||||||
|
ClearPassword.createRaw(ClearPassword.ALGORITHM_CLEAR, secret.toCharArray())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
org.keycloak.vault.ElytronCSKeyStoreProviderFactory
|
|
@ -0,0 +1,377 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.vault;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.hamcrest.Description;
|
||||||
|
import org.hamcrest.Matcher;
|
||||||
|
import org.hamcrest.TypeSafeMatcher;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.ExpectedException;
|
||||||
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.wildfly.security.credential.store.WildFlyElytronCredentialStoreProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the {@link ElytronCSKeyStoreProvider} and associated {@link ElytronCSKeyStoreProviderFactory}.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class ElytronCSKeyStoreProviderTest {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public ExpectedException expectedException = ExpectedException.none();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the initialization of the {@link ElytronCSKeyStoreProviderFactory} using a valid configuration. As a result,
|
||||||
|
* the Elytron credential store security provider should have been installed by the factory.
|
||||||
|
*
|
||||||
|
* @throws Exception if an exception occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testInitFactoryWithValidConfig() throws Exception {
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = null;
|
||||||
|
try {
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/credential-store.p12",
|
||||||
|
"MASK-3u2HNQaMogJJ8VP7J6gRIl;12345678;321", "PKCS12");
|
||||||
|
config.setKeyResolvers("KEY_ONLY, REALM_UNDERSCORE_KEY");
|
||||||
|
factory = new ElytronCSKeyStoreProviderFactory();
|
||||||
|
|
||||||
|
factory.init(config);
|
||||||
|
// should initialize without errors and the elytron credential store provider is installed.
|
||||||
|
Assert.assertNotNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
} finally {
|
||||||
|
if (factory != null) {
|
||||||
|
factory.close();
|
||||||
|
}
|
||||||
|
// elytron credential store provider should be removed on close.
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the initialization of the {@link ElytronCSKeyStoreProviderFactory} using an empty config (this happens when
|
||||||
|
* the factory is loaded via SPI but is not configured in keycloak). The factory must initialize without errors but
|
||||||
|
* it won't be able to create providers.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while runnig the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testInitFactoryWithEmptyConfig() throws Exception {
|
||||||
|
ProviderConfig config = new ProviderConfig(null, null, null);
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = new ElytronCSKeyStoreProviderFactory();
|
||||||
|
factory.init(config);
|
||||||
|
// should initialize without exceptions being thrown, but the elytron credential store provider isn't installed.
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the initialization of the {@link ElytronCSKeyStoreProviderFactory} using a config that points to a credential
|
||||||
|
* store location that does not exist. The initialization is expected to fail in this case.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testInitFactoryWithInvalidLocationThrowsException() throws Exception {
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/non-existing.p12", "secretpw1!", "JCEKS");
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = new ElytronCSKeyStoreProviderFactory();
|
||||||
|
this.expectedException.expect(VaultNotFoundException.class);
|
||||||
|
try {
|
||||||
|
factory.init(config);
|
||||||
|
} finally {
|
||||||
|
// make sure the elytron credential store provider wasn't installed.
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the initialization of the {@link ElytronCSKeyStoreProviderFactory} using a config that specifies only invalid
|
||||||
|
* key resolvers. One of the resolvers configured in this test is the {@code FACTORY_PROVIDED}, which is a valid name,
|
||||||
|
* but the {@link ElytronCSKeyStoreProviderFactory} doesn't implement the {@code getFactoryResolver} method and as such
|
||||||
|
* is unable to provide a valid resolver.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testInitFactoryWithInvalidResolversThrowsException() throws Exception {
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/credential-store.p12", "secretpw1!", "PKCS12");
|
||||||
|
config.setKeyResolvers("INVALID_RESOLVER, FACTORY_PROVIDED");
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = new ElytronCSKeyStoreProviderFactory();
|
||||||
|
this.expectedException.expect(VaultConfigurationException.class);
|
||||||
|
try {
|
||||||
|
factory.init(config);
|
||||||
|
} finally {
|
||||||
|
// make sure the elytron credential store provider wasn't installed.
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the creation of a provider using the {@link ElytronCSKeyStoreProviderFactory}. The test plays with the configuration
|
||||||
|
* to check if the factory returns a proper {@link ElytronCSKeyStoreProvider} instance when fed with valid configuration and
|
||||||
|
* if {@code null} is returned when a required configuration property is missing.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testCreateProvider() throws Exception {
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = null;
|
||||||
|
try {
|
||||||
|
// init the factory with valid config and check it can successfully create a provider instance.
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/credential-store.p12", "secretpw1!", "PKCS12");
|
||||||
|
factory = new ElytronCSKeyStoreProviderFactory() {
|
||||||
|
@Override
|
||||||
|
protected String getRealmName(KeycloakSession session) {
|
||||||
|
return "master";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
factory.init(config);
|
||||||
|
Assert.assertNotNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
|
||||||
|
VaultProvider provider = factory.create(null);
|
||||||
|
Assert.assertNotNull(provider);
|
||||||
|
|
||||||
|
// init the factory without a location and check that it returns a null provider on create.
|
||||||
|
config.setLocation(null);
|
||||||
|
factory.init(config);
|
||||||
|
provider = factory.create(null);
|
||||||
|
Assert.assertNull(provider);
|
||||||
|
|
||||||
|
// init the factory without a password and check that it returns a null provider on create.
|
||||||
|
config.setLocation("src/test/resources/org/keycloak/vault/credential-store.p12");
|
||||||
|
config.setPassword(null);
|
||||||
|
factory.init(config);
|
||||||
|
provider = factory.create(null);
|
||||||
|
Assert.assertNull(provider);
|
||||||
|
|
||||||
|
// init the factory with an invalid keystore type and check that it returns a null provider on create.
|
||||||
|
config.setPassword("secretpw1!");
|
||||||
|
config.setKeyStoreType("INV_TYPE");
|
||||||
|
factory.init(config);
|
||||||
|
provider = factory.create(null);
|
||||||
|
Assert.assertNull(provider);
|
||||||
|
} finally {
|
||||||
|
if (factory != null) {
|
||||||
|
factory.close();
|
||||||
|
}
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the retrieval of secrets using the {@link ElytronCSKeyStoreProvider}. The test relies on the factory to obtain
|
||||||
|
* an instance of the provider and then checks if the provider is capable of retrieving secrets using the configured
|
||||||
|
* Elytron credential store.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRetrieveSecretFromVault() throws Exception {
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = null;
|
||||||
|
try {
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/credential-store.p12",
|
||||||
|
"MASK-3u2HNQaMogJJ8VP7J6gRIl;12345678;321", "PKCS12");
|
||||||
|
config.setKeyResolvers("KEY_ONLY, REALM_UNDERSCORE_KEY");
|
||||||
|
factory = new ElytronCSKeyStoreProviderFactory() {
|
||||||
|
@Override
|
||||||
|
protected String getRealmName(KeycloakSession session) {
|
||||||
|
return "master";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
factory.init(config);
|
||||||
|
Assert.assertNotNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
|
||||||
|
VaultProvider provider = factory.create(null);
|
||||||
|
Assert.assertNotNull(provider);
|
||||||
|
|
||||||
|
// obtain a secret using a key that exists (matches the key provided by the REALM_UNDERSCORE_KEY resolver).
|
||||||
|
VaultRawSecret secret = provider.obtainSecret("smtp_key");
|
||||||
|
Assert.assertNotNull(secret);
|
||||||
|
Assert.assertTrue(secret.get().isPresent());
|
||||||
|
Assert.assertThat(secret, SecretContains.secretContains("secure_master_smtp_secret"));
|
||||||
|
|
||||||
|
// try to retrieve a secret using a key that doesn't exist (neither of the key resolvers provides a key that exists in the vault).
|
||||||
|
secret = provider.obtainSecret("another_key");
|
||||||
|
Assert.assertNotNull(secret);
|
||||||
|
Assert.assertFalse(secret.get().isPresent());
|
||||||
|
} finally {
|
||||||
|
if (factory != null) {
|
||||||
|
factory.close();
|
||||||
|
}
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the retrieval of secrets using a custom {@link VaultProviderFactory} that extends the {@link ElytronCSKeyStoreProviderFactory}
|
||||||
|
* and overrides the {@code getFactoryResolver} method to return a key resolver that combines the realm and key using the
|
||||||
|
* {@code realm###key} format. We have an entry ({@code test###smtp_key}) in the test credential store that matches the format ,
|
||||||
|
* so using a config that sets the {@code keyResolvers} property to {@code FACTORY_PROVIDED} should result in the proper
|
||||||
|
* secret being retrieved from the vault.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRetrieveSecretUsingCustomFactory() throws Exception {
|
||||||
|
ElytronCSKeyStoreProviderFactory factory = null;
|
||||||
|
try {
|
||||||
|
ProviderConfig config = new ProviderConfig("src/test/resources/org/keycloak/vault/credential-store.p12",
|
||||||
|
"MASK-3u2HNQaMogJJ8VP7J6gRIl;12345678;321", "PKCS12");
|
||||||
|
config.setKeyResolvers("FACTORY_PROVIDED");
|
||||||
|
factory = new ElytronCSKeyStoreProviderFactory() {
|
||||||
|
@Override
|
||||||
|
protected String getRealmName(KeycloakSession session) {
|
||||||
|
return "test";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected VaultKeyResolver getFactoryResolver() {
|
||||||
|
return (realm, key) -> realm + "###" + key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
factory.init(config);
|
||||||
|
Assert.assertNotNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
|
||||||
|
VaultProvider provider = factory.create(null);
|
||||||
|
Assert.assertNotNull(provider);
|
||||||
|
|
||||||
|
// obtain a secret using a key that exists (matches the key provided by the FACTORY_PROVIDED resolver).
|
||||||
|
VaultRawSecret secret = provider.obtainSecret("smtp_key");
|
||||||
|
Assert.assertNotNull(secret);
|
||||||
|
Assert.assertTrue(secret.get().isPresent());
|
||||||
|
Assert.assertThat(secret, SecretContains.secretContains("custom_smtp_secret"));
|
||||||
|
} finally {
|
||||||
|
if (factory != null) {
|
||||||
|
factory.close();
|
||||||
|
}
|
||||||
|
Assert.assertNull(Security.getProvider(WildFlyElytronCredentialStoreProvider.getInstance().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link Config.Scope} to be used for the tests.
|
||||||
|
*/
|
||||||
|
private static class ProviderConfig implements Config.Scope {
|
||||||
|
|
||||||
|
private Map<String, String> config = new HashMap<>();
|
||||||
|
|
||||||
|
ProviderConfig(final String location, final String password, final String keyStoreType) {
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_LOCATION, location);
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_SECRET, password);
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_KEYSTORE_TYPE, keyStoreType);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLocation(final String location) {
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_LOCATION, location);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPassword(final String password) {
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_SECRET, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setKeyStoreType(final String keyStoreType) {
|
||||||
|
this.config.put(ElytronCSKeyStoreProviderFactory.CS_KEYSTORE_TYPE, keyStoreType);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setKeyResolvers(final String keyResolvers) {
|
||||||
|
this.config.put(AbstractVaultProviderFactory.KEY_RESOLVERS, keyResolvers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String get(String key) {
|
||||||
|
return this.config.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String get(String key, String defaultValue) {
|
||||||
|
return this.config.get(key) != null ? this.config.get(key) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getArray(String key) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getInt(String key) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getInt(String key, Integer defaultValue) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getLong(String key) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getLong(String key, Long defaultValue) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Boolean getBoolean(String key) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Boolean getBoolean(String key, Boolean defaultValue) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Config.Scope scope(String... scope) {
|
||||||
|
throw new UnsupportedOperationException("not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class SecretContains extends TypeSafeMatcher<VaultRawSecret> {
|
||||||
|
|
||||||
|
private String thisVaultAsString;
|
||||||
|
|
||||||
|
SecretContains(String thisVaultAsString) {
|
||||||
|
this.thisVaultAsString = thisVaultAsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean matchesSafely(VaultRawSecret secret) {
|
||||||
|
String convertedSecret = StandardCharsets.UTF_8.decode(secret.get().get()).toString();
|
||||||
|
return thisVaultAsString.equals(convertedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void describeTo(Description description) {
|
||||||
|
description.appendText("is equal to " + thisVaultAsString);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Matcher<VaultRawSecret> secretContains(String thisVaultAsString) {
|
||||||
|
return new SecretContains(thisVaultAsString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
wildfly/extensions/src/test/resources/log4j.properties
Executable file
5
wildfly/extensions/src/test/resources/log4j.properties
Executable file
|
@ -0,0 +1,5 @@
|
||||||
|
log4j.rootLogger=info, stdout
|
||||||
|
|
||||||
|
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
|
||||||
|
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
|
||||||
|
log4j.appender.stdout.layout.ConversionPattern=%d{HH:mm:ss,SSS} %-5p %t [%c] %m%n
|
Binary file not shown.
Loading…
Reference in a new issue