Skip to content
Merged
7 changes: 5 additions & 2 deletions .github/instructions/java.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ Always use `headerReplace(name, value)` — never `accept()`, `contentType()`, o

- New/updated code should throw only fcli-domain exceptions (`Fcli*Exception`), module-domain exceptions (for example `Aviator*Exception` in Aviator modules), or picocli exceptions (`ParameterException` and related) when integrating with command parsing.
- Avoid throwing standard Java runtime exceptions (`IllegalArgumentException`, `IllegalStateException`, `RuntimeException`, and similar) for user-facing or command-flow errors.
- **Checked exceptions** (e.g., `IOException`, `JsonProcessingException`): Wrap in `FcliTechnicalException` to preserve the cause chain.
- **Runtime exceptions** (e.g., `UnexpectedHttpResponseException`): Re-throw as-is unless special handling is needed (e.g., a specific error code requires a user-friendly message). Avoid unnecessary wrapping to keep stack traces short and relevant.

| Scenario | Exception |
|----------|-----------|
| Invalid/missing user input | `FcliSimpleException` |
| External resource not found | `FcliSimpleException` with remediation |
| User abort | `FcliAbortedByUserException` |
| I/O, network, JSON parse | `FcliTechnicalException` (wrap cause) |
| Checked exception (I/O, JSON parse) | `FcliTechnicalException` (wrap cause) |
| Runtime exception (no special handling needed) | Re-throw as-is |
| Invariant violation, unreachable | `FcliBugException` |

Messages: actionable, sentence case, no trailing periods. Preserve root cause in wrapping.
Messages: actionable, sentence case, no trailing periods. Wrap root cause only for checked exceptions.

