Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.dtolabs.rundeck.core.execution.workflow.steps.FailureReason;
import com.dtolabs.rundeck.core.execution.workflow.steps.StepException;
import com.dtolabs.rundeck.core.execution.workflow.steps.StepFailureReason;
import com.dtolabs.rundeck.core.storage.ResourceMeta;
import com.dtolabs.rundeck.core.utils.IPropertyLookup;
import com.dtolabs.rundeck.plugins.PluginLogger;
import com.dtolabs.rundeck.plugins.step.PluginStepContext;
Expand All @@ -30,7 +29,6 @@
import org.dom4j.io.XMLWriter;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.constructor.SafeConstructor;

import java.io.*;
Expand All @@ -41,13 +39,14 @@
import java.util.HashMap;
import java.util.Map;

public class HttpBuilder {

Check warning on line 42 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String AUTH_NONE = "None";

Check warning on line 43 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String AUTH_BASIC = "Basic";

Check warning on line 44 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String AUTH_BEARER = "Bearer";

Check warning on line 45 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String AUTH_OAUTH2 = "OAuth 2.0";

Check warning on line 46 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String XML_FORMAT = "xml";

Check warning on line 47 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String JSON_FORMAT = "json";

Check warning on line 48 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
public static final String[] HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"};

Check warning on line 49 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment

private Integer maxAttempts = 5;
private PluginLogger log;
Expand Down Expand Up @@ -83,13 +82,13 @@
*/
Map<String, OAuthClient> oauthClients = Collections.synchronizedMap(new HashMap<String, OAuthClient>());

public enum Reason implements FailureReason {

Check warning on line 85 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
OAuthFailure, // Failure from the OAuth protocol
HTTPFailure // Any HTTP related failures.
}


public CloseableHttpClient getHttpClient(Map<String, Object> options) throws GeneralSecurityException, StepException {

Check warning on line 91 in src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java

View workflow job for this annotation

GitHub Actions / build

no comment
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();

httpClientBuilder.disableAuthCaching();
Expand Down Expand Up @@ -367,6 +366,16 @@

//As per RFC2617 the Basic Authentication standard has to send the credentials Base64 encoded.
authHeader = "Basic " + com.dtolabs.rundeck.core.utils.Base64.encode(authHeader);
} else if (authentication.equals(AUTH_BEARER)) {
// The password holds the token to send verbatim as a Bearer credential.
// A blank token would produce a meaningless "Bearer " header, so it is
// treated the same as a missing one.
if(password == null || password.trim().isEmpty()) {
throw new StepException("Token not provided for Bearer Authentication",
StepFailureReason.ConfigurationFailure);
}

authHeader = "Bearer " + password;
} else if (authentication.equals(AUTH_OAUTH2)) {
// Get an OAuth token and setup the auth header for OAuth
String tokenEndpoint = getStringOption(options, "oauthTokenEndpoint");
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/edu/ohio/ais/rundeck/HttpDescription.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public Description getDescription() {
.description("Authentication mechanism to use.")
.required(false)
.defaultValue(HttpBuilder.AUTH_NONE)
.values(HttpBuilder.AUTH_NONE, HttpBuilder.AUTH_BASIC, HttpBuilder.AUTH_OAUTH2)
.values(HttpBuilder.AUTH_NONE, HttpBuilder.AUTH_BASIC, HttpBuilder.AUTH_BEARER, HttpBuilder.AUTH_OAUTH2)
.renderingOption(StringRenderingConstants.GROUP_NAME,"Authentication")
.build())
.property(PropertyBuilder.builder()
Expand All @@ -80,8 +80,8 @@ public Description getDescription() {
.build())
.property(PropertyBuilder.builder()
.string("password")
.title("Password/Client Secret")
.description("Password or Client Secret to use for authentication.")
.title("Password/Client Secret/Token")
.description("Password, Client Secret, or Bearer Token to use for authentication.")
.required(false)
.renderingOption(StringRenderingConstants.SELECTION_ACCESSOR_KEY,
StringRenderingConstants.SelectionAccessor.STORAGE_PATH)
Expand Down
162 changes: 162 additions & 0 deletions src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java
Original file line number Diff line number Diff line change
@@ -1,29 +1,79 @@
package edu.ohio.ais.rundeck;

import com.dtolabs.rundeck.core.execution.ExecutionContext;
import com.dtolabs.rundeck.core.execution.workflow.steps.StepException;
import com.dtolabs.rundeck.core.execution.workflow.steps.StepFailureReason;
import com.dtolabs.rundeck.core.storage.ResourceMeta;
import com.dtolabs.rundeck.core.storage.StorageTree;
import com.dtolabs.rundeck.plugins.PluginLogger;
import com.dtolabs.rundeck.plugins.step.PluginStepContext;
import org.apache.http.client.methods.RequestBuilder;
import org.junit.Before;
import org.junit.Test;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.rundeck.storage.api.Resource;

import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;

import static edu.ohio.ais.rundeck.HttpBuilder.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;

public class HttpBuilderTest {

private HttpBuilder builder;
private RequestBuilder request;
private PluginStepContext pluginStepContext;
private ExecutionContext executionContext;

@Before
public void setUp() {
builder = new HttpBuilder();
request = mock(RequestBuilder.class);

// getAuthHeader always tries key storage first; with no storage tree
// stubbed, SecretBundleUtil logs the failure and falls back to using
// the raw option value as the password.
pluginStepContext = mock(PluginStepContext.class);
executionContext = mock(ExecutionContext.class);
when(pluginStepContext.getExecutionContext()).thenReturn(executionContext);
when(executionContext.getExecutionLogger()).thenReturn(mock(PluginLogger.class));
}

/**
* Stub the key storage tree so the given path resolves to the given content.
*/
private void stubStoragePassword(String path, final String content) {
StorageTree storageTree = mock(StorageTree.class);
@SuppressWarnings("unchecked")
Resource<ResourceMeta> resource = mock(Resource.class);
ResourceMeta meta = mock(ResourceMeta.class);

when(executionContext.getStorageTree()).thenReturn(storageTree);
when(storageTree.getResource(path)).thenReturn(resource);
when(resource.getContents()).thenReturn(meta);
try {
when(meta.writeContent(any(OutputStream.class))).thenAnswer(new Answer<Long>() {
@Override
public Long answer(InvocationOnMock invocation) throws Throwable {
byte[] bytes = content.getBytes();
((OutputStream) invocation.getArguments()[0]).write(bytes);
return (long) bytes.length;
}
});
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Test
Expand Down Expand Up @@ -170,4 +220,116 @@ public void headerValueToString_fractionalDouble_emitsDecimalString() {
assertEquals("1.5", headerValueToString(1.5));
}

// The "Bearer" authentication type sends the resolved password verbatim as
// a Bearer credential in the Authorization header.

@Test
public void getAuthHeader_bearerAuth_returnsBearerHeader() throws StepException {
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("password", "my-token");

assertEquals("Bearer my-token", builder.getAuthHeader(pluginStepContext, options));
}

@Test
public void getAuthHeader_bearerAuth_ignoresUsername() throws StepException {
// Unlike BASIC, the username plays no part in the header.
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("username", "user");
options.put("password", "my-token");

assertEquals("Bearer my-token", builder.getAuthHeader(pluginStepContext, options));
}

@Test
public void getAuthHeader_bearerAuth_doesNotContactTokenEndpoint() throws StepException {
// No OAuth client should be built even if OAuth endpoints are left
// configured from a previous authentication choice.
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("username", "client-id");
options.put("password", "my-token");
options.put("oauthTokenEndpoint", "http://localhost:1/token");

assertEquals("Bearer my-token", builder.getAuthHeader(pluginStepContext, options));
assertTrue("Expected no OAuth client to be created", builder.getOauthClients().isEmpty());
}

@Test
public void getAuthHeader_bearerAuthWithStoragePath_usesStoredValue() throws StepException {
stubStoragePassword("keys/my/token", "stored-token");

Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("password", "keys/my/token");

assertEquals("Bearer stored-token", builder.getAuthHeader(pluginStepContext, options));
}

@Test
public void getAuthHeader_bearerAuthWithoutPassword_throwsConfigurationFailure() {
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);

try {
builder.getAuthHeader(pluginStepContext, options);
fail("Expected StepException for missing token");
} catch (StepException se) {
assertEquals(StepFailureReason.ConfigurationFailure, se.getFailureReason());
}
}

@Test
public void getAuthHeader_bearerAuthWithEmptyPassword_throwsConfigurationFailure() {
// The option is present but blank, i.e. the field was left empty in the UI.
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("password", "");

try {
builder.getAuthHeader(pluginStepContext, options);
fail("Expected StepException for empty token");
} catch (StepException se) {
assertEquals(StepFailureReason.ConfigurationFailure, se.getFailureReason());
}
}

@Test
public void getAuthHeader_bearerAuthWithBlankPassword_throwsConfigurationFailure() {
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BEARER);
options.put("password", " ");

try {
builder.getAuthHeader(pluginStepContext, options);
fail("Expected StepException for blank token");
} catch (StepException se) {
assertEquals(StepFailureReason.ConfigurationFailure, se.getFailureReason());
}
}

@Test
public void getAuthHeader_basicAuth_returnsBasicHeader() throws StepException {
// BASIC is unaffected by the new authentication type.
Map<String, Object> options = new HashMap<>();
options.put("authentication", AUTH_BASIC);
options.put("username", "user");
options.put("password", "my-token");

assertEquals("Basic " + com.dtolabs.rundeck.core.utils.Base64.encode("user:my-token"),
builder.getAuthHeader(pluginStepContext, options));
}

@Test
public void getAuthHeader_noAuth_returnsNull() throws StepException {
// A password on its own does not produce a header; the authentication
// type has to select "Bearer" explicitly.
Map<String, Object> options = new HashMap<>();
options.put("password", "my-token");

assertNull(builder.getAuthHeader(pluginStepContext, options));
}

}
55 changes: 55 additions & 0 deletions src/test/java/edu/ohio/ais/rundeck/HttpDescriptionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package edu.ohio.ais.rundeck;

import com.dtolabs.rundeck.core.plugins.configuration.Description;
import com.dtolabs.rundeck.core.plugins.configuration.Property;
import org.junit.Before;
import org.junit.Test;

import java.util.Arrays;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;

public class HttpDescriptionTest {

private Description description;

@Before
public void setUp() {
description = new HttpDescription("test-provider", "Test Title", "Test Description").getDescription();
}

private Property property(String name) {
for (Property property : description.getProperties()) {
if (name.equals(property.getName())) {
return property;
}
}
return null;
}

@Test
public void describes_authenticationWithBearerValue() {
Property property = property("authentication");

assertNotNull("Expected an authentication property", property);
assertEquals(Arrays.asList(HttpBuilder.AUTH_NONE, HttpBuilder.AUTH_BASIC, HttpBuilder.AUTH_BEARER,
HttpBuilder.AUTH_OAUTH2),
property.getSelectValues());
}

@Test
public void describes_authenticationDefaultsToNone() {
Property property = property("authentication");

assertNotNull("Expected an authentication property", property);
assertEquals(HttpBuilder.AUTH_NONE, property.getDefaultValue());
}

@Test
public void describes_noPasswordBearerTokenProperty() {
// Replaced by the "Bearer" authentication value.
assertNull(property("passwordBearerToken"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public class HttpWorkflowNodeStepPluginTest {
protected static final String REMOTE_URL = "/trigger";
protected static final String BOGUS_URL = "/bogus";
protected static final String REMOTE_BASIC_URL = "/trigger-basic";
protected static final String REMOTE_BEARER_URL = "/trigger-bearer";
protected static final String BEARER_TOKEN = "my-bearer-token";
protected static final String REMOTE_SLOW_URL = "/slow-trigger";
protected static final String REMOTE_OAUTH_URL = "/oauth";
protected static final String REMOTE_OAUTH_EXPIRED_URL = "/oauth-expired";
Expand Down Expand Up @@ -90,6 +92,21 @@ public Map<String, Object> getBasicOptions(String method) {
return options;
}

/**
* Setup options for execution for the given method using a Bearer token.
* @param method HTTP Method to use.
* @return Options for the execution.
*/
public Map<String, Object> getBearerOptions(String method) {
Map<String, Object> options = getExecutionOptions(method);

options.put("remoteUrl", OAuthClientTest.BASE_URI + REMOTE_BEARER_URL);
options.put("password", BEARER_TOKEN);
options.put("authentication", HttpBuilder.AUTH_BEARER);

return options;
}

/**
* Setup options for simple execution for the given method using OAuth 2.0.
* @param method HTTP Method to use.
Expand Down Expand Up @@ -133,6 +150,15 @@ public void setUp() {
.willReturn(WireMock.aResponse()
.withStatus(200)));

// Bearer token, with a 401 for anything but the expected token
WireMock.stubFor(WireMock.request(method, WireMock.urlEqualTo(REMOTE_BEARER_URL)).atPriority(1)
.withHeader("Authorization", WireMock.equalTo("Bearer " + BEARER_TOKEN))
.willReturn(WireMock.aResponse()
.withStatus(200)));
WireMock.stubFor(WireMock.request(method, WireMock.urlEqualTo(REMOTE_BEARER_URL)).atPriority(2)
.willReturn(WireMock.aResponse()
.withStatus(401)));

// OAuth with a fresh token
WireMock.stubFor(WireMock.request(method, WireMock.urlEqualTo(REMOTE_OAUTH_URL))
.withHeader("Authorization", WireMock.equalTo("Bearer " + OAuthClientTest.ACCESS_TOKEN_VALID))
Expand Down Expand Up @@ -285,6 +311,37 @@ public void canCallBasicEndpoint() throws NodeStepException {
}
}

@Test()
public void canCallBearerEndpoint() throws NodeStepException {
for(String method : HttpBuilder.HTTP_METHODS) {
this.plugin.executeNodeStep(pluginContext, this.getBearerOptions(method), node );
}
}

@Test(expected = NodeStepException.class)
public void cannotCallBearerEndpointWithWrongToken() throws NodeStepException {
Map<String, Object> options = this.getBearerOptions("GET");
options.put("password", "wrong-token");

this.plugin.executeNodeStep(pluginContext, options, node );
}

@Test
public void canValidateBearerConfiguration() {
// Selecting Bearer without a token is a configuration error.
Map<String, Object> options = new HashMap<>();
options.put("remoteUrl", OAuthClientTest.BASE_URI + REMOTE_BEARER_URL);
options.put("method", "GET");
options.put("authentication", HttpBuilder.AUTH_BEARER);

try {
this.plugin.executeNodeStep(pluginContext, options, node );
fail("Expected configuration exception.");
} catch (NodeStepException se) {
assertEquals(StepFailureReason.ConfigurationFailure, se.getFailureReason());
}
}

@Test(expected = NodeStepException.class)
public void canHandle500Error() throws NodeStepException {
Map<String, Object> options = new HashMap<>();
Expand Down
Loading