diff --git a/build.gradle b/build.gradle index e8eb874ccb..9d8ed86a6e 100644 --- a/build.gradle +++ b/build.gradle @@ -183,6 +183,7 @@ ext.javaLibraries = [ 'apiml-security-common', 'apiml-tomcat-common', 'certificate-analyser', + 'zosmf-jwt-check', 'common-service-core', 'security-service-client-spring', 'apiml-sample-extension', diff --git a/gradle/publish.gradle b/gradle/publish.gradle index 607f189068..7a6982ce50 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -11,6 +11,7 @@ ext.javaLibraries = [ 'apiml-security-common', 'apiml-tomcat-common', 'certificate-analyser', + 'zosmf-jwt-check', 'common-service-core', 'security-service-client-spring', 'apiml-sample-extension', diff --git a/settings.gradle b/settings.gradle index 2fadb0f914..aeb34a82e7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -54,6 +54,7 @@ include 'onboarding-enabler-python' include 'zaas-client' include 'mock-services' include 'certificate-analyser' +include 'zosmf-jwt-check' include 'apiml-tomcat-common' include 'apiml-sample-extension' include 'apiml-sample-extension-package' diff --git a/zosmf-jwt-check/README.md b/zosmf-jwt-check/README.md new file mode 100644 index 0000000000..7536e4aa71 --- /dev/null +++ b/zosmf-jwt-check/README.md @@ -0,0 +1,317 @@ +# z/OSMF JWT Check Tool + +A Java utility that verifies connectivity to the z/OSMF JWK endpoint. This tool helps diagnose configuration issues early such as incorrect hostnames, unreachable ports, missing certificates, or misconfigured z/OSMF by performing a lightweight HTTP(S) call to the z/OSMF JWK endpoint at `/jwt/ibm/api/zOSMFBuilder/jwk`. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Building](#building) +- [Usage](#usage) +- [CLI Flags Reference](#cli-flags-reference) +- [Certificate Verification Modes](#certificate-verification-modes) +- [Exit Codes](#exit-codes) +- [Response Interpretation](#response-interpretation) +- [Testing Scenarios](#testing-scenarios) + - [1. Quick Test — DISABLED Mode](#1-quick-test--disabled-mode-no-truststore-needed) + - [2. STRICT Mode — Full Verification](#2-strict-mode--full-certificate-and-hostname-verification) + - [3. NONSTRICT Mode — Skip Hostname Check](#3-nonstrict-mode--certificate-chain-verified-hostname-check-skipped) + - [4. HTTP Mode (No SSL)](#4-http-mode-no-ssl) + - [5. Validation Error Tests](#5-validation-error-tests) +- [SAF Keyrings](#saf-keyrings) +- [Troubleshooting](#troubleshooting) + +--- +## Prerequisites + +- **Java 17 or higher** (Java 17, 21, or any later version) +- Network access to the z/OSMF server +- A truststore containing the z/OSMF server's CA certificate (required for STRICT and NONSTRICT modes) + +## Building + +From the root of the `api-layer` repository: + +```bash +./gradlew :zosmf-jwt-check:build +``` + +On Windows: + +```powershell +.\gradlew :zosmf-jwt-check:build +``` + +The fat JAR (with all dependencies bundled) will be generated at: + +``` +zosmf-jwt-check/build/libs/zosmf-jwt-check-.jar +``` + +For example: `zosmf-jwt-check/build/libs/zosmf-jwt-check-3.5.12-SNAPSHOT.jar` + +## Usage + +```bash +java -jar zosmf-jwt-check-.jar --zosmf-host --zosmf-port [options] +``` + +**Minimal example (DISABLED mode, quickest way to test):** + +```bash +java -jar zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 11443 \ + --verify-certificates DISABLED +``` + +**Full example (STRICT mode with truststore):** + +```bash +java -jar zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 11443 \ + --truststore-file /path/to/truststore.p12 \ + --truststore-password changeit +``` + +**Display help:** + +```bash +java -jar zosmf-jwt-check-.jar --help +``` + +## CLI Flags Reference + +### Required Flags + +| Flag | Description | Example | +|------|-------------|---------| +| `--zosmf-host` | Hostname or IP address of the z/OSMF server | `--zosmf-host myzosmf.example.com` | +| `--zosmf-port` | Port number of the z/OSMF server | `--zosmf-port 11443` | + +> **Note:** If `--zosmf-host` or `--zosmf-port` are omitted, picocli will display: +> `Missing required option: '--zosmf-host='` + +### Conditionally Required Flags + +These flags are required when `--scheme=https` (the default) and `--verify-certificates` is **not** `DISABLED`: + +| Flag | Description | Error when missing | +|------|-------------|-------------------| +| `--truststore-file` | Path to the truststore file containing the z/OSMF CA certificate | `ERROR: --truststore-file is required when --scheme=https and verification is not DISABLED.` | +| `--truststore-password` | Password for the truststore. If specified without a value, you will be prompted interactively. | `ERROR: --truststore-password is required when --scheme=https and verification is not DISABLED.` | + +### Optional Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--scheme` | `https` | Protocol to use: `http` or `https` | +| `--verify-certificates` | `STRICT` | Certificate verification mode: `STRICT`, `NONSTRICT`, or `DISABLED` | +| `--truststore-type` | `PKCS12` | Format of the truststore file (e.g., `PKCS12`, `JKS`, `JCERACFKS`) | +| `--keystore-file` | *(none)* | Path to keystore file (only needed for mutual TLS / client certificate authentication) | +| `--keystore-password` | *(none)* | Password for the keystore. If specified without a value, you will be prompted interactively. | +| `--keystore-type` | `PKCS12` | Format of the keystore file | +| `-h`, `--help` | | Display usage help and exit | + +## Certificate Verification Modes + +The `--verify-certificates` flag controls how SSL/TLS certificates are validated when connecting over HTTPS. This mirrors the `zowe.verifyCertificates` setting in the Zowe configuration (`zowe.yaml`). + +### STRICT (Default) + +```bash +--verify-certificates STRICT +``` + +- **Certificate chain**: Fully validated against the truststore +- **Hostname verification**: The server certificate's CN/SAN must match the `--zosmf-host` value +- **Truststore**: Required +- **Use case**: Production environments — maximum security + +### NONSTRICT + +```bash +--verify-certificates NONSTRICT +``` + +- **Certificate chain**: Fully validated against the truststore +- **Hostname verification**: Skipped — the server certificate does not need to match the hostname +- **Truststore**: Required +- **Use case**: Environments where the z/OSMF certificate is issued for a different hostname (e.g., accessing via IP address when the cert has a DNS name) + +### DISABLED + +```bash +--verify-certificates DISABLED +``` + +- **Certificate chain**: Not validated — all certificates are trusted +- **Hostname verification**: Skipped +- **Truststore**: Not required +- **Use case**: Development/testing environments, or initial connectivity debugging +- **Warning**: Prints `WARNING: SSL certificate verification is DISABLED. All certificates will be trusted.` + +> **Security Note:** `DISABLED` mode should **never** be used in production. It is vulnerable to man-in-the-middle attacks. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | **Success** — z/OSMF JWK endpoint is reachable and responding | +| `4` | **Failure** — connection failed, SSL error, endpoint not found, or configuration error | +| `8` | **Help** — help/version was displayed; no check was performed | + +## Response Interpretation + +The tool interprets HTTP response codes from the z/OSMF JWK endpoint as follows: + +| HTTP Code | Result | Message | +|-----------|--------|---------| +| 200-299 | **SUCCESS** | `z/OSMF JWK endpoint is reachable and responding. HTTP ` | +| 401 | **SUCCESS** | `z/OSMF JWK endpoint exists (returned 401 Unauthorized — expected without credentials). HTTP 401` | +| 404 | **FAILURE** | `z/OSMF JWK endpoint not found. HTTP 404` — Consider configuring `jwtAutoConfiguration` to LTPA | +| 4xx (other) | **FAILURE** | `z/OSMF JWK endpoint returned unexpected client error. HTTP ` | +| 5xx | **FAILURE** | `z/OSMF JWK endpoint returned server error. HTTP ` | + +**Note:** A `401 Unauthorized` is treated as **success** because the tool does not send authentication credentials. A 401 confirms the endpoint exists and z/OSMF is processing requests. + +### Connection-Level Errors + +| Error | Message | +|-------|---------| +| SSL handshake failure | `FAILURE: SSL handshake failed. Verify that the truststore contains the z/OSMF server certificate.` | +| Connection refused | `FAILURE: Cannot connect to :. Verify the host and port are correct and z/OSMF is running.` | +| Connection timeout | `FAILURE: Connection timed out to :.` | + +## Testing Scenarios + +Below are step-by-step commands for testing all modes. Replace `` with your actual JAR version (e.g., `3.5.12-SNAPSHOT`) and adjust the host/port for your environment. + +### 1. Quick Test — DISABLED Mode (No Truststore Needed) + +The fastest way to verify basic TCP + HTTP connectivity: + +```bash +java -jar zosmf-jwt-check/build/libs/zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 11443 \ + --verify-certificates DISABLED +``` + +**Expected output (success):** + +``` +WARNING: SSL certificate verification is DISABLED. All certificates will be trusted. +Checking z/OSMF JWK endpoint: https://myzosmf.example.com:11443/jwt/ibm/api/zOSMFBuilder/jwk +SUCCESS: z/OSMF JWK endpoint exists (returned 401 Unauthorized — expected without credentials). HTTP 401 +``` + +### 2. STRICT Mode — Full Certificate and Hostname Verification + +Requires a truststore containing the z/OSMF server's CA certificate (see [Creating a Truststore](#creating-a-truststore)): + +```bash +java -jar zosmf-jwt-check/build/libs/zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 11443 \ + --truststore-file /path/to/zosmf-truststore.p12 \ + --truststore-password password +``` + +**Expected output (success):** + +``` +Checking z/OSMF JWK endpoint: https://myzosmf.example.com:11443/jwt/ibm/api/zOSMFBuilder/jwk +SUCCESS: z/OSMF JWK endpoint exists (returned 401 Unauthorized — expected without credentials). HTTP 401 +``` + +**Expected output (SSL failure — wrong truststore):** + +``` +FAILURE: SSL handshake failed when connecting to https://myzosmf.example.com:11443/jwt/ibm/api/zOSMFBuilder/jwk. +Verify that the truststore contains the z/OSMF server certificate. +Details: PKIX path building failed: ...unable to find valid certification path to requested target +``` + +### 3. NONSTRICT Mode — Certificate Chain Verified, Hostname Check Skipped + +Useful when connecting via IP address but the certificate has a DNS name: + +```bash +java -jar zosmf-jwt-check/build/libs/zosmf-jwt-check-.jar \ + --zosmf-host 10.0.0.50 \ + --zosmf-port 11443 \ + --truststore-file /path/to/zosmf-truststore.p12 \ + --truststore-password password \ + --verify-certificates NONSTRICT +``` + +**Expected output (success):** + +``` +INFO: Hostname verification is disabled (NONSTRICT mode). +Checking z/OSMF JWK endpoint: https://10.0.0.50:11443/jwt/ibm/api/zOSMFBuilder/jwk +SUCCESS: z/OSMF JWK endpoint exists (returned 401 Unauthorized — expected without credentials). HTTP 401 +``` + +### 4. HTTP Mode (No SSL) + +For z/OSMF instances running on plain HTTP (uncommon): + +```bash +java -jar zosmf-jwt-check/build/libs/zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 80 \ + --scheme http +``` + +### 5. Validation Error Tests + +**Missing required flags:** + +```bash +# No arguments at all +java -jar zosmf-jwt-check-.jar +# Output: Missing required options: '--zosmf-host=', '--zosmf-port=' + +# Missing truststore in STRICT mode +java -jar zosmf-jwt-check-.jar --zosmf-host myhost --zosmf-port 443 +# Output: ERROR: --truststore-file is required when --scheme=https and verification is not DISABLED. + +# Missing truststore password +java -jar zosmf-jwt-check-.jar --zosmf-host myhost --zosmf-port 443 --truststore-file my.p12 +# Output: ERROR: --truststore-password is required when --scheme=https and verification is not DISABLED. +``` + +**Invalid values:** + +```bash +# Invalid scheme +java -jar zosmf-jwt-check-.jar --zosmf-host myhost --zosmf-port 443 --scheme ftp +# Output: ERROR: --scheme must be 'http' or 'https', got: ftp + +# Invalid verify mode +java -jar zosmf-jwt-check-.jar --zosmf-host myhost --zosmf-port 443 --verify-certificates INVALID +# Output: ERROR: --verify-certificates must be STRICT, NONSTRICT, or DISABLED, got: INVALID +``` + +**Unreachable host:** + +```bash +java -jar zosmf-jwt-check-.jar --zosmf-host nonexistent.host --zosmf-port 443 --verify-certificates DISABLED +# Output: FAILURE: Cannot connect to nonexistent.host:443. +``` + +## SAF Keyrings + +On z/OS, if you are using SAF keyrings instead of file-based keystores/truststores, provide the keyring path in the `safkeyring://` format and add the JVM protocol handler: + +```bash +java -Djava.protocol.handler.pkgs=com.ibm.crypto.provider \ + -jar zosmf-jwt-check-.jar \ + --zosmf-host myzosmf.example.com \ + --zosmf-port 11443 \ + --truststore-file safkeyring://IZUSVR/ZoweKeyring \ + --truststore-password password \ + --truststore-type JCERACFKS +``` diff --git a/zosmf-jwt-check/build.gradle b/zosmf-jwt-check/build.gradle new file mode 100644 index 0000000000..e95188ccdf --- /dev/null +++ b/zosmf-jwt-check/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java' +} + +dependencies { + implementation libs.picocli + annotationProcessor libs.picocli.codegen + + testImplementation libs.mockito.core + testImplementation libs.hamcrest +} + +compileJava { + options.compilerArgs += ["-Aproject=${project.group}/${project.name}"] +} + +jar { + manifest { + attributes( + 'Main-Class': 'org.zowe.apiml.ZosmfJwtCheck' + ) + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/HttpClientWrapper.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/HttpClientWrapper.java new file mode 100644 index 0000000000..8faca04157 --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/HttpClientWrapper.java @@ -0,0 +1,108 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * HTTP/HTTPS client wrapping {@link java.net.HttpURLConnection}. + * Supports custom {@link SSLContext} and {@link HostnameVerifier} for + * STRICT, NONSTRICT, and DISABLED certificate verification modes. + */ +@SuppressWarnings("squid:S106") +public class HttpClientWrapper { + + private static final int CONNECT_TIMEOUT = 5000; + private static final int READ_TIMEOUT = 5000; + + public static class Response { + private final int statusCode; + private final String body; + + public Response(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + public int getStatusCode() { return statusCode; } + public String getBody() { return body; } + } + + private final SSLContext sslContext; + private final boolean useHttps; + private final HostnameVerifier hostnameVerifier; + + public HttpClientWrapper(SSLContext sslContext, HostnameVerifier hostnameVerifier) { + this.sslContext = sslContext; + this.useHttps = true; + this.hostnameVerifier = hostnameVerifier; + } + + public HttpClientWrapper() { + this.sslContext = null; + this.useHttps = false; + this.hostnameVerifier = null; + } + + public Response executeCall(URL url, Map headers) throws IOException { + HttpURLConnection con; + if (useHttps) { + HttpsURLConnection httpsCon = (HttpsURLConnection) url.openConnection(); + httpsCon.setSSLSocketFactory(sslContext.getSocketFactory()); + if (hostnameVerifier != null) { + httpsCon.setHostnameVerifier(hostnameVerifier); + } + con = httpsCon; + } else { + con = (HttpURLConnection) url.openConnection(); + } + + con.setRequestMethod("GET"); + con.setConnectTimeout(CONNECT_TIMEOUT); + con.setReadTimeout(READ_TIMEOUT); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + con.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + + try { + int responseCode = con.getResponseCode(); + String body = readBody(con); + return new Response(responseCode, body); + } finally { + con.disconnect(); + } + } + + private String readBody(HttpURLConnection con) { + try { + InputStream is = con.getErrorStream() != null ? con.getErrorStream() : con.getInputStream(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (Exception e) { + return null; + } + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/JwkEndpointChecker.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/JwkEndpointChecker.java new file mode 100644 index 0000000000..06f5b0681a --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/JwkEndpointChecker.java @@ -0,0 +1,146 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import javax.net.ssl.SSLHandshakeException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Checks z/OSMF JWK endpoint availability at {@code /jwt/ibm/api/zOSMFBuilder/jwk}. + * Interprets the HTTP response code to determine if the endpoint is functional + */ +@SuppressWarnings("squid:S106") +public class JwkEndpointChecker { + + static final String JWK_ENDPOINT_PATH = "/jwt/ibm/api/zOSMFBuilder/jwk"; + private static final String ZOSMF_CSRF_HEADER = "X-CSRF-ZOSMF-HEADER"; + private static final Pattern N_VALUE_PATTERN = Pattern.compile("\"n\"\\s*:\\s*\"([^\"]*)\""); + + private final HttpClientWrapper httpClient; + private final ZosmfJwtCheckConfig conf; + + public JwkEndpointChecker(HttpClientWrapper httpClient, ZosmfJwtCheckConfig conf) { + this.httpClient = httpClient; + this.conf = conf; + } + + public boolean check() { + String urlString = conf.getScheme() + "://" + conf.getZosmfHost() + ":" + conf.getZosmfPort() + JWK_ENDPOINT_PATH; + + Map headers = new HashMap<>(); + headers.put(ZOSMF_CSRF_HEADER, ""); + + try { + URL url = new URL(urlString); + System.out.println("Checking z/OSMF JWK endpoint: " + urlString); + + HttpClientWrapper.Response response = httpClient.executeCall(url, headers); + if (conf.isVerbose() && response.getBody() != null) { + System.out.println("Response body:\n" + response.getBody()); + } + return evaluateResponseCode(response.getStatusCode(), response.getBody(), urlString); + } catch (SSLHandshakeException e) { + System.err.println("FAILURE: SSL handshake failed when connecting to " + urlString + "."); + System.err.println("Verify that the truststore contains the z/OSMF server certificate."); + System.err.println("Details: " + e.getMessage()); + return false; + } catch (ConnectException e) { + System.err.println("FAILURE: Cannot connect to " + conf.getZosmfHost() + ":" + conf.getZosmfPort() + "."); + System.err.println("Verify the host and port are correct and z/OSMF is running."); + System.err.println("Details: " + e.getMessage()); + return false; + } catch (SocketTimeoutException e) { + System.err.println("FAILURE: Connection timed out to " + conf.getZosmfHost() + ":" + conf.getZosmfPort() + "."); + System.err.println("This is commonly caused by an incorrect host/port or a firewall blocking the connection."); + System.err.println("Verify the z/OSMF host and port are correct and that no firewall is blocking access."); + return false; + } catch (UnknownHostException e) { + System.err.println("FAILURE: Error when calling " + urlString + " verify hostname and port."); + System.err.println("The host '" + conf.getZosmfHost() + "' could not be resolved."); + return false; + } catch (Exception e) { + System.err.println("FAILURE: Error when calling " + urlString + " verify hostname and port."); + System.err.println("Details: " + e.getMessage()); + return false; + } + } + + boolean evaluateResponseCode(int responseCode, String body, String urlString) { + if (responseCode >= 200 && responseCode < 300) { + if (!validateJwkBody(body)) { + return false; + } + System.out.println("SUCCESS: z/OSMF JWK endpoint is reachable and responding. HTTP " + responseCode); + return true; + } + + if (responseCode == 401) { + System.out.println("SUCCESS: z/OSMF JWK endpoint exists (returned 401 Unauthorized, expected without credentials). HTTP 401"); + return true; + } + + if (responseCode == 404) { + System.err.println("FAILURE: z/OSMF JWK endpoint not found. HTTP 404"); + System.err.println("JWT support not found, may not be configured. LTPA may be used as an alternative"); + return false; + } + + if (responseCode >= 400 && responseCode < 500) { + System.err.println("FAILURE: z/OSMF JWK endpoint returned unexpected client error. HTTP " + responseCode); + System.err.println("URL: " + urlString); + return false; + } + + if (responseCode >= 500) { + System.err.println("FAILURE: z/OSMF JWK endpoint returned server error. HTTP " + responseCode); + System.err.println("URL: " + urlString); + return false; + } + + System.err.println("FAILURE: z/OSMF JWK endpoint returned unexpected response code. HTTP " + responseCode); + System.err.println("URL: " + urlString); + return false; + } + + boolean validateJwkBody(String body) { + if (body == null || body.isEmpty()) { + System.err.println("WARNING: z/OSMF JWK endpoint returned an empty response body."); + System.err.println("Response body: " + (body == null ? "" : "")); + return false; + } + + Matcher matcher = N_VALUE_PATTERN.matcher(body); + if (!matcher.find()) { + System.err.println("WARNING: JWK response does not contain an RSA modulus (\"n\" key)."); + System.err.println("The z/OSMF JWK endpoint may not be properly configured."); + System.err.println("Response body: " + body); + return false; + } + + String nValue = matcher.group(1); + if (nValue == null || nValue.trim().isEmpty()) { + System.err.println("FAILURE: JWK response contains an empty RSA modulus (\"n\" key is empty)."); + System.err.println("The z/OSMF server returned a key that cannot be used for JWT verification."); + System.err.println("Check z/OSMF JWT configuration and ensure the signing key is properly generated."); + System.err.println("Response body: " + body); + return false; + } + + return true; + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/SSLContextFactory.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/SSLContextFactory.java new file mode 100644 index 0000000000..8e4f8cedaf --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/SSLContextFactory.java @@ -0,0 +1,104 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * Builds a TLSv1.2 {@link SSLContext} in two modes: + *
    + *
  • {@link #initSSLContext(Stores)} — normal mode using real truststore/keystore
  • + *
  • {@link #initTrustAllSSLContext()} — trust-all mode for DISABLED verification
  • + *
+ */ +@SuppressWarnings("squid:S106") +public class SSLContextFactory { + + private final Stores stores; + private SSLContext sslContext; + + private SSLContextFactory(Stores stores) { + this.stores = stores; + } + + public SSLContext getSslContext() { + return sslContext; + } + + /** + * Creates an SSLContext using the provided keystore and truststore. + * + * @param stores loaded keystore/truststore pair + * @return factory holding the initialized SSLContext + */ + public static SSLContextFactory initSSLContext(Stores stores) throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException, KeyManagementException, CertificateException, IOException { + SSLContextFactory factory = new SSLContextFactory(stores); + + TrustManagerFactory trustFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustFactory.init(stores.getTrustStore()); + + KeyManagerFactory keyFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + if (stores.getKeyStore() != null) { + keyFactory.init(stores.getKeyStore(), stores.getConf().getKeyStorePassword().toCharArray()); + } else { + KeyStore emptyKeystore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeystore.load(null, null); + keyFactory.init(emptyKeystore, null); + } + + factory.sslContext = SSLContext.getInstance("TLSv1.2"); + factory.sslContext.init(keyFactory.getKeyManagers(), trustFactory.getTrustManagers(), new SecureRandom()); + return factory; + } + + /** + * Creates an SSLContext that trusts all certificates. Use only when verification is DISABLED. + * + * @return factory holding the trust-all SSLContext + */ + public static SSLContextFactory initTrustAllSSLContext() throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, CertificateException, IOException, UnrecoverableKeyException { + SSLContextFactory factory = new SSLContextFactory(null); + + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // trust all + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // trust all + } + } + }; + + KeyStore emptyKeystore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeystore.load(null, null); + KeyManagerFactory keyFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyFactory.init(emptyKeystore, null); + + factory.sslContext = SSLContext.getInstance("TLSv1.2"); + factory.sslContext.init(keyFactory.getKeyManagers(), trustAllCerts, new SecureRandom()); + System.out.println("WARNING: SSL certificate verification is DISABLED. All certificates will be trusted."); + return factory; + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/Stores.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/Stores.java new file mode 100644 index 0000000000..ef7b527e67 --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/Stores.java @@ -0,0 +1,144 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Loads Java {@link java.security.KeyStore} instances from the filesystem + * or z/OS SAF keyrings. Supports PKCS12, JKS, and {@code safkeyring://} URIs. + */ +@SuppressWarnings("squid:S106") +public class Stores { + + private static final Pattern KEYRING_PATTERN = Pattern.compile("^(safkeyring[^:]*):/{2,4}([^/]+)/([^/]+)$"); + + private KeyStore keyStore; + private KeyStore trustStore; + private final ZosmfJwtCheckConfig conf; + + public Stores(ZosmfJwtCheckConfig conf) { + this.conf = conf; + init(); + } + + /** + * Checks whether the given path is a SAF keyring URI. + * + * @param input store path to check + * @return {@code true} if the path matches the keyring pattern + */ + public static boolean isKeyring(String input) { + if (input == null) return false; + Matcher matcher = KEYRING_PATTERN.matcher(input); + return matcher.matches(); + } + + /** + * Normalizes a keyring URI to the canonical {@code safkeyring://userId/keyRing} format. + * + * @param input raw keyring URI + * @return normalized URI, or the original input if not a keyring + */ + public static String formatKeyringUrl(String input) { + if (input == null) return null; + Matcher matcher = KEYRING_PATTERN.matcher(input); + if (matcher.matches()) { + return matcher.group(1) + "://" + matcher.group(2) + "/" + matcher.group(3); + } + return input; + } + + void init() { + try { + initKeystore(); + if (trustStore == null) { + initTruststore(); + } + } catch (FileNotFoundException e) { + throw new StoresNotInitializeException("Error while loading keystore file. Error message: " + e.getMessage() + "\n" + + "Possible solution: Verify correct path to the keystore. Change owner or permission to the keystore file."); + } catch (Exception e) { + throw new StoresNotInitializeException(e.getMessage()); + } + } + + private void initTruststore() throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException { + if (conf.getTrustStore() == null) { + System.out.println("No truststore specified, will use empty."); + try { + this.trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + this.trustStore.load(null, null); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + System.err.println(e.getMessage()); + } + return; + } + try (InputStream trustStoreIStream = new FileInputStream(conf.getTrustStore())) { + this.trustStore = readKeyStore(trustStoreIStream, conf.getTrustStorePassword().toCharArray(), conf.getTrustStoreType()); + } + } + + private void initKeystore() throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException { + if (conf.getKeyStore() == null) { + return; + } + if (isKeyring(conf.getKeyStore())) { + try (InputStream keyringIStream = keyRingUrl(conf.getKeyStore()).openStream()) { + this.keyStore = readKeyStore(keyringIStream, conf.getKeyStorePassword().toCharArray(), conf.getKeyStoreType()); + this.trustStore = this.keyStore; + } catch (Exception e) { + throw new StoresNotInitializeException(e.getMessage()); + } + } else { + try (InputStream keyStoreIStream = new FileInputStream(conf.getKeyStore())) { + this.keyStore = readKeyStore(keyStoreIStream, conf.getKeyStorePassword().toCharArray(), conf.getKeyStoreType()); + } + } + } + + public static KeyStore readKeyStore(InputStream is, char[] pass, String type) throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { + KeyStore keyStore = KeyStore.getInstance(type); + keyStore.load(is, pass); + return keyStore; + } + + public KeyStore getKeyStore() { + return keyStore; + } + + public KeyStore getTrustStore() { + return trustStore; + } + + public ZosmfJwtCheckConfig getConf() { + return conf; + } + + public static URL keyRingUrl(String uri) throws MalformedURLException { + if (!isKeyring(uri)) { + throw new StoresNotInitializeException("Incorrect key ring format: " + uri + + ". Make sure you use format safkeyring://userId/keyRing"); + } + return new URL(formatKeyringUrl(uri)); + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/StoresNotInitializeException.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/StoresNotInitializeException.java new file mode 100644 index 0000000000..c6b5231cf2 --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/StoresNotInitializeException.java @@ -0,0 +1,22 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +/** + * Thrown when keystore or truststore initialization fails + * (e.g. missing file, wrong password, invalid keyring format). + */ +public class StoresNotInitializeException extends RuntimeException { + + public StoresNotInitializeException(String message) { + super(message); + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheck.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheck.java new file mode 100644 index 0000000000..bb13cc57bc --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheck.java @@ -0,0 +1,104 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import picocli.CommandLine; + +import javax.net.ssl.HostnameVerifier; + +/** + * Entry point and orchestrator for the z/OSMF JWT check tool. + * Parses CLI arguments, validates configuration, builds the appropriate + * SSL context and HTTP client, then delegates to {@link JwkEndpointChecker}. + * + *

Exit codes: 0 = success, 4 = failure/error, 8 = help displayed.

+ */ +@SuppressWarnings("squid:S106") +public class ZosmfJwtCheck { + + static final String VERIFY_STRICT = "STRICT"; + static final String VERIFY_NONSTRICT = "NONSTRICT"; + static final String VERIFY_DISABLED = "DISABLED"; + + public static int mainWithExitCode(String[] args) { + try { + ZosmfJwtCheckConf conf = new ZosmfJwtCheckConf(); + CommandLine cmd = new CommandLine(conf); + cmd.parseArgs(args); + + if (conf.isHelpRequested()) { + cmd.printVersionHelp(System.out); + CommandLine.usage(new ZosmfJwtCheckConf(), System.out); + return 8; + } + + validateConfig(conf); + + HttpClientWrapper httpClient; + if ("https".equalsIgnoreCase(conf.getScheme())) { + String verifyMode = conf.getVerifyCertificates().toUpperCase(); + + if (VERIFY_DISABLED.equals(verifyMode)) { + SSLContextFactory sslContextFactory = SSLContextFactory.initTrustAllSSLContext(); + HostnameVerifier noopVerifier = (hostname, session) -> true; + httpClient = new HttpClientWrapper(sslContextFactory.getSslContext(), noopVerifier); + } else { + Stores stores = new Stores(conf); + SSLContextFactory sslContextFactory = SSLContextFactory.initSSLContext(stores); + + HostnameVerifier hostnameVerifier; + if (VERIFY_NONSTRICT.equals(verifyMode)) { + hostnameVerifier = (hostname, session) -> true; + System.out.println("INFO: Hostname verification is disabled (NONSTRICT mode)."); + } else { + hostnameVerifier = null; // use default JDK hostname verifier + } + httpClient = new HttpClientWrapper(sslContextFactory.getSslContext(), hostnameVerifier); + } + } else { + httpClient = new HttpClientWrapper(); + } + + JwkEndpointChecker checker = new JwkEndpointChecker(httpClient, conf); + boolean success = checker.check(); + return success ? 0 : 4; + } catch (Exception e) { + System.err.println("ERROR: " + e.getMessage()); + return 4; + } + } + + static void validateConfig(ZosmfJwtCheckConf conf) { + String scheme = conf.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("--scheme must be 'http' or 'https', got: " + scheme); + } + + String verifyMode = conf.getVerifyCertificates().toUpperCase(); + if (!VERIFY_STRICT.equals(verifyMode) && !VERIFY_NONSTRICT.equals(verifyMode) && !VERIFY_DISABLED.equals(verifyMode)) { + throw new IllegalArgumentException("--verify-certificates must be STRICT, NONSTRICT, or DISABLED, got: " + conf.getVerifyCertificates()); + } + + if ("https".equalsIgnoreCase(scheme) && !VERIFY_DISABLED.equals(verifyMode)) { + if (conf.getTrustStore() == null) { + throw new IllegalArgumentException("--truststore-file is required when --scheme=https and verification is not DISABLED. " + + "Provide the path to the truststore containing the z/OSMF server certificate."); + } + if (conf.getTrustStorePassword() == null) { + throw new IllegalArgumentException("--truststore-password is required when --scheme=https and verification is not DISABLED."); + } + } + } + + public static void main(String[] args) { + System.exit(mainWithExitCode(args)); + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConf.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConf.java new file mode 100644 index 0000000000..9bb9096ecc --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConf.java @@ -0,0 +1,127 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import picocli.CommandLine; +import picocli.CommandLine.Option; + +/** + * CLI argument parser backed by picocli. + * Maps command-line flags (e.g. {@code --zosmf-host}, {@code --verify-certificates}) + * to configuration properties exposed via {@link ZosmfJwtCheckConfig}. + */ +@CommandLine.Command( + name = "zosmf-jwt-check", + version = { + "z/OSMF JWT Check 1.0", + "JVM: ${java.version} (${java.vendor} ${java.vm.name} ${java.vm.version})", + "OS: ${os.name} ${os.version} ${os.arch}" + }, + description = "Checks connectivity to the z/OSMF JWK endpoint." +) +public class ZosmfJwtCheckConf implements ZosmfJwtCheckConfig { + + @Option(names = {"--zosmf-host"}, required = true, description = "Hostname or IP of the z/OSMF server") + private String zosmfHost; + + @Option(names = {"--zosmf-port"}, required = true, description = "Port of the z/OSMF server") + private int zosmfPort; + + @Option(names = {"--scheme"}, description = "http or https (default: ${DEFAULT-VALUE})") + private String scheme = "https"; + + @Option(names = {"--keystore-file"}, description = "Path to the keystore file (for HTTPS mutual TLS)") + private String keyStore; + + @Option(names = {"--keystore-password"}, arity = "0..1", interactive = true, description = "Password for the keystore") + private String keyStorePassword; + + @Option(names = {"--keystore-type"}, description = "Type of keystore (default: ${DEFAULT-VALUE})") + private String keyStoreType = "PKCS12"; + + @Option(names = {"--truststore-file"}, description = "Path to the truststore file (for HTTPS)") + private String trustStore; + + @Option(names = {"--truststore-password"}, arity = "0..1", interactive = true, description = "Password for the truststore") + private String trustStorePassword; + + @Option(names = {"--truststore-type"}, description = "Type of truststore (default: ${DEFAULT-VALUE})") + private String trustStoreType = "PKCS12"; + + @Option(names = {"--verify-certificates"}, description = "Certificate verification mode: STRICT, NONSTRICT, or DISABLED (default: ${DEFAULT-VALUE})") + private String verifyCertificates = "STRICT"; + + @Option(names = {"-v", "--verbose"}, description = "Print the response body from the endpoint") + private boolean verbose = false; + + @Option(names = {"-h", "--help"}, usageHelp = true, description = "Display a help message") + private boolean helpRequested = false; + + @Override + public String getZosmfHost() { + return zosmfHost; + } + + @Override + public int getZosmfPort() { + return zosmfPort; + } + + @Override + public String getScheme() { + return scheme; + } + + @Override + public String getKeyStore() { + return keyStore; + } + + @Override + public String getKeyStorePassword() { + return keyStorePassword; + } + + @Override + public String getKeyStoreType() { + return keyStoreType; + } + + @Override + public String getTrustStore() { + return trustStore; + } + + @Override + public String getTrustStorePassword() { + return trustStorePassword; + } + + @Override + public String getTrustStoreType() { + return trustStoreType; + } + + @Override + public String getVerifyCertificates() { + return verifyCertificates; + } + + @Override + public boolean isVerbose() { + return verbose; + } + + @Override + public boolean isHelpRequested() { + return helpRequested; + } +} diff --git a/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConfig.java b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConfig.java new file mode 100644 index 0000000000..f52f7832e1 --- /dev/null +++ b/zosmf-jwt-check/src/main/java/org/zowe/apiml/ZosmfJwtCheckConfig.java @@ -0,0 +1,43 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +/** + * Configuration contract for the z/OSMF JWT check tool. + * Exposes all user-supplied settings such as z/OSMF host, port, scheme, + * keystore/truststore paths, and certificate verification mode. + */ +public interface ZosmfJwtCheckConfig { + + String getZosmfHost(); + + int getZosmfPort(); + + String getScheme(); + + String getKeyStore(); + + String getKeyStorePassword(); + + String getKeyStoreType(); + + String getTrustStore(); + + String getTrustStorePassword(); + + String getTrustStoreType(); + + String getVerifyCertificates(); + + boolean isVerbose(); + + boolean isHelpRequested(); +} diff --git a/zosmf-jwt-check/src/test/java/org/zowe/apiml/JwkEndpointCheckerTest.java b/zosmf-jwt-check/src/test/java/org/zowe/apiml/JwkEndpointCheckerTest.java new file mode 100644 index 0000000000..7e30a847d8 --- /dev/null +++ b/zosmf-jwt-check/src/test/java/org/zowe/apiml/JwkEndpointCheckerTest.java @@ -0,0 +1,157 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLHandshakeException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.net.ConnectException; +import java.net.SocketTimeoutException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwkEndpointCheckerTest { + + private final ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + private final ByteArrayOutputStream errStream = new ByteArrayOutputStream(); + private final PrintStream originalOut = System.out; + private final PrintStream originalErr = System.err; + + private HttpClientWrapper mockClient; + private ZosmfJwtCheckConfig mockConf; + + @BeforeEach + void setUp() { + System.setOut(new PrintStream(outStream)); + System.setErr(new PrintStream(errStream)); + + mockClient = mock(HttpClientWrapper.class); + mockConf = mock(ZosmfJwtCheckConfig.class); + when(mockConf.getScheme()).thenReturn("https"); + when(mockConf.getZosmfHost()).thenReturn("zosmf.example.com"); + when(mockConf.getZosmfPort()).thenReturn(443); + when(mockConf.isVerbose()).thenReturn(false); + } + + @AfterEach + void restoreStreams() { + System.setOut(originalOut); + System.setErr(originalErr); + } + + @Nested + class SuccessResponses { + + @Test + void response200IsSuccess() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(200, "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"validModulusValue\"}]}")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertTrue(checker.check()); + assertTrue(outStream.toString().contains("SUCCESS")); + assertTrue(outStream.toString().contains("200")); + } + + @Test + void response401IsSuccess() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(401, "")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertTrue(checker.check()); + assertTrue(outStream.toString().contains("SUCCESS")); + assertTrue(outStream.toString().contains("401")); + } + + @Test + void response200WithEmptyModulusIsFailure() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(200, "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"\"}]}")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("empty RSA modulus")); + } + } + + @Nested + class FailureResponses { + + @Test + void response404IsFailure() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(404, "")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("FAILURE")); + assertTrue(errStream.toString().contains("404")); + } + + @Test + void response500IsFailure() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(500, "")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("FAILURE")); + assertTrue(errStream.toString().contains("server error")); + } + + @Test + void response403IsFailure() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenReturn(new HttpClientWrapper.Response(403, "")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("FAILURE")); + assertTrue(errStream.toString().contains("client error")); + } + } + + @Nested + class ExceptionHandling { + + @Test + void sslHandshakeExceptionReportsCertificateError() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenThrow(new SSLHandshakeException("certificate unknown")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("SSL handshake failed")); + assertTrue(errStream.toString().contains("truststore")); + } + + @Test + void connectExceptionReportsUnreachable() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenThrow(new ConnectException("Connection refused")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("Cannot connect")); + } + + @Test + void socketTimeoutExceptionReportsTimeout() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenThrow(new SocketTimeoutException("Read timed out")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("timed out")); + } + + @Test + void unexpectedExceptionIsHandled() throws IOException { + when(mockClient.executeCall(any(), anyMap())).thenThrow(new IOException("unexpected")); + JwkEndpointChecker checker = new JwkEndpointChecker(mockClient, mockConf); + assertFalse(checker.check()); + assertTrue(errStream.toString().contains("verify hostname and port")); + } + } +} diff --git a/zosmf-jwt-check/src/test/java/org/zowe/apiml/ZosmfJwtCheckTest.java b/zosmf-jwt-check/src/test/java/org/zowe/apiml/ZosmfJwtCheckTest.java new file mode 100644 index 0000000000..9da86cbcea --- /dev/null +++ b/zosmf-jwt-check/src/test/java/org/zowe/apiml/ZosmfJwtCheckTest.java @@ -0,0 +1,118 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ZosmfJwtCheckTest { + + private final ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + private final ByteArrayOutputStream errStream = new ByteArrayOutputStream(); + private final PrintStream originalOut = System.out; + private final PrintStream originalErr = System.err; + + @BeforeEach + void setupStreams() { + System.setOut(new PrintStream(outStream)); + System.setErr(new PrintStream(errStream)); + } + + @AfterEach + void restoreStreams() { + System.setOut(originalOut); + System.setErr(originalErr); + } + + @Test + void helpFlagReturnsExitCode8() { + String[] args = {"--help"}; + assertEquals(8, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(outStream.toString().contains("z/OSMF JWT Check")); + } + + @Test + void missingRequiredArgsReturnsExitCode4() { + String[] args = {}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + } + + @Nested + class ValidationTests { + + @Test + void invalidSchemeIsRejected() { + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "443", "--scheme", "ftp"}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(errStream.toString().contains("--scheme must be 'http' or 'https'")); + } + + @Test + void invalidVerifyCertificatesIsRejected() { + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "443", "--scheme", "https", + "--truststore-file", "some/path.p12", "--truststore-password", "pass", + "--verify-certificates", "INVALID"}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(errStream.toString().contains("--verify-certificates must be STRICT, NONSTRICT, or DISABLED")); + } + + @Test + void httpsStrictWithoutTruststoreIsRejected() { + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "443", "--scheme", "https"}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(errStream.toString().contains("--truststore-file is required")); + } + + @Test + void httpsNonstrictWithoutTruststoreIsRejected() { + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "443", "--scheme", "https", + "--verify-certificates", "NONSTRICT"}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(errStream.toString().contains("--truststore-file is required")); + } + + @Test + void httpsWithoutTruststorePasswordIsRejected() { + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "443", "--scheme", "https", + "--truststore-file", "some/path.p12"}; + assertEquals(4, ZosmfJwtCheck.mainWithExitCode(args)); + assertTrue(errStream.toString().contains("--truststore-password is required")); + } + + @Test + void httpsDisabledDoesNotRequireTruststore() { + // DISABLED mode skips certificate verification entirely — no truststore needed + // Will fail to connect to a non-existent server, but should pass validation + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "19999", "--scheme", "https", + "--verify-certificates", "DISABLED"}; + int exitCode = ZosmfJwtCheck.mainWithExitCode(args); + assertEquals(4, exitCode); + assertFalse(errStream.toString().contains("--truststore-file is required")); + } + + @Test + void httpDoesNotRequireTruststore() { + // This will fail to connect but should not fail validation + String[] args = {"--zosmf-host", "localhost", "--zosmf-port", "19999", "--scheme", "http"}; + int exitCode = ZosmfJwtCheck.mainWithExitCode(args); + // Should be 4 (connection failure) not a validation error + assertEquals(4, exitCode); + assertFalse(errStream.toString().contains("--truststore-file is required")); + } + } +}