## Design Patterns

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper;
import com.fortify.cli.fod._common.session.helper.oauth.FoDTokenCreateResponse;
import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials;
import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials;
import com.fortify.cli.fod._common.session.helper.oauth.impl.BasicFoDUserCredentials;
import com.fortify.cli.ssc._common.session.cli.mixin.SSCAndScanCentralSessionLoginOptions.SSCAndScanCentralUrlConfigOptions.SSCComponentDisable;
import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralCredentialsConfig;
import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig;
Expand Down Expand Up @@ -348,11 +348,11 @@ private FoDTokenCreateResponse createFoDTokenResponse(ParsedAuthorization auth,
try {
return FoDOAuthHelper.createToken(
urlConfig,
new HttpMcpFoDUserCredentials(
auth.fodTenant(),
auth.fodUser(),
pwd
),
BasicFoDUserCredentials.builder()
.tenant(auth.fodTenant())
.user(auth.fodUser())
.password(pwd)
.build(),
DEFAULT_FOD_SCOPES
);
} finally {
Expand Down Expand Up @@ -380,33 +380,6 @@ public String getClientSecret() {
}
}

private static final class HttpMcpFoDUserCredentials implements IFoDUserCredentials {
private final String tenant;
private final String user;
private final char[] password;

private HttpMcpFoDUserCredentials(String tenant, String user, char[] password) {
this.tenant = tenant;
this.user = user;
this.password = password;
}

@Override
public String getUser() {
return user;
}

@Override
public char[] getPassword() {
return password;
}

@Override
public String getTenant() {
return tenant;
}
}

private static final class HttpMcpSscUrlConfig implements ISSCAndScanCentralUrlConfig {
private final MCPServerHttpConfig.SscConfig config;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import com.fortify.cli.common.exception.FcliSimpleException;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException;
import com.fortify.cli.common.rest.unirest.config.IUrlConfig;
import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand;
import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier;
Expand All @@ -34,12 +35,31 @@ public class FoDSessionLoginCommand extends AbstractSessionLoginCommand<FoDSessi
@Getter private FoDSessionHelper sessionHelper = FoDSessionHelper.instance();
@Mixin private FoDSessionLoginOptions loginOptions;
@Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplierMixin;


private static final String MFA_GUIDANCE = "If MFA is required, provide the security code:\n"
+ " --code <code> (or -c <code>) to provide the security code\n"
+ " --totp to indicate the code is from a TOTP authenticator app";

private static final String ERROR_WITH_CODE = "Authentication failed. Possible causes:\n"
+ " - Incorrect username or password\n"
+ " - MFA/TOTP code incorrect, expired, or wrong type (TOTP vs MFA)\n"
+ "Please verify your credentials and MFA/TOTP code if applicable:\n"
+ MFA_GUIDANCE;

private static final String ERROR_WITHOUT_CODE = "Authentication failed. Possible causes:\n"
+ " - Incorrect username or password\n"
+ " - FoD tenant requires MFA/TOTP authentication\n\n"
+ MFA_GUIDANCE;

private static final String ERROR_CLIENT_CREDENTIALS = "Authentication failed. Possible causes:\n"
+ " - Incorrect client ID or client secret\n"
+ " - Client credentials have expired";

@Override
public ISessionNameSupplier getSessionNameSupplier() {
return unirestInstanceSupplierMixin;
}

@Override
protected void logoutBeforeNewLogin(String sessionName, FoDSessionDescriptor sessionDescriptor) {
unirestInstanceSupplierMixin.close(sessionName);
Expand All @@ -48,17 +68,36 @@ protected void logoutBeforeNewLogin(String sessionName, FoDSessionDescriptor ses

@Override
protected FoDSessionDescriptor login(String sessionName) {
FoDSessionDescriptor sessionDescriptor;
FoDSessionDescriptor sessionDescriptor = null;
IUrlConfig urlConfig = loginOptions.getUrlConfigOptions();
if ( loginOptions.hasClientCredentials() ) {
FoDTokenCreateResponse createTokenResponse = FoDOAuthHelper.createToken(urlConfig, loginOptions.getClientCredentialOptions(), loginOptions.getAuthOptions().getScopes());
sessionDescriptor = new FoDSessionDescriptor(urlConfig, createTokenResponse);
} else if ( loginOptions.hasUserCredentials() ) {
FoDTokenCreateResponse createTokenResponse = FoDOAuthHelper.createToken(urlConfig, loginOptions.getUserCredentials(), loginOptions.getAuthOptions().getScopes());
sessionDescriptor = new FoDSessionDescriptor(urlConfig, createTokenResponse);
if (loginOptions.hasClientCredentials()) {
try {
FoDTokenCreateResponse createTokenResponse = FoDOAuthHelper.createToken(urlConfig,
loginOptions.getClientCredentialOptions(), loginOptions.getAuthOptions().getScopes());
sessionDescriptor = new FoDSessionDescriptor(urlConfig, createTokenResponse);
} catch (UnexpectedHttpResponseException e) {
handleUnexpectedHttpResponseException(e, ERROR_CLIENT_CREDENTIALS);
}
} else if (loginOptions.hasUserCredentials()) {
try {
FoDTokenCreateResponse createTokenResponse = FoDOAuthHelper.createToken(urlConfig,
loginOptions.getUserCredentials(), loginOptions.getAuthCode(),
loginOptions.getAuthOptions().getScopes());
sessionDescriptor = new FoDSessionDescriptor(urlConfig, createTokenResponse);
} catch (UnexpectedHttpResponseException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Current exception handling block is too long, taking away attention from actual logic above. Closely related, it doesn't make sense to construct three local strings with static contents; better to move these to actual constants, which would automatically make exception handling code much shorter
  • Why do we have dedicated exception handling when authenticating with user credentials, but not when authenticating with client credentials? Shouldn't we throw a similar FcliSimpleException with appropriate guidance (like client credentials incorrect or expired)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have had class-level constants created for the messages and have used them.

Note: Across the entire codebase, every other command throws FcliSimpleException with the message string inline at the throw site, either as a string literal directly or via String.format(...). No other command pre-defines multi-line message constants like MFA_GUIDANCE, ERROR_WITH_CODE, and ERROR_WITHOUT_CODE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added dedicated exception handling for client credentials as well.

String errorMessage = loginOptions.hasSecurityCode() ? ERROR_WITH_CODE : ERROR_WITHOUT_CODE;
handleUnexpectedHttpResponseException(e, errorMessage);
}
} else {
throw new FcliSimpleException("Either FoD client or user credentials must be provided");
}
return sessionDescriptor;
}

private void handleUnexpectedHttpResponseException(UnexpectedHttpResponseException e, String msg) {
if (e.getStatus() == 400) {
throw new FcliSimpleException(msg);
}
throw e;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
import com.fortify.cli.common.session.cli.mixin.UserCredentialOptions;
import com.fortify.cli.fod._common.rest.helper.FoDProductHelper;
import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials;
import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserAuthCode;
import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials;
import com.fortify.cli.fod._common.session.helper.oauth.impl.BasicFoDUserAuthCode;
import com.fortify.cli.fod._common.session.helper.oauth.impl.BasicFoDUserCredentials;

import lombok.Getter;
import lombok.SneakyThrows;
Expand All @@ -33,28 +36,33 @@

public class FoDSessionLoginOptions {
@Mixin @Getter private FoDUrlConfigOptions urlConfigOptions = new FoDUrlConfigOptions();

@ArgGroup(exclusive = false, multiplicity = "1", order = 2)
@Getter private FoDAuthOptions authOptions = new FoDAuthOptions();

public static class FoDAuthOptions {
@ArgGroup(exclusive = true, multiplicity = "1", order = 3)
@Getter private FoDCredentialOptions credentialOptions = new FoDCredentialOptions();
@Option(names="--scopes", defaultValue="api-tenant", split=",")
@Getter private String[] scopes;
}

public static class FoDCredentialOptions {
@ArgGroup(exclusive = false, multiplicity = "1", order = 1)
@ArgGroup(exclusive = false, multiplicity = "1", order = 1)
@Getter private FoDUserCredentialOptions userCredentialOptions = new FoDUserCredentialOptions();
@ArgGroup(exclusive = false, multiplicity = "1", order = 2)
@ArgGroup(exclusive = false, multiplicity = "1", order = 2)
@Getter private FoDClientCredentialOptions clientCredentialOptions = new FoDClientCredentialOptions();
}

public static class FoDUserCredentialOptions extends UserCredentialOptions {
@Option(names = {"-t", "--tenant"}, required = true)
@MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT")
@Getter private String tenant;
@Option(names = {"--code", "-c" }, paramLabel = "<code>", arity = "0..1", interactive = true, echo = false)
@MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TOTP/MFA CODE")
@Getter private String securityCode;
@Option(names = {"--totp" })
@Getter private boolean isTotp;
}

public static class FoDClientCredentialOptions implements IFoDClientCredentials {
Expand All @@ -72,7 +80,7 @@ public FoDUserCredentialOptions getUserCredentialOptions() {
.map(FoDCredentialOptions::getUserCredentialOptions)
.orElse(null);
}

public FoDClientCredentialOptions getClientCredentialOptions() {
return Optional.ofNullable(authOptions)
.map(FoDAuthOptions::getCredentialOptions)
Expand All @@ -89,55 +97,56 @@ public final boolean hasUserCredentials() {
&& userCredentialOptions.getPassword().length > 0;
}

public final BasicFoDUserCredentials getUserCredentials() {
public final IFoDUserCredentials getUserCredentials() {
var u = getUserCredentialOptions();
return BasicFoDUserCredentials.builder().tenant(u.getTenant()).user(u.getUser()).password(u.getPassword()).build();
return BasicFoDUserCredentials.builder()
.tenant(u.getTenant())
.user(u.getUser())
.password(u.getPassword())
.build();
}

public final boolean hasClientCredentials() {
FoDClientCredentialOptions clientCredentialOptions = getClientCredentialOptions();
return clientCredentialOptions!=null
&& StringUtils.isNotBlank(clientCredentialOptions.getClientId())
&& StringUtils.isNotBlank(clientCredentialOptions.getClientSecret());
}


public boolean hasSecurityCode() {
var userCred = getUserCredentialOptions();
return userCred != null && StringUtils.isNotBlank(userCred.getSecurityCode());
}

public String getSecurityCode() {
var userCred = getUserCredentialOptions();
return userCred != null ? userCred.getSecurityCode() : null;
}

public boolean isTotp() {
var userCred = getUserCredentialOptions();
return userCred != null && userCred.isTotp();
}

public IFoDUserAuthCode getAuthCode() {
var u = getUserCredentialOptions();
if (u == null || StringUtils.isBlank(u.getSecurityCode())) { return null; }
return BasicFoDUserAuthCode.builder()
.securityCode(u.getSecurityCode())
.isTotp(u.isTotp())
.build();
}

@Command
public static final class FoDUrlConfigOptions extends UrlConfigOptions {
@Override @SneakyThrows
public String getUrl() {
return FoDProductHelper.INSTANCE.getApiUrl(super.getUrl());
}

@Override
protected int getDefaultSocketTimeoutInMillis() {
return 600000;
}
}

/**
* Basic immutable FoD user credentials with builder pattern.
*/
public static final class BasicFoDUserCredentials implements IFoDUserCredentials {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (but please double-check) that BasicFoDUserCredentials and the new BasicFoDUserAuthCode are currently only referenced within FoDSessionLoginOptions, so from that perspective, it makes more sense to keep these as inner classes instead of separate top-level types. However, the HTTP MCP server for example defines its own HttpMcpFoDUserCredentials class with similar implementation as BasicFoDUserCredentials. As such, it may makes sense to keep the new top-level BasicFoD* classes, and change the MCP HTTP server (and any other code that creates IFoDUserCredentials) to utilize these top-level BasicFoD* classes instead of providing their own implementations of IFoDUserCredentials.

Can you please check:

  • Which classes currently implement IFoDUserCredentials and similar interfaces (like SSC variant)?
  • Whether these can be easily replaced with a single, top-level Basic* implementation

To summarize:

  • If existing implementations of IFoDUserCredentials can be easily merged, keep the top-level Basic* classes and refactor all code to use this single implementation
  • If there are significant differences between implementations and Basic* classes are only instantiated in FoDSessionLoginOptions, move those Basic* classes back to FoDSessionLoginOptions as inner classes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Basic* classes are kept as top-level classes. please review the code now.

private final String tenant;
private final String user;
private final char[] password;
private BasicFoDUserCredentials(Builder b) {
this.tenant = b.tenant;
this.user = b.user;
this.password = b.password;
}
public static Builder builder() { return new Builder(); }
@Override public String getTenant() { return tenant; }
@Override public String getUser() { return user; }
@Override public char[] getPassword() { return password; }
public static final class Builder {
private String tenant; private String user; private char[] password;
public Builder tenant(String tenant){ this.tenant=tenant; return this; }
public Builder user(String user){ this.user=user; return this; }
public Builder password(char[] password){ this.password=password; return this; }
public BasicFoDUserCredentials build(){
return new BasicFoDUserCredentials(this);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,28 @@
// TODO Consider moving all classes in this package to a more appropriate package,
// for example as a sub-package of the 'rest' package.
public class FoDOAuthHelper {

public static final FoDTokenCreateResponse createToken(IUrlConfig urlConfig, IFoDUserCredentials uc, String... scopes) {
Map<String,Object> formData = generateTokenRequest(uc, scopes);
try ( var unirest = UnirestHelper.createUnirestInstance() ) {
Map<String,Object> formData = generateTokenRequest(uc, null, scopes);
try ( var unirest = UnirestHelper.createUnirestInstance() ) {
return createToken(unirest, urlConfig, formData);
}
}

public static final FoDTokenCreateResponse createToken(IUrlConfig urlConfig, IFoDUserCredentials uc, IFoDUserAuthCode authCode, String... scopes) {
Map<String,Object> formData = generateTokenRequest(uc, authCode, scopes);
try ( var unirest = UnirestHelper.createUnirestInstance() ) {
return createToken(unirest, urlConfig, formData);
}
}

public static final FoDTokenCreateResponse createToken(IUrlConfig urlConfig, IFoDClientCredentials cc, String... scopes) {
Map<String,Object> formData = generateTokenRequest(cc, scopes);
try ( var unirest = UnirestHelper.createUnirestInstance() ) {
try ( var unirest = UnirestHelper.createUnirestInstance() ) {
return createToken(unirest, urlConfig, formData);
}
}

private static final FoDTokenCreateResponse createToken(UnirestInstance unirest, IUrlConfig urlConfig, Map<String, Object> formData) {
configureUnirest(unirest, urlConfig);
return unirest.post("/oauth/token")
Expand All @@ -60,12 +68,16 @@ private static final void configureUnirest(UnirestInstance unirest, IUrlConfig u
UnirestJsonHeaderConfigurer.configure(unirest);
}

private static final Map<String, Object> generateTokenRequest(IFoDUserCredentials uc, String... scopes) {
private static final Map<String, Object> generateTokenRequest(IFoDUserCredentials uc, IFoDUserAuthCode authCode, String... scopes) {
Map<String,Object> result = new LinkedHashMap<>();
result.put("scope", String.join(",", scopes));
result.put("grant_type", "password");
result.put("username", String.format("%s\\%s", uc.getTenant(), uc.getUser()));
result.put("password", String.valueOf(uc.getPassword()));
if (null != authCode && null != authCode.getSecurityCode()) {
result.put("security_code", authCode.getSecurityCode());
result.put("do_totp", authCode.isTotp());
}
return result;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright 2021-2026 Open Text.
*
* The only warranties for products and services of Open Text
* and its affiliates and licensors ("Open Text") are as may
* be set forth in the express warranty statements accompanying
* such products and services. Nothing herein should be construed
* as constituting an additional warranty. Open Text shall not be
* liable for technical or editorial errors or omissions contained
* herein. The information contained herein is subject to change
* without notice.
*/
package com.fortify.cli.fod._common.session.helper.oauth;

public interface IFoDUserAuthCode {
default String getSecurityCode() { return null; }
default boolean isTotp() { return false; }
}
Loading
Loading