[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:
Stefan Guilhen 2019-11-11 16:36:07 -03:00 committed by Hynek Mlnařík
parent 26458125cb
commit 9f69386a53
28 changed files with 1236 additions and 55 deletions

View file

@ -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>

View file

@ -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> {
}

View file

@ -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);
}

View file

@ -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;
}
}
}

View file

@ -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("_", "__"));
}
} }

View file

@ -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();
}
} }

View file

@ -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);
}
}

View file

@ -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;
/** /**

View file

@ -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

View file

@ -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());

View file

@ -0,0 +1 @@
secret2

View file

@ -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>

View file

@ -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();
} }

View file

@ -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)

View file

@ -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;
} }

View file

@ -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;
}
}

View file

@ -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;
} }

View file

@ -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");

View file

@ -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.
}

View file

@ -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}"
} }
} }
} }

View file

@ -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>

View file

@ -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() {
}
}

View file

@ -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())));
}
}
}

View file

@ -0,0 +1 @@
org.keycloak.vault.ElytronCSKeyStoreProviderFactory

View file

@ -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);
}
}
}

View 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