diff --git a/.github/instructions/java.instructions.md b/.github/instructions/java.instructions.md index 0ddc7742e3c..6b0b54a46d2 100644 --- a/.github/instructions/java.instructions.md +++ b/.github/instructions/java.instructions.md @@ -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 diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 8a37a37baa0..c7bd5d5059f 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -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; @@ -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 { @@ -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; diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java index b6f9bbcd016..2daed82ee59 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java @@ -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; @@ -34,12 +35,31 @@ public class FoDSessionLoginCommand extends AbstractSessionLoginCommand (or -c ) 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); @@ -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) { + 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; + } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java index 3250f0e6596..484ef85b831 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java @@ -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; @@ -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 = "", 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 { @@ -72,7 +80,7 @@ public FoDUserCredentialOptions getUserCredentialOptions() { .map(FoDCredentialOptions::getUserCredentialOptions) .orElse(null); } - + public FoDClientCredentialOptions getClientCredentialOptions() { return Optional.ofNullable(authOptions) .map(FoDAuthOptions::getCredentialOptions) @@ -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 { - 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); - } - } - } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java index 7dd2e21a28e..90ed7e0f0e4 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java @@ -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 formData = generateTokenRequest(uc, scopes); - try ( var unirest = UnirestHelper.createUnirestInstance() ) { + Map 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 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 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 formData) { configureUnirest(unirest, urlConfig); return unirest.post("/oauth/token") @@ -60,12 +68,16 @@ private static final void configureUnirest(UnirestInstance unirest, IUrlConfig u UnirestJsonHeaderConfigurer.configure(unirest); } - private static final Map generateTokenRequest(IFoDUserCredentials uc, String... scopes) { + private static final Map generateTokenRequest(IFoDUserCredentials uc, IFoDUserAuthCode authCode, String... scopes) { Map 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; } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/IFoDUserAuthCode.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/IFoDUserAuthCode.java new file mode 100644 index 00000000000..2a2daa088f0 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/IFoDUserAuthCode.java @@ -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; } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserAuthCode.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserAuthCode.java new file mode 100644 index 00000000000..14396eb81fa --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserAuthCode.java @@ -0,0 +1,30 @@ +/* + * 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.impl; + +import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserAuthCode; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Basic immutable FoD user auth code with builder pattern. + */ +@Builder +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public final class BasicFoDUserAuthCode implements IFoDUserAuthCode { + @Getter private final String securityCode; + @Getter private final boolean isTotp; +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserCredentials.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserCredentials.java new file mode 100644 index 00000000000..f7a0834327a --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/impl/BasicFoDUserCredentials.java @@ -0,0 +1,31 @@ +/* + * 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.impl; + +import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Basic immutable FoD user credentials with builder pattern. + */ +@Builder +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public final class BasicFoDUserCredentials implements IFoDUserCredentials { + @Getter private final String tenant; + @Getter private final String user; + @Getter private final char[] password; +} diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 62dcf5b6c6d..15976845f03 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -123,6 +123,8 @@ fcli.fod.session.login.client-secret = FoD client secret. fcli.fod.session.login.scopes = FoD scopes to request. Default value: ${DEFAULT-VALUE} fcli.fod.session.login.fod-session = Name for this FoD session. Default value: ${DEFAULT-VALUE}. fcli.fod.session.login.header = Repeatable option to add custom HTTP headers in requests to FoD for this session, in format `NAME: VALUE`. +fcli.fod.session.login.code = Security code (TOTP from authenticator or MFA code from email/SMS). +fcli.fod.session.login.totp = Indicates the provided code is TOTP from authenticator app (sets do_totp=true). fcli.fod.session.logout.usage.header = Terminate FoD session. fcli.fod.session.logout.usage.description = This command terminates an FoD session previously created \