diff --git a/.gitignore b/.gitignore
index 3093712e..3dae3a42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,4 +43,6 @@ nb-configuration.xml
streamx.log
streamx.*
-streamx-*.log
\ No newline at end of file
+streamx-*.log
+# Spec unpacked from the aggregated-openapi artifact at build time (not committed).
+src/main/openapi/
diff --git a/pom.xml b/pom.xml
index 8a528fc4..d4a7b3ed 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,6 +23,7 @@
3.6.0
25.0.2
2.0.37
+ 2.1.0-SNAPSHOT
4.0.1
false
@@ -52,6 +53,17 @@
quarkus-picocli
+
+ io.quarkiverse.openapi.generator
+ quarkus-openapi-generator
+ 2.11.0
+
+
+
+ io.quarkus
+ quarkus-rest-client-jackson
+
+
io.quarkus
quarkus-arc
@@ -311,6 +323,35 @@
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ unpack-platform-openapi-spec
+ initialize
+
+ unpack
+
+
+
+
+ com.streamx.platform
+ streamx-platform-aggregated-openapi
+ ${aggregated-openapi.version}
+ jar
+ META-INF/openapi/openapi.yaml
+ ${project.basedir}/src/main/openapi
+
+
+
+
+
+ true
+
+
+
+
io.quarkus.platform
quarkus-maven-plugin
diff --git a/src/main/java/com/streamx/cli/auth/AccessTokenClaims.java b/src/main/java/com/streamx/cli/auth/AccessTokenClaims.java
new file mode 100644
index 00000000..ca64f9be
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/AccessTokenClaims.java
@@ -0,0 +1,29 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.streamx.cli.framework.CliException;
+import java.io.IOException;
+import java.util.Base64;
+
+public final class AccessTokenClaims {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AccessTokenClaims() {
+ }
+
+ public static JsonNode of(String accessToken) {
+ String[] parts = accessToken.split("\\.");
+ if (parts.length < 2) {
+ throw new CliException(msg.authTokenMalformed());
+ }
+ try {
+ return MAPPER.readTree(Base64.getUrlDecoder().decode(parts[1]));
+ } catch (IOException | IllegalArgumentException e) {
+ throw new CliException(msg.authTokenMalformed(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/AuthConfig.java b/src/main/java/com/streamx/cli/auth/AuthConfig.java
new file mode 100644
index 00000000..e7bab833
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/AuthConfig.java
@@ -0,0 +1,52 @@
+package com.streamx.cli.auth;
+
+import com.streamx.cli.config.StreamxHome;
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.PropertiesConfigSource;
+import io.smallrye.config.SmallRyeConfigBuilder;
+import io.smallrye.config.WithDefault;
+import io.smallrye.config.WithName;
+import java.io.IOException;
+import java.util.Optional;
+import org.apache.commons.lang3.BooleanUtils;
+
+@ConfigMapping
+public interface AuthConfig {
+ String DEFAULT_REALM = "streamx";
+ String DEFAULT_CLIENT_ID = "streamx-cli";
+
+ String STREAMX_AUTH_SERVER_URL = "streamx.auth.server-url";
+ String STREAMX_AUTH_REALM = "streamx.auth.realm";
+ String STREAMX_AUTH_CLIENT_ID = "streamx.auth.client-id";
+ String STREAMX_AUTH_INSECURE = "streamx.auth.insecure";
+
+ @WithName(STREAMX_AUTH_SERVER_URL)
+ Optional serverUrl();
+
+ @WithName(STREAMX_AUTH_REALM)
+ @WithDefault(DEFAULT_REALM)
+ String realm();
+
+ @WithName(STREAMX_AUTH_CLIENT_ID)
+ @WithDefault(DEFAULT_CLIENT_ID)
+ String clientId();
+
+ @WithName(STREAMX_AUTH_INSECURE)
+ @WithDefault(BooleanUtils.FALSE)
+ boolean insecure();
+
+ static AuthConfig load() {
+ SmallRyeConfigBuilder builder = new SmallRyeConfigBuilder()
+ .withMapping(AuthConfig.class)
+ .addDefaultSources();
+
+ try {
+ builder.withSources(new PropertiesConfigSource(StreamxHome.getConfigUrl(), 260));
+ } catch (IOException expected) {
+ }
+
+ return builder
+ .build()
+ .getConfigMapping(AuthConfig.class);
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/Credentials.java b/src/main/java/com/streamx/cli/auth/Credentials.java
new file mode 100644
index 00000000..2bda0716
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/Credentials.java
@@ -0,0 +1,13 @@
+package com.streamx.cli.auth;
+
+import java.time.Instant;
+
+public record Credentials(
+ String accessToken,
+ String refreshToken,
+ Instant expiresAt,
+ String issuerUrl,
+ String clientId,
+ boolean insecure
+) {
+}
diff --git a/src/main/java/com/streamx/cli/auth/CredentialsStore.java b/src/main/java/com/streamx/cli/auth/CredentialsStore.java
new file mode 100644
index 00000000..45c480e6
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/CredentialsStore.java
@@ -0,0 +1,119 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.streamx.cli.config.StreamxHome;
+import com.streamx.cli.framework.CliException;
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.Set;
+
+public class CredentialsStore {
+ private static final String CREDENTIALS_FILE = "credentials.json";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String ACCESS_TOKEN = "access_token";
+ private static final String REFRESH_TOKEN = "refresh_token";
+ private static final String EXPIRES_AT = "expires_at";
+ private static final String ISSUER_URL = "issuer_url";
+ private static final String CLIENT_ID = "client_id";
+ private static final String INSECURE = "insecure";
+
+ public static Path getCredentialsPath() {
+ return StreamxHome.getConfigDir().resolve(CREDENTIALS_FILE);
+ }
+
+ public static boolean exists() {
+ return Files.isRegularFile(getCredentialsPath());
+ }
+
+ public static Optional load() {
+ Path path = getCredentialsPath();
+ if (!Files.isRegularFile(path)) {
+ return Optional.empty();
+ }
+
+ try {
+ JsonNode node = MAPPER.readTree(Files.readString(path));
+ return Optional.of(new Credentials(
+ node.path(ACCESS_TOKEN).asText(null),
+ node.path(REFRESH_TOKEN).asText(null),
+ Instant.ofEpochSecond(node.path(EXPIRES_AT).asLong()),
+ node.path(ISSUER_URL).asText(null),
+ node.path(CLIENT_ID).asText(null),
+ node.path(INSECURE).asBoolean(false)
+ ));
+ } catch (IOException e) {
+ throw new CliException(msg.authCredentialsUnreadable(path.toString(), e.getMessage()), e);
+ }
+ }
+
+ public static void save(Credentials credentials) {
+ Path path = getCredentialsPath();
+
+ ObjectNode node = MAPPER.createObjectNode();
+ node.put(ACCESS_TOKEN, credentials.accessToken());
+ node.put(REFRESH_TOKEN, credentials.refreshToken());
+ node.put(EXPIRES_AT, credentials.expiresAt().getEpochSecond());
+ node.put(ISSUER_URL, credentials.issuerUrl());
+ node.put(CLIENT_ID, credentials.clientId());
+ node.put(INSECURE, credentials.insecure());
+
+ Path temporary = path.resolveSibling(path.getFileName() + ".tmp");
+ try {
+ Files.createDirectories(path.getParent());
+ Files.deleteIfExists(temporary);
+ createOwnerOnlyFile(temporary);
+ Files.writeString(temporary, MAPPER.writeValueAsString(node));
+ moveIntoPlace(temporary, path);
+ } catch (IOException e) {
+ quietlyDelete(temporary);
+ throw new CliException(msg.authCredentialsNotSaved(path.toString(), e.getMessage()), e);
+ }
+ }
+
+ private static void moveIntoPlace(Path temporary, Path path) throws IOException {
+ try {
+ Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private static void quietlyDelete(Path path) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException expected) {
+ }
+ }
+
+ public static void delete() {
+ Path path = getCredentialsPath();
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ throw new CliException(msg.authCredentialsNotDeleted(path.toString(), e.getMessage()), e);
+ }
+ }
+
+ private static void createOwnerOnlyFile(Path path) throws IOException {
+ if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
+ Files.createFile(path, PosixFilePermissions.asFileAttribute(
+ Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)));
+ } else {
+ Files.createFile(path);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/Identity.java b/src/main/java/com/streamx/cli/auth/Identity.java
new file mode 100644
index 00000000..2a3a3b3b
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/Identity.java
@@ -0,0 +1,16 @@
+package com.streamx.cli.auth;
+
+import io.quarkus.runtime.annotations.RegisterForReflection;
+
+@RegisterForReflection
+public record Identity(
+ String username,
+ String name,
+ String email,
+ String subject,
+ String issuer,
+ String expiresAt,
+ boolean expired,
+ String tokenId
+) {
+}
diff --git a/src/main/java/com/streamx/cli/auth/InteractiveGrant.java b/src/main/java/com/streamx/cli/auth/InteractiveGrant.java
new file mode 100644
index 00000000..01d819b3
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/InteractiveGrant.java
@@ -0,0 +1,58 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.auth.OidcClient.Endpoints;
+import com.streamx.cli.auth.OidcDeviceFlow.DeviceAuthorization;
+import com.streamx.cli.framework.CliException;
+
+public final class InteractiveGrant {
+
+ private InteractiveGrant() {
+ }
+
+ public static OidcClient clientFromConfig(AuthConfig config) {
+ String serverUrl = config.serverUrl()
+ .filter(url -> !url.isBlank())
+ .orElseThrow(() -> new CliException(
+ msg.authServerUrlNotConfigured(AuthConfig.STREAMX_AUTH_SERVER_URL)));
+
+ return new OidcClient(
+ OidcClient.issuerUrl(serverUrl, config.realm()),
+ config.clientId(),
+ config.insecure());
+ }
+
+ public static boolean preferDeviceFlow(boolean noBrowser) {
+ return noBrowser
+ || System.getenv("SSH_CONNECTION") != null
+ || System.getenv("SSH_TTY") != null;
+ }
+
+ public static Credentials run(OidcClient client, Endpoints endpoints, String scope,
+ boolean noBrowser) {
+ if (preferDeviceFlow(noBrowser)) {
+ return deviceGrant(client, endpoints, scope);
+ }
+ try {
+ return new OidcAuthCodeFlow(client).login(endpoints, scope);
+ } catch (OidcAuthCodeFlow.BrowserUnavailableException e) {
+ System.err.println(msg.authBrowserFallbackToDevice());
+ return deviceGrant(client, endpoints, scope);
+ }
+ }
+
+ private static Credentials deviceGrant(OidcClient client, Endpoints endpoints, String scope) {
+ OidcDeviceFlow flow = new OidcDeviceFlow(client);
+ DeviceAuthorization authorization = flow.requestDeviceAuthorization(endpoints, scope);
+
+ System.err.println(msg.authLoginInstructions(
+ authorization.verificationUri(),
+ authorization.userCode()));
+ if (authorization.verificationUriComplete() != null) {
+ System.err.println(msg.authLoginDirectLink(authorization.verificationUriComplete()));
+ }
+
+ return flow.pollForToken(endpoints, authorization);
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/OidcAuthCodeFlow.java b/src/main/java/com/streamx/cli/auth/OidcAuthCodeFlow.java
new file mode 100644
index 00000000..4e672522
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/OidcAuthCodeFlow.java
@@ -0,0 +1,238 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.auth.OidcClient.Endpoints;
+import com.streamx.cli.auth.OidcClient.Response;
+import com.streamx.cli.framework.CliException;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public class OidcAuthCodeFlow {
+ private static final String LOOPBACK_HOST = "127.0.0.1";
+ private static final String CALLBACK_PATH = "/callback";
+ private static final Duration LOGIN_TIMEOUT = Duration.ofMinutes(5);
+
+ private final OidcClient client;
+ private final BrowserLauncher browserLauncher;
+
+ public OidcAuthCodeFlow(OidcClient client) {
+ this(client, OidcAuthCodeFlow::openInBrowser);
+ }
+
+ public OidcAuthCodeFlow(OidcClient client, BrowserLauncher browserLauncher) {
+ this.client = client;
+ this.browserLauncher = browserLauncher;
+ }
+
+ public interface BrowserLauncher {
+ void open(String url) throws IOException;
+ }
+
+ public Credentials login(Endpoints endpoints) {
+ return login(endpoints, Scopes.LOGIN);
+ }
+
+ public Credentials login(Endpoints endpoints, String scope) {
+ if (endpoints.authorizationEndpoint() == null) {
+ throw new CliException(msg.authCodeFlowUnsupported());
+ }
+
+ Pkce pkce = Pkce.generate();
+ String state = Pkce.randomUrlSafe(32);
+
+ LoopbackReceiver receiver = new LoopbackReceiver(state);
+ receiver.start();
+ try {
+ String authUrl = authorizationUrl(endpoints, receiver.redirectUri(), pkce, state, scope);
+ try {
+ browserLauncher.open(authUrl);
+ } catch (IOException e) {
+ throw new BrowserUnavailableException();
+ }
+
+ System.err.println(msg.authLoginOpeningBrowser());
+ System.err.println(" " + authUrl);
+
+ String code = receiver.awaitCode();
+ return exchangeCode(endpoints, code, pkce.verifier(), receiver.redirectUri());
+ } finally {
+ receiver.stop();
+ }
+ }
+
+ public static class BrowserUnavailableException extends RuntimeException {
+ }
+
+ private String authorizationUrl(
+ Endpoints endpoints, String redirectUri, Pkce pkce, String state, String scope) {
+ Map params = Map.of(
+ "client_id", client.clientId(),
+ "response_type", "code",
+ "scope", scope,
+ "redirect_uri", redirectUri,
+ "state", state,
+ "code_challenge", pkce.challenge(),
+ "code_challenge_method", Pkce.METHOD
+ );
+ StringBuilder query = new StringBuilder();
+ for (Map.Entry entry : params.entrySet()) {
+ if (!query.isEmpty()) {
+ query.append('&');
+ }
+ query.append(entry.getKey()).append('=')
+ .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
+ }
+ return endpoints.authorizationEndpoint() + "?" + query;
+ }
+
+ private Credentials exchangeCode(
+ Endpoints endpoints, String code, String codeVerifier, String redirectUri) {
+ String url = endpoints.tokenEndpoint();
+ Response response = client.exchange(OidcClient.postForm(url, Map.of(
+ "grant_type", "authorization_code",
+ "code", code,
+ "client_id", client.clientId(),
+ "redirect_uri", redirectUri,
+ "code_verifier", codeVerifier
+ )), url);
+
+ if (!response.isSuccess()) {
+ String error = response.body().path("error").asText(null);
+ throw new CliException(
+ error != null ? msg.authLoginFailed(error)
+ : msg.authRequestFailedWithStatus(url, response.statusCode()));
+ }
+ return client.toCredentials(response.body(), null);
+ }
+
+ private static void openInBrowser(String url) throws IOException {
+ String os = System.getProperty("os.name", "").toLowerCase();
+ ProcessBuilder builder;
+ if (os.contains("mac")) {
+ builder = new ProcessBuilder("open", url);
+ } else if (os.contains("win")) {
+ builder = new ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", url);
+ } else {
+ builder = new ProcessBuilder("xdg-open", url);
+ }
+ builder.redirectOutput(ProcessBuilder.Redirect.DISCARD)
+ .redirectError(ProcessBuilder.Redirect.DISCARD)
+ .start();
+ }
+
+ private static final class LoopbackReceiver {
+ private final String expectedState;
+ private final BlockingQueue result = new ArrayBlockingQueue<>(1);
+ private HttpServer server;
+
+ private LoopbackReceiver(String expectedState) {
+ this.expectedState = expectedState;
+ }
+
+ private record Result(String code, String error) {
+ }
+
+ private void start() {
+ try {
+ server = HttpServer.create(new InetSocketAddress(LOOPBACK_HOST, 0), 0);
+ } catch (IOException e) {
+ throw new CliException(msg.authLoopbackFailed(e.getMessage()), e);
+ }
+ server.createContext(CALLBACK_PATH, this::handle);
+ server.start();
+ }
+
+ private String redirectUri() {
+ return "http://" + LOOPBACK_HOST + ":" + server.getAddress().getPort() + CALLBACK_PATH;
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ Map query = parseQuery(exchange.getRequestURI());
+ String state = query.get("state");
+
+ // Validate state before anything else (RFC 6749 ยง4.1.2.1), on both success and error.
+ if (!expectedState.equals(state)) {
+ writeHtml(exchange, msg.authLoopbackDenied());
+ return;
+ }
+
+ String error = query.get("error");
+ String code = query.get("code");
+ String body;
+ if (error != null) {
+ body = msg.authLoopbackDenied();
+ result.offer(new Result(null, error));
+ } else if (code != null) {
+ body = msg.authLoopbackSuccess();
+ result.offer(new Result(code, null));
+ } else {
+ body = msg.authLoopbackDenied();
+ result.offer(new Result(null, "invalid_request"));
+ }
+ writeHtml(exchange, body);
+ }
+
+ private static void writeHtml(HttpExchange exchange, String body) throws IOException {
+ byte[] bytes = ("" + body + "").getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private String awaitCode() {
+ Result received;
+ try {
+ received = result.poll(LOGIN_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new CliException(msg.authLoginInterrupted(), e);
+ }
+ if (received == null) {
+ throw new CliException(msg.authLoginExpired());
+ }
+ if (received.error() != null) {
+ throw new CliException("access_denied".equals(received.error())
+ ? msg.authLoginDenied()
+ : msg.authLoginFailed(received.error()));
+ }
+ return received.code();
+ }
+
+ private void stop() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ private static Map parseQuery(URI uri) {
+ Map params = new java.util.HashMap<>();
+ String query = uri.getRawQuery();
+ if (query == null) {
+ return params;
+ }
+ for (String pair : query.split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq > 0) {
+ params.put(
+ java.net.URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
+ java.net.URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
+ }
+ }
+ return params;
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/OidcClient.java b/src/main/java/com/streamx/cli/auth/OidcClient.java
new file mode 100644
index 00000000..9e13ce92
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/OidcClient.java
@@ -0,0 +1,245 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.streamx.cli.framework.CliException;
+import com.streamx.cli.framework.Urls;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import javax.net.ssl.SSLContext;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.conn.ssl.TrustAllStrategy;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.ssl.SSLContexts;
+import org.apache.http.util.EntityUtils;
+
+public class OidcClient {
+ private static final String DISCOVERY_PATH = "/.well-known/openid-configuration";
+ static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final String issuerUrl;
+ private final String clientId;
+ private final boolean insecure;
+ private final CloseableHttpClient httpClient;
+
+ public OidcClient(String issuerUrl, String clientId, boolean insecure) {
+ if (Urls.isCleartextRemote(issuerUrl)) {
+ throw new CliException(msg.authCleartextHttpBlocked(issuerUrl));
+ }
+ this.issuerUrl = issuerUrl;
+ this.clientId = clientId;
+ this.insecure = insecure;
+ this.httpClient = buildHttpClient(insecure);
+ }
+
+ public static String issuerUrl(String serverUrl, String realm) {
+ String base = serverUrl.endsWith("/")
+ ? serverUrl.substring(0, serverUrl.length() - 1)
+ : serverUrl;
+ return base + "/realms/" + realm;
+ }
+
+ public String clientId() {
+ return clientId;
+ }
+
+ public record Endpoints(
+ String authorizationEndpoint,
+ String deviceAuthorizationEndpoint,
+ String tokenEndpoint,
+ String revocationEndpoint
+ ) {
+ }
+
+ public Endpoints discover() {
+ String url = issuerUrl + DISCOVERY_PATH;
+ JsonNode node = send(get(url), url, true);
+
+ String documentIssuer = node.path("issuer").asText(null);
+ if (!issuerUrl.equals(documentIssuer)) {
+ throw new CliException(msg.authIssuerMismatch(issuerUrl, String.valueOf(documentIssuer)));
+ }
+ String authorization = node.path("authorization_endpoint").asText(null);
+ String device = node.path("device_authorization_endpoint").asText(null);
+ String token = node.path("token_endpoint").asText(null);
+ String revocation = node.path("revocation_endpoint").asText(null);
+
+ for (String endpoint : new String[] {authorization, device, token, revocation}) {
+ if (endpoint != null && Urls.isCleartextRemote(endpoint)) {
+ throw new CliException(msg.authCleartextHttpBlocked(endpoint));
+ }
+ }
+ return new Endpoints(
+ authorization,
+ device,
+ token,
+ revocation
+ );
+ }
+
+ public Credentials refresh(String refreshToken) {
+ String url = discover().tokenEndpoint();
+
+ Response response = exchange(postForm(url, Map.of(
+ "grant_type", "refresh_token",
+ "refresh_token", refreshToken,
+ "client_id", clientId
+ )), url);
+
+ if (!response.isSuccess()) {
+ String error = response.body().path("error").asText(null);
+ // Only invalid_grant means the refresh token is dead; other errors keep the session.
+ if ("invalid_grant".equals(error)) {
+ throw new CliException(msg.authSessionExpired());
+ }
+ if (error != null) {
+ String detail = response.body().path("error_description").asText(error);
+ throw new CliException(msg.authTokenRequestRejected(response.statusCode(), detail));
+ }
+ throw new CliException(msg.authRequestFailedWithStatus(url, response.statusCode()));
+ }
+ return toCredentials(response.body(), refreshToken);
+ }
+
+ public void revoke(String refreshToken) {
+ Endpoints endpoints = discover();
+ String url = endpoints.revocationEndpoint();
+ if (url == null) {
+ throw new CliException(msg.authRevocationUnsupported());
+ }
+ send(postForm(url, Map.of(
+ "client_id", clientId,
+ "token", refreshToken,
+ "token_type_hint", "refresh_token"
+ )), url, true);
+ }
+
+ public void revokeQuietly(String refreshToken) {
+ try {
+ Endpoints endpoints = discover();
+ if (endpoints.revocationEndpoint() == null) {
+ return;
+ }
+ send(postForm(endpoints.revocationEndpoint(), Map.of(
+ "client_id", clientId,
+ "token", refreshToken,
+ "token_type_hint", "refresh_token"
+ )), endpoints.revocationEndpoint(), false);
+ } catch (RuntimeException expected) {
+ }
+ }
+
+ Credentials toCredentials(JsonNode node, String fallbackRefreshToken) {
+ String accessToken = node.path("access_token").asText(null);
+ if (accessToken == null || accessToken.isBlank()) {
+ throw new CliException(msg.authTokenResponseIncomplete());
+ }
+ return new Credentials(
+ accessToken,
+ node.path("refresh_token").asText(fallbackRefreshToken),
+ Instant.now().plusSeconds(node.path("expires_in").asLong(0)),
+ issuerUrl,
+ clientId,
+ insecure
+ );
+ }
+
+ static HttpGet get(String url) {
+ HttpGet request = new HttpGet(url);
+ request.setHeader("Accept", "application/json");
+ return request;
+ }
+
+ static HttpPost postForm(String url, Map form) {
+ HttpPost request = new HttpPost(url);
+ request.setHeader("Accept", "application/json");
+
+ List params = new ArrayList<>();
+ for (Map.Entry entry : form.entrySet()) {
+ params.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
+ }
+ request.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
+ return request;
+ }
+
+ record Response(int statusCode, JsonNode body) {
+ boolean isSuccess() {
+ return statusCode >= 200 && statusCode < 300;
+ }
+ }
+
+ JsonNode send(HttpUriRequest request, String url, boolean failOnErrorStatus) {
+ Response response = exchange(request, url);
+ if (failOnErrorStatus && !response.isSuccess()) {
+ throw new CliException(msg.authRequestFailedWithStatus(url, response.statusCode()));
+ }
+ return response.body();
+ }
+
+ Response exchange(HttpUriRequest request, String url) {
+ String body;
+ int statusCode;
+ try (CloseableHttpResponse response = httpClient.execute(request)) {
+ statusCode = response.getStatusLine().getStatusCode();
+ body = response.getEntity() == null
+ ? ""
+ : EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new CliException(msg.authRequestFailed(url, e.getMessage()), e);
+ }
+
+ try {
+ JsonNode parsed = body.isBlank() ? MAPPER.createObjectNode() : MAPPER.readTree(body);
+ return new Response(statusCode, parsed);
+ } catch (IOException e) {
+ throw new CliException(msg.authResponseNotJson(url), e);
+ }
+ }
+
+ private static CloseableHttpClient buildHttpClient(boolean insecure) {
+ int timeoutMillis = (int) REQUEST_TIMEOUT.toMillis();
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectTimeout(timeoutMillis)
+ .setConnectionRequestTimeout(timeoutMillis)
+ .setSocketTimeout(timeoutMillis)
+ .build();
+
+ if (!insecure) {
+ return HttpClients.custom()
+ .setDefaultRequestConfig(requestConfig)
+ .build();
+ }
+
+ try {
+ SSLContext sslContext = SSLContexts.custom()
+ .loadTrustMaterial(null, TrustAllStrategy.INSTANCE)
+ .build();
+ return HttpClients.custom()
+ .setDefaultRequestConfig(requestConfig)
+ .setSSLContext(sslContext)
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ .build();
+ } catch (GeneralSecurityException e) {
+ throw new CliException(msg.authInsecureTlsFailed(e.getMessage()), e);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/OidcDeviceFlow.java b/src/main/java/com/streamx/cli/auth/OidcDeviceFlow.java
new file mode 100644
index 00000000..db2a8754
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/OidcDeviceFlow.java
@@ -0,0 +1,113 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.streamx.cli.auth.OidcClient.Endpoints;
+import com.streamx.cli.auth.OidcClient.Response;
+import com.streamx.cli.framework.CliException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+
+public class OidcDeviceFlow {
+ private static final String DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
+ private static final int DEFAULT_POLL_INTERVAL_SECONDS = 5;
+
+ private static final int SLOW_DOWN_INCREMENT_SECONDS = 5;
+
+ private final OidcClient client;
+
+ public OidcDeviceFlow(OidcClient client) {
+ this.client = client;
+ }
+
+ public record DeviceAuthorization(
+ String deviceCode,
+ String userCode,
+ String verificationUri,
+ String verificationUriComplete,
+ int intervalSeconds,
+ Instant expiresAt,
+ Pkce pkce
+ ) {
+ }
+
+ public DeviceAuthorization requestDeviceAuthorization(Endpoints endpoints) {
+ return requestDeviceAuthorization(endpoints, Scopes.LOGIN);
+ }
+
+ public DeviceAuthorization requestDeviceAuthorization(Endpoints endpoints, String scope) {
+ String url = endpoints.deviceAuthorizationEndpoint();
+ if (url == null) {
+ throw new CliException(msg.authDeviceFlowUnsupported(url));
+ }
+ Pkce pkce = Pkce.generate();
+ Map form = Map.of(
+ "client_id", client.clientId(),
+ "scope", scope,
+ "code_challenge", pkce.challenge(),
+ "code_challenge_method", Pkce.METHOD);
+ JsonNode node = client.send(OidcClient.postForm(url, form), url, true);
+
+ int interval = node.path("interval").asInt(DEFAULT_POLL_INTERVAL_SECONDS);
+ long expiresIn = node.path("expires_in").asLong(0);
+
+ return new DeviceAuthorization(
+ node.path("device_code").asText(null),
+ node.path("user_code").asText(null),
+ node.path("verification_uri").asText(null),
+ node.path("verification_uri_complete").asText(null),
+ interval > 0 ? interval : DEFAULT_POLL_INTERVAL_SECONDS,
+ Instant.now().plusSeconds(expiresIn),
+ pkce
+ );
+ }
+
+ public Credentials pollForToken(Endpoints endpoints, DeviceAuthorization authorization) {
+ String url = endpoints.tokenEndpoint();
+ Map form = Map.of(
+ "grant_type", DEVICE_CODE_GRANT,
+ "device_code", authorization.deviceCode(),
+ "client_id", client.clientId(),
+ "code_verifier", authorization.pkce().verifier()
+ );
+
+ int intervalSeconds = authorization.intervalSeconds();
+
+ while (Instant.now().isBefore(authorization.expiresAt())) {
+ sleepSeconds(intervalSeconds);
+
+ Response response = client.exchange(OidcClient.postForm(url, form), url);
+ String error = response.body().path("error").asText(null);
+
+ if (response.isSuccess()) {
+ return client.toCredentials(response.body(), null);
+ }
+ if (error == null) {
+ throw new CliException(msg.authRequestFailedWithStatus(url, response.statusCode()));
+ }
+
+ if ("slow_down".equals(error)) {
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
+ } else if ("access_denied".equals(error)) {
+ throw new CliException(msg.authLoginDenied());
+ } else if ("expired_token".equals(error)) {
+ throw new CliException(msg.authLoginExpired());
+ } else if (!"authorization_pending".equals(error)) {
+ throw new CliException(msg.authLoginFailed(error));
+ }
+ }
+
+ throw new CliException(msg.authLoginExpired());
+ }
+
+ private void sleepSeconds(int seconds) {
+ try {
+ Thread.sleep(Duration.ofSeconds(seconds).toMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new CliException(msg.authLoginInterrupted(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/Pkce.java b/src/main/java/com/streamx/cli/auth/Pkce.java
new file mode 100644
index 00000000..ebc17aa5
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/Pkce.java
@@ -0,0 +1,37 @@
+package com.streamx.cli.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.framework.CliException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+public record Pkce(String verifier, String challenge) {
+
+ public static final String METHOD = "S256";
+
+ public static Pkce generate() {
+ String verifier = randomUrlSafe(64);
+ return new Pkce(verifier, challenge(verifier));
+ }
+
+ public static String randomUrlSafe(int bytes) {
+ byte[] buffer = new byte[bytes];
+ // Per-call, never a static field: GraalVM rejects a SecureRandom in the native image heap.
+ new SecureRandom().nextBytes(buffer);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer);
+ }
+
+ private static String challenge(String verifier) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(verifier.getBytes(StandardCharsets.US_ASCII));
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new CliException(msg.authPkceFailed(e.getMessage()), e);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/auth/Scopes.java b/src/main/java/com/streamx/cli/auth/Scopes.java
new file mode 100644
index 00000000..f31acf9e
--- /dev/null
+++ b/src/main/java/com/streamx/cli/auth/Scopes.java
@@ -0,0 +1,9 @@
+package com.streamx.cli.auth;
+
+public final class Scopes {
+
+ public static final String LOGIN = "openid profile email";
+
+ private Scopes() {
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java
index 808126c1..49c89dbf 100644
--- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java
+++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java
@@ -1,5 +1,6 @@
package com.streamx.cli.commands;
+import com.streamx.cli.commands.auth.AuthCommand;
import com.streamx.cli.commands.completion.CompleteContextNamesCommand;
import com.streamx.cli.commands.completion.CompleteNonDefaultTemplateIdsCommand;
import com.streamx.cli.commands.completion.CompleteRegisteredTemplateIdsCommand;
@@ -8,6 +9,7 @@
import com.streamx.cli.commands.completion.CompleteTemplateIdsCommand;
import com.streamx.cli.commands.completion.CompletionCommand;
import com.streamx.cli.commands.context.ContextCommand;
+import com.streamx.cli.commands.info.InfoCommand;
import com.streamx.cli.commands.local.LocalCommand;
import com.streamx.cli.commands.publish.PublishCommand;
import com.streamx.cli.commands.settings.SettingsCommand;
@@ -18,10 +20,12 @@
name = "streamx",
header = "StreamX CLI. More info at https://streamx.com",
subcommands = {
+ AuthCommand.class,
ContextCommand.class,
LocalCommand.class,
SettingsCommand.class,
PublishCommand.class,
+ InfoCommand.class,
CompletionCommand.class,
CompleteTemplateIdsCommand.class,
CompleteRegisteredTemplateIdsCommand.class,
diff --git a/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java b/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java
new file mode 100644
index 00000000..e4d2cb8b
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/auth/AuthCommand.java
@@ -0,0 +1,19 @@
+package com.streamx.cli.commands.auth;
+
+import com.streamx.cli.commands.auth.login.LoginCommand;
+import com.streamx.cli.commands.auth.logout.LogoutCommand;
+import com.streamx.cli.commands.auth.whoami.WhoamiCommand;
+import com.streamx.cli.framework.AbstractCommandGroup;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+ name = "auth",
+ header = "Manage StreamX authentication",
+ subcommands = {
+ LoginCommand.class,
+ LogoutCommand.class,
+ WhoamiCommand.class
+ }
+)
+public class AuthCommand extends AbstractCommandGroup {
+}
diff --git a/src/main/java/com/streamx/cli/commands/auth/login/LoginCommand.java b/src/main/java/com/streamx/cli/commands/auth/login/LoginCommand.java
new file mode 100644
index 00000000..ba87d6d9
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/auth/login/LoginCommand.java
@@ -0,0 +1,59 @@
+package com.streamx.cli.commands.auth.login;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.auth.AuthConfig;
+import com.streamx.cli.auth.Credentials;
+import com.streamx.cli.auth.CredentialsStore;
+import com.streamx.cli.auth.InteractiveGrant;
+import com.streamx.cli.auth.OidcClient;
+import com.streamx.cli.auth.Scopes;
+import com.streamx.cli.framework.AbstractSilentCommand;
+import com.streamx.cli.framework.CommandResult;
+import java.util.Optional;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+ name = "login",
+ header = "Log in to StreamX",
+ description = {
+ "Uses the browser (authorization code + PKCE) when a local browser is available,",
+ "and falls back to the device flow over SSH or with --no-browser."
+ }
+)
+public class LoginCommand extends AbstractSilentCommand {
+
+ @CommandLine.Option(
+ names = "--no-browser",
+ description = "Use the device flow instead of opening a local browser "
+ + "(for SSH sessions and headless machines)"
+ )
+ public boolean noBrowser;
+
+ @Override
+ public CommandResult runCommand() {
+ AuthConfig config = AuthConfig.load();
+ OidcClient client = InteractiveGrant.clientFromConfig(config);
+
+ Credentials credentials = InteractiveGrant.run(
+ client, client.discover(), Scopes.LOGIN, noBrowser);
+
+ Optional previous = CredentialsStore.load();
+ CredentialsStore.save(credentials);
+ previous.ifPresent(LoginCommand::revokePrevious);
+
+ System.out.println(msg.authLoginSuccess());
+ return new CommandResult<>(null);
+ }
+
+ private static void revokePrevious(Credentials previous) {
+ if (previous.refreshToken() == null || previous.issuerUrl() == null) {
+ return;
+ }
+ new OidcClient(
+ previous.issuerUrl(),
+ previous.clientId(),
+ previous.insecure())
+ .revokeQuietly(previous.refreshToken());
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/auth/logout/LogoutCommand.java b/src/main/java/com/streamx/cli/commands/auth/logout/LogoutCommand.java
new file mode 100644
index 00000000..41ff38b2
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/auth/logout/LogoutCommand.java
@@ -0,0 +1,45 @@
+package com.streamx.cli.commands.auth.logout;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.auth.Credentials;
+import com.streamx.cli.auth.CredentialsStore;
+import com.streamx.cli.auth.OidcClient;
+import com.streamx.cli.framework.AbstractSilentCommand;
+import com.streamx.cli.framework.CliException;
+import com.streamx.cli.framework.CommandResult;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+ name = "logout",
+ header = "Log out of StreamX"
+)
+public class LogoutCommand extends AbstractSilentCommand {
+ @Override
+ public CommandResult runCommand() {
+ if (!CredentialsStore.exists()) {
+ System.out.println(msg.authLogoutNotLoggedIn());
+ return new CommandResult<>(null);
+ }
+
+ try {
+ CredentialsStore.load().ifPresent(LogoutCommand::revoke);
+ } catch (CliException expected) {
+ }
+
+ CredentialsStore.delete();
+
+ System.out.println(msg.authLogoutSuccess());
+ return new CommandResult<>(null);
+ }
+
+ private static void revoke(Credentials credentials) {
+ if (credentials.refreshToken() != null && credentials.issuerUrl() != null) {
+ new OidcClient(
+ credentials.issuerUrl(),
+ credentials.clientId(),
+ credentials.insecure())
+ .revokeQuietly(credentials.refreshToken());
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/auth/whoami/WhoamiCommand.java b/src/main/java/com/streamx/cli/commands/auth/whoami/WhoamiCommand.java
new file mode 100644
index 00000000..0d988092
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/auth/whoami/WhoamiCommand.java
@@ -0,0 +1,109 @@
+package com.streamx.cli.commands.auth.whoami;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.streamx.cli.auth.AccessTokenClaims;
+import com.streamx.cli.auth.Credentials;
+import com.streamx.cli.auth.CredentialsStore;
+import com.streamx.cli.auth.Identity;
+import com.streamx.cli.framework.AbstractCommand;
+import com.streamx.cli.framework.CliException;
+import com.streamx.cli.framework.CommandResult;
+import com.streamx.cli.platform.AccessTokens;
+import com.streamx.cli.platform.PlatformClients;
+import com.streamx.cli.platform.ProfileApi;
+import com.streamx.cli.platform.generated.model.Profile;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+ name = "whoami",
+ header = "Display the currently logged in user"
+)
+public class WhoamiCommand extends AbstractCommand {
+
+ @Override
+ public String getTextOutput(CommandResult result) {
+ Identity identity = result.getData();
+ String expires = identity.expiresAt() == null
+ ? "-"
+ : identity.expiresAt() + (identity.expired() ? " (expired)" : "");
+ return """
+ username = %s
+ name = %s
+ email = %s
+ subject = %s
+ issuer = %s
+ expires = %s
+ auth = %s
+ token id = %s"""
+ .formatted(
+ orDash(identity.username()),
+ orDash(identity.name()),
+ orDash(identity.email()),
+ orDash(identity.subject()),
+ orDash(identity.issuer()),
+ expires,
+ AccessTokens.usingPlatformToken() ? "personal access token" : "login session",
+ orDash(identity.tokenId()));
+ }
+
+ private static String orDash(String value) {
+ return value == null ? "-" : value;
+ }
+
+ private static Identity identityFromPlatform() {
+ Profile profile;
+ try (PlatformClients client = PlatformClients.fromConfig()) {
+ profile = new ProfileApi(client).get();
+ }
+ if (profile == null) {
+ throw new CliException(msg.authTokenIdentityUnavailable());
+ }
+ return new Identity(
+ firstNonBlank(profile.getDisplayName(), profile.getEmail()),
+ firstNonBlank(profile.getDisplayName(),
+ join(profile.getFirstName(), profile.getLastName())),
+ profile.getEmail(),
+ profile.getUserId(),
+ null,
+ null,
+ false,
+ AccessTokens.platformTokenId().orElse(null));
+ }
+
+ private static String join(String first, String last) {
+ return firstNonBlank(((first == null ? "" : first) + " "
+ + (last == null ? "" : last)).trim(), null);
+ }
+
+ private static String firstNonBlank(String value, String fallback) {
+ return value == null || value.isBlank() ? fallback : value;
+ }
+
+ @Override
+ public CommandResult runCommand() {
+ if (AccessTokens.usingPlatformToken()) {
+ // A personal access token is opaque and carries no claims: ask the platform who it acts as.
+ return new CommandResult<>(identityFromPlatform());
+ }
+
+ Credentials credentials = CredentialsStore.load()
+ .orElseThrow(() -> new CliException(msg.platformNotLoggedIn()));
+
+ JsonNode claims = AccessTokenClaims.of(credentials.accessToken());
+ Instant expiresAt = credentials.expiresAt();
+
+ return new CommandResult<>(new Identity(
+ claims.path("preferred_username").asText(null),
+ claims.path("name").asText(null),
+ claims.path("email").asText(null),
+ claims.path("sub").asText(null),
+ claims.path("iss").asText(null),
+ expiresAt == null ? null : DateTimeFormatter.ISO_INSTANT.format(expiresAt),
+ expiresAt != null && Instant.now().isAfter(expiresAt),
+ null));
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/info/InfoCommand.java b/src/main/java/com/streamx/cli/commands/info/InfoCommand.java
new file mode 100644
index 00000000..f50047ab
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/info/InfoCommand.java
@@ -0,0 +1,586 @@
+package com.streamx.cli.commands.info;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.streamx.cli.auth.AuthConfig;
+import com.streamx.cli.auth.Credentials;
+import com.streamx.cli.auth.CredentialsStore;
+import com.streamx.cli.auth.OidcClient;
+import com.streamx.cli.commands.info.InfoResult.Probe;
+import com.streamx.cli.commands.info.InfoResult.Setting;
+import com.streamx.cli.config.StreamxHome;
+import com.streamx.cli.framework.AbstractCommand;
+import com.streamx.cli.framework.CliException;
+import com.streamx.cli.framework.CommandResult;
+import com.streamx.cli.framework.TextTable;
+import com.streamx.cli.framework.Urls;
+import com.streamx.cli.ingestion.IngestionClientConfig;
+import com.streamx.cli.platform.AccessTokens;
+import com.streamx.cli.platform.PlatformConfig;
+import com.streamx.cli.platform.PlatformContext;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.conn.ssl.TrustAllStrategy;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContexts;
+import org.apache.http.util.EntityUtils;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+ name = "info",
+ header = "Show CLI, context and connectivity diagnostics",
+ description = "Reports the CLI version, the active context and where it came from, the "
+ + "effective endpoint settings, the stored login, and probes the configured endpoints. "
+ + "Useful as the first thing to share when something does not work."
+)
+public class InfoCommand extends AbstractCommand {
+
+ private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(3);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String UP = "UP";
+ private static final String DEGRADED = "DEGRADED";
+ private static final String DOWN = "DOWN";
+ private static final String TLS_ERROR = "TLS ERROR";
+ private static final String AUTH_REJECTED = "AUTH REJECTED";
+
+ @CommandLine.Option(
+ names = "--check",
+ description = "Exit with code 1 unless every configured endpoint probes healthy (UP)"
+ )
+ public boolean check;
+
+ /** Diagnostics must keep working when the selected context is broken or missing. */
+ @Override
+ public boolean needsContext() {
+ return false;
+ }
+
+ @Override
+ public CommandResult runCommand() {
+ List warnings = new ArrayList<>();
+
+ InfoResult.Cli cli = new InfoResult.Cli(
+ cliVersion(),
+ runtime(),
+ StreamxHome.getStreamxHome().toString(),
+ StreamxHome.getStreamxHomeSource());
+
+ String active = null;
+ try {
+ active = StreamxHome.getActiveContext();
+ } catch (CliException e) {
+ warnings.add(e.getMessage());
+ }
+ boolean exists = active != null && StreamxHome.contextExists(active);
+ InfoResult.Context context = new InfoResult.Context(
+ active,
+ StreamxHome.getActiveContextSource(),
+ exists,
+ active == null ? null : StreamxHome.getConfigDirOf(active)
+ .resolve("application.properties").toString(),
+ quiet(PlatformContext::effectiveOrg),
+ quiet(PlatformContext::effectiveOrgSource),
+ quiet(PlatformContext::effectiveProject),
+ quiet(PlatformContext::effectiveProjectSource));
+ if (active != null && !exists) {
+ warnings.add("Context '" + active + "' does not exist yet");
+ }
+
+ Properties fileSettings = exists ? loadSettings(active) : new Properties();
+ List settings = collectSettings(fileSettings, warnings);
+
+ InfoResult.Login login = describeLogin(fileSettings, warnings);
+
+ List connectivity = probeEndpoints(fileSettings);
+
+ InfoResult result =
+ new InfoResult(cli, context, settings, login, connectivity, warnings);
+ CommandResult commandResult = new CommandResult<>(result);
+ if (check && connectivity.stream().anyMatch(p -> !UP.equals(p.status()))) {
+ commandResult.setExitCodeOverride(1);
+ }
+ return commandResult;
+ }
+
+ // ---- report assembly -----------------------------------------------------
+
+ private static List collectSettings(Properties file, List warnings) {
+ List settings = new ArrayList<>();
+ settings.add(describeSetting(file, AuthConfig.STREAMX_AUTH_SERVER_URL, null));
+ settings.add(describeSetting(file, AuthConfig.STREAMX_AUTH_REALM, AuthConfig.DEFAULT_REALM));
+ settings.add(describeSetting(
+ file, AuthConfig.STREAMX_AUTH_CLIENT_ID, AuthConfig.DEFAULT_CLIENT_ID));
+ settings.add(describeSetting(file, AuthConfig.STREAMX_AUTH_INSECURE, "false"));
+ settings.add(describeSetting(file, PlatformConfig.STREAMX_PLATFORM_URL, null));
+ settings.add(describeSetting(file, PlatformConfig.STREAMX_PLATFORM_INSECURE, "false"));
+ settings.add(describeSetting(file, IngestionClientConfig.STREAMX_INGESTION_URL, null));
+ settings.add(describeSetting(file, IngestionClientConfig.STREAMX_INGESTION_INSECURE, "false"));
+
+ if (effective(file, AuthConfig.STREAMX_AUTH_SERVER_URL) == null) {
+ warnings.add("Auth server is not configured - run: streamx context configure");
+ }
+ for (String urlKey : List.of(AuthConfig.STREAMX_AUTH_SERVER_URL,
+ PlatformConfig.STREAMX_PLATFORM_URL, IngestionClientConfig.STREAMX_INGESTION_URL)) {
+ String url = effective(file, urlKey);
+ if (url != null && url.startsWith("http://")) {
+ warnings.add(urlKey + " uses cleartext http - credentials may be exposed");
+ }
+ }
+ return settings;
+ }
+
+ private static Setting describeSetting(Properties file, String key, String defaultValue) {
+ String fileValue = file.getProperty(key);
+ String systemValue = System.getProperty(key);
+ if (systemValue != null && !systemValue.equals(fileValue)) {
+ return new Setting(key, systemValue, "system property override");
+ }
+ if (fileValue != null && !fileValue.isBlank()) {
+ return new Setting(key, fileValue, "context");
+ }
+ if (defaultValue != null) {
+ return new Setting(key, defaultValue, "default");
+ }
+ return new Setting(key, null, "not set");
+ }
+
+ private static String effective(Properties file, String key) {
+ String systemValue = System.getProperty(key);
+ if (systemValue != null && !systemValue.isBlank()) {
+ return systemValue;
+ }
+ String fileValue = file.getProperty(key);
+ return fileValue == null || fileValue.isBlank() ? null : fileValue;
+ }
+
+ private InfoResult.Login describeLogin(Properties file, List warnings) {
+ if (AccessTokens.usingPlatformToken()) {
+ // The env credential outranks any stored session; a token is opaque and never expires.
+ return new InfoResult.Login("authenticated with a personal access token", null, null, null);
+ }
+ Optional credentials;
+ try {
+ credentials = CredentialsStore.load();
+ } catch (RuntimeException e) {
+ warnings.add("Stored login is unreadable: " + e.getMessage());
+ return new InfoResult.Login("unreadable", null, null, null);
+ }
+ if (credentials.isEmpty()) {
+ return new InfoResult.Login("not logged in", null, null, null);
+ }
+ Credentials creds = credentials.get();
+ boolean expired = creds.expiresAt() != null && creds.expiresAt().isBefore(Instant.now());
+ String state = expired ? "expired (a refresh is attempted on use)" : "logged in";
+ String user = usernameFromJwt(creds.accessToken());
+
+ String serverUrl = effective(file, AuthConfig.STREAMX_AUTH_SERVER_URL);
+ String realm = Optional.ofNullable(effective(file, AuthConfig.STREAMX_AUTH_REALM))
+ .orElse(AuthConfig.DEFAULT_REALM);
+ if (serverUrl != null && creds.issuerUrl() != null) {
+ String expected = OidcClient.issuerUrl(serverUrl, realm);
+ if (!expected.equals(creds.issuerUrl())) {
+ warnings.add("Stored login belongs to " + creds.issuerUrl()
+ + " but this context is configured for " + expected
+ + " - run: streamx auth login");
+ }
+ }
+ return new InfoResult.Login(
+ state,
+ user,
+ creds.expiresAt() == null ? null : creds.expiresAt().toString(),
+ creds.issuerUrl());
+ }
+
+ /** Display-only decode of the JWT payload; the token is never verified here. */
+ private static String usernameFromJwt(String accessToken) {
+ if (accessToken == null) {
+ return null;
+ }
+ String[] parts = accessToken.split("\\.");
+ if (parts.length < 2) {
+ return null;
+ }
+ try {
+ JsonNode payload =
+ MAPPER.readTree(Base64.getUrlDecoder().decode(parts[1]));
+ String username = payload.path("preferred_username").asText(null);
+ return username != null ? username : payload.path("email").asText(null);
+ } catch (IOException | IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ // ---- endpoint probes -----------------------------------------------------
+
+ private List probeEndpoints(Properties file) {
+ String authUrl = effective(file, AuthConfig.STREAMX_AUTH_SERVER_URL);
+ String realm = Optional.ofNullable(effective(file, AuthConfig.STREAMX_AUTH_REALM))
+ .orElse(AuthConfig.DEFAULT_REALM);
+ boolean authInsecure = Boolean.parseBoolean(effective(file, AuthConfig.STREAMX_AUTH_INSECURE));
+ String platformUrl = effective(file, PlatformConfig.STREAMX_PLATFORM_URL);
+ boolean platformInsecure =
+ Boolean.parseBoolean(effective(file, PlatformConfig.STREAMX_PLATFORM_INSECURE));
+ Optional credentials;
+ try {
+ credentials = CredentialsStore.load();
+ } catch (RuntimeException unreadable) {
+ credentials = Optional.empty();
+ }
+ String ingestionUrl = effective(file, IngestionClientConfig.STREAMX_INGESTION_URL);
+ boolean ingestionInsecure =
+ Boolean.parseBoolean(effective(file, IngestionClientConfig.STREAMX_INGESTION_INSECURE));
+
+ List> probes = new ArrayList<>();
+ if (authUrl != null) {
+ String discovery = OidcClient.issuerUrl(authUrl, realm) + "/.well-known/openid-configuration";
+ probes.add(() -> probeAuth(discovery, authInsecure));
+ }
+ if (platformUrl != null) {
+ String base = platformUrl.replaceAll("/+$", "");
+ probes.add(() -> probePlatform(base, platformInsecure));
+ }
+ if (ingestionUrl != null) {
+ String base = ingestionUrl.replaceAll("/+$", "");
+ probes.add(() -> probeHealth("ingestion", base, ingestionInsecure));
+ }
+ if (platformUrl != null && (credentials.isPresent() || AccessTokens.usingPlatformToken())) {
+ String base = platformUrl.replaceAll("/+$", "");
+ if (Urls.isCleartextRemote(base)) {
+ probes.add(() -> new Probe("api (authenticated)", base, DEGRADED, null,
+ "not probed: refusing to send a credential over cleartext http"));
+ } else {
+ // AccessTokens prefers the env personal access token, then the stored session.
+ String token = quiet(AccessTokens::current);
+ if (token == null) {
+ probes.add(() -> new Probe("api (authenticated)", base, DEGRADED, null,
+ "not probed: no usable credential - run: streamx auth login"));
+ } else {
+ probes.add(() -> probeAuthenticatedApi(base, platformInsecure, token));
+ }
+ }
+ }
+ if (probes.isEmpty()) {
+ return List.of();
+ }
+
+ ExecutorService pool = Executors.newFixedThreadPool(probes.size());
+ try {
+ List> futures = pool.invokeAll(
+ probes, PROBE_TIMEOUT.toSeconds() + 3, TimeUnit.SECONDS);
+ List results = new ArrayList<>();
+ for (Future future : futures) {
+ try {
+ results.add(future.get());
+ } catch (Exception e) {
+ results.add(new Probe("unknown", null, DOWN, null, "probe did not finish"));
+ }
+ }
+ return results;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return List.of();
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ private static Probe probeAuth(String discoveryUrl, boolean insecure) {
+ HttpProbe probe = get(discoveryUrl, insecure, null);
+ if (probe.status == 200) {
+ return new Probe("auth", discoveryUrl, UP, probe.latencyMs, "discovery document served");
+ }
+ if (probe.status == 404) {
+ return new Probe("auth", discoveryUrl, DOWN, probe.latencyMs,
+ "server responds but the realm does not exist");
+ }
+ return failure("auth", discoveryUrl, probe);
+ }
+
+ private Probe probePlatform(String baseUrl, boolean insecure) {
+ Probe health = probeHealth("platform", baseUrl, insecure);
+ if (UP.equals(health.status()) || TLS_ERROR.equals(health.status())) {
+ return health;
+ }
+ String apiUrl = baseUrl + "/api/v1/organizations";
+ HttpProbe fallback = get(apiUrl, insecure, null);
+ boolean alive = fallback.status >= 200 && fallback.status < 400
+ || fallback.status == 401 || fallback.status == 403;
+ if (alive) {
+ return new Probe("platform", apiUrl, UP, fallback.latencyMs,
+ "health endpoint unavailable; API answered HTTP " + fallback.status);
+ }
+ if (fallback.status == 404) {
+ return new Probe("platform", apiUrl, DOWN, fallback.latencyMs,
+ "answers HTTP 404 - whatever is at this URL is not the StreamX platform API; "
+ + "check streamx.platform.url");
+ }
+ if (fallback.status > 0) {
+ return new Probe("platform", apiUrl, DOWN, fallback.latencyMs,
+ "HTTP " + fallback.status + " - the endpoint is reachable but the platform "
+ + "is not serving");
+ }
+ return health;
+ }
+
+ private static Probe probeHealth(String name, String baseUrl, boolean insecure) {
+ String url = baseUrl + "/q/health/ready";
+ HttpProbe probe = get(url, insecure, null);
+ if (probe.status == 200) {
+ boolean up = probe.body != null && probe.body.contains("\"UP\"");
+ return new Probe(name, url, up ? UP : DEGRADED, probe.latencyMs,
+ up ? "ready" : "health endpoint reports not ready");
+ }
+ if (probe.status > 0) {
+ return new Probe(name, url, DEGRADED, probe.latencyMs, "HTTP " + probe.status);
+ }
+ return failure(name, url, probe);
+ }
+
+ private static Probe probeAuthenticatedApi(String baseUrl, boolean insecure, String token) {
+ String url = baseUrl + "/api/v1/organizations";
+ HttpProbe probe = get(url, insecure, token);
+ if (probe.status == 200) {
+ String detail = "token accepted";
+ try {
+ JsonNode node = MAPPER.readTree(probe.body);
+ if (node.isArray()) {
+ detail = "token accepted; member of " + node.size() + " organization(s)";
+ }
+ } catch (IOException expected) {
+ }
+ return new Probe("api (authenticated)", url, UP, probe.latencyMs, detail);
+ }
+ if (probe.status == 401 || probe.status == 403) {
+ // 'auth login' is impossible in CI; point at the credential actually in use.
+ String remedy = AccessTokens.usingPlatformToken()
+ ? "check the personal access token in " + AccessTokens.STREAMX_PLATFORM_TOKEN
+ : "run: streamx auth login";
+ return new Probe("api (authenticated)", url, AUTH_REJECTED, probe.latencyMs,
+ "token rejected (HTTP " + probe.status + ") - " + remedy);
+ }
+ if (probe.status > 0) {
+ return new Probe("api (authenticated)", url, DEGRADED, probe.latencyMs,
+ "HTTP " + probe.status);
+ }
+ return failure("api (authenticated)", url, probe);
+ }
+
+ /**
+ * Failed probes never reached the server, so the elapsed time is reported as time-to-failure
+ * in the detail (fast = DNS/refused, ~timeout = blackholed) - not in the latency column.
+ */
+ private static Probe failure(String name, String url, HttpProbe probe) {
+ String after = "failed after " + probe.latencyMs + "ms: ";
+ if (probe.tlsError) {
+ return new Probe(name, url, TLS_ERROR, null,
+ after + probe.error + " - self-signed certs need the matching *.insecure=true setting");
+ }
+ return new Probe(name, url, DOWN, null, after + probe.error);
+ }
+
+ private record HttpProbe(int status, String body, Long latencyMs, String error,
+ boolean tlsError) {
+ }
+
+ private static HttpProbe get(String url, boolean insecure, String bearerToken) {
+ long start = System.nanoTime();
+ try (CloseableHttpClient client = buildHttpClient(insecure, bearerToken == null)) {
+ HttpGet request = new HttpGet(url);
+ request.setHeader("Accept", "application/json");
+ if (bearerToken != null) {
+ request.setHeader("Authorization", "Bearer " + bearerToken);
+ }
+ try (CloseableHttpResponse response = client.execute(request)) {
+ long latency = (System.nanoTime() - start) / 1_000_000;
+ String body = response.getEntity() == null ? null
+ : EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ return new HttpProbe(
+ response.getStatusLine().getStatusCode(), body, latency, null, false);
+ }
+ } catch (SSLException e) {
+ return new HttpProbe(0, null, elapsedMs(start), rootMessage(e), true);
+ } catch (IOException | RuntimeException e) {
+ return new HttpProbe(0, null, elapsedMs(start), rootMessage(e), false);
+ }
+ }
+
+ private static long elapsedMs(long startNanos) {
+ return (System.nanoTime() - startNanos) / 1_000_000;
+ }
+
+ private static String rootMessage(Throwable throwable) {
+ Throwable root = throwable;
+ while (root.getCause() != null && root.getCause() != root) {
+ root = root.getCause();
+ }
+ String message = root.getMessage();
+ return message == null || message.isBlank() ? root.getClass().getSimpleName() : message;
+ }
+
+ private static CloseableHttpClient buildHttpClient(boolean insecure, boolean followRedirects) {
+ int timeoutMillis = (int) PROBE_TIMEOUT.toMillis();
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectTimeout(timeoutMillis)
+ .setConnectionRequestTimeout(timeoutMillis)
+ .setSocketTimeout(timeoutMillis)
+ .build();
+ HttpClientBuilder builder = HttpClients.custom()
+ .setDefaultRequestConfig(requestConfig)
+ .disableAutomaticRetries();
+ if (!followRedirects) {
+ builder.disableRedirectHandling();
+ }
+ if (!insecure) {
+ return builder.build();
+ }
+ try {
+ SSLContext sslContext = SSLContexts.custom()
+ .loadTrustMaterial(null, TrustAllStrategy.INSTANCE)
+ .build();
+ return builder
+ .setSSLContext(sslContext)
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ .build();
+ } catch (GeneralSecurityException e) {
+ throw new CliException(e.getMessage(), e);
+ }
+ }
+
+ // ---- helpers -------------------------------------------------------------
+
+ /** "native (Substrate VM, JDK x)" in a native image, "JVM ( )" otherwise. */
+ private static String runtime() {
+ String vm = System.getProperty("java.vm.name", "unknown");
+ String version = System.getProperty("java.version", "");
+ if ("runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode"))) {
+ return "native (" + vm + ", JDK " + version + ")";
+ }
+ return "JVM (" + vm + " " + version + ")";
+ }
+
+ private static String quiet(java.util.function.Supplier supplier) {
+ try {
+ return supplier.get();
+ } catch (RuntimeException brokenContext) {
+ return null;
+ }
+ }
+
+ private String cliVersion() {
+ try {
+ String[] version = new com.streamx.cli.util.VersionProvider().getVersion();
+ return version.length > 0 ? version[0] : "unknown";
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ private static Properties loadSettings(String context) {
+ Properties properties = new Properties();
+ Path path = StreamxHome.getConfigDirOf(context).resolve("application.properties");
+ if (!Files.isRegularFile(path)) {
+ return properties;
+ }
+ try (InputStream in = Files.newInputStream(path)) {
+ properties.load(in);
+ } catch (IOException unreadable) {
+ return properties;
+ }
+ return properties;
+ }
+
+ // ---- text rendering ------------------------------------------------------
+
+ @Override
+ public String getTextOutput(CommandResult result) {
+ InfoResult info = result.getData();
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("CLI\n");
+ row(sb, "version", info.cli().version());
+ row(sb, "runtime", info.cli().runtime());
+ row(sb, "streamx home", info.cli().home() + " (" + info.cli().homeSource() + ")");
+
+ sb.append("\nContext\n");
+ row(sb, "active", valueOrDash(info.context().active())
+ + " (" + info.context().source() + ")");
+ row(sb, "exists", info.context().exists() ? "yes" : "no");
+ row(sb, "settings file", valueOrDash(info.context().settingsFile()));
+ row(sb, "current org", valueOrDash(info.context().currentOrg())
+ + (info.context().currentOrgSource() == null
+ ? "" : " (" + info.context().currentOrgSource() + ")"));
+ row(sb, "current project", valueOrDash(info.context().currentProject())
+ + (info.context().currentProjectSource() == null
+ ? "" : " (" + info.context().currentProjectSource() + ")"));
+
+ sb.append("\nSettings\n");
+ sb.append(TextTable.render(
+ List.of("KEY", "VALUE", "SOURCE"),
+ info.settings().stream()
+ .map(s -> List.of(s.key(), valueOrDash(s.value()), s.source()))
+ .toList()).indent(2));
+
+ sb.append("\nLogin\n");
+ row(sb, "state", info.login().state());
+ row(sb, "user", valueOrDash(info.login().user()));
+ row(sb, "expires", valueOrDash(info.login().expiresAt()));
+ row(sb, "issuer", valueOrDash(info.login().issuer()));
+
+ sb.append("\nConnectivity\n");
+ if (info.connectivity().isEmpty()) {
+ sb.append(" (no endpoints configured)\n");
+ } else {
+ sb.append(TextTable.render(
+ List.of("ENDPOINT", "STATUS", "LATENCY", "DETAIL"),
+ info.connectivity().stream()
+ .map(p -> List.of(
+ p.name(),
+ p.status(),
+ p.latencyMs() == null ? "-" : p.latencyMs() + "ms",
+ valueOrDash(p.detail())))
+ .toList()).indent(2));
+ }
+
+ if (!info.warnings().isEmpty()) {
+ sb.append("\nWarnings\n");
+ for (String warning : info.warnings()) {
+ sb.append(" ! ").append(warning).append('\n');
+ }
+ }
+ return sb.toString().stripTrailing();
+ }
+
+ private static void row(StringBuilder sb, String key, String value) {
+ sb.append(" ").append(String.format("%-16s", key)).append(' ')
+ .append(value == null ? "-" : value).append('\n');
+ }
+
+ private static String valueOrDash(String value) {
+ return value == null || value.isBlank() ? "-" : value;
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/info/InfoResult.java b/src/main/java/com/streamx/cli/commands/info/InfoResult.java
new file mode 100644
index 00000000..be3a59eb
--- /dev/null
+++ b/src/main/java/com/streamx/cli/commands/info/InfoResult.java
@@ -0,0 +1,43 @@
+package com.streamx.cli.commands.info;
+
+import io.quarkus.runtime.annotations.RegisterForReflection;
+import java.util.List;
+
+@RegisterForReflection
+public record InfoResult(
+ Cli cli,
+ Context context,
+ List settings,
+ Login login,
+ List connectivity,
+ List warnings
+) {
+
+ @RegisterForReflection
+ public record Cli(String version, String runtime, String home, String homeSource) {
+ }
+
+ @RegisterForReflection
+ public record Context(
+ String active,
+ String source,
+ boolean exists,
+ String settingsFile,
+ String currentOrg,
+ String currentOrgSource,
+ String currentProject,
+ String currentProjectSource) {
+ }
+
+ @RegisterForReflection
+ public record Setting(String key, String value, String source) {
+ }
+
+ @RegisterForReflection
+ public record Login(String state, String user, String expiresAt, String issuer) {
+ }
+
+ @RegisterForReflection
+ public record Probe(String name, String target, String status, Long latencyMs, String detail) {
+ }
+}
diff --git a/src/main/java/com/streamx/cli/commands/settings/SettingsSetKeyCompletionCandidates.java b/src/main/java/com/streamx/cli/commands/settings/SettingsSetKeyCompletionCandidates.java
index de52a140..ac2617f5 100644
--- a/src/main/java/com/streamx/cli/commands/settings/SettingsSetKeyCompletionCandidates.java
+++ b/src/main/java/com/streamx/cli/commands/settings/SettingsSetKeyCompletionCandidates.java
@@ -1,7 +1,9 @@
package com.streamx.cli.commands.settings;
+import com.streamx.cli.auth.AuthConfig;
import com.streamx.cli.commands.publish.event.EventTemplateLoader;
import com.streamx.cli.ingestion.IngestionClientConfig;
+import com.streamx.cli.platform.PlatformConfig;
import com.streamx.runner.config.StreamxBaseConfig;
import java.util.Iterator;
import java.util.List;
@@ -14,6 +16,12 @@ public class SettingsSetKeyCompletionCandidates implements Iterable {
IngestionClientConfig.STREAMX_INGESTION_URL,
IngestionClientConfig.STREAMX_INGESTION_AUTH_TOKEN,
IngestionClientConfig.STREAMX_INGESTION_INSECURE,
+ AuthConfig.STREAMX_AUTH_SERVER_URL,
+ AuthConfig.STREAMX_AUTH_REALM,
+ AuthConfig.STREAMX_AUTH_CLIENT_ID,
+ AuthConfig.STREAMX_AUTH_INSECURE,
+ PlatformConfig.STREAMX_PLATFORM_URL,
+ PlatformConfig.STREAMX_PLATFORM_INSECURE,
StreamxBaseConfig.PN_OBSERVABILITY_ENABLED,
StreamxBaseConfig.PN_OBSERVABILITY_WAIT_FOR_STARTUP,
StreamxBaseConfig.PN_CONTAINER_STARTUP_TIMEOUT_SECONDS,
diff --git a/src/main/java/com/streamx/cli/platform/AccessTokens.java b/src/main/java/com/streamx/cli/platform/AccessTokens.java
new file mode 100644
index 00000000..2e2a32dd
--- /dev/null
+++ b/src/main/java/com/streamx/cli/platform/AccessTokens.java
@@ -0,0 +1,97 @@
+package com.streamx.cli.platform;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.streamx.cli.auth.Credentials;
+import com.streamx.cli.auth.CredentialsStore;
+import com.streamx.cli.auth.OidcClient;
+import com.streamx.cli.framework.CliException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+public final class AccessTokens {
+ private static final Duration EXPIRY_SKEW = Duration.ofSeconds(30);
+
+ public static final String STREAMX_PLATFORM_TOKEN = "STREAMX_PLATFORM_TOKEN";
+
+ private AccessTokens() {
+ }
+
+ public static String current() {
+ String envToken = platformTokenOverride();
+ if (envToken != null) {
+ return envToken;
+ }
+ Credentials credentials = stored();
+ if (!isExpiring(credentials)) {
+ return credentials.accessToken();
+ }
+ return refresh(credentials);
+ }
+
+ public static String forceRefresh() {
+ String envToken = platformTokenOverride();
+ if (envToken != null) {
+ // A personal access token cannot be refreshed; a 401 means it is invalid or revoked.
+ return envToken;
+ }
+ return refresh(stored());
+ }
+
+ public static boolean usingPlatformToken() {
+ return platformTokenOverride() != null;
+ }
+
+ public static void requireInteractiveSession() {
+ if (usingPlatformToken()) {
+ throw new CliException(msg.authTokenNeedsLoginSession(STREAMX_PLATFORM_TOKEN));
+ }
+ }
+
+ public static Optional platformTokenId() {
+ String token = platformTokenOverride();
+ if (token == null) {
+ return Optional.empty();
+ }
+ String[] parts = token.split("_");
+ return parts.length == 5 && "sxp".equals(parts[0]) && !parts[2].isBlank()
+ ? Optional.of(parts[2])
+ : Optional.empty();
+ }
+
+ private static String platformTokenOverride() {
+ String value = System.getenv(STREAMX_PLATFORM_TOKEN);
+ if (value == null || value.isBlank()) {
+ value = System.getProperty(STREAMX_PLATFORM_TOKEN);
+ }
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+
+ private static Credentials stored() {
+ return CredentialsStore.load()
+ .orElseThrow(() -> new CliException(msg.platformNotLoggedIn()));
+ }
+
+ private static String refresh(Credentials credentials) {
+ if (credentials.refreshToken() == null || credentials.issuerUrl() == null) {
+ throw new CliException(msg.authSessionExpired());
+ }
+
+ // Refresh under the TLS policy the session was created with, not the current config.
+ Credentials refreshed = new OidcClient(
+ credentials.issuerUrl(),
+ credentials.clientId(),
+ credentials.insecure())
+ .refresh(credentials.refreshToken());
+
+ CredentialsStore.save(refreshed);
+ return refreshed.accessToken();
+ }
+
+ private static boolean isExpiring(Credentials credentials) {
+ return Optional.ofNullable(credentials.expiresAt())
+ .map(expiry -> Instant.now().plus(EXPIRY_SKEW).isAfter(expiry))
+ .orElse(true);
+ }
+}
diff --git a/src/main/java/com/streamx/cli/platform/AuthHeaderFilter.java b/src/main/java/com/streamx/cli/platform/AuthHeaderFilter.java
new file mode 100644
index 00000000..efc67b0a
--- /dev/null
+++ b/src/main/java/com/streamx/cli/platform/AuthHeaderFilter.java
@@ -0,0 +1,14 @@
+package com.streamx.cli.platform;
+
+import jakarta.ws.rs.client.ClientRequestContext;
+import jakarta.ws.rs.client.ClientRequestFilter;
+import jakarta.ws.rs.core.HttpHeaders;
+
+public class AuthHeaderFilter implements ClientRequestFilter {
+
+ @Override
+ public void filter(ClientRequestContext context) {
+ context.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + AccessTokens.current());
+ context.getHeaders().putSingle(HttpHeaders.ACCEPT, "application/json");
+ }
+}
diff --git a/src/main/java/com/streamx/cli/platform/PlatformClients.java b/src/main/java/com/streamx/cli/platform/PlatformClients.java
new file mode 100644
index 00000000..7de03229
--- /dev/null
+++ b/src/main/java/com/streamx/cli/platform/PlatformClients.java
@@ -0,0 +1,191 @@
+package com.streamx.cli.platform;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.streamx.cli.framework.CliException;
+import com.streamx.cli.framework.Urls;
+import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+public class PlatformClients implements AutoCloseable {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ private static final long TIMEOUT_MS = 30_000;
+ private static final long COMPLETION_TIMEOUT_MS = 3_000;
+
+ private final URI baseUri;
+ private final boolean insecure;
+ private final long timeoutMs;
+ private final List built = new ArrayList<>();
+
+ PlatformClients(String url, boolean insecure, long timeoutMs) {
+ if (Urls.isCleartextRemote(url)) {
+ throw new CliException(msg.platformCleartextHttpBlocked(url));
+ }
+ this.baseUri = URI.create(url.endsWith("/") ? url.substring(0, url.length() - 1) : url);
+ this.insecure = insecure;
+ this.timeoutMs = timeoutMs;
+ }
+
+ public static PlatformClients fromConfig() {
+ return create(TIMEOUT_MS);
+ }
+
+ public static PlatformClients completion() {
+ return create(COMPLETION_TIMEOUT_MS);
+ }
+
+ private static PlatformClients create(long timeoutMs) {
+ PlatformConfig config = PlatformConfig.load();
+ String url = config.url()
+ .filter(value -> !value.isBlank())
+ .orElseThrow(() -> new CliException(
+ msg.platformUrlNotConfigured(PlatformConfig.STREAMX_PLATFORM_URL)));
+ return new PlatformClients(url, config.insecure(), timeoutMs);
+ }
+
+ public T api(Class apiType) {
+ QuarkusRestClientBuilder builder = QuarkusRestClientBuilder.newBuilder()
+ .baseUri(baseUri)
+ .connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+ .readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+ .register(AuthHeaderFilter.class);
+ if (insecure) {
+ builder.trustAll(true).verifyHost(false);
+ }
+ T client = builder.build(apiType);
+ if (client instanceof AutoCloseable closeable) {
+ built.add(closeable);
+ }
+ return client;
+ }
+
+ @Override
+ public void close() {
+ for (AutoCloseable client : built) {
+ try {
+ client.close();
+ } catch (Exception ignored) {
+ continue;
+ }
+ }
+ built.clear();
+ }
+
+ public T call(Supplier operation, Class type) {
+ Response response = invoke(operation);
+ // A personal access token cannot be refreshed, so retrying would just re-send the same
+ // rejected credential; only a login session is worth a second attempt.
+ if (response.getStatus() == 401 && !AccessTokens.usingPlatformToken()) {
+ AccessTokens.forceRefresh();
+ response = invoke(operation);
+ }
+ return handle(response, type);
+ }
+
+ public void call(Supplier operation) {
+ call(operation, null);
+ }
+
+ public List callList(Supplier operation, Class type) {
+ JsonNode array = call(operation, JsonNode.class);
+ List items = new ArrayList<>();
+ if (array != null && array.isArray()) {
+ for (JsonNode node : array) {
+ items.add(MAPPER.convertValue(node, type));
+ }
+ }
+ return items;
+ }
+
+ private Response invoke(Supplier operation) {
+ try {
+ return operation.get();
+ } catch (WebApplicationException errorStatus) {
+ return errorStatus.getResponse();
+ } catch (RuntimeException failure) {
+ throw asCliException(failure);
+ }
+ }
+
+ private T handle(Response response, Class type) {
+ int status = response.getStatus();
+ if (status == 404) {
+ throw new NotFoundException(msg.platformNotFound());
+ }
+ if (status < 200 || status >= 300) {
+ String body = response.hasEntity() ? response.readEntity(String.class) : "";
+ throw new CliException(errorMessage(status, body));
+ }
+ if (type == null || !response.hasEntity()) {
+ return null;
+ }
+ return response.readEntity(type);
+ }
+
+ private CliException asCliException(RuntimeException failure) {
+ for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
+ if (cause instanceof CliException cli) {
+ return cli;
+ }
+ }
+ return new CliException(
+ msg.platformRequestFailed(baseUri.toString(), failure.getMessage()), failure);
+ }
+
+ private String errorMessage(int status, String body) {
+ String detail = extractErrorMessage(body);
+ return switch (status) {
+ // Keep the server's explanation (e.g. "personal access tokens cannot manage tokens"),
+ // and point at the credential actually in use rather than always at 'auth login'.
+ case 401 -> withDetail(AccessTokens.usingPlatformToken()
+ ? msg.platformTokenUnauthorized() : msg.platformUnauthorized(), detail);
+ case 403 -> withDetail(msg.platformAccessDenied(), detail);
+ default -> detail == null
+ ? msg.platformRequestFailedWithStatus(baseUri.toString(), status)
+ : msg.platformRequestRejected(status, detail);
+ };
+ }
+
+ private static String withDetail(String message, String detail) {
+ return detail == null || detail.isBlank() ? message : message + "\n " + detail;
+ }
+
+ private static String extractErrorMessage(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ JsonNode node = MAPPER.readTree(body);
+ String message = node.path("errorMessage").asText(null);
+ StringBuilder detail = new StringBuilder(message == null ? "" : message);
+ for (JsonNode violation : node.path("violations")) {
+ detail.append("\n ")
+ .append(violation.path("field").asText(""))
+ .append(": ")
+ .append(violation.path("message").asText(""));
+ }
+ return detail.isEmpty() ? null : detail.toString();
+ } catch (com.fasterxml.jackson.core.JsonProcessingException notJson) {
+ return null;
+ }
+ }
+
+ public static class NotFoundException extends CliException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/src/main/java/com/streamx/cli/platform/ProfileApi.java b/src/main/java/com/streamx/cli/platform/ProfileApi.java
new file mode 100644
index 00000000..ffa83c63
--- /dev/null
+++ b/src/main/java/com/streamx/cli/platform/ProfileApi.java
@@ -0,0 +1,19 @@
+package com.streamx.cli.platform;
+
+import com.streamx.cli.platform.generated.api.ProfileResourceApi;
+import com.streamx.cli.platform.generated.model.Profile;
+
+public class ProfileApi {
+
+ private final PlatformClients clients;
+ private final ProfileResourceApi api;
+
+ public ProfileApi(PlatformClients clients) {
+ this.clients = clients;
+ this.api = clients.api(ProfileResourceApi.class);
+ }
+
+ public Profile get() {
+ return clients.call(() -> api.getProfile(null, null), Profile.class);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 3a541d51..d21c0d53 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -32,3 +32,11 @@ quarkus.native.additional-build-args=\
# Fixes "Cannot load required properties from maven-build.properties" error
# when run streamx-runner with native-image executable.
quarkus.native.resources.includes=maven-build.properties,default-event-templates/**
+# OpenAPI-generated platform client (spec unpacked from streamx-platform-aggregated-openapi)
+quarkus.openapi-generator.codegen.spec.openapi_yaml.base-package=com.streamx.cli.platform.generated
+# The aggregated spec repeats the X-Permission-Check header on every operation (invalid per the
+# OpenAPI param-uniqueness rule); skip validation so codegen proceeds and dedupes it.
+quarkus.openapi-generator.codegen.validateSpec=false
+# Every operation returns jakarta.ws.rs.core.Response so the client factory has one uniform
+# path for auth-retry, error mapping and body deserialization.
+quarkus.openapi-generator.codegen.spec.openapi_yaml.return-response=true
diff --git a/src/test/java/com/streamx/cli/auth/OidcClientCleartextTest.java b/src/test/java/com/streamx/cli/auth/OidcClientCleartextTest.java
new file mode 100644
index 00000000..1d17b6de
--- /dev/null
+++ b/src/test/java/com/streamx/cli/auth/OidcClientCleartextTest.java
@@ -0,0 +1,22 @@
+package com.streamx.cli.auth;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.streamx.cli.framework.CliException;
+import org.junit.jupiter.api.Test;
+
+class OidcClientCleartextTest {
+
+ @Test
+ void refusesCleartextRemoteAuthServer() {
+ assertThatThrownBy(() -> new OidcClient("http://idp.example.com/realms/x", "cli", false))
+ .isInstanceOf(CliException.class)
+ .hasMessageContaining("cleartext HTTP");
+ }
+
+ @Test
+ void allowsLoopbackHttpForLocalDevelopment() {
+ assertThat(new OidcClient("http://127.0.0.1:8080/realms/x", "cli", false)).isNotNull();
+ }
+}
diff --git a/src/test/java/com/streamx/cli/commands/auth/AuthCodeFlowIT.java b/src/test/java/com/streamx/cli/commands/auth/AuthCodeFlowIT.java
new file mode 100644
index 00000000..6785e399
--- /dev/null
+++ b/src/test/java/com/streamx/cli/commands/auth/AuthCodeFlowIT.java
@@ -0,0 +1,109 @@
+package com.streamx.cli.commands.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.streamx.cli.auth.OidcAuthCodeFlow;
+import com.streamx.cli.auth.OidcClient;
+import com.streamx.cli.auth.OidcClient.Endpoints;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AuthCodeFlowIT {
+
+ private static final String REALM = "streamx";
+
+ private StubOidcServer oidcServer;
+ private Endpoints endpoints;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ oidcServer = new StubOidcServer(REALM, 0);
+ endpoints = new OidcClient(
+ oidcServer.getServerUrl() + "/realms/" + REALM, "streamx-cli", false).discover();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (oidcServer != null) {
+ oidcServer.close();
+ }
+ }
+
+ private static void simulateBrowser(String authorizationUrl) {
+ HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
+ try {
+ HttpResponse response = http.send(
+ HttpRequest.newBuilder(URI.create(authorizationUrl)).GET().build(),
+ HttpResponse.BodyHandlers.discarding());
+ String location = response.headers().firstValue("Location").orElseThrow();
+ http.send(HttpRequest.newBuilder(URI.create(location)).GET().build(),
+ HttpResponse.BodyHandlers.discarding());
+ } catch (IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void shouldCompletePkceLoginWithoutACode() {
+ OidcClient client = new OidcClient(
+ oidcServer.getServerUrl() + "/realms/" + REALM, "streamx-cli", false);
+
+ var flow = new OidcAuthCodeFlow(client, AuthCodeFlowIT::simulateBrowser);
+ var credentials = flow.login(endpoints);
+
+ assertThat(credentials.accessToken()).isEqualTo(StubOidcServer.ACCESS_TOKEN);
+ assertThat(credentials.refreshToken()).isEqualTo(StubOidcServer.REFRESH_TOKEN);
+ }
+
+ @Test
+ void shouldSendPkceChallengeAndVerifier() {
+ OidcClient client = new OidcClient(
+ oidcServer.getServerUrl() + "/realms/" + REALM, "streamx-cli", false);
+
+ new OidcAuthCodeFlow(client, AuthCodeFlowIT::simulateBrowser).login(endpoints);
+
+ assertThat(oidcServer.getLastAuthorizationRequest())
+ .containsEntry("code_challenge_method", "S256")
+ .containsKey("code_challenge");
+ assertThat(oidcServer.getLastAuthorizationRequest().get("redirect_uri"))
+ .startsWith("http://127.0.0.1:");
+ assertThat(oidcServer.getLastTokenRequestBody())
+ .contains("grant_type=authorization_code")
+ .contains("code_verifier=")
+ .contains("code=" + StubOidcServer.AUTH_CODE);
+ }
+
+ @Test
+ void shouldFailWhenAuthorizationIsDenied() {
+ oidcServer.denyAuthorization();
+ OidcClient client = new OidcClient(
+ oidcServer.getServerUrl() + "/realms/" + REALM, "streamx-cli", false);
+
+ OidcAuthCodeFlow flow = new OidcAuthCodeFlow(client, AuthCodeFlowIT::simulateBrowser);
+
+ assertThat(org.junit.jupiter.api.Assertions.assertThrows(
+ RuntimeException.class, () -> flow.login(endpoints)).getMessage())
+ .contains(msg.authLoginDenied());
+ }
+
+ @Test
+ void shouldSignalBrowserUnavailableWhenLauncherFails() {
+ OidcClient client = new OidcClient(
+ oidcServer.getServerUrl() + "/realms/" + REALM, "streamx-cli", false);
+
+ OidcAuthCodeFlow.BrowserLauncher failing = url -> {
+ throw new java.io.IOException("no browser");
+ };
+ OidcAuthCodeFlow flow = new OidcAuthCodeFlow(client, failing);
+
+ org.junit.jupiter.api.Assertions.assertThrows(
+ OidcAuthCodeFlow.BrowserUnavailableException.class, () -> flow.login(endpoints));
+ }
+}
diff --git a/src/test/java/com/streamx/cli/commands/auth/AuthCommandIT.java b/src/test/java/com/streamx/cli/commands/auth/AuthCommandIT.java
new file mode 100644
index 00000000..efc17558
--- /dev/null
+++ b/src/test/java/com/streamx/cli/commands/auth/AuthCommandIT.java
@@ -0,0 +1,263 @@
+package com.streamx.cli.commands.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.streamx.cli.auth.AuthConfig;
+import com.streamx.cli.test.CliBaseIT;
+import io.quarkus.test.junit.QuarkusTest;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Properties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class AuthCommandIT extends CliBaseIT {
+ private static final String REALM = "streamx";
+
+ private StubOidcServer oidcServer;
+
+ private Path getCredentialsPath() {
+ return streamxHome.resolve("contexts/default/config/credentials.json");
+ }
+
+ private void writeAuthConfig(String serverUrl) throws IOException {
+ Properties properties = new Properties();
+ if (serverUrl != null) {
+ properties.setProperty(AuthConfig.STREAMX_AUTH_SERVER_URL, serverUrl);
+ properties.setProperty(AuthConfig.STREAMX_AUTH_REALM, REALM);
+ properties.setProperty(AuthConfig.STREAMX_AUTH_CLIENT_ID, "streamx-cli");
+ }
+ Path configFile = getConfigPath();
+ Files.createDirectories(configFile.getParent());
+ try (OutputStream out = Files.newOutputStream(configFile)) {
+ properties.store(out, null);
+ }
+ }
+
+ @BeforeEach
+ void cleanState() throws IOException {
+ Files.deleteIfExists(getCredentialsPath());
+ Files.deleteIfExists(getConfigPath());
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (oidcServer != null) {
+ oidcServer.close();
+ oidcServer = null;
+ }
+ }
+
+ @Test
+ void shouldPrintUserCodeAndStoreCredentialsOnLogin() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertSuccess();
+ assertThat(result.stderr()).contains(StubOidcServer.USER_CODE);
+ assertThat(result.stdout()).contains(msg.authLoginSuccess());
+
+ assertThat(getCredentialsPath()).exists();
+ String credentials = Files.readString(getCredentialsPath());
+ assertThat(credentials).contains(StubOidcServer.ACCESS_TOKEN);
+ assertThat(credentials).contains(StubOidcServer.REFRESH_TOKEN);
+ }
+
+ @Test
+ void shouldKeepPollingWhileAuthorizationIsPending() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 2);
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertSuccess();
+ assertThat(oidcServer.getTokenRequestCount()).isEqualTo(3);
+ assertThat(getCredentialsPath()).exists();
+ }
+
+ @Test
+ void shouldStoreCredentialsOwnerReadableOnly() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(getCredentialsPath())))
+ .isEqualTo("rw-------");
+ }
+
+ @Test
+ void shouldReportLoggedInUserForWhoami() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ ProcessResult result = exec("auth", "whoami");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains("username = " + StubOidcServer.USERNAME);
+ assertThat(result.stdout()).contains("email = " + StubOidcServer.EMAIL);
+ assertThat(result.stdout()).contains("subject = " + StubOidcServer.SUBJECT);
+ assertThat(result.stdout()).contains("expires = ");
+ }
+
+ @Test
+ void shouldReportWhoamiWhenIdentityProviderIsUnreachable() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ oidcServer.close();
+ oidcServer = null;
+
+ ProcessResult result = exec("auth", "whoami");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains("username = " + StubOidcServer.USERNAME);
+ }
+
+ @Test
+ void shouldReportWhoamiAsJson() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ ProcessResult result = exec("auth", "whoami", "--output", "json");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains("\"username\" : \"" + StubOidcServer.USERNAME + "\"");
+ assertThat(result.stdout()).contains("\"subject\" : \"" + StubOidcServer.SUBJECT + "\"");
+ }
+
+ @Test
+ void shouldFailWhoamiWhenNotLoggedIn() throws Exception {
+ ProcessResult result = exec("auth", "whoami");
+
+ result.assertExitCode(1);
+ assertThat(result.stderr()).contains(msg.platformNotLoggedIn());
+ }
+
+ @Test
+ void shouldFailWhenServerUrlNotConfigured() throws Exception {
+ writeAuthConfig(null);
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertExitCode(1);
+ assertThat(result.stderr())
+ .contains(msg.authServerUrlNotConfigured(AuthConfig.STREAMX_AUTH_SERVER_URL));
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldNotTreatServerErrorAsSuccessfulLogin() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ oidcServer.failTokenWithStatus(503, "{}");
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertExitCode(1);
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldRejectTokenResponseWithoutAccessToken() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ oidcServer.failTokenWithStatus(200, "{\"token_type\":\"Bearer\"}");
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertExitCode(1);
+ assertThat(result.stderr()).contains(msg.authTokenResponseIncomplete());
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldRejectMismatchedDiscoveryIssuer() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ oidcServer.returnWrongIssuer();
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertExitCode(1);
+ assertThat(result.stderr()).contains("does not match");
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldFailWhenLoginIsDenied() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ oidcServer.failTokenWith("access_denied");
+ writeAuthConfig(oidcServer.getServerUrl());
+
+ ProcessResult result = exec("auth", "login", "--no-browser");
+
+ result.assertExitCode(1);
+ assertThat(result.stderr()).contains(msg.authLoginDenied());
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldRevokeRefreshTokenAndRemoveCredentialsOnLogout() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ ProcessResult result = exec("auth", "logout");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains(msg.authLogoutSuccess());
+ assertThat(getCredentialsPath()).doesNotExist();
+ assertThat(oidcServer.getRevokedTokens()).containsExactly(StubOidcServer.REFRESH_TOKEN);
+ }
+
+ @Test
+ void shouldReportNotLoggedInWhenLoggingOutWithoutCredentials() throws Exception {
+ writeAuthConfig(null);
+
+ ProcessResult result = exec("auth", "logout");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains(msg.authLogoutNotLoggedIn());
+ }
+
+ @Test
+ void shouldRemoveCredentialsEvenWhenIdentityProviderIsUnreachable() throws Exception {
+ oidcServer = new StubOidcServer(REALM, 0);
+ writeAuthConfig(oidcServer.getServerUrl());
+ exec("auth", "login", "--no-browser").assertSuccess();
+
+ oidcServer.close();
+ oidcServer = null;
+
+ ProcessResult result = exec("auth", "logout");
+
+ result.assertSuccess();
+ assertThat(getCredentialsPath()).doesNotExist();
+ }
+
+ @Test
+ void shouldClearCorruptCredentialsOnLogout() throws Exception {
+ writeAuthConfig(null);
+ Path credentials = getCredentialsPath();
+ Files.createDirectories(credentials.getParent());
+ Files.writeString(credentials, "{ not valid json");
+
+ ProcessResult result = exec("auth", "logout");
+
+ result.assertSuccess();
+ assertThat(result.stdout()).contains(msg.authLogoutSuccess());
+ assertThat(credentials).doesNotExist();
+ }
+}
diff --git a/src/test/java/com/streamx/cli/commands/auth/AuthCommandRealKeycloakIT.java b/src/test/java/com/streamx/cli/commands/auth/AuthCommandRealKeycloakIT.java
new file mode 100644
index 00000000..7ff728f6
--- /dev/null
+++ b/src/test/java/com/streamx/cli/commands/auth/AuthCommandRealKeycloakIT.java
@@ -0,0 +1,146 @@
+package com.streamx.cli.commands.auth;
+
+import static com.streamx.cli.i18n.MessageProvider.msg;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.streamx.cli.auth.AuthConfig;
+import com.streamx.cli.test.CliBaseIT;
+import io.quarkus.test.junit.QuarkusTest;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+@QuarkusTest
+@EnabledIfSystemProperty(
+ named = AuthTestEndpoints.SERVER_URL_PROPERTY,
+ matches = ".+",
+ disabledReason = "Set -D" + AuthTestEndpoints.SERVER_URL_PROPERTY + " to run against a real IdP"
+)
+class AuthCommandRealKeycloakIT extends CliBaseIT {
+ private static final Pattern VERIFICATION_LINK =
+ Pattern.compile("(https?://\\S*user_code=[A-Za-z0-9-]+)");
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private Path getCredentialsPath() {
+ return streamxHome.resolve("contexts/default/config/credentials.json");
+ }
+
+ @BeforeEach
+ void configureAgainstRealIdentityProvider() throws IOException {
+ Files.deleteIfExists(getCredentialsPath());
+
+ Properties properties = new Properties();
+ properties.setProperty(AuthConfig.STREAMX_AUTH_SERVER_URL, AuthTestEndpoints.serverUrl());
+ properties.setProperty(AuthConfig.STREAMX_AUTH_REALM, AuthTestEndpoints.realm());
+ properties.setProperty(AuthConfig.STREAMX_AUTH_CLIENT_ID, AuthTestEndpoints.clientId());
+ properties.setProperty(
+ AuthConfig.STREAMX_AUTH_INSECURE, String.valueOf(AuthTestEndpoints.insecure()));
+
+ Path configFile = getConfigPath();
+ Files.createDirectories(configFile.getParent());
+ try (OutputStream out = Files.newOutputStream(configFile)) {
+ properties.store(out, null);
+ }
+ }
+
+ @Test
+ void shouldCompleteDeviceFlowAgainstRealKeycloak() throws Exception {
+ AsyncProcessHandle login = execAsync("auth", "login", "--no-browser");
+
+ String verificationUri = awaitVerificationUri(login);
+ try (KeycloakDeviceApprover approver =
+ new KeycloakDeviceApprover(AuthTestEndpoints.insecure())) {
+ approver.approve(
+ verificationUri, AuthTestEndpoints.username(), AuthTestEndpoints.password());
+ }
+
+ Awaitility.await("cli stores credentials after approval")
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(1, TimeUnit.SECONDS)
+ .until(() -> Files.exists(getCredentialsPath()));
+
+ login.interruptAndJoin(TimeUnit.SECONDS.toMillis(10));
+ assertThat(login.getStdout()).contains(msg.authLoginSuccess());
+
+ JsonNode claims = accessTokenClaims();
+ assertThat(claims.path("iss").asText())
+ .isEqualTo(AuthTestEndpoints.serverUrl() + "/realms/" + AuthTestEndpoints.realm());
+ assertThat(claims.path("azp").asText()).isEqualTo(AuthTestEndpoints.clientId());
+ assertThat(claims.path("preferred_username").asText())
+ .isEqualTo(AuthTestEndpoints.username());
+ }
+
+ @Test
+ void shouldRevokeRealRefreshTokenOnLogout() throws Exception {
+ AsyncProcessHandle login = execAsync("auth", "login", "--no-browser");
+ String verificationUri = awaitVerificationUri(login);
+ try (KeycloakDeviceApprover approver =
+ new KeycloakDeviceApprover(AuthTestEndpoints.insecure())) {
+ approver.approve(
+ verificationUri, AuthTestEndpoints.username(), AuthTestEndpoints.password());
+ }
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(1, TimeUnit.SECONDS)
+ .until(() -> Files.exists(getCredentialsPath()));
+ login.interruptAndJoin(TimeUnit.SECONDS.toMillis(10));
+
+ String refreshToken = credentials().path("refresh_token").asText();
+ assertThat(refreshTokenAccepted(refreshToken))
+ .as("refresh token should work before logout")
+ .isTrue();
+
+ exec("auth", "logout").assertSuccess();
+
+ assertThat(getCredentialsPath()).doesNotExist();
+ assertThat(refreshTokenAccepted(refreshToken))
+ .as("refresh token should be revoked at the identity provider after logout")
+ .isFalse();
+ }
+
+ private String awaitVerificationUri(AsyncProcessHandle login) {
+ return Awaitility.await("cli prints the verification link")
+ .atMost(60, TimeUnit.SECONDS)
+ .pollInterval(500, TimeUnit.MILLISECONDS)
+ .until(() -> {
+ Matcher matcher = VERIFICATION_LINK.matcher(login.getStderr() + "\n" + login.getStdout());
+ return matcher.find() ? matcher.group(1) : null;
+ }, uri -> uri != null);
+ }
+
+ private JsonNode credentials() throws IOException {
+ return MAPPER.readTree(Files.readString(getCredentialsPath()));
+ }
+
+ private JsonNode accessTokenClaims() throws IOException {
+ String payload = credentials().path("access_token").asText().split("\\.")[1];
+ return MAPPER.readTree(Base64.getUrlDecoder().decode(payload));
+ }
+
+ private boolean refreshTokenAccepted(String refreshToken) throws Exception {
+ String tokenEndpoint = AuthTestEndpoints.serverUrl()
+ + "/realms/" + AuthTestEndpoints.realm()
+ + "/protocol/openid-connect/token";
+ String form = "grant_type=refresh_token"
+ + "&client_id=" + AuthTestEndpoints.clientId()
+ + "&refresh_token=" + java.net.URLEncoder.encode(refreshToken, StandardCharsets.UTF_8);
+
+ try (var client = new TestHttpClient(AuthTestEndpoints.insecure())) {
+ return client.postForm(tokenEndpoint, form).contains("access_token");
+ }
+ }
+}
diff --git a/src/test/java/com/streamx/cli/commands/auth/AuthTestEndpoints.java b/src/test/java/com/streamx/cli/commands/auth/AuthTestEndpoints.java
new file mode 100644
index 00000000..fd7c4c34
--- /dev/null
+++ b/src/test/java/com/streamx/cli/commands/auth/AuthTestEndpoints.java
@@ -0,0 +1,39 @@
+package com.streamx.cli.commands.auth;
+
+import com.streamx.cli.auth.AuthConfig;
+
+public final class AuthTestEndpoints {
+ public static final String SERVER_URL_PROPERTY = "streamx.test.auth.server-url";
+ public static final String REALM_PROPERTY = "streamx.test.auth.realm";
+ public static final String CLIENT_ID_PROPERTY = "streamx.test.auth.client-id";
+ public static final String USERNAME_PROPERTY = "streamx.test.auth.username";
+ public static final String PASSWORD_PROPERTY = "streamx.test.auth.password";
+ public static final String INSECURE_PROPERTY = "streamx.test.auth.insecure";
+
+ private AuthTestEndpoints() {
+ }
+
+ public static String serverUrl() {
+ return System.getProperty(SERVER_URL_PROPERTY);
+ }
+
+ public static String realm() {
+ return System.getProperty(REALM_PROPERTY, AuthConfig.DEFAULT_REALM);
+ }
+
+ public static String clientId() {
+ return System.getProperty(CLIENT_ID_PROPERTY, AuthConfig.DEFAULT_CLIENT_ID);
+ }
+
+ public static String username() {
+ return System.getProperty(USERNAME_PROPERTY, "user1");
+ }
+
+ public static String password() {
+ return System.getProperty(PASSWORD_PROPERTY, "user1");
+ }
+
+ public static boolean insecure() {
+ return Boolean.parseBoolean(System.getProperty(INSECURE_PROPERTY, "true"));
+ }
+}
diff --git a/src/test/java/com/streamx/cli/commands/auth/KeycloakDeviceApprover.java b/src/test/java/com/streamx/cli/commands/auth/KeycloakDeviceApprover.java
new file mode 100644
index 00000000..53373f51
--- /dev/null
+++ b/src/test/java/com/streamx/cli/commands/auth/KeycloakDeviceApprover.java
@@ -0,0 +1,144 @@
+package com.streamx.cli.commands.auth;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.net.ssl.SSLContext;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.conn.ssl.TrustAllStrategy;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.impl.client.LaxRedirectStrategy;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.ssl.SSLContexts;
+import org.apache.http.util.EntityUtils;
+
+public class KeycloakDeviceApprover implements AutoCloseable {
+ private static final Pattern FORM_ACTION = Pattern.compile("