From e3149561f7f62d44978502fbc1e0f58e5004e880 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Fri, 24 Jul 2026 16:13:38 -0700 Subject: [PATCH 1/2] Add Bearer authentication type Adds "Bearer" as an authentication value alongside None, Basic and OAuth 2.0. The password/token field holds the token, resolved through key storage like the other types, and is sent verbatim as "Authorization: Bearer ". Selecting Bearer without a token is a configuration failure, matching Basic's handling of a missing password. Tests cover the header built by getAuthHeader (including a token read from key storage), end-to-end requests against a bearer-protected endpoint for both the step and node step plugins, and the plugin property description. Co-Authored-By: Claude Opus 5 (1M context) --- .../edu/ohio/ais/rundeck/HttpBuilder.java | 11 +- .../edu/ohio/ais/rundeck/HttpDescription.java | 6 +- .../edu/ohio/ais/rundeck/HttpBuilderTest.java | 133 ++++++++++++++++++ .../ohio/ais/rundeck/HttpDescriptionTest.java | 55 ++++++++ .../HttpWorkflowNodeStepPluginTest.java | 57 ++++++++ .../rundeck/HttpWorkflowStepPluginTest.java | 57 ++++++++ 6 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 src/test/java/edu/ohio/ais/rundeck/HttpDescriptionTest.java diff --git a/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java b/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java index 98ff28b..b425366 100644 --- a/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java +++ b/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java @@ -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; @@ -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.*; @@ -44,6 +42,7 @@ public class HttpBuilder { public static final String AUTH_NONE = "None"; public static final String AUTH_BASIC = "Basic"; + public static final String AUTH_BEARER = "Bearer"; public static final String AUTH_OAUTH2 = "OAuth 2.0"; public static final String XML_FORMAT = "xml"; public static final String JSON_FORMAT = "json"; @@ -367,6 +366,14 @@ String getAuthHeader(PluginStepContext pluginStepContext, Map o //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. + if(password == null) { + 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"); diff --git a/src/main/java/edu/ohio/ais/rundeck/HttpDescription.java b/src/main/java/edu/ohio/ais/rundeck/HttpDescription.java index 2f1838d..9dc1dbb 100644 --- a/src/main/java/edu/ohio/ais/rundeck/HttpDescription.java +++ b/src/main/java/edu/ohio/ais/rundeck/HttpDescription.java @@ -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() @@ -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) diff --git a/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java b/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java index 8fa0c4f..7661205 100644 --- a/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java +++ b/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java @@ -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 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() { + @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 @@ -170,4 +220,87 @@ 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 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 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 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 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 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_basicAuth_returnsBasicHeader() throws StepException { + // BASIC is unaffected by the new authentication type. + Map 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 options = new HashMap<>(); + options.put("password", "my-token"); + + assertNull(builder.getAuthHeader(pluginStepContext, options)); + } + } diff --git a/src/test/java/edu/ohio/ais/rundeck/HttpDescriptionTest.java b/src/test/java/edu/ohio/ais/rundeck/HttpDescriptionTest.java new file mode 100644 index 0000000..9147ab8 --- /dev/null +++ b/src/test/java/edu/ohio/ais/rundeck/HttpDescriptionTest.java @@ -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")); + } +} diff --git a/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowNodeStepPluginTest.java b/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowNodeStepPluginTest.java index 9defd01..e60e319 100644 --- a/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowNodeStepPluginTest.java +++ b/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowNodeStepPluginTest.java @@ -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"; @@ -90,6 +92,21 @@ public Map 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 getBearerOptions(String method) { + Map 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. @@ -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)) @@ -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 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 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 options = new HashMap<>(); diff --git a/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowStepPluginTest.java b/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowStepPluginTest.java index 4ce9635..1805dda 100644 --- a/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowStepPluginTest.java +++ b/src/test/java/edu/ohio/ais/rundeck/HttpWorkflowStepPluginTest.java @@ -32,6 +32,8 @@ public class HttpWorkflowStepPluginTest { 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"; @@ -82,6 +84,21 @@ public Map 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 getBearerOptions(String method) { + Map 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. @@ -119,6 +136,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)) @@ -415,6 +441,37 @@ public void canHandleAuthenticationRequired() throws StepException { this.plugin.executeStep(pluginContext, options); } + @Test() + public void canCallBearerEndpoint() throws StepException { + for(String method : HttpBuilder.HTTP_METHODS) { + this.plugin.executeStep(pluginContext, this.getBearerOptions(method)); + } + } + + @Test(expected = StepException.class) + public void cannotCallBearerEndpointWithWrongToken() throws StepException { + Map options = this.getBearerOptions("GET"); + options.put("password", "wrong-token"); + + this.plugin.executeStep(pluginContext, options); + } + + @Test + public void canValidateBearerConfiguration() { + // Selecting Bearer without a token is a configuration error. + Map 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.executeStep(pluginContext, options); + fail("Expected configuration exception."); + } catch (StepException se) { + assertEquals(StepFailureReason.ConfigurationFailure, se.getFailureReason()); + } + } + @Test() public void canCallOAuthEndpoint() throws StepException { for(String method : HttpBuilder.HTTP_METHODS) { From 72a61fcecccbe44b2d140fc10caee82d38e56d56 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Fri, 24 Jul 2026 16:59:25 -0700 Subject: [PATCH 2/2] Reject blank Bearer tokens A password option present but empty resolved to a non-null empty string, so Bearer auth built a meaningless "Bearer " header instead of failing. Treat a blank token as a missing one. Co-Authored-By: Claude Opus 5 (1M context) --- .../edu/ohio/ais/rundeck/HttpBuilder.java | 4 ++- .../edu/ohio/ais/rundeck/HttpBuilderTest.java | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java b/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java index b425366..c4509b4 100644 --- a/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java +++ b/src/main/java/edu/ohio/ais/rundeck/HttpBuilder.java @@ -368,7 +368,9 @@ String getAuthHeader(PluginStepContext pluginStepContext, Map o 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. - if(password == null) { + // 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); } diff --git a/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java b/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java index 7661205..c275bed 100644 --- a/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java +++ b/src/test/java/edu/ohio/ais/rundeck/HttpBuilderTest.java @@ -281,6 +281,35 @@ public void getAuthHeader_bearerAuthWithoutPassword_throwsConfigurationFailure() } } + @Test + public void getAuthHeader_bearerAuthWithEmptyPassword_throwsConfigurationFailure() { + // The option is present but blank, i.e. the field was left empty in the UI. + Map 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 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.