Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(WIP) Okta token auth for Process Automation 5.1.0 #545

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
31 changes: 31 additions & 0 deletions rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ public Builder<A> tokenAuth(final String authToken) {
buildTokenAuth(okhttp, baseUrl, authToken);
return this;
}
public Builder<A> bearerTokenAuth(final String bearerToken) {
buildBearerAuth(okhttp, baseUrl, bearerToken);
return this;
}

public Builder<A> passwordAuth(final String username, final String password) {
buildFormAuth(baseUrl, username, password, okhttp);
Expand Down Expand Up @@ -228,6 +232,17 @@ private static void buildTokenAuth(
validateNotempty(authToken, "Token cannot be blank or null");
builder.addInterceptor(new StaticHeaderInterceptor("X-Rundeck-Auth-Token", authToken));
}
private static void buildBearerAuth(
final OkHttpClient.Builder builder,
final String baseUrl,
final String bearerToken
)
{
HttpUrl parse = HttpUrl.parse(baseUrl);
validateBaseUrl(baseUrl, parse);
validateNotempty(bearerToken, "Token cannot be blank or null");
builder.addInterceptor(new StaticHeaderInterceptor("Authorization", "Bearer " + bearerToken));
}

private static void buildFormAuth(
final String baseUrl,
Expand Down Expand Up @@ -399,6 +414,16 @@ public static Builder<RundeckApi> builder(String baseUrl, String authToken) {
return rundeckApiBuilder;
}

/**
* @return new Builder configured with the baseUrl and bearer token authentication
*/
public static Builder<RundeckApi> builderBearerToken(String baseUrl, String authToken) {
Builder<RundeckApi> rundeckApiBuilder = new Builder<>(RundeckApi.class);
rundeckApiBuilder.baseUrl(baseUrl);
rundeckApiBuilder.bearerTokenAuth(authToken);
return rundeckApiBuilder;
}

/**
* @return new Builder configured with the baseUrl and password authentication
*/
Expand All @@ -415,6 +440,12 @@ public static Builder<RundeckApi> builder(String baseUrl, String username, Strin
public static Client<RundeckApi> with(String baseUrl, String authToken) {
return builder(baseUrl,authToken).build();
}
/**
* @return new Client with given baseUrl and authToken
*/
public static Client<RundeckApi> withBearerToken(String baseUrl, String authToken) {
return builderBearerToken(baseUrl, authToken).build();
}

/**
* @return new Client with given baseUrl and password authentication
Expand Down
43 changes: 37 additions & 6 deletions rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
mixinStandardHelpOptions = true,
subcommands = {
Adhoc.class,
Auth.class,
Jobs.class,
Projects.class,
Executions.class,
Expand All @@ -81,6 +82,7 @@ public class Main {
public static final String RD_USER = "RD_USER";
public static final String RD_PASSWORD = "RD_PASSWORD";
public static final String RD_TOKEN = "RD_TOKEN";
public static final String RD_AUTH = "RD_AUTH";
public static final String RD_URL = "RD_URL";
public static final String RD_API_VERSION = "RD_API_VERSION";
public static final String RD_AUTH_PROMPT = "RD_AUTH_PROMPT";
Expand Down Expand Up @@ -506,10 +508,12 @@ public static <T> Client<T> createClient(Rd config, Class<T> api, Integer reques

if (auth.isTokenAuth()) {
builder.tokenAuth(auth.getToken());
} else if (auth.isSSOAuth()) {
builder.bearerTokenAuth(auth.getBearerToken());
} else {
if (null == auth.getUsername() || "".equals(auth.getUsername().trim())) {
throw new IllegalArgumentException("Username or token must be entered, or use environment variable " +
RD_USER + " or " + RD_TOKEN);
RD_USER + " or " + RD_TOKEN + " or "+RD_AUTH);
}
if (null == auth.getPassword() || "".equals(auth.getPassword().trim())) {
throw new IllegalArgumentException("Password must be entered, or use environment variable " +
Expand All @@ -525,8 +529,9 @@ public static <T> Client<T> createClient(Rd config, Class<T> api, Integer reques

interface Auth {
default boolean isConfigured() {
return null != getToken() || (
null != getUsername() && null != getPassword()
return null != getToken() ||
null != getBearerToken() ||
(null != getUsername() && null != getPassword()
);
}

Expand All @@ -541,6 +546,9 @@ default String getPassword() {
default String getToken() {
return null;
}
default String getBearerToken() {
return null;
}

default boolean isTokenAuth() {
String username = getUsername();
Expand All @@ -550,6 +558,17 @@ default boolean isTokenAuth() {
String token = getToken();
return null != token && !"".equals(token);
}
default boolean isSSOAuth() {
String username = getUsername();
if (null != username && !username.trim().isEmpty()) {
return false;
}
String token = getToken();
if(null != token && !token.isEmpty()){
return false;
}
return null != getBearerToken() && !"".equals(getBearerToken());
}

default Auth chain(Auth auth) {
return new ChainAuth(Arrays.asList(this, auth));
Expand Down Expand Up @@ -582,12 +601,13 @@ public String getPassword() {
public String getToken() {
return config.get(RD_TOKEN);
}
@Override
public String getBearerToken() {
return config.get(RD_AUTH);
}
}

static class ConsoleAuth implements Auth {
String username;
String pass;
String token;
final String header;
boolean echoHeader;

Expand Down Expand Up @@ -624,6 +644,12 @@ public String getToken() {
char[] chars = System.console().readPassword("Enter auth token: ");
return new String(chars);
}
@Override
public String getBearerToken() {
echo();
char[] chars = System.console().readPassword("Enter Bearer token: ");
return new String(chars);
}
}

static class ChainAuth implements Auth {
Expand Down Expand Up @@ -657,6 +683,11 @@ public String getPassword() {
public String getToken() {
return findFirst(Auth::getToken);
}

@Override
public String getBearerToken() {
return findFirst(Auth::getBearerToken);
}
}


Expand Down
169 changes: 169 additions & 0 deletions rd-cli-tool/src/main/java/org/rundeck/client/tool/commands/Auth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package org.rundeck.client.tool.commands;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import okhttp3.ResponseBody;
import org.rundeck.client.tool.InputError;
import org.rundeck.client.tool.extension.BaseCommand;
import org.rundeck.client.tool.options.VerboseOption;
import org.rundeck.client.util.DataOutput;
import picocli.CommandLine;
import retrofit2.Response;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.Headers;
import retrofit2.http.POST;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@CommandLine.Command(
description = "Authenticate via SSO provider",
name = "auth",
showEndOfOptionsDelimiterInUsageHelp = true,
mixinStandardHelpOptions = true
)
public class Auth extends BaseCommand {

@Getter
@Setter
@ToString
public static class AuthOptions extends VerboseOption {
@CommandLine.Option(names = {"-u", "--url"}, description = "Client URL", required = true)
private String clientUrl;
@CommandLine.Option(names = {"-i", "--id"}, description = "Client ID", required = true)
private String clientId;
@CommandLine.Option(
names = {"-s", "--scope"},
arity = "1..*",
description = "Custom scope name",
required = true
)
private List<String> scope;
@CommandLine.Option(names = {"-e", "--clientSecretEnv"}, description = "Env Var to use for client secret")
private String clientSecretEnv;
@CommandLine.Option(
names = {"-S", "--clientSecret"},
description = "Client secret.",
arity = "0..1",
interactive = true
)
private char[] clientSecret;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class OktaToken implements DataOutput {
String access_token;
String token_type;
Integer expires_in;
String scope;

@Override
public Map<?, ?> asMap() {
return new HashMap<String, Object>() {{
put("access_token", access_token);
put("token_type", token_type);
put("expires_in", expires_in);
put("scope", scope);
}};
}
}

public static interface OktaApi {
@Headers(
"Accept: application/json"
)
@FormUrlEncoded
@POST("oauth2/default/v1/token")
retrofit2.Call<OktaToken> token(
@Field("grant_type") String grantType,
@Field("scope") String scope
);
}

public static interface OktaApiProvider {
OktaApi get(Auth.AuthOptions options, char[] clientSecret);
}

Executions.Interactive interactive = new Executions.ConsoleInteractive();
OktaApiProvider oktaApiProvider = new DefaultOktaApiProvider();


@CommandLine.Command(
description = "Authenticate to Okta and acquire JWT token. If a client secret is not specified, user will" +
" be prompted for the secret.",
name = "okta",
mixinStandardHelpOptions = true
)
public int okta(@CommandLine.Mixin Auth.AuthOptions options) throws IOException, InputError {
if (options.isVerbose()) {
getRdOutput().info("Client URL: " + options.clientUrl);
getRdOutput().info("Client ID: " + options.clientId);
getRdOutput().info("Scope: " + options.scope);
}
char[] clientSecret = options.clientSecret;
if (null == clientSecret && null != options.clientSecretEnv) {
String getenv = System.getenv(options.clientSecretEnv);
if (getenv != null) {
clientSecret = getenv.toCharArray();
}
}
if (null == clientSecret && interactive.isEnabled()) {
clientSecret = interactive.readPassword("Enter client secret: ");
}

if (null == clientSecret) {
getRdOutput().error(
"No user interaction available. Use --clientSecret or --clientSecretEnv to specify client secret");
return 2;
}

OktaApi okta = oktaApiProvider.get(options, clientSecret);
retrofit2.Call<OktaToken> clientCredentials = okta.token(
"client_credentials",
String.join(" ", options.scope)
);

if (options.isVerbose()) {
getRdOutput().info("Generating JWT token...");
}

Response<OktaToken> execute = clientCredentials.execute();
if (execute.isSuccessful()) {
OktaToken body = execute.body();
if (null == body) {
getRdOutput().error("Unable to get response body");
return 1;
}
if (options.isVerbose()) {
getRdOutput().info("Token generated successfully.");
getRdOutput().info("You can use the access_token value for the RD_AUTH environment variable:");
getRdOutput().output(body);
} else {
getRdOutput().output(body.access_token);
}
return 0;
} else {
try (ResponseBody responseBody = execute.errorBody()) {
String body = "(no response body)";
if (null == responseBody) {
getRdOutput().error("Unable to get error response body");
} else {
body = responseBody.string();
}
getRdOutput().error("Error: " + execute.code() + ": " + execute.message() + ": " + body);
return 2;
}
}
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.rundeck.client.tool.commands;

import okhttp3.OkHttpClient;
import org.rundeck.client.util.StaticHeaderInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;

public class DefaultOktaApiProvider implements Auth.OktaApiProvider {
public static final String AUTHORIZATION = "Authorization";
public static final String BASIC = "Basic ";

@Override
public Auth.OktaApi get(Auth.AuthOptions options, char[] clientSecret) {
Retrofit rfit = new Retrofit.Builder()
.baseUrl(options.getClientUrl())
.client(
new OkHttpClient.Builder()
.addInterceptor(new StaticHeaderInterceptor(
AUTHORIZATION,
BASIC + basicAuthString(options.getClientId(), clientSecret)
))
.build()
)
.addConverterFactory(JacksonConverterFactory.create())
.build();
return rfit.create(Auth.OktaApi.class);
}

/**
* Create basic auth header value
*
* @param clientId client ID
* @param clientSecret client secret
* @return encoded header value
*/
public static String basicAuthString(String clientId, char[] clientSecret) {
byte[] idBytes = clientId.getBytes(StandardCharsets.UTF_8);

ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(clientSecret));
byte[] secretBytes = Arrays.copyOfRange(byteBuffer.array(), byteBuffer.position(), byteBuffer.limit());

byte[] buffer = new byte[idBytes.length + secretBytes.length + 1];
System.arraycopy(idBytes, 0, buffer, 0, idBytes.length);
buffer[idBytes.length] = ':';
System.arraycopy(secretBytes, 0, buffer, idBytes.length + 1, secretBytes.length);

return new String(Base64.getEncoder().encode(buffer));
}
}
Loading
Loading